From a35ba8fa5f4e0b1ca91aa7991f742a689c6d58c8 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sat, 1 Aug 2026 08:49:44 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 2 + backend/app/services/bambu_mqtt.py | 209 +- .../tests/unit/services/test_bambu_mqtt.py | 174 + frontend/src/components/KProfilesView.tsx | 85 +- frontend/src/i18n/locales/de.ts | 1 + frontend/src/i18n/locales/en.ts | 1 + frontend/src/i18n/locales/es.ts | 1 + frontend/src/i18n/locales/fr.ts | 1 + frontend/src/i18n/locales/it.ts | 1 + frontend/src/i18n/locales/ja.ts | 1 + frontend/src/i18n/locales/ko.ts | 1 + frontend/src/i18n/locales/pt-BR.ts | 1 + frontend/src/i18n/locales/ru.ts | 1 + frontend/src/i18n/locales/tr.ts | 1 + frontend/src/i18n/locales/uk.ts | 1 + frontend/src/i18n/locales/zh-CN.ts | 1 + frontend/src/i18n/locales/zh-TW.ts | 1 + static/assets/index-BjA77HfH.css | 2 - static/assets/index-De7B8wt_.js | 8761 ----------------- .../{index-CxAiFpme.js => index-QysAxcAd.js} | 58 +- static/index.html | 2 +- 21 files changed, 400 insertions(+), 8906 deletions(-) delete mode 100644 static/assets/index-BjA77HfH.css delete mode 100644 static/assets/index-De7B8wt_.js rename static/assets/{index-CxAiFpme.js => index-QysAxcAd.js} (96%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 560ce2623..6a9afb840 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index e87b653a1..18ea58d49 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -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 [] diff --git a/backend/tests/unit/services/test_bambu_mqtt.py b/backend/tests/unit/services/test_bambu_mqtt.py index 61d7d9f86..04639c059 100644 --- a/backend/tests/unit/services/test_bambu_mqtt.py +++ b/backend/tests/unit/services/test_bambu_mqtt.py @@ -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. diff --git a/frontend/src/components/KProfilesView.tsx b/frontend/src/components/KProfilesView.tsx index 7d5c15a9b..332385bf4 100644 --- a/frontend/src/components/KProfilesView.tsx +++ b/frontend/src/components/KProfilesView.tsx @@ -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 )} - {flowType} {diameter} + {[flowType, diameter].filter(Boolean).join(' ')} {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 === '' && ( + + )} @@ -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() { )} -
- -
+ {hasFlowTypeInfo && ( +
+ +
+ )}
without a
`);let s=e.getAttribute(`formaction`)||o.getAttribute(`action`);if(r=s?hn(s,t):null,n=e.getAttribute(`formmethod`)||o.getAttribute(`method`)||zr,i=Qr(e.getAttribute(`formenctype`))||Qr(o.getAttribute(`enctype`))||Br,a=new FormData(o,e),!Xr()){let{name:t,type:n,value:r}=e;if(n===`image`){let e=t?`${t}.`:``;a.append(`${e}x`,`0`),a.append(`${e}y`,`0`)}else t&&a.append(t,r)}}else if(Vr(e))throw Error(`Cannot submit element that is not ,