fix(kprofiles): read the nozzle diameter the printer actually sent (issue #1748)

Every K-profile came back as 0.4mm on printers running any other
nozzle (#1748, reporters @Liquidmasl and @jmoore-skild). The printer
puts nozzle_diameter on the extrusion_cali_get envelope only; the
per-filament entries carry setting_id, filament_id, name, k_value,
n_coef and cali_idx, and nothing else. The parser read the field per
entry with a hardcoded "0.4" fallback, so the fallback fired on every
profile of every response. The envelope value was already in scope,
read into response_nozzle and used only to match the request.

This never reproduced on H2D because that firmware does include the
field per entry. Both construction sites are in the same handler, so
the code path is shared; what differs is the payload, and every
single-nozzle model omits it.

The display was the least of it. Editing is delete-and-re-add on
single-nozzle printers, and the dialog rebuilt nozzle_id and
nozzle_diameter from its own greyed-out selects, so saving an
untouched 0.6mm profile rewrote it on the printer as HH00-0.4.
Deleting aimed extrusion_cali_del at the wrong nozzle the same way.
Both now pass through what the printer reported. The cali_idx cascade
in inventory.py, spoolman_inventory.py and spoolman.py matches on
nozzle_diameter, so on a 0.6 or 0.8 nozzle it never found the
printer-side entry and the assignment silently failed to stick --
that is the "cannot auto-map a K-profile" half of the report, fixed
at the source without touching those three call sites.

nozzle_id has no source in the payload at all, and state.nozzles
carries material (hardened_steel), not flow, so it cannot honestly
produce HH/HS. Rather than keep inventing one, the UI now says the
printer did not report it: the card shows the diameter alone, the
dialog shows "Not reported by printer", and the High Flow / Standard
filter is hidden instead of being offered as a control that can only
ever empty the list. Import stops stamping HH00 on profiles whose
source reported none.

Also correlates K-profile requests by sequence_id. Responses were
matched by nozzle diameter through a single shared expectation slot,
so a second request overwrote the first's and the first's valid
answer was discarded as a mismatch -- the "Failed to get K-profiles
after 3 attempts" in the same logs, with the printer having answered
correctly both times. Pending state is now one entry per request,
keyed by the id we already send, with the nozzle match kept as a
fallback for firmware that does not echo it back.

Fixes the flow-type select naming a new profile with the opposite
label, which contradicted the identical expression 44 lines above it.
This commit is contained in:
maziggy 2026-08-01 08:49:44 +02:00
parent b8225d9e9f
commit a35ba8fa5f
21 changed files with 400 additions and 8906 deletions

View file

@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file.
## [1.2.6b1] - Unreleased
### Fixed
- **K-profiles no longer all report 0.4mm / High Flow (#1748, reporters @Liquidmasl and @jmoore-skild)** — On any printer running a nozzle other than 0.4mm, every K-profile showed up as `0.4` with a flow type nobody had set, and the same profile disagreed with itself: the list said **S**, the edit dialog said **High Flow**. The printer reports the nozzle diameter once, on the response envelope; the individual profile entries carry no diameter and no nozzle id at all. Bambuddy read the diameter *per entry* and fell back to a hardcoded `0.4` when it wasn't there — which was always. The envelope value is now used, so profiles report the nozzle they were actually calibrated for. This was not only cosmetic. Editing a profile is delete-and-re-add on single-nozzle printers, and the dialog rebuilt the nozzle fields from its own (greyed-out) dropdowns, so saving an untouched 0.6mm profile rewrote it on the printer as 0.4mm High Flow. Deleting one aimed the command at the wrong nozzle for the same reason. Both now pass through exactly what the printer reported. Assigning a spool's stored calibration to an AMS slot was affected too: that lookup matches on nozzle diameter, so on a 0.6 or 0.8 nozzle it never found the printer-side entry and the cali_idx silently failed to stick — the "can't auto-map a K-profile" half of the report. Where the printer sends no nozzle id, Bambuddy now says so instead of picking one: the list shows the diameter alone, the dialog shows **Not reported by printer**, and the High Flow / Standard filter is hidden rather than offered as a control that can only ever empty the list. Also fixes the flow type filter selecting the opposite label when naming a new profile. Translated in all locales. Covered by backend tests.
- **K-profile requests no longer time out when two run at once (#1748)** — Fetching profiles for one nozzle size while another fetch was open made the first one time out, with `Failed to get K-profiles after 3 attempts` in the log, even though the printer had answered both correctly. Responses were matched to requests by nozzle diameter held in a single shared slot, so the second request overwrote the first's expectation and the first's valid answer was discarded as a mismatch. Requests are now correlated by the sequence id Bambuddy already sends and tracked one entry per request, with the old nozzle match kept as a fallback for firmware that doesn't echo the id back. An unsolicited broadcast arriving mid-fetch also no longer replaces the profile list the fetch is waiting on. Covered by backend tests.
- **Git backup now actually writes cloud profiles (#2717, reporter @jmoore-skild)** — Enabling **Cloud Profiles** for a Git backup produced nothing. The collector looked for a `setting` list in the Bambu Cloud response, which is keyed by preset type instead, so the loop never ran once — and it asked for the credential store used when authentication is *disabled*, so on any install with authentication on it found no account to collect from in the first place. Neither failure was visible: `backup_metadata.json` still recorded `cloud_profiles: true`, and the log line read `Collected cloud profiles: 0 filament, 0 printer, 0 process`, which looks like a successful backup of an empty account. Cloud profiles are now collected from **every connected account across both Bambu Cloud and Orca Cloud**, one directory per cloud per account, keyed by user ID so no email address is written into a backup repository. Bambu presets are stored with the payload needed to recreate them rather than just their names, and Bambu's bundled public catalogue is skipped — it is identical for everyone, re-downloadable, and would rewrite the repository on every run. The metadata now records what was actually collected, per cloud and per account, and a run that collects nothing while the category is enabled says so as a warning instead of an INFO line that reads like success. The Cloud Profiles checkbox no longer keys off your own Bambu sign-in — it enables when *any* account is connected and shows how many are in scope, which matters on a multi-user install where the presets being backed up are other people's. A backup also no longer disconnects an Orca Cloud account whose session it can't refresh: Orca reports every rejection with one composite reason, so a genuine revocation is indistinguishable from a lost token-rotation race, and an unattended job should not be the thing that guesses. The account is skipped with a warning, and the dead credentials are cleared the next time you open Orca Cloud Profiles — where you can pair again on the spot. Translated in all locales; wiki updated. Covered by backend tests.
### Added

View file

@ -812,10 +812,12 @@ class BambuMQTTClient:
# so that missing-serial / missing-firmware warnings fire only once per connection.
self._ams_version_warned: set[tuple[int | str, str]] = set()
# K-profile command tracking
# K-profile command tracking. One entry per in-flight extrusion_cali_get,
# keyed by the sequence_id we sent, so two concurrent requests for
# different nozzle sizes can't steal each other's response (#1748).
# Value: {"nozzle": str, "event": asyncio.Event, "profiles": list | None}.
self._sequence_id: int = 0
self._pending_kprofile_response: asyncio.Event | None = None
self._kprofile_response_data: list | None = None
self._pending_kprofile_requests: dict[str, dict] = {}
# Xcam hold timers - OrcaSlicer pattern: ignore incoming data for 3 seconds after command
# Key: module_name, Value: timestamp when command was sent
@ -5412,98 +5414,120 @@ class BambuMQTTClient:
self._drying_targets.pop(ams_id, None)
return True
@staticmethod
def _parse_kprofile_entries(filaments: list, response_nozzle: str | None, log_errors: bool) -> list[KProfile]:
"""Build KProfile objects from an ``extrusion_cali_get`` filaments array.
The printer reports ``nozzle_diameter`` **only on the response
envelope** the per-filament entries carry just setting_id,
filament_id, name, k_value, n_coef and cali_idx. Defaulting the
per-entry lookup to "0.4" therefore stamped every profile 0.4mm on
single-nozzle printers regardless of the installed nozzle (#1748),
which broke the K-Profiles display and, worse, the cali_idx cascade
in the inventory/Spoolman assign paths that matches on
nozzle_diameter. Fall back to the envelope value instead, and only
to "0.4" when the envelope has none either.
``or`` rather than a dict default on purpose: it also covers an entry
that carries the key with an empty value, and stops ``str()`` turning
a missing envelope value into the literal "None".
"""
profiles: list[KProfile] = []
for i, f in enumerate(filaments):
if not isinstance(f, dict):
continue
try:
profiles.append(
KProfile(
# cali_idx is the actual slot/calibration index from the printer
slot_id=f.get("cali_idx", i),
extruder_id=int(f.get("extruder_id", 0)),
nozzle_id=str(f.get("nozzle_id", "")),
nozzle_diameter=str(f.get("nozzle_diameter") or response_nozzle or "0.4"),
filament_id=str(f.get("filament_id", "")),
name=str(f.get("name", "")),
k_value=str(f.get("k_value", "0.000000")),
n_coef=str(f.get("n_coef", "0.000000")),
ams_id=int(f.get("ams_id", 0)),
tray_id=int(f.get("tray_id", -1)),
setting_id=f.get("setting_id"),
)
)
except (ValueError, TypeError) as e:
# Skip malformed entries; the remaining profiles stay usable.
# Unsolicited broadcasts arrive constantly, so only a response
# someone is actually waiting on is worth a warning.
if log_errors:
logger.warning("Failed to parse K-profile: %s", e)
else:
logger.debug("Failed to parse K-profile from broadcast: %s", e)
return profiles
def _handle_kprofile_response(self, data: dict):
"""Handle K-profile response from printer."""
response_nozzle = data.get("nozzle_diameter")
response_seq_id = data.get("sequence_id", "?")
response_seq_id = str(data.get("sequence_id", ""))
filaments = data.get("filaments", [])
expected_nozzle = getattr(self, "_expected_kprofile_nozzle", None)
has_pending_request = self._pending_kprofile_response is not None
# Log all incoming responses when we have a pending request (for debugging)
if has_pending_request:
# Snapshot the map: the asyncio thread adds and removes entries while
# this MQTT callback thread walks it.
pending = dict(self._pending_kprofile_requests)
request = pending.get(response_seq_id)
if request is None and pending:
# Firmware that doesn't echo our sequence_id still has to be
# served, so fall back to the pre-#1748 rule of matching on the
# nozzle size. Only requests still waiting are eligible, and the
# sequence_id lookup above has already claimed any response that
# identifies itself, so this can no longer hand request A's
# answer to request B when both are in flight.
request = next(
(r for r in pending.values() if r["nozzle"] == response_nozzle and r["profiles"] is None),
None,
)
if pending:
logger.info(
f"[{self.serial_number}] K-profile response: nozzle={response_nozzle}, "
f"seq_id={response_seq_id}, {len(filaments)} profiles, expected={expected_nozzle}"
"[%s] K-profile response: nozzle=%s, seq_id=%s, %d profiles, matched=%s",
self.serial_number,
response_nozzle,
response_seq_id or "?",
len(filaments),
request is not None,
)
# If we have a pending request, only accept responses with matching nozzle_diameter
# The printer broadcasts 0.4mm profiles constantly - we need to wait for the actual response
if has_pending_request and expected_nozzle and response_nozzle != expected_nozzle:
# Ignore this broadcast, keep waiting for matching response
if request is None and pending:
# A request is outstanding and this isn't its answer. The printer
# broadcasts extrusion_cali_get unsolicited, so letting this
# through would replace state.kprofiles with another nozzle's
# profiles while the caller is still waiting.
logger.debug(
f"[{self.serial_number}] Ignoring broadcast: got nozzle={response_nozzle}, waiting for {expected_nozzle}"
"[%s] Ignoring unmatched K-profile response: nozzle=%s, seq_id=%s",
self.serial_number,
response_nozzle,
response_seq_id or "?",
)
return
# If no pending request, this is just a broadcast - update state silently and return early
if not has_pending_request:
# Still parse profiles to keep state updated, but don't log
profiles = []
for f in filaments:
if isinstance(f, dict):
try:
cali_idx = f.get("cali_idx", 0)
profiles.append(
KProfile(
slot_id=cali_idx,
extruder_id=int(f.get("extruder_id", 0)),
nozzle_id=str(f.get("nozzle_id", "")),
nozzle_diameter=str(f.get("nozzle_diameter", "0.4")),
filament_id=str(f.get("filament_id", "")),
name=str(f.get("name", "")),
k_value=str(f.get("k_value", "0.000000")),
n_coef=str(f.get("n_coef", "0.000000")),
ams_id=int(f.get("ams_id", 0)),
tray_id=int(f.get("tray_id", -1)),
setting_id=f.get("setting_id"),
)
)
except (ValueError, TypeError):
pass # Skip malformed K-profile entries; remaining profiles still usable
self.state.kprofiles = profiles
profiles = self._parse_kprofile_entries(filaments, response_nozzle, log_errors=request is not None)
self.state.kprofiles = profiles
if request is None:
# Unsolicited broadcast with nothing in flight: state is refreshed,
# nobody to wake.
return
profiles = []
logger.info("[%s] Got %s K-profiles for nozzle=%s", self.serial_number, len(profiles), response_nozzle)
request["profiles"] = profiles
for i, f in enumerate(filaments):
if isinstance(f, dict):
try:
# cali_idx is the actual slot/calibration index from the printer
cali_idx = f.get("cali_idx", i)
profiles.append(
KProfile(
slot_id=cali_idx,
extruder_id=int(f.get("extruder_id", 0)),
nozzle_id=str(f.get("nozzle_id", "")),
nozzle_diameter=str(f.get("nozzle_diameter", "0.4")),
filament_id=str(f.get("filament_id", "")),
name=str(f.get("name", "")),
k_value=str(f.get("k_value", "0.000000")),
n_coef=str(f.get("n_coef", "0.000000")),
ams_id=int(f.get("ams_id", 0)),
tray_id=int(f.get("tray_id", -1)),
setting_id=f.get("setting_id"),
)
)
except (ValueError, TypeError) as e:
logger.warning("Failed to parse K-profile: %s", e)
self.state.kprofiles = profiles
self._kprofile_response_data = profiles
# Signal that we received the response (only if we were waiting for one)
# Use thread-safe method since MQTT callbacks run in a different thread
# Capture in local var to avoid TOCTOU race: asyncio thread can clear
# self._pending_kprofile_response between the check and the .set() call
event = self._pending_kprofile_response
if event:
logger.info("[%s] Got %s K-profiles for nozzle=%s", self.serial_number, len(profiles), response_nozzle)
if self._loop and self._loop.is_running():
self._loop.call_soon_threadsafe(event.set)
else:
# Fallback for when loop is not available
event.set()
# Signal the waiter. Use the thread-safe path since MQTT callbacks run
# in a different thread than the event loop.
event = request["event"]
if self._loop and self._loop.is_running():
self._loop.call_soon_threadsafe(event.set)
else:
# Fallback for when loop is not available
event.set()
async def get_kprofiles(
self, nozzle_diameter: str = "0.4", timeout: float = 5.0, max_retries: int = 3
@ -5533,11 +5557,13 @@ class BambuMQTTClient:
return []
for attempt in range(max_retries):
# Set up response event for this attempt
# Register this attempt under its own sequence_id so a concurrent
# request for a different nozzle size can't consume its response
# (#1748) — the pending map is keyed by exactly the id we send.
self._sequence_id += 1
self._pending_kprofile_response = asyncio.Event()
self._kprofile_response_data = None
self._expected_kprofile_nozzle = nozzle_diameter # Track which nozzle response we expect
seq_id = str(self._sequence_id)
request: dict = {"nozzle": nozzle_diameter, "event": asyncio.Event(), "profiles": None}
self._pending_kprofile_requests[seq_id] = request
# Send the command with nozzle_diameter filter
command = {
@ -5545,20 +5571,20 @@ class BambuMQTTClient:
"command": "extrusion_cali_get",
"filament_id": "",
"nozzle_diameter": nozzle_diameter,
"sequence_id": str(self._sequence_id),
"sequence_id": seq_id,
}
}
logger.info(
f"[{self.serial_number}] Requesting K-profiles for nozzle_diameter={nozzle_diameter} (attempt {attempt + 1}/{max_retries})"
f"[{self.serial_number}] Requesting K-profiles for nozzle_diameter={nozzle_diameter} (attempt {attempt + 1}/{max_retries}, seq_id={seq_id})"
)
logger.debug("[%s] K-profile request JSON: %s", self.serial_number, json.dumps(command))
self._client.publish(self.topic_publish, json.dumps(command), qos=1)
# Wait for response (response handler already filters by nozzle_diameter)
# Wait for the response (the handler matches it back to this entry)
try:
await asyncio.wait_for(self._pending_kprofile_response.wait(), timeout=timeout)
profiles = self._kprofile_response_data or []
self._client.publish(self.topic_publish, json.dumps(command), qos=1)
await asyncio.wait_for(request["event"].wait(), timeout=timeout)
profiles = request["profiles"] or []
logger.info(
f"[{self.serial_number}] Got {len(profiles)} K-profiles for nozzle={nozzle_diameter} on attempt {attempt + 1}"
)
@ -5571,8 +5597,7 @@ class BambuMQTTClient:
# Brief delay before retry
await asyncio.sleep(0.5)
finally:
self._pending_kprofile_response = None
self._expected_kprofile_nozzle = None
self._pending_kprofile_requests.pop(seq_id, None)
logger.error("[%s] Failed to get K-profiles after %s attempts", self.serial_number, max_retries)
return []

View file

@ -4,9 +4,11 @@ Tests for the BambuMQTTClient service.
These tests focus on timelapse tracking during prints.
"""
import asyncio
import json
import logging
import time
from unittest.mock import MagicMock
import pytest
@ -6832,6 +6834,178 @@ class TestKProfileResponseDoesNotClobberNozzle:
assert mqtt_client.state.nozzles[0].nozzle_diameter == "0.4"
class TestKProfileNozzleDiameterFromEnvelope:
"""#1748: every K-profile came back as 0.4mm on single-nozzle printers.
``extrusion_cali_get`` carries ``nozzle_diameter`` only on the response
envelope the per-filament entries hold just setting_id, filament_id,
name, k_value, n_coef and cali_idx. The parser read the field per entry
with a "0.4" default, so a 0.6/0.8 nozzle's profiles were all stamped 0.4.
Beyond the K-Profiles display that broke the cali_idx cascade in the
inventory and Spoolman assign paths, which match on nozzle_diameter.
"""
@pytest.fixture
def mqtt_client(self):
from backend.app.services.bambu_mqtt import BambuMQTTClient
return BambuMQTTClient(
ip_address="192.168.1.100",
serial_number="X1ETEST",
access_code="12345678",
)
@staticmethod
def _response(nozzle="0.8", entries=None, seq="48"):
"""A verbatim-shaped extrusion_cali_get payload from the #1748 report."""
if entries is None:
entries = [
{
"setting_id": "GFSNLS02_07",
"filament_id": "GFSNL02",
"name": "SUNLU PLA Matte WHITE 0.8",
"k_value": "0.01750",
"n_coef": "1.000",
"cali_idx": 265,
"is_history_setting": True,
}
]
print_data = {"command": "extrusion_cali_get", "filament_id": "", "filaments": entries}
if nozzle is not None:
print_data["nozzle_diameter"] = nozzle
if seq is not None:
print_data["sequence_id"] = seq
return {"print": print_data}
def test_broadcast_uses_envelope_diameter(self, mqtt_client):
# No request in flight: the unsolicited broadcast still has to record
# the right diameter, because state.kprofiles is what the assign paths
# read when nobody has just fetched.
mqtt_client._process_message(self._response(nozzle="0.8"))
assert [p.nozzle_diameter for p in mqtt_client.state.kprofiles] == ["0.8"]
@pytest.mark.asyncio
async def test_awaited_response_uses_envelope_diameter(self, mqtt_client):
profiles = await self._fetch(mqtt_client, "0.6", self._response(nozzle="0.6", seq="7"))
assert [p.nozzle_diameter for p in profiles] == ["0.6"]
def test_entry_value_still_wins(self, mqtt_client):
# Dual-nozzle firmware does put the field on each entry; that stays
# authoritative, since a batch can legitimately span nozzles.
entries = [{"cali_idx": 1, "filament_id": "GFA00", "name": "PLA", "nozzle_diameter": "0.4"}]
mqtt_client._process_message(self._response(nozzle="0.8", entries=entries))
assert mqtt_client.state.kprofiles[0].nozzle_diameter == "0.4"
def test_empty_entry_value_falls_back_to_envelope(self, mqtt_client):
entries = [{"cali_idx": 1, "filament_id": "GFA00", "name": "PLA", "nozzle_diameter": ""}]
mqtt_client._process_message(self._response(nozzle="0.8", entries=entries))
assert mqtt_client.state.kprofiles[0].nozzle_diameter == "0.8"
def test_no_envelope_value_falls_back_to_default(self, mqtt_client):
# Neither source available: keep the old default rather than let
# str(None) write the literal string "None" into the profile.
mqtt_client._process_message(self._response(nozzle=None))
assert mqtt_client.state.kprofiles[0].nozzle_diameter == "0.4"
@staticmethod
async def _fetch(client, nozzle, response):
"""Run get_kprofiles, feeding `response` in as the printer's answer."""
client.state.connected = True
client._client = MagicMock()
client._client.publish.side_effect = lambda *a, **kw: client._process_message(response)
return await client.get_kprofiles(nozzle_diameter=nozzle, timeout=2.0)
class TestKProfileRequestCorrelation:
"""#1748: K-profile requests timed out whenever two were in flight.
Responses were matched to requests by nozzle diameter alone, held in one
shared ``_expected_kprofile_nozzle`` slot. A second request overwrote the
first's expectation, so the first's valid answer was discarded as a
mismatch and that request timed out even though the printer had replied.
Correlation now runs off the sequence_id we send, with the nozzle match
kept as a fallback for firmware that doesn't echo it.
"""
@pytest.fixture
def mqtt_client(self):
from backend.app.services.bambu_mqtt import BambuMQTTClient
client = BambuMQTTClient(
ip_address="192.168.1.100",
serial_number="X1ETEST",
access_code="12345678",
)
client.state.connected = True
client._client = MagicMock()
return client
@staticmethod
def _response(nozzle, seq, name):
return {
"print": {
"command": "extrusion_cali_get",
"nozzle_diameter": nozzle,
"sequence_id": seq,
"filaments": [{"cali_idx": 1, "filament_id": "GFA00", "name": name, "k_value": "0.020000"}],
}
}
@pytest.mark.asyncio
async def test_concurrent_requests_each_get_their_own_response(self, mqtt_client):
# The failing sequence from the report: 0.8 is requested, then 0.4,
# then the 0.8 answer lands. Under nozzle-only matching the expected
# slot already said 0.4, so the 0.8 answer was dropped on the floor.
seen: list[str] = []
def publish(_topic, payload, **_kw):
seen.append(json.loads(payload)["print"]["sequence_id"])
mqtt_client._client.publish.side_effect = publish
big = asyncio.create_task(mqtt_client.get_kprofiles(nozzle_diameter="0.8", timeout=5.0))
small = asyncio.create_task(mqtt_client.get_kprofiles(nozzle_diameter="0.4", timeout=5.0))
await asyncio.sleep(0) # let both publish before either answer arrives
assert len(seen) == 2
mqtt_client._process_message(self._response("0.8", seen[0], "wide"))
mqtt_client._process_message(self._response("0.4", seen[1], "narrow"))
assert [p.name for p in await big] == ["wide"]
assert [p.name for p in await small] == ["narrow"]
@pytest.mark.asyncio
async def test_falls_back_to_nozzle_match_when_sequence_id_is_not_echoed(self, mqtt_client):
# Firmware that answers with its own sequence_id must keep working.
mqtt_client._client.publish.side_effect = lambda *a, **kw: mqtt_client._process_message(
self._response("0.6", "9999", "echoed-nothing")
)
profiles = await mqtt_client.get_kprofiles(nozzle_diameter="0.6", timeout=2.0)
assert [p.name for p in profiles] == ["echoed-nothing"]
@pytest.mark.asyncio
async def test_unrelated_broadcast_does_not_clobber_a_pending_fetch(self, mqtt_client):
# The printer broadcasts 0.4 profiles unsolicited. One arriving while a
# 0.8 fetch is open must neither satisfy nor overwrite it.
def publish(_topic, payload, **_kw):
seq = json.loads(payload)["print"]["sequence_id"]
mqtt_client._process_message(self._response("0.4", "9999", "broadcast"))
mqtt_client._process_message(self._response("0.8", seq, "wanted"))
mqtt_client._client.publish.side_effect = publish
profiles = await mqtt_client.get_kprofiles(nozzle_diameter="0.8", timeout=2.0)
assert [p.name for p in profiles] == ["wanted"]
assert [p.name for p in mqtt_client.state.kprofiles] == ["wanted"]
@pytest.mark.asyncio
async def test_pending_entry_is_released_on_timeout(self, mqtt_client):
# A timed-out attempt must not leave its entry behind, or a later
# broadcast would be matched to a request nobody is waiting on.
profiles = await mqtt_client.get_kprofiles(nozzle_diameter="0.8", timeout=0.01, max_retries=1)
assert profiles == []
assert mqtt_client._pending_kprofile_requests == {}
class TestConnectRefusalReporting:
"""#2698: a refused CONNACK must leave a trace.

View file

@ -42,16 +42,21 @@ const truncateK = (value: string) => {
return (Math.trunc(num * 1000) / 1000).toFixed(3);
};
// Get flow type label from nozzle_id (e.g., "HH00-0.4" -> "HF", "HS00-0.4" -> "S")
// Get flow type label from nozzle_id (e.g., "HH00-0.4" -> "HF", "HS00-0.4" -> "S").
// Single-nozzle printers omit nozzle_id from their extrusion_cali_get response
// entirely (#1748), and there is no other field to recover the flow type from —
// so return '' and let the caller show nothing rather than assert "Standard".
const getFlowTypeLabel = (nozzleId: string) => {
if (nozzleId.startsWith('HH')) return 'HF'; // High Flow
return 'S'; // Standard Flow (default)
if (nozzleId.startsWith('HS')) return 'S'; // Standard Flow
return ''; // not reported by the printer
};
// Extract nozzle type prefix from nozzle_id (e.g., "HH00-0.4" -> "HH00")
// Extract nozzle type prefix from nozzle_id (e.g., "HH00-0.4" -> "HH00").
// '' when the printer reported no nozzle_id — see getFlowTypeLabel.
const getNozzleTypePrefix = (nozzleId: string) => {
const match = nozzleId.match(/^([A-Z]{2}\d{2})/);
return match ? match[1] : 'HH00';
return match ? match[1] : '';
};
// Extract filament name from profile name (e.g., "High Flow_Devil Design PLA Basic" -> "Devil Design PLA Basic")
@ -120,7 +125,7 @@ function KProfileCard({ profile, onEdit, onCopy, selectionMode, isSelected, onTo
</span>
)}
<span className="text-xs text-bambu-gray whitespace-nowrap">
{flowType} {diameter}
{[flowType, diameter].filter(Boolean).join(' ')}
</span>
</div>
{note && (
@ -183,8 +188,12 @@ function KProfileModal({
);
const [filamentId, setFilamentId] = useState(profile?.filament_id || '');
// Split nozzle into type and diameter
// Both selects are read-only while editing: they report what the printer
// holds, they don't set it. '' means the printer reported no nozzle_id, which
// single-nozzle models never do (#1748) — showing "High Flow" there was the
// UI inventing a value the printer never sent.
const [nozzleType, setNozzleType] = useState(
profile?.nozzle_id ? getNozzleTypePrefix(profile.nozzle_id) : 'HH00'
profile ? getNozzleTypePrefix(profile.nozzle_id) : 'HH00'
);
const [modalDiameter, setModalDiameter] = useState(
profile?.nozzle_diameter || nozzleDiameter
@ -316,14 +325,22 @@ function KProfileModal({
// Combine nozzle type and diameter into nozzle_id (e.g., "HH00-0.4")
const nozzleId = `${nozzleType}-${modalDiameter}`;
// An edit is delete + re-add on single-nozzle printers, so the nozzle
// fields have to survive the round trip untouched — both selects are
// disabled while editing. Rebuilding them from the selects is what let a
// 0.6mm profile come back as "HH00-0.4" once the parse defaults had
// stamped it 0.4 (#1748); pass through what the printer reported instead.
const editNozzleId = profile ? profile.nozzle_id : nozzleId;
const editDiameter = profile ? profile.nozzle_diameter : modalDiameter;
// For editing or single extruder: just save one profile
if (profile || selectedExtruders.length === 1) {
const payload = {
name: name,
k_value: formattedKValue,
filament_id: filamentId,
nozzle_id: nozzleId,
nozzle_diameter: modalDiameter,
nozzle_id: editNozzleId,
nozzle_diameter: editDiameter,
extruder_id: profile ? profile.extruder_id : selectedExtruders[0],
setting_id: profile?.setting_id,
slot_id: profile?.slot_id ?? 0,
@ -508,7 +525,7 @@ function KProfileModal({
if (!profile && filamentId && !name) {
const selectedFilament = knownFilaments.find(f => f.id === filamentId);
if (selectedFilament) {
const flowLabel = newNozzleType === 'HS00' ? 'HF' : 'S';
const flowLabel = newNozzleType === 'HH00' ? 'HF' : 'S';
setName(`${flowLabel} ${selectedFilament.name}`);
}
}
@ -516,6 +533,12 @@ function KProfileModal({
disabled={!!profile}
className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
>
{/* Only reachable when editing a profile the printer
reported without a nozzle_id the select is disabled
there, so this is a readout, not a choice. */}
{nozzleType === '' && (
<option value="">{t('kProfiles.modal.flowTypeNotReported')}</option>
)}
<option value="HH00">{t('kProfiles.modal.highFlow')}</option>
<option value="HS00">{t('kProfiles.modal.standard')}</option>
</select>
@ -865,6 +888,21 @@ export function KProfilesView() {
return builtinFilamentMap.get(profile.filament_id) || extractFilamentName(profile.name);
}, [builtinFilamentMap]);
// Whether the printer reports a nozzle_id at all. Single-nozzle models omit
// it from every extrusion_cali_get entry (#1748), so a flow-type filter there
// could only ever match nothing — hide it instead of offering a control that
// silently empties the list.
const hasFlowTypeInfo = React.useMemo(
() => (kprofiles?.profiles ?? []).some((p) => getFlowTypeLabel(p.nozzle_id) !== ''),
[kprofiles?.profiles]
);
// Don't strand the list behind a filter whose control just disappeared
// (switching printers, or a refetch that no longer carries nozzle ids).
useEffect(() => {
if (!hasFlowTypeInfo) setFlowTypeFilter('all');
}, [hasFlowTypeInfo]);
// Filter and sort profiles
// Note: nozzle diameter filtering is done server-side via MQTT request
const filteredProfiles = React.useMemo(() => {
@ -1002,7 +1040,10 @@ export function KProfilesView() {
name: p.name,
k_value: parseFloat(p.k_value).toFixed(6),
filament_id: p.filament_id,
nozzle_id: p.nozzle_id || `HH00-${nozzleDiameter}`,
// Keep an absent nozzle_id absent. Exports from single-nozzle
// printers carry none (#1748), and HH00 vs HS00 is a coin flip
// we'd be writing to the printer as if it were fact.
nozzle_id: p.nozzle_id || '',
nozzle_diameter: p.nozzle_diameter || nozzleDiameter,
extruder_id: p.extruder_id ?? 0,
slot_id: 0, // Always create new
@ -1248,17 +1289,19 @@ export function KProfilesView() {
</select>
</div>
)}
<div className="w-32">
<select
value={flowTypeFilter}
onChange={(e) => setFlowTypeFilter(e.target.value as FlowTypeFilter)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="all">{t('kProfiles.allFlow')}</option>
<option value="hf">{t('kProfiles.hfOnly')}</option>
<option value="s">{t('kProfiles.sOnly')}</option>
</select>
</div>
{hasFlowTypeInfo && (
<div className="w-32">
<select
value={flowTypeFilter}
onChange={(e) => setFlowTypeFilter(e.target.value as FlowTypeFilter)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="all">{t('kProfiles.allFlow')}</option>
<option value="hf">{t('kProfiles.hfOnly')}</option>
<option value="s">{t('kProfiles.sOnly')}</option>
</select>
</div>
)}
<div className="w-32">
<select
value={sortOption}

View file

@ -5038,6 +5038,7 @@ export default {
flowType: 'Flusstyp',
highFlow: 'Hoher Durchfluss',
standard: 'Standard',
flowTypeNotReported: 'Vom Drucker nicht gemeldet',
nozzleSize: 'Düsengröße',
extruder: 'Extruder',
extruders: 'Extruder',

View file

@ -5082,6 +5082,7 @@ export default {
flowType: 'Flow Type',
highFlow: 'High Flow',
standard: 'Standard',
flowTypeNotReported: 'Not reported by printer',
nozzleSize: 'Nozzle Size',
extruder: 'Extruder',
extruders: 'Extruders',

View file

@ -5047,6 +5047,7 @@ export default {
flowType: 'Tipo de flujo',
highFlow: 'Flujo alto',
standard: 'Estándar',
flowTypeNotReported: 'No informado por la impresora',
nozzleSize: 'Tamaño de la boquilla',
extruder: 'Extrusor',
extruders: 'Extrusores',

View file

@ -5028,6 +5028,7 @@ export default {
flowType: 'Type de débit',
highFlow: 'Haut Débit (HF)',
standard: 'Standard',
flowTypeNotReported: 'Non communiqué par l\'imprimante',
nozzleSize: 'Taille buse',
extruder: 'Extrudeur',
extruders: 'Extrudeurs',

View file

@ -5027,6 +5027,7 @@ export default {
flowType: 'Tipo flow',
highFlow: 'Alto flusso',
standard: 'Standard',
flowTypeNotReported: 'Non riportato dalla stampante',
nozzleSize: 'Dimensione ugello',
extruder: 'Estrusore',
extruders: 'Estrusori',

View file

@ -5039,6 +5039,7 @@ export default {
flowType: 'フロータイプ',
highFlow: 'ハイフロー',
standard: 'スタンダード',
flowTypeNotReported: 'プリンターから報告なし',
nozzleSize: 'ノズルサイズ',
extruder: 'エクストルーダー',
extruders: 'エクストルーダー',

View file

@ -4782,6 +4782,7 @@ export default {
flowType: '유량 유형',
highFlow: '고유량',
standard: '표준',
flowTypeNotReported: '프린터에서 보고하지 않음',
nozzleSize: '노즐 크기',
extruder: '압출기',
extruders: '압출기',

View file

@ -5027,6 +5027,7 @@ export default {
flowType: 'Tipo de Fluxo',
highFlow: 'Alto Fluxo',
standard: 'Padrão',
flowTypeNotReported: 'Não informado pela impressora',
nozzleSize: 'Tamanho do Bico',
extruder: 'Extrusor',
extruders: 'Extrusores',

View file

@ -4770,6 +4770,7 @@ export default {
flowType: "Тип потока",
highFlow: "Высокопоточный",
standard: "Стандартный",
flowTypeNotReported: "Принтер не сообщает",
nozzleSize: "Диаметр сопла",
extruder: "Экструдер",
extruders: "Экструдеры",

View file

@ -5007,6 +5007,7 @@ export default {
flowType: 'Akış Türü',
highFlow: 'Yüksek Akış',
standard: 'Standart',
flowTypeNotReported: 'Yazıcı tarafından bildirilmedi',
nozzleSize: 'Nozul Boyutu',
extruder: 'Ekstrüder',
extruders: 'Ekstrüderler',

View file

@ -5082,6 +5082,7 @@ export default {
flowType: "Тип потоку",
highFlow: "Сопло з високим потоком",
standard: "Стандартний",
flowTypeNotReported: "Принтер не повідомляє",
nozzleSize: "Розмір сопла",
extruder: "Екструдер",
extruders: "Екструдери",

View file

@ -5027,6 +5027,7 @@ export default {
flowType: '流量类型',
highFlow: '高流量',
standard: '标准',
flowTypeNotReported: '打印机未报告',
nozzleSize: '喷嘴尺寸',
extruder: '挤出机',
extruders: '挤出机',

View file

@ -5027,6 +5027,7 @@ export default {
flowType: '流量類型',
highFlow: '高流量',
standard: '標準',
flowTypeNotReported: '印表機未回報',
nozzleSize: '噴嘴尺寸',
extruder: '擠出機',
extruders: '擠出機',

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-CxAiFpme.js"></script>
<script type="module" crossorigin src="/assets/index-QysAxcAd.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-oReXTzKG.css">
</head>
<body>