From 1234f0830a4452207b80dd19924ff32e753d5457 Mon Sep 17 00:00:00 2001 From: maziggy Date: Mon, 27 Jul 2026 09:15:34 +0200 Subject: [PATCH] fix(mqtt): don't let K-profile responses clobber the nozzle size (#2663) Fetching K-profiles probes every nozzle size in turn (extrusion_cali_get for 0.2/0.4/0.6/0.8mm), and each response echoes the requested diameter at the top level. _process_message passed every "print" message -- including these responses -- to _update_state, which treats a top-level nozzle_diameter as the installed hardware. So the last size probed (0.8) overwrote the real nozzle in memory; a genuine status push corrected it and the next K-profile fetch broke it again, which is why it flickered between 0.8, empty and the correct 0.4. Since 1.2.5 the #1899 mismatch guard then refused to dispatch, failing prints with a bogus "printer has 0.8mm". Handle extrusion_cali_get responses only via the K-profile parser and skip _update_state for them, mirroring the existing get_accessories guard. The nozzle size now comes solely from the real status push. In-memory only -- affected printers self-correct on the next push after updating. --- CHANGELOG.md | 1 + backend/app/services/bambu_mqtt.py | 13 +++- .../tests/unit/services/test_bambu_mqtt.py | 69 +++++++++++++++++++ 3 files changed, 81 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25a4b7a06..c000452bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file. ## [1.2.6b1] - Unreleased ### Fixed +- **A printer's nozzle size got overwritten to the wrong value (often 0.8mm), then blocked prints as a nozzle mismatch (#2663, reporter @huykent)** — A1 printers with a 0.4mm nozzle intermittently showed **0.8mm** (or no size at all) on the dashboard, and since 1.2.5 that wrong value made the nozzle-mismatch guard (#1899) refuse to dispatch the job — "File sliced for a 0.4mm nozzle, but the printer has 0.8mm installed." It was intermittent and could flip *after* a job was sent. **Root cause.** Bambuddy fetches K-profiles by probing every nozzle size in turn — it sends an `extrusion_cali_get` request for 0.2, 0.4, 0.6 **and** 0.8mm. The printer's response to each echoes the *requested* nozzle diameter at the top level, and the MQTT handler passed every `print` message — including these K-profile responses — through `_update_state`, which treats a top-level `nozzle_diameter` as the installed hardware. So the last size probed (0.8) clobbered the real nozzle size in memory; a later genuine status push would correct it, and the next K-profile fetch would break it again, which is why it flickered and "changed after the job was sent." The raw MQTT status always reported the correct 0.4 — only the derived hardware-nozzle field was corrupted. **Fix.** `extrusion_cali_get` responses are now handled *only* by the K-profile parser and no longer fed to `_update_state`, so they can't touch the nozzle hardware state — mirroring the existing guard that already stops `get_accessories` responses from doing the same thing. The installed nozzle size now comes solely from the printer's real status push, where it was always correct. No configuration or migration needed: the value lives in memory and self-corrects on the next status push after updating. Covered by tests: a 0.8mm K-profile response leaves a 0.4mm nozzle untouched, the response's profiles are still parsed into `state.kprofiles`, and a genuine status push still sets (and corrects) the nozzle. - **The print queue couldn't be reordered on a phone, and the reorder controls were invisible in portrait (#2667, reporter @aporlebeke)** — On mobile there was no way to reorder the queue: in portrait the reorder controls simply weren't visible, and even in landscape (where the desktop drag handle appears) touch-dragging didn't move anything. **Root cause.** The drag grip and selection checkbox on every pending row are `hidden sm:flex`, so below the 640px breakpoint (phone portrait) they disappear entirely — there's no affordance to grab. Above it (landscape phone/tablet) the grip shows, but it carried `touch-action: manipulation` and the only drag sensor is dnd-kit's `PointerSensor` with an 8px activation distance, so on touch the browser claimed the vertical gesture as a scroll before the drag ever started. The whole reorder mechanism was effectively mouse-only. **Fix.** Pending rows now get tap-friendly **up/down arrow buttons** on mobile (the "arrow select" the reporter asked for), shown below `sm` where the drag handle is hidden. They move a row one step among its siblings — standalone items, whole batches, and items within a batch, in both the flat and per-printer layouts — and persist through the same `POST /queue/reorder` path as drag, so arrows and drag agree. Arrows appear only in the manual "position" sort (with shortest-job-first off), where a position actually has meaning, and are gated on the same `queue:reorder` permission; the up arrow on the first row and the down arrow on the last are shown disabled. Separately, the desktop drag handle's `touch-action` is now `none`, so mouse-style drag also works on touch (landscape phones, tablets). Reuses the existing `queue.moveUp` / `queue.moveDown` translations (already present in all locales). Covered by tests: the controls render for pending items, moving the first item down persists the swapped order, and the boundary arrows are disabled. - **3D Preview plate thumbnails were broken (401) in File Manager when login was enabled (#2661, reporter @fbordonaro)** — Opening a multi-plate 3MF via **File Manager → 3D Preview** showed broken-image icons for every plate thumbnail, and the network tab showed `GET /api/v1/library/files//plate-thumbnail/` returning **401 "Valid camera stream token required."** The Slice dialog displayed the same file's thumbnails correctly, which is what made it look inconsistent. **Root cause.** The plate-thumbnail endpoints (both archive and library) are gated behind a **camera stream token** passed as a `?token=` query param, because an `` tag can't send an `Authorization: Bearer` header. Every place that renders these thumbnails is supposed to append the token via the `withStreamToken()` helper — `PlatePickerModal` (the Slice dialog's multi-plate picker) and the Print modal's `PlateSelector` both do — but the **3D Preview dialog** (`ModelViewerModal`) rendered the raw `thumbnail_url` with no token, so with auth enabled the browser fetched without one and got a 401. **Fix.** `ModelViewerModal` now wraps the plate thumbnail `src` in `withStreamToken()`, matching the two existing call sites. The token is already synced app-wide (the same global the working pickers read), and `withStreamToken()` is a no-op when auth is off, so nothing changes for non-auth setups. Covered by a component test asserting the plate thumbnail `` carries the `?token=` query param. - **Force color match dispatched a print onto the wrong PLA variant — Matte jobs went to Basic and Silk printers alike, and the wrong AMS slot on a printer holding two same-colour variants (#2650, reporter @MartinNYHC)** — With **Force color match** on, a job sliced for **White PLA Matte** was dispatched to every printer that had *any* white PLA loaded — the ones holding White PLA **Basic** and White PLA **Silk+** included — so a matte model came out glossy on the wrong machine. **Root cause.** Bambu's MQTT status reports every PLA sub-variant as `tray_type == "PLA"`; the Basic/Matte/Silk distinction is carried only in `tray_info_idx` (`GFA00` = Basic, `GFA01` = Matte, `GFA06` = Silk, …), which the 3MF's `slice_info.config` also records per filament. Three places dropped it: the Virtual-Printer queue built each force override as `{slot_id, type, color, force_color_match}` without the parsed `tray_info_idx`; the scheduler's eligibility check (`_get_missing_force_color_slots`) compared loaded trays on `(type, colour)` only — so `(PLA, #FFFFFF)` matched Basic, Matte and Silk indiscriminately and all three printers looked eligible; and the AMS slot mapper cleared `tray_info_idx` when applying the override, so even on the correct printer it could pick a different-variant tray of the same colour. **Fix.** The force override now carries the 3MF's `tray_info_idx`; a slot counts as satisfied only when a loaded tray matches type **and** colour **and** the variant (identical `tray_info_idx`, *or* either side lacks one); and the slot mapper now keeps the variant for force-colour overrides so it pins the matching tray. A blank idx on either side (custom/third-party spools report none, and older 3MFs carry none) falls back to the historical type+colour behaviour, so those setups are unaffected, and a manual filament *swap* (a preference override) still clears the idx so it matches the swapped-in spool rather than the old one. A job sliced for GFA01 now goes only to a printer with GFA01 loaded, and lands on that printer's GFA01 tray. The printer-card queue-compatibility hint (which printers show a pending job as runnable) now applies the same variant rule. Covered by scheduler tests (Matte requirement rejects Basic/Silk, accepts Matte, blank loaded idx falls back, requirement without an idx unchanged; the mapper pins the GFA01 tray over a same-colour GFA00 on both the 3MF and no-3MF paths; a preference swap still matches by colour), a Virtual-Printer test asserting the override carries `tray_info_idx`, and frontend tests for the variant-aware queue hint (rejects other variants, accepts the match, blank-idx and no-variant-data fall back). diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index 7f73c19f5..142d9991d 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -1427,10 +1427,19 @@ class BambuMQTTClient: elif cmd == "ams_filament_setting": self._last_ams_cmd_time = 0.0 self._ams_cmd_unanswered = 0 - if "command" in print_data and print_data.get("command") == "extrusion_cali_get": + is_kprofile_response = "command" in print_data and print_data.get("command") == "extrusion_cali_get" + if is_kprofile_response: self._handle_kprofile_response(print_data) - self._update_state(print_data) + # An extrusion_cali_get response echoes the *requested* nozzle + # diameter (get_kprofiles probes 0.2/0.4/0.6/0.8 in turn), not the + # installed hardware. Feeding it to _update_state clobbered the real + # nozzle size (#2663) — typically leaving 0.8, the last size probed, + # which then failed the #1899 dispatch guard. The response carries no + # status telemetry, so skip it; the true nozzle comes from pushall. + # (Same reasoning as get_accessories in _handle_system_response.) + if not is_kprofile_response: + self._update_state(print_data) def _handle_system_response(self, data: dict): """Handle system responses including accessories info. diff --git a/backend/tests/unit/services/test_bambu_mqtt.py b/backend/tests/unit/services/test_bambu_mqtt.py index 13d9b1103..6110e2404 100644 --- a/backend/tests/unit/services/test_bambu_mqtt.py +++ b/backend/tests/unit/services/test_bambu_mqtt.py @@ -6468,3 +6468,72 @@ class TestPresumedPowerOffRecovery: self._report(mqtt_client, {"print": {"wifi_signal": "-30dBm"}}) assert mqtt_client.state.state == "FINISH" + + +class TestKProfileResponseDoesNotClobberNozzle: + """#2663: the K-profile fetch (get_kprofiles) probes every nozzle size + 0.2/0.4/0.6/0.8 with an ``extrusion_cali_get`` request. Each response + echoes the *requested* nozzle_diameter at the top level, which is NOT the + installed hardware. _process_message must not feed those responses to + _update_state, or the real nozzle size gets overwritten (typically to 0.8, + the last size probed) and the #1899 dispatch guard then blocks prints. + """ + + @pytest.fixture + def mqtt_client(self): + from backend.app.services.bambu_mqtt import BambuMQTTClient + + return BambuMQTTClient( + ip_address="192.168.1.100", + serial_number="A1TEST", + access_code="12345678", + ) + + def test_kprofile_response_does_not_overwrite_nozzle_diameter(self, mqtt_client): + # A genuine pushall reports the real 0.4mm nozzle. + mqtt_client._process_message({"print": {"nozzle_diameter": "0.4"}}) + assert mqtt_client.state.nozzles[0].nozzle_diameter == "0.4" + + # The K-profile probe's 0.8mm response arrives (as it did on the + # reporter's A1s). It must NOT clobber the hardware nozzle. + mqtt_client._process_message( + { + "print": { + "command": "extrusion_cali_get", + "nozzle_diameter": "0.8", + "filaments": [], + "sequence_id": "1501", + } + } + ) + assert mqtt_client.state.nozzles[0].nozzle_diameter == "0.4" + + def test_kprofile_response_is_still_parsed(self, mqtt_client): + # Skipping _update_state must not skip the K-profile handler: the + # response's profiles still populate state.kprofiles. + mqtt_client._process_message( + { + "print": { + "command": "extrusion_cali_get", + "nozzle_diameter": "0.4", + "filaments": [ + { + "cali_idx": 0, + "nozzle_diameter": "0.4", + "filament_id": "GFA00", + "name": "PLA", + "k_value": "0.020000", + } + ], + } + } + ) + assert len(mqtt_client.state.kprofiles) == 1 + assert mqtt_client.state.kprofiles[0].filament_id == "GFA00" + + def test_genuine_pushall_still_updates_nozzle(self, mqtt_client): + # The one legitimate source of the hardware nozzle still works, and a + # later pushall corrects a value an old build left wrong. + mqtt_client.state.nozzles[0].nozzle_diameter = "0.8" + mqtt_client._process_message({"print": {"nozzle_diameter": "0.4"}}) + assert mqtt_client.state.nozzles[0].nozzle_diameter == "0.4"