diff --git a/CHANGELOG.md b/CHANGELOG.md index 75d83e6cd..0e038e98d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,9 @@ All notable changes to Bambuddy will be documented in this file. - **Print Log page: per-row delete (#1687 part 1, reported by @IndividualGhost1905)** — Reporter noted that the existing "Also remove this print from Quick Stats" toggle on archive delete is one-shot: if you tick "keep stats" at delete time, there was no later way to drop the row from /stats; and rows that aren't tied to an archive (errors, aborts, manual entries) had no delete affordance at all. **Fix:** every row in the Archives → Print Log table now has a trash icon next to the filament cell, gated on `archives:delete_own` (own rows) or `archives:delete_all` (any row), matching the archive-delete permission shape. Click → confirm modal → row is gone, and because /archives/stats aggregates over `PrintLogEntry` the filament / time / cost contribution drops out of Quick Stats in the same response cycle. The matching archive (if any) is untouched — the log row is a sibling, not a child. **Backend:** new `DELETE /print-log/{entry_id}` mirrors `delete_archive`'s ownership flow via `require_ownership_permission(ARCHIVES_DELETE_ALL, ARCHIVES_DELETE_OWN)`; owners can drop their own rows, admins can drop any row, missing IDs return 404 rather than 200-silently. **Frontend:** new `deletePrintLogEntry` API helper, per-row mutation that invalidates both `print-log` and `archives-stats` query keys so the totals re-render without a manual refresh. **i18n:** 4 new keys (`deleteEntryTitle`, `deleteEntryConfirm`, `entryDeleted`, `entryDeleteFailed`) translated across all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). **Tests:** 3 backend integration cases — delete drops the row from /stats while keeping the linked archive listed, missing ID returns 404, delete-one does not touch siblings (regression guard against an accidental `delete(PrintLogEntry)` without a `where`). Frontend ArchivesPage / PrintLogModal vitests stay green (31 / 31). i18n parity green (5099 leaves × 11 locales). Issue #1687 also asks for per-row tagging (already covered by `EditArchiveModal`'s tags field) and per-row filament-usage-history edits (deferred — see the issue thread for the reasoning). ### Fixed -- **Filament Override "Original" label shows base type + generic color instead of the sliced sub-brand + actual color (#1718, reported by @SamNuttall)** — The Print Queue's filament-override panel rendered every "Original" row as `{type} ({colorName})` — just the raw 3MF `` attribute, which is always the base material ("PLA", "PETG-HF") — plus the generic color-bucket name from `getColorName(hex)`. A model sliced with "Bambu PLA Matte Charcoal" therefore showed up as "PLA (Black)" in the dropdown's original-filament option, and the schedule dialog gave no way to confirm the user was actually overriding what they thought they were. The 3MF DOES carry the Bambu SKU (`tray_info_idx`, e.g. `GFA01`) on each `` element — `backend/app/api/routes/archives.py:3634/3665` already returns it in the `/archives/{id}/filament-requirements` response — but `FilamentReqsData` at `frontend/src/components/PrintModal/types.ts:178` didn't carry the field, so `FilamentOverride.tsx` couldn't see it. The resolution path was also already in place: `_BUILTIN_FILAMENT_NAMES` at `backend/app/api/routes/cloud.py:568` maps Bambu factory SKUs (`GFA01` → "Bambu PLA Matte"), exposed as `/cloud/builtin-filaments`; `/cloud/filament-id-map` returns the same shape for user custom presets (`P*` prefix). `KProfilesView.tsx:791` already merges those two for its own labels. **Fix:** add `tray_info_idx?: string` to the `FilamentReqsData.filaments` type. `FilamentOverride` now loads both maps via `useQuery(['builtin-filaments'])` + `useQuery(['filament-id-map'])` (both shared caches the rest of the app already populates, `staleTime: 5 min`) and merges them into a single `idx → name` lookup — user cloud preset names win over the builtin entries for the same id (the user-authored label is more specific). Both the dropdown's "original" placeholder option AND the swatch tooltip use the resolved name; the raw `req.type` stays as the fallback when the SKU is unknown to both sources so unknown ids degrade to today's behaviour instead of rendering blank. Color side note: Bambu Studio's specific color names ("Charcoal") live in their cloud catalog, not in the 3MF — the file carries only the hex — so Bambuddy still renders the color from `getColorName(hex)`. "Bambu PLA Matte (Black)" is the realistic best we can do; user-readable sub-brand IS now exposed. **Color disambiguation (round 2):** the sub-brand half above is necessary but not sufficient — `getColorName(hex)` resolved through `/api/inventory/colors/map`, which collapses every catalog entry sharing a hex to a single name via "Bambu Lab > is_default > first" priority. Hex `#000000` has 9 Bambu Lab catalog entries (Black for 8 materials, Charcoal for PLA Matte) all at the same priority, so "Black" — first encountered — wins the race and "Charcoal" is dropped before the frontend ever sees it. A new endpoint `GET /api/inventory/colors/by-material?hex=X&material=Y` (`backend/app/api/routes/inventory.py:get_color_by_material`) preserves the material context: same case-insensitive hex match as `/colors/map`, then a `material` filter on top. When no entry matches the requested material it falls back to the same priority order as `/colors/map`, so callers without a material hint (or with an unknown one) get exactly the existing answer — no regression for the flat-map consumers (PrintersPage, InventoryPage). `FilamentOverride.tsx` derives a material hint from the resolved sub-brand by stripping the leading brand token ("Bambu PLA Matte" → "PLA Matte", "PolyLite ABS" → "ABS"), dispatches one `useQuery` per slot via `useQueries` keyed on `(hex, material)`, and uses `data.color_name || getColorName(hex)` so a slow query never blanks out the placeholder. Five new tests in `test_color_catalog_extras.py` pin: same hex + different material returns the correctly-paired name; unknown material falls back to priority order; missing hex returns `color_name=null` (no 404); mixed-case input on both sides matches; invalid hex (<6 chars) returns null without crashing. Three new vitest cases pin: PLA Matte Charcoal scenario lands "Bambu PLA Matte (Charcoal)", per-slot disambiguation (regression guard so a Matte slot doesn't adopt a Basic slot's answer when both share a hex), null lookup falls back to `getColorName(hex)`. **Tests overall:** 20 `FilamentOverride.test.tsx` cases green; 12 `test_color_catalog_extras.py` integration cases green; combined PrintModal + FilamentOverride + FilamentMapping suite 79/79 green. **Same fix applies to printer-mode FilamentMapping (round 3):** the schedule modal's "Specific Printer" branch renders `FilamentMapping` instead of `FilamentOverride` and was reading the same raw fields (`item.type` + generic `getColorName(item.color)`) for the required-side row and the colour swatch tooltip — so a Charcoal slice opened against a specific printer still showed "Required: PLA - Black" while the model-mode branch already read "Bambu PLA Matte - Charcoal" against the same 3MF (caught when Sam's Specific-Printer screenshot still showed the old text after round 2 shipped). Extracted the three-query resolution machinery from `FilamentOverride.tsx` into a shared hook `useFilamentLabels` in `frontend/src/components/PrintModal/useFilamentLabels.ts` so the two panels can't drift on label content; `FilamentOverride` and `FilamentMapping` now both call `useFilamentLabels(filamentReqs?.filaments)` and read positional `{ resolvedName, colorLabel }` per slot. The hook also exports the `extractMaterialHint` helper so backend material-hint test parity is mechanical (one source of truth for "strip the leading brand token"). FilamentMapping's required-side type label now reads `{resolvedName}` instead of raw `{item.type}`, and the colour swatch tooltip reads `Required: {resolvedName} - {colorLabel}` instead of `Required: {item.type} - getColorName(item.color)`. New vitest case `renders sub-brand + material-disambiguated colour on the required side (#1718)` mirrors the FilamentOverride Charcoal scenario against FilamentMapping (msw stubs for builtin-filaments + by-material). Existing FTS dropdown-filter / force-color-match cases stay green. Hook itself gets direct unit coverage in a new `useFilamentLabels.test.tsx` (11 cases — extractMaterialHint corner cases, SKU resolution, cloud-over-builtin precedence, fallbacks, positional alignment across slots with same hex but different materials, and the `enabled: !!color` query gate). The earlier "case-insensitive on both inputs" backend test (in `test_color_catalog_extras.py`) is rewritten to actually seed an upper-case stored hex and query it with lower-case input — the original version only checked invalid-hex returns null, which is the wrong assertion for the test name. Combined PrintModal + FilamentOverride + FilamentMapping + useFilamentLabels + useFilamentMapping suite 144/144 green; eslint clean, build clean. **What this fix can NOT recover:** for hexes the catalog has no entry for (third-party filament manually loaded, etc.), the color label degrades to the existing HSL-bucket name from `getColorName(hex)` — still strictly better than blank, but Bambu's specific color names only live in the seeded catalog. Frontend + backend; no migration, no new i18n keys; ruff clean, eslint clean, frontend build clean, i18n parity unchanged. +- **Virtual printer cache drained capability/lifecycle fields between pushalls, greying out Device-tab UIs (#1622 round 4, reported by @shaddowlink)** — Reporter on a P1S in archive mode saw the AMS-slot filament dropdown empty and the "Manage calibration data" UI disabled in BambuStudio's Device tab, while the same panels worked correctly on his H2D. After three rounds of triage on the printer-side payload (which traced clean — bridge passes `vt_tray` byte-identical, `tray_info_idx` resolves, AMS slots populate), the actual asymmetry surfaced in the bridge cache dumps: P1S cached `print` state contained 17 top-level keys; H2D contained 99. The missing fields were exactly the capability/lifecycle gates BambuStudio reads to decide which Device-tab UIs to enable (`cali_version`, `print_type`, `gcode_state`, `mc_print_stage`, `mc_stage`, `device`, `cfg`, `home_flag`, the `mc_*` family, fan speeds — ~80 fields). **Root cause:** Bambu firmware sends a full top-level field set in pushall responses (on `pushall` request / printer reconnect) and ~1 Hz incrementals carrying just what changed (typically temps, fan, wifi, status). `_on_printer_raw` in `mqtt_bridge.py` cached the latest push as `new_state = copy.deepcopy(print_data)` — replacing the prior cache wholesale — then re-merged only a hand-picked allowlist (`_SLICER_VISIBLE_STICKY_KEYS`) of 14 keys back from prev. The allowlist covered the #1371 / #1387 / #1228 / #1558 failure modes but missed capability/lifecycle fields entirely, so every 1 Hz incremental drained ~80 fields out of the cache and the slicer's gated UIs flipped off as soon as the cache thinned. The code comment claimed the cache "mirrors the same preservation pattern Bambuddy uses for its own internal state in bambu_mqtt.py" but it didn't: internal state is updated per-field (`if "X" in data: self.state.X = ...`), never drops what it's seen, and accumulates monotonically. **Fix:** replace the allowlist-preserve with per-field accumulate. For every key in the prior cache, carry over verbatim when the incoming push omits it; let new values overwrite when present. The `_merge_ams_dict` deep-merge for partial `ams` blobs stays (#1387 / #1371 regression guards still pass). `_SLICER_VISIBLE_STICKY_KEYS` is removed entirely — the new logic is a strict superset of every case the allowlist handled. **Why most P1S users don't hit it:** timing. The typical workflow is connect → BS issues pushall → cache fills → click Device tab within seconds → UI works. shaddowlink's sequence kept BS idle long enough between pushalls that the cache thinned to incremental-only state before he clicked. X1C users hit the same drain but don't notice — older BS capability spec doesn't gate the same UIs on `cali_version` / `mc_print_stage`. H2D escaped detection because his captures happened to land close to a pushall reply (cache still fat). **Tests:** new `test_incremental_push_preserves_non_allowlisted_capability_fields` regression case in `test_vp_mqtt_bridge.py::TestPushStatusCache` constructs a full push with `cali_version` / `print_type` / `gcode_state` / `mc_print_stage` / `mc_stage` / `device` / `cfg` / `home_flag`, follows it with a temps-only incremental, and asserts every capability field survives. All 51 existing bridge cache tests stay green — same behaviour for the allowlist subset, plus the formerly-dropped fields. Bridge code path; no migration, no new i18n keys. + + — The Print Queue's filament-override panel rendered every "Original" row as `{type} ({colorName})` — just the raw 3MF `` attribute, which is always the base material ("PLA", "PETG-HF") — plus the generic color-bucket name from `getColorName(hex)`. A model sliced with "Bambu PLA Matte Charcoal" therefore showed up as "PLA (Black)" in the dropdown's original-filament option, and the schedule dialog gave no way to confirm the user was actually overriding what they thought they were. The 3MF DOES carry the Bambu SKU (`tray_info_idx`, e.g. `GFA01`) on each `` element — `backend/app/api/routes/archives.py:3634/3665` already returns it in the `/archives/{id}/filament-requirements` response — but `FilamentReqsData` at `frontend/src/components/PrintModal/types.ts:178` didn't carry the field, so `FilamentOverride.tsx` couldn't see it. The resolution path was also already in place: `_BUILTIN_FILAMENT_NAMES` at `backend/app/api/routes/cloud.py:568` maps Bambu factory SKUs (`GFA01` → "Bambu PLA Matte"), exposed as `/cloud/builtin-filaments`; `/cloud/filament-id-map` returns the same shape for user custom presets (`P*` prefix). `KProfilesView.tsx:791` already merges those two for its own labels. **Fix:** add `tray_info_idx?: string` to the `FilamentReqsData.filaments` type. `FilamentOverride` now loads both maps via `useQuery(['builtin-filaments'])` + `useQuery(['filament-id-map'])` (both shared caches the rest of the app already populates, `staleTime: 5 min`) and merges them into a single `idx → name` lookup — user cloud preset names win over the builtin entries for the same id (the user-authored label is more specific). Both the dropdown's "original" placeholder option AND the swatch tooltip use the resolved name; the raw `req.type` stays as the fallback when the SKU is unknown to both sources so unknown ids degrade to today's behaviour instead of rendering blank. Color side note: Bambu Studio's specific color names ("Charcoal") live in their cloud catalog, not in the 3MF — the file carries only the hex — so Bambuddy still renders the color from `getColorName(hex)`. "Bambu PLA Matte (Black)" is the realistic best we can do; user-readable sub-brand IS now exposed. **Color disambiguation (round 2):** the sub-brand half above is necessary but not sufficient — `getColorName(hex)` resolved through `/api/inventory/colors/map`, which collapses every catalog entry sharing a hex to a single name via "Bambu Lab > is_default > first" priority. Hex `#000000` has 9 Bambu Lab catalog entries (Black for 8 materials, Charcoal for PLA Matte) all at the same priority, so "Black" — first encountered — wins the race and "Charcoal" is dropped before the frontend ever sees it. A new endpoint `GET /api/inventory/colors/by-material?hex=X&material=Y` (`backend/app/api/routes/inventory.py:get_color_by_material`) preserves the material context: same case-insensitive hex match as `/colors/map`, then a `material` filter on top. When no entry matches the requested material it falls back to the same priority order as `/colors/map`, so callers without a material hint (or with an unknown one) get exactly the existing answer — no regression for the flat-map consumers (PrintersPage, InventoryPage). `FilamentOverride.tsx` derives a material hint from the resolved sub-brand by stripping the leading brand token ("Bambu PLA Matte" → "PLA Matte", "PolyLite ABS" → "ABS"), dispatches one `useQuery` per slot via `useQueries` keyed on `(hex, material)`, and uses `data.color_name || getColorName(hex)` so a slow query never blanks out the placeholder. Five new tests in `test_color_catalog_extras.py` pin: same hex + different material returns the correctly-paired name; unknown material falls back to priority order; missing hex returns `color_name=null` (no 404); mixed-case input on both sides matches; invalid hex (<6 chars) returns null without crashing. Three new vitest cases pin: PLA Matte Charcoal scenario lands "Bambu PLA Matte (Charcoal)", per-slot disambiguation (regression guard so a Matte slot doesn't adopt a Basic slot's answer when both share a hex), null lookup falls back to `getColorName(hex)`. **Tests overall:** 20 `FilamentOverride.test.tsx` cases green; 12 `test_color_catalog_extras.py` integration cases green; combined PrintModal + FilamentOverride + FilamentMapping suite 79/79 green. **Same fix applies to printer-mode FilamentMapping (round 3):** the schedule modal's "Specific Printer" branch renders `FilamentMapping` instead of `FilamentOverride` and was reading the same raw fields (`item.type` + generic `getColorName(item.color)`) for the required-side row and the colour swatch tooltip — so a Charcoal slice opened against a specific printer still showed "Required: PLA - Black" while the model-mode branch already read "Bambu PLA Matte - Charcoal" against the same 3MF (caught when Sam's Specific-Printer screenshot still showed the old text after round 2 shipped). Extracted the three-query resolution machinery from `FilamentOverride.tsx` into a shared hook `useFilamentLabels` in `frontend/src/components/PrintModal/useFilamentLabels.ts` so the two panels can't drift on label content; `FilamentOverride` and `FilamentMapping` now both call `useFilamentLabels(filamentReqs?.filaments)` and read positional `{ resolvedName, colorLabel }` per slot. The hook also exports the `extractMaterialHint` helper so backend material-hint test parity is mechanical (one source of truth for "strip the leading brand token"). FilamentMapping's required-side type label now reads `{resolvedName}` instead of raw `{item.type}`, and the colour swatch tooltip reads `Required: {resolvedName} - {colorLabel}` instead of `Required: {item.type} - getColorName(item.color)`. New vitest case `renders sub-brand + material-disambiguated colour on the required side (#1718)` mirrors the FilamentOverride Charcoal scenario against FilamentMapping (msw stubs for builtin-filaments + by-material). Existing FTS dropdown-filter / force-color-match cases stay green. Hook itself gets direct unit coverage in a new `useFilamentLabels.test.tsx` (11 cases — extractMaterialHint corner cases, SKU resolution, cloud-over-builtin precedence, fallbacks, positional alignment across slots with same hex but different materials, and the `enabled: !!color` query gate). The earlier "case-insensitive on both inputs" backend test (in `test_color_catalog_extras.py`) is rewritten to actually seed an upper-case stored hex and query it with lower-case input — the original version only checked invalid-hex returns null, which is the wrong assertion for the test name. Combined PrintModal + FilamentOverride + FilamentMapping + useFilamentLabels + useFilamentMapping suite 144/144 green; eslint clean, build clean. **What this fix can NOT recover:** for hexes the catalog has no entry for (third-party filament manually loaded, etc.), the color label degrades to the existing HSL-bucket name from `getColorName(hex)` — still strictly better than blank, but Bambu's specific color names only live in the seeded catalog. Frontend + backend; no migration, no new i18n keys; ruff clean, eslint clean, frontend build clean, i18n parity unchanged. - **Force-color-match checkbox missing when scheduling against a specific printer (#1717, reported by @SamNuttall)** — The Print Queue's schedule dialog hides the per-slot "Force color match" checkbox in the "Specific printer" path. Picking "Any A1" (model-mode dispatch) renders `FilamentOverride` which carries the checkbox, but picking a single printer renders `FilamentMapping` instead — a separate component that had no force-match UI. The dispatcher in `print_scheduler.py:535` already honours `force_color_match` regardless of how the queue item was created (the flag survives end-to-end on the `filament_overrides` payload `buildFilamentOverridesArray` constructs in `PrintModal/index.tsx:613`), so this was a pure UI gap — printer-mode users could not request the safety guard from the modal even though the backend would have respected it. **Fix:** `FilamentMapping` accepts new optional `forceColorMatch` + `onForceColorMatchChange` props mirroring `FilamentOverride`'s shape; it renders the same ``-iconed checkbox under each filament row when a handler is provided. `PrintModal/index.tsx:1100` passes the existing `forceColorMatch` state and a `setForceColorMatch` setter through — same state object both modes write into, so toggling between modes preserves what the user selected. No new i18n keys (the existing `printModal.forceColorMatch` key already ships in all 11 locales). The checkbox is suppressed when no handler is wired (avoids dead UI in callers that don't manage the flag). **Tests:** new `renders the per-slot force-color-match checkbox in printer mode (#1717)` case clicks the checkbox and asserts `onForceColorMatchChange(slotId, true)` fires; companion `omits the force-color-match checkbox when no handler is provided` case pins the absent-handler branch. Existing FTS dropdown-filter tests stay green. `FilamentMapping.test.tsx` 4/4 green; combined PrintModal + FilamentOverride + FilamentMapping suite 73/73 green; eslint clean; frontend build clean; i18n parity 5120 leaves × 11 locales green. diff --git a/backend/app/services/virtual_printer/mqtt_bridge.py b/backend/app/services/virtual_printer/mqtt_bridge.py index 38fc37cf1..29efc5fe8 100644 --- a/backend/app/services/virtual_printer/mqtt_bridge.py +++ b/backend/app/services/virtual_printer/mqtt_bridge.py @@ -52,38 +52,18 @@ logger = logging.getLogger(__name__) REFRESH_INTERVAL_SECONDS = 30.0 -# Top-level push_status fields that Bambu firmware sends in FULL pushall -# responses (on `pushall` request / printer reconnect) but typically OMITS -# from 1 Hz incremental push_status updates. Without preserving these -# fields across incremental updates, the bridge cache would lose AMS info -# (and friends) between pushalls — slicers reading the cache would see a -# stripped-down state and the fix would only re-appear on a manual printer -# power-cycle (#1371). Mirrors the same set Bambuddy itself preserves in -# bambu_mqtt.py:2686-2711 for its own internal raw_data, with a few more -# entries that the slicer cares about (net, ipcam, lights_report). -_SLICER_VISIBLE_STICKY_KEYS: tuple[str, ...] = ( - "ams", - "vt_tray", - "ams_extruder_map", - "mapping", - "net", - "ipcam", - "lights_report", - # Pre-flight / Prepare-tab fields that BambuStudio reads off cached - # push_status. Bambu firmware emits them in full pushall but typically - # OMITS them from 1 Hz incremental updates, so without sticky-preservation - # the cache drops them after the very next tick and the slicer's - # "block Send while busy / unknown firmware" branch kicks in. Same shape - # as #1228 (storage indicators) and #1558 (live-progress fields) — - # cached-branch field-shape parity, not a new mechanism. - "upgrade_state", # Send pre-flight reads dis_state / force_upgrade - "xcam", # Prepare-tab reads spaghetti / first-layer / halt sensitivity - "hw_switch_state", # Hardware switch state (Prepare tab) - "nozzle_diameter", - "nozzle_type", - "online", # Module online map (ahb / rfid / version) - "ams_status", # AMS overall status; can be ams_status-only incremental -) +# Bambuddy's internal printer state in bambu_mqtt.py (around line 2686+) is +# updated per-field — each `if "X" in data: self.state.X = ...` block leaves +# every other field untouched, so the state accumulates everything the +# printer has ever sent. The bridge cache below mirrors that pattern: when +# the incoming push_status omits a field, the previous value is preserved +# verbatim; only fields actually present in the new push overwrite. This +# stops capability/lifecycle fields (cali_version, print_type, mc_print_stage, +# device, ...) draining out of the cache between pushalls, which surfaced +# as #1622 (BambuStudio's Device-tab UIs greying out on P1S after the +# cache drained to a thin incremental snapshot). The `ams` field still +# gets unit-/tray-level deep merge via `_merge_ams_dict` because firmware +# sends partial `ams` blobs under the same key (#1387). def _ip_to_uint32_le(ip_str: str) -> int: @@ -602,39 +582,32 @@ class MQTTBridge: new_state = copy.deepcopy(print_data) # Bambu firmware sends two kinds of push_status: full pushall # responses (on `pushall` requests / printer reconnect) which - # include AMS, vt_tray, net, etc. — and ~1 Hz incremental - # updates with just the fields that changed (typically temps, - # fan, wifi). Without preserving sticky fields from the previous - # cache, the first incremental push after a pushall would wipe - # AMS info from the bridge cache, and slicers reading the cache - # between pushalls would see a stripped-down printer state with - # no AMS visible until the next pushall — typically only when - # the user power-cycles the printer (#1371). Mirror the same - # preservation pattern Bambuddy uses for its own internal state - # in bambu_mqtt.py (see _SLICER_VISIBLE_STICKY_KEYS below). + # include the full top-level field set (AMS, vt_tray, net, + # cali_version, print_type, mc_print_stage, device, ...) — and + # ~1 Hz incrementals with just the fields that changed (temps, + # fan, wifi, status). Carry over every prev field the incoming + # push doesn't overwrite, mirroring the per-field accumulate + # pattern in bambu_mqtt.py's internal state handler — without + # this the cache thins out to whatever the latest incremental + # carried (~17 keys on P1S in #1622), and the slicer's Device- + # tab capability gates (manage-calibration, AMS-assign dropdown, + # …) flip off because their gating fields drained from the + # cache. The deep-copy is defensive: without it the carried- + # over nested dicts/lists are shared with the previous cache, + # so any in-place mutation later would corrupt both. prev = self._latest_print_state if prev is not None: - for sticky_key in _SLICER_VISIBLE_STICKY_KEYS: - if sticky_key not in new_state: - if sticky_key in prev: - # Defensive deep copy — without this the carried-over - # nested dicts/lists are shared between new_state and - # the previous cache, so any in-place mutation later - # (current or future code paths) would corrupt both. - new_state[sticky_key] = copy.deepcopy(prev[sticky_key]) - continue - # Key IS in new_state — but firmware sends partial blobs - # (status-only / tray-targeted) under the same key on - # incremental updates, which would overwrite the cached - # full blob and break the slicer's AMS render (#1387). - # For `ams` specifically the deep-merge mirrors what - # Bambuddy already does internally in `_handle_ams_data`. - if ( - sticky_key == "ams" - and isinstance(new_state.get("ams"), dict) - and isinstance(prev.get("ams"), dict) - ): - new_state["ams"] = _merge_ams_dict(prev["ams"], new_state["ams"]) + for prev_key, prev_value in prev.items(): + if prev_key not in new_state: + new_state[prev_key] = copy.deepcopy(prev_value) + # Firmware sends partial `ams` blobs (status-only / unit- + # targeted / tray-targeted) under the same key on + # incremental updates, which would overwrite the cached + # full blob and break the slicer's AMS render (#1387 / + # #1371). Deep-merge mirrors what bambu_mqtt.py does + # internally in `_handle_ams_data`. + if isinstance(new_state.get("ams"), dict) and isinstance(prev.get("ams"), dict): + new_state["ams"] = _merge_ams_dict(prev["ams"], new_state["ams"]) self._latest_print_state = new_state dump_wire(self.vp_name, "in", new_state) return diff --git a/backend/tests/unit/test_vp_mqtt_bridge.py b/backend/tests/unit/test_vp_mqtt_bridge.py index 5e6ac13b1..1cef1a80a 100644 --- a/backend/tests/unit/test_vp_mqtt_bridge.py +++ b/backend/tests/unit/test_vp_mqtt_bridge.py @@ -402,6 +402,73 @@ class TestPushStatusCache: await bridge.stop() + @pytest.mark.asyncio + async def test_incremental_push_preserves_non_allowlisted_capability_fields(self): + """Regression for #1622: BambuStudio gates Device-tab UIs (manage + calibration, AMS-slot filament dropdown, ...) on capability / + lifecycle fields (cali_version, print_type, mc_print_stage, + device, ...) it reads off the cached push_status. Before the fix + these fields were not in the allowlist and drained out of the + bridge cache on the first 1 Hz incremental tick, so the slicer's + Device tab would grey out the gated UIs once the cache thinned. + After the fix the cache accumulates everything the printer has + ever sent, dropped only when explicitly overwritten. + """ + server = _make_server() + bridge = _make_bridge(server) + await bridge.start() + + full_push = json.dumps( + { + "print": { + "command": "push_status", + "cali_version": 2, + "print_type": "idle", + "gcode_state": "IDLE", + "mc_print_stage": "0", + "mc_stage": 0, + "device": {"ext_tool": {"info": []}}, + "cfg": "", + "home_flag": 256, + "wifi_signal": "-50dBm", + } + } + ).encode() + bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", full_push) + await asyncio.sleep(0.01) + + # Incremental push carrying only temps + wifi — none of the + # capability/lifecycle fields above are mentioned. + incremental_push = json.dumps( + { + "print": { + "command": "push_status", + "wifi_signal": "-55dBm", + "nozzle_temper": 24.5, + } + } + ).encode() + bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", incremental_push) + await asyncio.sleep(0.01) + + cached = bridge.get_latest_print_state() + # Incremental values applied. + assert cached["wifi_signal"] == "-55dBm" + assert cached["nozzle_temper"] == 24.5 + # Capability / lifecycle fields preserved from the prior pushall + # — the symptoms in #1622 (Device-tab UIs disabled) trace to these + # exact keys missing. + assert cached["cali_version"] == 2 + assert cached["print_type"] == "idle" + assert cached["gcode_state"] == "IDLE" + assert cached["mc_print_stage"] == "0" + assert cached["mc_stage"] == 0 + assert cached["device"] == {"ext_tool": {"info": []}} + assert cached["cfg"] == "" + assert cached["home_flag"] == 256 + + await bridge.stop() + @pytest.mark.asyncio async def test_partial_ams_status_update_preserves_unit_list(self): """#1387: Bambu firmware also sends `ams` updates where the key is