Bladeren bron

fix(ams): show "?" not "Empty" for non-RFID spools using tray_exist_bits (#2527)

A spool with no readable RFID was reported by the standard AMS with an empty
tray_type and state=9 — structurally identical to a truly-empty slot at the
tray level — so the AMS card rendered it "Empty" while Bambu Studio correctly
showed "?". The authoritative "a spool is physically here" signal is firmware's
AMS-level tray_exist_bits bitmask (what Studio uses), but Bambuddy inferred
emptiness from the per-tray state/tray_type. Confirmed from the reporter's
bundle: tray_exist_bits=f (all four slots present) with tray_is_bbl_bits=5
(only slots 0,2 Bambu) — the present-but-non-Bambu slots were the ones shown
Empty. Supersedes closed #1838.

apply_tray_exist_bits() already parses the bitmask to clear stale fields on
absent slots; it now also annotates each slot with an authoritative `exists`
bool, gated behind a new annotate_exists flag so only the printer-card path
sets it. The VP bridge leaves it off, so the `exists` key never reaches the
slicer wire format. `exists` flows through the AMSTray schema/serialization to
the frontend, where getEmptySlotKind() uses it: exists===true + no tray_type
-> "?" (present, unconfigured), exists===false -> "Empty", exists absent ->
the previous state=9/10 heuristic (AMS-HT and missing-bitmask paths unchanged).
H2D/X1C already reported present-unknown slots with a non-9 state and took the
"?" path; with the fix they reach it via `exists` and are unaffected.
maziggy 1 maand geleden
bovenliggende
commit
f6c6cfbad3

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 1 - 0
backend/app/api/routes/printers.py

@@ -535,6 +535,7 @@ async def get_printer_status(
                         drying_temp=tray_data.get("drying_temp"),
                         drying_time=tray_data.get("drying_time"),
                         state=tray_data.get("state"),
+                        exists=tray_data.get("exists"),
                     )
                 )
             # Prefer humidity_raw (percentage) over humidity (index 1-5)

+ 4 - 0
backend/app/schemas/printer.py

@@ -181,6 +181,10 @@ class AMSTray(BaseModel):
     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
+    # Firmware's authoritative "spool physically present" bit (from tray_exist_bits).
+    # True for a non-RFID spool the firmware can't identify — the UI shows "?" rather
+    # than "Empty" (#2527). None when the bitmask was unavailable (→ state-based fallback).
+    exists: bool | None = None
 
 
 class AMSUnit(BaseModel):

+ 12 - 0
backend/app/services/bambu_mqtt.py

@@ -56,6 +56,7 @@ def apply_tray_exist_bits(
     *,
     power_on_flag: bool = True,
     log_label: str | None = None,
+    annotate_exists: bool = False,
 ) -> int:
     """Wipe stale per-tray filament fields on slots whose `tray_exist_bits` bit is 0.
 
@@ -87,6 +88,14 @@ def apply_tray_exist_bits(
     way). Ints are tolerated for defensive symmetry but typically not seen
     on the wire. ``None`` / empty / unparseable → no-op.
 
+    ``annotate_exists`` writes a per-tray ``exists`` bool (from the bitmask) on
+    every processed slot. This is firmware's authoritative "spool physically
+    present" signal — the same one BambuStudio uses to draw a ``?`` for a
+    non-RFID spool in an otherwise-unidentified slot. Bambuddy's AMS card keys
+    empty-vs-unknown off it so a non-Bambu spool shows ``?`` instead of "Empty"
+    (#2527). Only the internal (printer-card) caller sets this; the VP bridge
+    leaves it False so the ``exists`` key never reaches the slicer wire format.
+
     Mutates ``units`` in place. Returns the number of slots cleared.
     """
     if not tray_exist_bits_str:
@@ -131,6 +140,8 @@ def apply_tray_exist_bits(
                 continue
             global_bit = ams_id * 4 + tray_id
             slot_exists = (tray_exist_bits >> global_bit) & 1
+            if annotate_exists:
+                tray["exists"] = bool(slot_exists)
             if slot_exists:
                 continue
             tray["state"] = 9
@@ -2009,6 +2020,7 @@ class BambuMQTTClient:
                 ams_data.get("tray_exist_bits"),
                 power_on_flag=ams_data.get("power_on_flag", True),
                 log_label=self.serial_number,
+                annotate_exists=True,
             )
 
         self.state.raw_data["ams"] = merged_ams

+ 34 - 0
backend/tests/unit/services/test_bambu_mqtt.py

@@ -1330,6 +1330,40 @@ class TestApplyTrayExistBitsHelper:
         assert cleared == 0
         assert units[0]["tray"][0]["state"] == 9
 
+    def test_annotate_exists_marks_present_and_absent(self):
+        """#2527: annotate_exists writes the tray_exist_bits presence bit onto
+        every slot so a non-RFID spool (present, no tray_type) is distinguishable
+        from a truly-empty slot. 0x5 = slots 0,2 present; slots 1,3 absent."""
+        from backend.app.services.bambu_mqtt import apply_tray_exist_bits
+
+        units = [{"id": 0, "tray": [{"id": i} for i in range(4)]}]
+        apply_tray_exist_bits(units, "5", power_on_flag=True, annotate_exists=True)
+        exists = [t["exists"] for t in units[0]["tray"]]
+        assert exists == [True, False, True, False]
+
+    def test_annotate_exists_present_unknown_slot_not_cleared(self):
+        """A present slot with no tray_type (fresh non-RFID spool) keeps its
+        state and is marked exists=True — the UI then shows "?" not "Empty"."""
+        from backend.app.services.bambu_mqtt import apply_tray_exist_bits
+
+        # 0x1 = slot 0 present. Slot 0 has no tray_type (unidentified spool).
+        units = [{"id": 0, "tray": [{"id": 0, "state": 9}]}]
+        cleared = apply_tray_exist_bits(units, "1", power_on_flag=True, annotate_exists=True)
+        assert cleared == 0
+        assert units[0]["tray"][0]["exists"] is True
+        # Present slot is left untouched (only absent slots get state=9 forced).
+        assert units[0]["tray"][0]["state"] == 9
+
+    def test_annotate_exists_off_by_default_keeps_wire_clean(self):
+        """The VP bridge calls this without annotate_exists, so the slicer-facing
+        tray dict must NOT gain a non-standard `exists` key."""
+        from backend.app.services.bambu_mqtt import apply_tray_exist_bits
+
+        units = [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}, {"id": 1}]}]
+        apply_tray_exist_bits(units, "1", power_on_flag=True)
+        assert "exists" not in units[0]["tray"][0]
+        assert "exists" not in units[0]["tray"][1]
+
 
 class TestNozzleRackData:
     """Tests for nozzle rack data parsing from H2 series device.nozzle.info."""

+ 19 - 0
frontend/src/__tests__/components/spoolbuddy/AmsUnitCard.test.tsx

@@ -114,6 +114,25 @@ describe('AmsUnitCard', () => {
     expect(screen.getByText('Empty')).toBeDefined();
   });
 
+  it('shows "?" for a non-RFID spool the firmware reports as state 9 (#2527)', () => {
+    // A non-Bambu spool with no RFID is physically present (tray_exist_bits →
+    // exists=true) but some firmware (standard AMS on P1-series) reports its
+    // tray as state=9 with no tray_type — identical to a truly-empty slot at
+    // the tray level. exists is authoritative, so it must read "?" not "Empty".
+    const unit = makeUnit({
+      tray: [
+        makeTray({ id: 0, tray_type: 'PLA', remain: 80 }),
+        makeTray({ id: 1, tray_color: null, tray_type: '', remain: 0, state: 9, exists: true }),
+        makeTray({ id: 2, tray_type: 'ABS', remain: 10 }),
+        // exists=false with the same state=9 is a genuinely empty slot.
+        makeTray({ id: 3, tray_color: null, tray_type: '', remain: 0, state: 9, exists: false }),
+      ],
+    });
+    render(<AmsUnitCard unit={unit} activeSlot={null} />);
+    expect(screen.getByText('?')).toBeDefined();
+    expect(screen.getByText('Empty')).toBeDefined();
+  });
+
   it('renders fill level bars for slots with filament', () => {
     const { container } = render(
       <AmsUnitCard unit={makeUnit()} activeSlot={null} />

+ 1 - 0
frontend/src/api/client.ts

@@ -365,6 +365,7 @@ export interface AMSTray {
   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
+  exists?: boolean | null;         // Firmware tray_exist_bits: spool physically present (non-RFID → "?" not "Empty", #2527)
 }
 
 export interface AMSUnit {

+ 8 - 4
frontend/src/components/spoolbuddy/AmsUnitCard.tsx

@@ -10,12 +10,16 @@ function isTrayEmpty(tray: AMSTray): boolean {
   return !tray.tray_type || tray.tray_type === '';
 }
 
-// Mirror of PrintersPage.getEmptySlotKind (#1694): 'physical' when firmware
-// confirms no spool (state 9/10), 'reset' when tray_type is absent but the
-// firmware hasn't confirmed empty (= spool loaded, slot just unconfigured).
+// Mirror of PrintersPage.getEmptySlotKind (#1694, #2527): 'physical' when
+// firmware confirms no spool, 'reset' when a spool is present but has no
+// tray_type (= loaded, slot just unconfigured — e.g. a non-RFID spool).
+// tray_exist_bits (exists) is authoritative when present; otherwise fall back
+// to the state=9/10 heuristic.
 function getEmptySlotKind(tray: AMSTray): 'physical' | 'reset' | null {
   if (tray.tray_type) return null;
-  const state = (tray as { state?: number | null }).state ?? null;
+  if (tray.exists === true) return 'reset';
+  if (tray.exists === false) return 'physical';
+  const state = tray.state ?? null;
   return state === 9 || state === 10 ? 'physical' : 'reset';
 }
 

+ 8 - 1
frontend/src/pages/PrintersPage.tsx

@@ -863,8 +863,15 @@ function TemperatureIndicator({ temp, goodThreshold = 28, fairThreshold = 35, on
  *
  *  Returns null when the slot is loaded (tray_type is present).
  */
-function getEmptySlotKind(tray: { tray_type?: string | null; state?: number | null } | null | undefined): 'physical' | 'reset' | null {
+function getEmptySlotKind(tray: { tray_type?: string | null; state?: number | null; exists?: boolean | null } | null | undefined): 'physical' | 'reset' | null {
   if (tray?.tray_type) return null;
+  // tray_exist_bits is firmware's authoritative presence signal: a non-RFID
+  // spool the firmware can't identify is physically present (exists === true)
+  // but carries no tray_type, so it must read as "?" (loaded, unconfigured),
+  // never "Empty" (#2527). BambuStudio draws it the same way. Only fall back to
+  // the state=9/10 heuristic when the bitmask was unavailable (exists == null).
+  if (tray?.exists === true) return 'reset';
+  if (tray?.exists === false) return 'physical';
   return (tray?.state === 9 || tray?.state === 10) ? 'physical' : 'reset';
 }
 

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


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CJ6VzpV2.js"></script>
+    <script type="module" crossorigin src="/assets/index-CWda6lvk.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-blSspT6K.css">
   </head>
   <body>

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