From c9efa4b8bb1163ace1c8d52ba899a1f45129b5e1 Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 24 Mar 2026 16:03:34 +0100 Subject: [PATCH] Fix stale AMS slot data and empty slot button visibility (#784) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some printers (e.g. H2D) only send {id, state} in incremental MQTT tray updates — no tray_type, tray_color, or other fields. When filament is unloaded (state changes from 11 to 10), the old tray data persisted indefinitely because the merge logic only updates fields present in the incoming message. Backend: - Detect tray state != 11 without tray_type in incremental updates and clear stale tray data (bambu_mqtt.py) - Expose tray `state` field via REST API and WebSocket broadcasts (printer.py schema, printers.py route, printer_manager.py) - Include AMS tray content in WebSocket dedup key so load/unload transitions trigger broadcasts (main.py) Frontend: - Add `state` to AMSTray interface (client.ts) - Show configure/assign buttons for state=10 (spool present, not loaded) but hide for state=9 (truly empty) on AMS/HT slots - Hide fill level bar on empty slots Tests: - 5 new tests covering state-based clearing, preservation, reload, and idempotency --- CHANGELOG.md | 2 +- backend/app/api/routes/printers.py | 1 + backend/app/main.py | 14 +- backend/app/schemas/printer.py | 1 + backend/app/services/bambu_mqtt.py | 38 + backend/app/services/printer_manager.py | 1 + .../tests/unit/services/test_bambu_mqtt.py | 201 ++++ frontend/src/api/client.ts | 1 + .../components/spoolbuddy/DiagnosticModal.tsx | 6 +- frontend/src/i18n/locales/de.ts | 13 + frontend/src/pages/PrintersPage.tsx | 52 +- .../{index-CKvhQbq-.js => index-B2oRXWjB.js} | 855 +++++++++--------- static/assets/index-Dzv7xI7m.css | 1 - static/assets/index-_-nC2GSJ.css | 1 + static/index.html | 4 +- 15 files changed, 751 insertions(+), 440 deletions(-) rename static/assets/{index-CKvhQbq-.js => index-B2oRXWjB.js} (71%) delete mode 100644 static/assets/index-Dzv7xI7m.css create mode 100644 static/assets/index-_-nC2GSJ.css diff --git a/CHANGELOG.md b/CHANGELOG.md index dab9a952d..65c1355f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ All notable changes to Bambuddy will be documented in this file. - **SpoolBuddy Kiosk Starts Before Network Is Ready** — On fresh installs, the kiosk browser launched before the network was fully up, showing a connection error for 10-15 seconds until connectivity was restored. The getty@tty1 autologin override now waits for `network-online.target` so Chromium has connectivity when it starts. - **SpoolBuddy Update UI Stale After Restart** — After a SpoolBuddy update, the UI permanently showed the old version and "update available" because: (1) the SSH update set status to `"complete"` after the daemon had already re-registered, overwriting the cleared state; (2) the kiosk restart navigated away from the updates page; (3) query cache served stale data. Fixed by letting daemon re-registration clear all update status, removing the kiosk restart in favor of a frontend-driven `window.location.reload()` triggered via WebSocket when the daemon comes back online, and adding proper loading states to Check/Force Update buttons. - **Virtual Printer Proxy A1 Printing Fails** ([#757](https://github.com/maziggy/bambuddy/issues/757)) — BambuStudio could not send prints to A1 (and potentially P1S) virtual printers in proxy mode. The slicer connects to undocumented proprietary ports 2024-2026 on these models, which the proxy was not forwarding, causing BambuStudio to show an access code dialog instead of printing. Added transparent TCP pass-through proxying for ports 2024-2026. These ports are silently ignored on models that don't use them (X1C, H2C, P2S). Reported by @Utility9298. -- **Spool Assignment on Empty AMS Slots** ([#784](https://github.com/maziggy/bambuddy/issues/784)) — Empty AMS slots (no physical spool detected) showed "Assign Spool" and "Configure" buttons in the hover popup. Assigning a spool to an empty slot created a stuck state because no "Unassign" button is available for empty slots. Removed both buttons from empty AMS and HT AMS slot popups. External spool holders are unaffected. Reported by @RosdasHH. +- **Spool Assignment on Empty AMS Slots** ([#784](https://github.com/maziggy/bambuddy/issues/784)) — Empty AMS slots (no physical spool detected) showed "Assign Spool" and "Configure" buttons in the hover popup. Assigning a spool to an empty slot created a stuck state because no "Unassign" button is available for empty slots. Truly empty slots now hide both buttons, while slots with a spool inserted but filament not loaded still show configure/assign. Also fixed stale AMS slot data on H2D and other printers that only send `{id, state}` in incremental MQTT updates — filament load/unload transitions now update in real-time without requiring a reconnect. Reported by @RosdasHH. - **Log Flood: "State is FINISH but completion NOT triggered"** ([#790](https://github.com/maziggy/bambuddy/issues/790)) — A diagnostic log message introduced in 0.2.2.1 fired on every MQTT update while a printer sat in FINISH or FAILED state, flooding logs with thousands of lines per minute in printer farms. Fixed by only logging once on the initial state transition. Reported by @user. - **ffmpeg Process Leak Causing Memory Growth** ([#776](https://github.com/maziggy/bambuddy/issues/776)) — Camera stream ffmpeg processes accumulated over time, consuming several GB of RAM. When a user closed the camera viewer, the frontend sent a stop signal that killed the ffmpeg process, but the backend stream generator interpreted the dead process as a dropped connection and respawned ffmpeg — up to 30 reconnection attempts per stream. The orphan cleanup couldn't catch these because they were tracked as "active". Fixed by signaling the generator's disconnect event from the stop endpoint before killing the process, checking for stream removal before reconnecting, and tracking frame timestamps per-stream instead of per-printer so stale detection works correctly when multiple streams exist. Reported by @ChrisTheDBA, confirmed by @peter-k-de. diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index d80f96df2..8337de0d1 100644 --- a/backend/app/api/routes/printers.py +++ b/backend/app/api/routes/printers.py @@ -418,6 +418,7 @@ async def get_printer_status( nozzle_temp_max=tray_data.get("nozzle_temp_max"), drying_temp=tray_data.get("drying_temp"), drying_time=tray_data.get("drying_time"), + state=tray_data.get("state"), ) ) # Prefer humidity_raw (percentage) over humidity (index 1-5) diff --git a/backend/app/main.py b/backend/app/main.py index da2de2e5a..6a9936201 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -428,15 +428,25 @@ async def on_printer_status_change(printer_id: int, state: PrinterState): # Include tray_now and vt_tray hash so external spool changes trigger broadcasts vt_tray_key = hash(str(state.raw_data.get("vt_tray", []))) if state.raw_data else 0 - # Include AMS dry_time values so drying status changes trigger broadcasts + # Include AMS dry_time and tray state values so drying/slot changes trigger broadcasts ams_dry_key = tuple(a.get("dry_time", 0) for a in (state.raw_data.get("ams") or [])) if state.raw_data else () + # Include tray states so load/unload transitions (state 11→10) trigger broadcasts (#784) + ams_tray_key = ( + tuple( + (t.get("id"), t.get("tray_type", ""), t.get("state")) + for a in (state.raw_data.get("ams") or []) + for t in a.get("tray", []) + ) + if state.raw_data + else () + ) status_key = ( f"{state.connected}:{state.state}:{state.progress}:{state.layer_num}:" f"{nozzle_temp}:{bed_temp}:{nozzle_2_temp}:{chamber_temp}:" f"{state.stg_cur}:{bed_target}:{nozzle_target}:" f"{state.cooling_fan_speed}:{state.big_fan1_speed}:{state.big_fan2_speed}:" f"{state.chamber_light}:{state.active_extruder}:{state.tray_now}:{vt_tray_key}:" - f"{ams_dry_key}" + f"{ams_dry_key}:{ams_tray_key}" ) # MQTT relay - publish status (before dedup check - always publish to MQTT) diff --git a/backend/app/schemas/printer.py b/backend/app/schemas/printer.py index 38c384c26..19adaacc1 100644 --- a/backend/app/schemas/printer.py +++ b/backend/app/schemas/printer.py @@ -136,6 +136,7 @@ class AMSTray(BaseModel): nozzle_temp_max: int | None = None # Max nozzle temperature drying_temp: int | None = None # RFID-recommended drying temp drying_time: int | None = None # RFID-recommended drying time (hours) + state: int | None = None # AMS tray state: 9=empty, 10=spool present not loaded, 11=loaded class AMSUnit(BaseModel): diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index f207e12e5..d96524cd8 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -1358,6 +1358,44 @@ class BambuMQTTClient: # When tray_type is explicitly empty, clear everything # including RFID data (tag_uid/tray_uuid). slot_clearing = new_tray.get("tray_type") == "" + # Some printers (e.g. H2D) only send {id, state} in + # incremental updates when a tray is not fully loaded. + # state=11 means loaded; other values (9=empty, + # 10=spool present but filament not in feeder) indicate + # the slot should be cleared. Without this, old + # tray_type/tray_color persist indefinitely (#784). + tray_state = new_tray.get("state") + if ( + tray_state is not None + and tray_state != 11 + and "tray_type" not in new_tray + and merged_tray.get("tray_type") + ): + logger.info( + "[%s] AMS %s tray %s: state=%s (not loaded) — clearing stale tray data", + self.serial_number, + ams_id, + tray_id, + tray_state, + ) + slot_clearing = True + # The incremental update only has {id, state} — inject + # empty values for all content fields so the merge loop + # below clears the stale data from merged_tray. + new_tray.update( + { + "tray_type": "", + "tray_sub_brands": "", + "tray_color": "", + "tray_id_name": "", + "tray_info_idx": "", + "tag_uid": "0000000000000000", + "tray_uuid": "00000000000000000000000000000000", + "remain": 0, + "k": None, + "cali_idx": None, + } + ) for key, value in new_tray.items(): # Fields that should always be updated (even with empty/zero values): # - remain, k, id, cali_idx: status indicators where 0 is valid diff --git a/backend/app/services/printer_manager.py b/backend/app/services/printer_manager.py index 0d96c8c57..213e868bb 100644 --- a/backend/app/services/printer_manager.py +++ b/backend/app/services/printer_manager.py @@ -612,6 +612,7 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo "nozzle_temp_max": tray.get("nozzle_temp_max"), "drying_temp": tray.get("drying_temp"), "drying_time": tray.get("drying_time"), + "state": tray.get("state"), } ) # Prefer humidity_raw (actual percentage) over humidity (index 1-5) diff --git a/backend/tests/unit/services/test_bambu_mqtt.py b/backend/tests/unit/services/test_bambu_mqtt.py index 93e6bbe3d..8e3e784c8 100644 --- a/backend/tests/unit/services/test_bambu_mqtt.py +++ b/backend/tests/unit/services/test_bambu_mqtt.py @@ -734,6 +734,207 @@ class TestAMSDataMerging: ) +class TestAMSTrayStateClearning: + """Tests for AMS tray state-based clearing (#784). + + Some printers (e.g. H2D) only send {id, state} in incremental MQTT + updates when a tray is not fully loaded. state=11 means loaded; + other values (9=empty, 10=spool present but filament not in feeder) + should clear stale tray data that was set from an earlier pushall. + """ + + @pytest.fixture + def mqtt_client(self): + from backend.app.services.bambu_mqtt import BambuMQTTClient + + client = BambuMQTTClient( + ip_address="192.168.1.100", + serial_number="TEST_H2D", + access_code="12345678", + ) + return client + + def _seed_loaded_tray(self, mqtt_client): + """Seed AMS 0 with a fully loaded tray (state=11) and an empty slot.""" + initial = { + "ams": [ + { + "id": 0, + "tray": [ + { + "id": 0, + "tray_type": "PETG", + "tray_sub_brands": "PETG HF", + "tray_color": "00FF00FF", + "tray_id_name": "A00-G1", + "tray_info_idx": "GFG99", + "tag_uid": "AABBCCDD11223344", + "tray_uuid": "AABBCCDD11223344AABBCCDD11223344", + "remain": 75, + "k": 0.02, + "cali_idx": 5, + "state": 11, + }, + { + "id": 1, + "tray_type": "PLA", + "tray_color": "FF0000FF", + "remain": 50, + "state": 11, + }, + ], + } + ], + "power_on_flag": False, # H2D always sends False + } + mqtt_client._handle_ams_data(initial) + ams = mqtt_client.state.raw_data["ams"] + assert ams[0]["tray"][0]["tray_type"] == "PETG" + assert ams[0]["tray"][1]["tray_type"] == "PLA" + + def test_state_10_clears_stale_tray_data(self, mqtt_client): + """Incremental update with state=10 (spool present, not loaded) clears tray.""" + self._seed_loaded_tray(mqtt_client) + + # H2D sends only {id, state} when filament is retracted + update = { + "ams": [ + { + "id": 0, + "tray": [ + {"id": 0, "state": 10}, + {"id": 1, "state": 11}, # slot 1 still loaded + ], + } + ], + "power_on_flag": False, + } + mqtt_client._handle_ams_data(update) + + ams = mqtt_client.state.raw_data["ams"] + tray0 = ams[0]["tray"][0] + tray1 = ams[0]["tray"][1] + + # Tray 0 should be cleared + assert tray0["tray_type"] == "", "tray_type must be cleared on state=10" + assert tray0["tray_color"] == "", "tray_color must be cleared" + assert tray0["tray_sub_brands"] == "", "tray_sub_brands must be cleared" + assert tray0["tray_id_name"] == "", "tray_id_name must be cleared" + assert tray0["tray_info_idx"] == "", "tray_info_idx must be cleared" + assert tray0["tag_uid"] == "0000000000000000", "tag_uid must be cleared" + assert tray0["tray_uuid"] == "00000000000000000000000000000000", "tray_uuid must be cleared" + assert tray0["remain"] == 0, "remain must be 0" + assert tray0["k"] is None, "k must be cleared" + assert tray0["cali_idx"] is None, "cali_idx must be cleared" + assert tray0["state"] == 10, "state should be preserved" + + # Tray 1 should be untouched + assert tray1["tray_type"] == "PLA", "Loaded slot must be preserved" + assert tray1["remain"] == 50 + + def test_state_9_clears_stale_tray_data(self, mqtt_client): + """Incremental update with state=9 (empty, no spool) clears tray.""" + self._seed_loaded_tray(mqtt_client) + + update = { + "ams": [ + { + "id": 0, + "tray": [ + {"id": 0, "state": 9}, + {"id": 1, "state": 11}, + ], + } + ], + "power_on_flag": False, + } + mqtt_client._handle_ams_data(update) + + tray0 = mqtt_client.state.raw_data["ams"][0]["tray"][0] + assert tray0["tray_type"] == "", "state=9 must clear tray_type" + assert tray0["remain"] == 0 + + def test_state_11_preserves_tray_data(self, mqtt_client): + """Incremental update with state=11 (loaded) must NOT clear tray.""" + self._seed_loaded_tray(mqtt_client) + + update = { + "ams": [ + { + "id": 0, + "tray": [ + {"id": 0, "state": 11}, + {"id": 1, "state": 11}, + ], + } + ], + "power_on_flag": False, + } + mqtt_client._handle_ams_data(update) + + tray0 = mqtt_client.state.raw_data["ams"][0]["tray"][0] + assert tray0["tray_type"] == "PETG", "state=11 must preserve tray data" + assert tray0["tray_color"] == "00FF00FF" + assert tray0["remain"] == 75 + + def test_no_clearing_when_tray_type_already_empty(self, mqtt_client): + """Don't re-clear a tray that's already empty (avoids log spam).""" + self._seed_loaded_tray(mqtt_client) + + # First unload clears + update = { + "ams": [{"id": 0, "tray": [{"id": 0, "state": 10}, {"id": 1, "state": 11}]}], + "power_on_flag": False, + } + mqtt_client._handle_ams_data(update) + assert mqtt_client.state.raw_data["ams"][0]["tray"][0]["tray_type"] == "" + + # Second identical update should not trigger clearing again + # (merged_tray.get("tray_type") is already empty/falsy) + mqtt_client._handle_ams_data(update) + assert mqtt_client.state.raw_data["ams"][0]["tray"][0]["tray_type"] == "" + + def test_reload_after_unload_restores_data(self, mqtt_client): + """After clearing via state=10, a full update with state=11 restores data.""" + self._seed_loaded_tray(mqtt_client) + + # Unload + mqtt_client._handle_ams_data( + { + "ams": [{"id": 0, "tray": [{"id": 0, "state": 10}, {"id": 1, "state": 11}]}], + "power_on_flag": False, + } + ) + assert mqtt_client.state.raw_data["ams"][0]["tray"][0]["tray_type"] == "" + + # Reload — full tray data arrives again + mqtt_client._handle_ams_data( + { + "ams": [ + { + "id": 0, + "tray": [ + { + "id": 0, + "tray_type": "PETG", + "tray_sub_brands": "PETG HF", + "tray_color": "00FF00FF", + "remain": 75, + "state": 11, + }, + {"id": 1, "state": 11}, + ], + } + ], + "power_on_flag": False, + } + ) + tray0 = mqtt_client.state.raw_data["ams"][0]["tray"][0] + assert tray0["tray_type"] == "PETG", "Reload must restore tray data" + assert tray0["tray_color"] == "00FF00FF" + assert tray0["remain"] == 75 + + class TestNozzleRackData: """Tests for nozzle rack data parsing from H2 series device.nozzle.info.""" diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 93b9458c9..97389d82d 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -129,6 +129,7 @@ export interface AMSTray { nozzle_temp_max: number | null; // Max nozzle temperature drying_temp: number | null; // RFID-recommended drying temp drying_time: number | null; // RFID-recommended drying time (hours) + state: number | null; // AMS tray state: 9=empty, 10=spool present not loaded, 11=loaded } export interface AMSUnit { diff --git a/frontend/src/components/spoolbuddy/DiagnosticModal.tsx b/frontend/src/components/spoolbuddy/DiagnosticModal.tsx index df8484d33..297833a99 100644 --- a/frontend/src/components/spoolbuddy/DiagnosticModal.tsx +++ b/frontend/src/components/spoolbuddy/DiagnosticModal.tsx @@ -50,7 +50,7 @@ export function DiagnosticModal({ type, deviceId, onClose }: DiagnosticModalProp try { result = await spoolbuddyApi.getDiagnosticResult(deviceId, type); break; - } catch (e) { + } catch { // Not ready yet, continue polling retryCount++; if (retryCount % 4 === 0) { @@ -74,7 +74,7 @@ export function DiagnosticModal({ type, deviceId, onClose }: DiagnosticModalProp } finally { setIsRunning(false); } - }, [type, deviceId]); + }, [type, deviceId, t]); const title = type === 'scale' ? t('spoolbuddy.diagnostic.scaleTitle', 'Scale Diagnostic') @@ -134,7 +134,7 @@ export function DiagnosticModal({ type, deviceId, onClose }: DiagnosticModalProp )} - + {/* Footer */}