Browse Source

Fix stale AMS slot data and empty slot button visibility (#784)

  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
maziggy 2 months ago
parent
commit
c9efa4b8bb

+ 1 - 1
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.
 

+ 1 - 0
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)

+ 12 - 2
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)

+ 1 - 0
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):

+ 38 - 0
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

+ 1 - 0
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)

+ 201 - 0
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."""
 

+ 1 - 0
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 {

+ 3 - 3
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
             </div>
           )}
         </div>
-        
+
         {/* Footer */}
         <div className="flex gap-2 p-4 border-t border-zinc-700 bg-zinc-800">
           <button

+ 13 - 0
frontend/src/i18n/locales/de.ts

@@ -4462,6 +4462,19 @@ export default {
       deviceInfo: 'Geräteinfo',
       hostname: 'Host',
       uptime: 'Betriebszeit',
+      systemConfig: 'Backend & Auth',
+      backendUrl: 'Bambuddy Backend URL',
+      apiToken: 'API-Token',
+      apiTokenPlaceholder: 'API-Token eingeben',
+      saveConfig: 'Konfiguration speichern',
+      systemQueued: 'Konfiguration in Warteschlange.',
+      nfcDiagnostic: 'NFC-Diagnose',
+      scaleDiagnostic: 'Waagen-Diagnose',
+      readTagDiagnostic: 'Tag-Lese-Diagnose',
+      testNfc: 'Leser testen',
+      testScale: 'Genauigkeit testen',
+      testReadTag: 'Tag lesen',
+      systemFieldsRequired: 'Backend-URL ist erforderlich.',
       // Display tab
       brightness: 'Helligkeit',
       saved: 'Gespeichert',

+ 48 - 4
frontend/src/pages/PrintersPage.tsx

@@ -3310,7 +3310,7 @@ function PrinterCard({
                                     </div>
                                     {/* Fill bar */}
                                     <div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
-                                      {effectiveFill !== null && effectiveFill >= 0 && tray && (
+                                      {effectiveFill !== null && effectiveFill >= 0 && !isEmpty && tray && (
                                         <div
                                           className="h-full rounded-full transition-all"
                                           style={{
@@ -3439,7 +3439,29 @@ function PrinterCard({
                                         {slotVisual}
                                       </FilamentHoverCard>
                                     ) : (
-                                      <EmptySlotHoverCard>
+                                      <EmptySlotHoverCard
+                                        configureSlot={tray?.state === 10 ? {
+                                          enabled: hasPermission('printers:control'),
+                                          onConfigure: () => setConfigureSlotModal({
+                                            amsId: ams.id,
+                                            trayId: slotIdx,
+                                            trayCount: ams.tray.length,
+                                            extruderId: mappedExtruderId,
+                                          }),
+                                        } : undefined}
+                                        inventory={tray?.state === 10 && !spoolmanEnabled ? {
+                                          onAssignSpool: () => setAssignSpoolModal({
+                                            printerId: printer.id,
+                                            amsId: ams.id,
+                                            trayId: slotIdx,
+                                            trayInfo: {
+                                              type: '',
+                                              color: '',
+                                              location: `${getAmsLabel(ams.id, ams.tray.length)} Slot ${slotIdx + 1}`,
+                                            },
+                                          }),
+                                        } : undefined}
+                                      >
                                         {slotVisual}
                                       </EmptySlotHoverCard>
                                     )}
@@ -3530,7 +3552,7 @@ function PrinterCard({
                             </div>
                             {/* Fill bar */}
                             <div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
-                              {htEffectiveFill !== null && htEffectiveFill >= 0 && (
+                              {htEffectiveFill !== null && htEffectiveFill >= 0 && !isEmpty && (
                                 <div
                                   className="h-full rounded-full transition-all"
                                   style={{
@@ -3735,7 +3757,29 @@ function PrinterCard({
                                     {slotVisual}
                                   </FilamentHoverCard>
                                 ) : (
-                                  <EmptySlotHoverCard>
+                                  <EmptySlotHoverCard
+                                    configureSlot={tray?.state === 10 ? {
+                                      enabled: hasPermission('printers:control'),
+                                      onConfigure: () => setConfigureSlotModal({
+                                        amsId: ams.id,
+                                        trayId: htSlotId,
+                                        trayCount: ams.tray.length,
+                                        extruderId: mappedExtruderId,
+                                      }),
+                                    } : undefined}
+                                    inventory={tray?.state === 10 && !spoolmanEnabled ? {
+                                      onAssignSpool: () => setAssignSpoolModal({
+                                        printerId: printer.id,
+                                        amsId: ams.id,
+                                        trayId: htSlotId,
+                                        trayInfo: {
+                                          type: '',
+                                          color: '',
+                                          location: getAmsLabel(ams.id, ams.tray.length),
+                                        },
+                                      }),
+                                    } : undefined}
+                                  >
                                     {slotVisual}
                                   </EmptySlotHoverCard>
                                 )}

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-B2oRXWjB.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-Dzv7xI7m.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-_-nC2GSJ.css


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CKvhQbq-.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-Dzv7xI7m.css">
+    <script type="module" crossorigin src="/assets/index-B2oRXWjB.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-_-nC2GSJ.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff