Explorar el Código

fix(ams): physically-empty slots report state=9 and render distinctly from reset slots (#1322 follow-up)

  Two-part fix for the #1322 follow-up by @RosdasHH.

  Data layer.
  The previous narrow heuristic in printer_manager.py only caught
  the bare {"id": N} payload firmware sends right after a printer
  restart. In steady-state operation — and on the more common
  post-Reset-Slot path on P1S and A1 Mini BMCU — firmware sends a
  populated payload and signals emptiness via the tray_exist_bits
  bitmask. We already parse that bitmask and use it to wipe stale
  tray_type / tray_color / tag_uid fields, but never touched the
  state field, so downstream readers (printers.py API serializer,
  inventory.py's tray_state in {9, 10} short-circuit, AMS card)
  saw state: null and had to guess from absent payload fields.

  Fix lifts tray["state"] = 9 (int — not "9"; inventory.py:1358
  uses == not `in {...}` so a string would silently miss and the
  reporter's deadlock would come back) to the outer `if not
  slot_exists` branch, so the bitmask path now writes the
  canonical "no spool" code for every empty slot regardless of
  stale fields. The narrow heuristic in printer_manager.py:797
  stays as belt-and-suspenders for any MQTT path that doesn't
  flow through _handle_ams_data.

  UI layer.
  With the data flow now consistent, the AMS slot card renders
  physically-empty slots distinctly from reset slots, per
  reporter's mockup. New helper getEmptySlotKind(tray) returns
  "physical" (state ∈ {9, 10}), "reset" (any other empty state),
  or null (loaded). The inline label below the slot circle reads
  "Empty" for physical and "Reset" for reset; pre-fix both showed
  an em-dash. FilamentSlotCircle gains an emptyKind prop that
  picks a quieter dashed border colour for reset slots so the
  visual hierarchy reads loaded > reset > physically empty.
  EmptySlotHoverCard gains a kind prop and switches between
  "Empty slot" and "Slot reset — no spool assigned".
maziggy hace 1 semana
padre
commit
e2df0fc601

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
CHANGELOG.md


+ 30 - 14
backend/app/services/bambu_mqtt.py

@@ -1756,20 +1756,36 @@ class BambuMQTTClient:
                         tray_id = int(tray_id_raw) if isinstance(tray_id_raw, str) else tray_id_raw
                         global_bit = ams_id * 4 + tray_id
                         slot_exists = (tray_exist_bits >> global_bit) & 1
-                        if not slot_exists and tray.get("tray_type"):
-                            # Slot is marked empty but has data - clear it
-                            logger.debug(
-                                f"[{self.serial_number}] Clearing empty slot: AMS {ams_id} slot {tray_id} "
-                                f"(tray_exist_bits bit {global_bit} = 0)"
-                            )
-                            tray["tray_type"] = ""
-                            tray["tray_sub_brands"] = ""
-                            tray["tray_color"] = ""
-                            tray["tray_id_name"] = ""
-                            tray["tag_uid"] = "0000000000000000"
-                            tray["tray_uuid"] = "00000000000000000000000000000000"
-                            tray["tray_info_idx"] = ""
-                            tray["remain"] = 0
+                        if not slot_exists:
+                            # #1322 follow-up (by @RosdasHH): the bitmask is
+                            # BambuStudio's canonical "no spool" signal, and
+                            # works across every firmware variant (P1S, A1
+                            # Mini, post-restart, post-Reset-Slot, steady-
+                            # state). Promote to state=9 (firmware's
+                            # explicit "no spool" code) so downstream
+                            # readers — printers.py's API serializer,
+                            # inventory.py's `tray_state in {9, 10}`
+                            # short-circuit, the AMS card — see one
+                            # canonical signal instead of guessing from
+                            # payload shape. Int (not "9") to match the
+                            # downstream `==` comparison.
+                            tray["state"] = 9
+                            if tray.get("tray_type"):
+                                # Stale data from before the slot went empty
+                                # — clear it so the AMS view doesn't render a
+                                # colour/material that's no longer there.
+                                logger.debug(
+                                    f"[{self.serial_number}] Clearing empty slot: AMS {ams_id} slot {tray_id} "
+                                    f"(tray_exist_bits bit {global_bit} = 0)"
+                                )
+                                tray["tray_type"] = ""
+                                tray["tray_sub_brands"] = ""
+                                tray["tray_color"] = ""
+                                tray["tray_id_name"] = ""
+                                tray["tag_uid"] = "0000000000000000"
+                                tray["tray_uuid"] = "00000000000000000000000000000000"
+                                tray["tray_info_idx"] = ""
+                                tray["remain"] = 0
 
         self.state.raw_data["ams"] = merged_ams
 

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

@@ -746,6 +746,66 @@ class TestAMSDataMerging:
         assert ams_data[0]["tray"][0]["tray_type"] == "PLA", "A1 should still have PLA"
         assert ams_data[1]["tray"][0]["tray_type"] == "PLA", "B1 should still have PLA"
 
+    def test_tray_exist_bits_promotes_empty_slot_to_state_9(self, mqtt_client):
+        """#1322 follow-up by @RosdasHH: the previous fix only caught the bare
+        {"id": N} payload firmware sends right after a printer restart. In
+        steady-state operation firmware sends a populated payload and signals
+        emptiness via tray_exist_bits — the canonical BambuStudio detection.
+        The bitmask handler now promotes empty slots to state=9 so the rest
+        of the app (API serializer, inventory short-circuit, AMS card) sees
+        one signal instead of guessing from payload shape.
+
+        State must be int 9, not "9" — `tray_state in {9, 10}` downstream
+        uses `==` comparison and would silently miss a string.
+        """
+        initial_ams = {
+            "ams": [
+                {
+                    "id": 0,
+                    "tray": [
+                        {"id": 0, "tray_type": "PLA", "tray_color": "FF0000", "state": 11, "remain": 80},
+                        {"id": 1, "tray_type": "PETG", "tray_color": "00FF00", "state": 11, "remain": 60},
+                    ],
+                }
+            ],
+            "tray_exist_bits": "3",  # both slots occupied (0b11)
+        }
+        mqtt_client._handle_ams_data(initial_ams)
+
+        # Slot 1 goes empty — populated payload, only the bitmask says so.
+        update_ams = {
+            "ams": [{"id": 0, "tray": [{"id": 0}, {"id": 1}]}],
+            "tray_exist_bits": "1",  # slot 1 now empty (0b01)
+        }
+        mqtt_client._handle_ams_data(update_ams)
+
+        slot1 = mqtt_client.state.raw_data["ams"][0]["tray"][1]
+        assert slot1["state"] == 9, "empty-by-bitmask slot must report state=9"
+        assert isinstance(slot1["state"], int), "state must be int for downstream == comparison"
+        # Loaded slot keeps its firmware state unchanged.
+        slot0 = mqtt_client.state.raw_data["ams"][0]["tray"][0]
+        assert slot0["state"] == 11, "loaded slot must keep its firmware state"
+
+    def test_tray_exist_bits_does_not_change_state_on_loaded_slots(self, mqtt_client):
+        """Belt and suspenders for the negative path: the new state=9
+        promotion must fire ONLY when the bitmask bit is 0. A loaded slot
+        with state=3 (or any other non-9 firmware value) must pass through
+        untouched, or we'd corrupt every printer that sends transitional
+        states like 'unloading'."""
+        initial_ams = {
+            "ams": [
+                {
+                    "id": 0,
+                    "tray": [
+                        {"id": 0, "tray_type": "PLA", "tray_color": "FF0000", "state": 3, "remain": 80},
+                    ],
+                }
+            ],
+            "tray_exist_bits": "1",  # slot occupied
+        }
+        mqtt_client._handle_ams_data(initial_ams)
+        assert mqtt_client.state.raw_data["ams"][0]["tray"][0]["state"] == 3
+
     def test_shutdown_message_preserves_ams_data(self, mqtt_client):
         """Printer shutdown (power_on_flag=False) must not wipe AMS slot data (#765).
 

+ 36 - 0
frontend/src/__tests__/pages/PrintersPage.test.tsx

@@ -1041,6 +1041,42 @@ describe('PrintersPage Phase 13 — EmptySlotHoverCard onAssignSpool wiring', ()
     }, { timeout: 3000 });
   });
 
+  it('#1322: empty slot kind is "physical" when state=9 and "reset" otherwise', async () => {
+    // Bambuddy now distinguishes a firmware-confirmed empty slot (state=9
+    // via tray_exist_bits) from a slot the user reset but where the
+    // firmware still has a spool registered. The kind prop drives both
+    // the inline label ("Empty" vs "Reset") and the hover card label.
+    server.use(
+      http.get('/api/v1/spoolman/settings', () => HttpResponse.json({
+        spoolman_enabled: 'false', spoolman_url: '',
+      })),
+      http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
+        ...mockPrinterStatus,
+        ams: [{
+          id: 0,
+          tray: [
+            { id: 0, tray_type: '', state: 9 },   // physically empty
+            { id: 1, tray_type: '', state: 3 },   // reset / unloading
+            { id: 2, tray_type: '', state: null }, // unknown empty
+            { id: 3, tray_type: 'PLA', state: 11 }, // loaded — no card here
+          ],
+        }],
+      })),
+    );
+    render(<PrintersPage />);
+
+    await waitFor(() => {
+      expect(phase13EmptySlotProps.filter(p => p.kind === 'physical').length).toBeGreaterThan(0);
+    }, { timeout: 3000 });
+
+    const physical = phase13EmptySlotProps.filter(p => p.kind === 'physical');
+    const reset = phase13EmptySlotProps.filter(p => p.kind === 'reset');
+    expect(physical.length).toBeGreaterThan(0);
+    expect(reset.length).toBeGreaterThan(0);
+    // state=null falls back to 'reset' too — the helper only returns
+    // 'physical' for the canonical 9/10 firmware codes.
+  });
+
   it('P13-1 (spoolman mode): EmptySlotHoverCard still receives onAssignSpool callback', async () => {
     server.use(
       http.get('/api/v1/spoolman/settings', () => HttpResponse.json({

+ 7 - 2
frontend/src/components/FilamentHoverCard.tsx

@@ -499,9 +499,14 @@ interface EmptySlotHoverCardProps {
   className?: string;
   configureSlot?: ConfigureSlotConfig;
   onAssignSpool?: () => void;
+  // #1322 follow-up: distinguish firmware-confirmed empty (state 9/10) from
+  // a user reset where the firmware still has a spool registered. "reset"
+  // surfaces the user-cleared label; undefined / "physical" keeps the
+  // historical "Empty slot" wording.
+  kind?: 'physical' | 'reset';
 }
 
-export function EmptySlotHoverCard({ children, className = '', configureSlot, onAssignSpool }: EmptySlotHoverCardProps) {
+export function EmptySlotHoverCard({ children, className = '', configureSlot, onAssignSpool, kind }: EmptySlotHoverCardProps) {
   const { t } = useTranslation();
   const [isVisible, setIsVisible] = useState(false);
   // Screen-space coords for the portaled card — same pattern as
@@ -579,7 +584,7 @@ export function EmptySlotHoverCard({ children, className = '', configureSlot, on
             rounded-md shadow-lg overflow-hidden
           ">
             <div className="px-3 py-1.5 text-xs text-bambu-gray whitespace-nowrap">
-              {t('ams.emptySlot')}
+              {kind === 'reset' ? t('ams.emptySlotReset') : t('ams.emptySlot')}
             </div>
             {/* Configure slot button */}
             {(configureSlot?.enabled || onAssignSpool) && (

+ 12 - 2
frontend/src/components/FilamentSlotCircle.tsx

@@ -8,6 +8,11 @@
  *   trayType   - Filament material string (e.g. "PLA").  Used to decide the
  *                fallback background when there is no color but a type is known.
  *   isEmpty    - Whether the slot contains no filament.
+ *   emptyKind  - Optional refinement of the empty state used to render the
+ *                slot border (#1322 follow-up): "physical" for firmware-
+ *                confirmed no spool (state 9/10), "reset" for slots where
+ *                the user cleared the assignment but the firmware hasn't
+ *                positively confirmed emptiness. Ignored when isEmpty is false.
  *   slotNumber - 1-based slot number to display inside the circle.
  */
 
@@ -15,6 +20,7 @@ interface FilamentSlotCircleProps {
   trayColor?: string | null;
   trayType?: string | null;
   isEmpty: boolean;
+  emptyKind?: 'physical' | 'reset' | null;
   slotNumber: number;
 }
 
@@ -26,13 +32,17 @@ function isLightFilamentColor(hex: string): boolean {
   return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6;
 }
 
-export function FilamentSlotCircle({ trayColor, trayType, isEmpty, slotNumber }: FilamentSlotCircleProps) {
+export function FilamentSlotCircle({ trayColor, trayType, isEmpty, emptyKind, slotNumber }: FilamentSlotCircleProps) {
+  // Reset slots get a quieter border than physical-empty so they read as
+  // "cleared but possibly still has a spool the firmware hasn't confirmed
+  // gone" rather than "definitely no spool".
+  const emptyBorderColor = emptyKind === 'reset' ? '#3d3d3d' : '#666';
   return (
     <div
       className="w-3.5 h-3.5 rounded-full mx-auto mb-0.5 border-2 flex items-center justify-center"
       style={{
         backgroundColor: trayColor ? `#${trayColor}` : (trayType ? '#333' : 'transparent'),
-        borderColor: isEmpty ? '#666' : 'rgba(255,255,255,0.1)',
+        borderColor: isEmpty ? emptyBorderColor : 'rgba(255,255,255,0.1)',
         borderStyle: isEmpty ? 'dashed' : 'solid',
       }}
     >

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

@@ -3822,6 +3822,8 @@ export default {
     slot: 'Slot',
     empty: 'Leer',
     emptySlot: 'Leerer Slot',
+    slotEmpty: 'Leer',
+    emptySlotReset: 'Keine Spule zugewiesen',
     unknown: 'Unbekannt',
     humidity: 'Luftfeuchtigkeit',
     temperature: 'Temperatur',

+ 2 - 0
frontend/src/i18n/locales/en.ts

@@ -3834,6 +3834,8 @@ export default {
     slot: 'Slot',
     empty: 'Empty',
     emptySlot: 'Empty slot',
+    slotEmpty: 'Empty',
+    emptySlotReset: 'No filament assigned',
     unknown: 'Unknown',
     humidity: 'Humidity',
     temperature: 'Temperature',

+ 2 - 0
frontend/src/i18n/locales/fr.ts

@@ -3811,6 +3811,8 @@ export default {
     slot: 'Emplacement',
     empty: 'Vide',
     emptySlot: 'Slot vide',
+    slotEmpty: 'Vide',
+    emptySlotReset: 'Aucune bobine assignée',
     unknown: 'Inconnu',
     humidity: 'Humidité',
     temperature: 'Température',

+ 2 - 0
frontend/src/i18n/locales/it.ts

@@ -3810,6 +3810,8 @@ export default {
     slot: 'Slot',
     empty: 'Vuoto',
     emptySlot: 'Slot vuoto',
+    slotEmpty: 'Vuoto',
+    emptySlotReset: 'Nessuna bobina assegnata',
     unknown: 'Sconosciuto',
     humidity: 'Umidità',
     temperature: 'Temperatura',

+ 2 - 0
frontend/src/i18n/locales/ja.ts

@@ -3822,6 +3822,8 @@ export default {
     slot: 'スロット',
     empty: '<空>',
     emptySlot: '空のスロット',
+    slotEmpty: '空',
+    emptySlotReset: 'スプール未割当',
     unknown: '不明',
     humidity: '湿度',
     temperature: '温度',

+ 2 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3810,6 +3810,8 @@ export default {
     slot: 'Slot',
     empty: 'Vazio',
     emptySlot: 'Slot vazio',
+    slotEmpty: 'Vazio',
+    emptySlotReset: 'Nenhum carretel atribuído',
     unknown: 'Desconhecido',
     humidity: 'Umidade',
     temperature: 'Temperatura',

+ 2 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3810,6 +3810,8 @@ export default {
     slot: '槽位',
     empty: '空',
     emptySlot: '空槽位',
+    slotEmpty: '空',
+    emptySlotReset: '未分配料盘',
     unknown: '未知',
     humidity: '湿度',
     temperature: '温度',

+ 2 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -3810,6 +3810,8 @@ export default {
     slot: '槽位',
     empty: '空',
     emptySlot: '空槽位',
+    slotEmpty: '空',
+    emptySlotReset: '未指派線材',
     unknown: '未知',
     humidity: '濕度',
     temperature: '溫度',

+ 31 - 3
frontend/src/pages/PrintersPage.tsx

@@ -786,6 +786,25 @@ function getAmsLabel(amsId: number | string, trayCount: number): string {
   return isHt ? `HT-${letter}` : `AMS-${letter}`;
 }
 
+/** Classify an empty AMS slot for UI rendering (#1322 follow-up).
+ *
+ *  "physical" — firmware positively confirmed no spool (state 9 or 10). The
+ *  bambu_mqtt handler now promotes tray_exist_bits=0 slots to state=9, so
+ *  every empty-by-bitmask slot lands here regardless of firmware payload
+ *  shape.
+ *
+ *  "reset" — tray_type is missing/empty but firmware hasn't confirmed
+ *  emptiness (state is null, 3, or any non-9/10 value). Typically a slot
+ *  the user cleared with "Reset Slot" where a physical spool may still be
+ *  loaded but unassigned.
+ *
+ *  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 {
+  if (tray?.tray_type) return null;
+  return (tray?.state === 9 || tray?.state === 10) ? 'physical' : 'reset';
+}
+
 
 function CoverImage({ url, printName }: { url: string | null; printName?: string }) {
   const { t } = useTranslation();
@@ -3515,6 +3534,7 @@ function PrinterCard({
                                 const tray = ams.tray[slotIdx] || ams.tray.find(t => t.id === slotIdx);
                                 const hasFillLevel = tray?.tray_type && tray.remain >= 0;
                                 const isEmpty = !tray?.tray_type;
+                                const emptyKind = getEmptySlotKind(tray);
                                 // Check if this is the currently loaded tray
                                 // Global tray ID = ams.id * 4 + slot index (for standard AMS)
                                 const globalTrayId = ams.id * 4 + slotIdx;
@@ -3592,10 +3612,11 @@ function PrinterCard({
                                       trayColor={tray?.tray_color}
                                       trayType={tray?.tray_type}
                                       isEmpty={isEmpty}
+                                      emptyKind={emptyKind}
                                       slotNumber={slotIdx + 1}
                                     />
                                     <div className="text-[9px] text-white font-bold truncate">
-                                      {tray?.tray_type || '—'}
+                                      {tray?.tray_type || t('ams.slotEmpty')}
                                     </div>
                                     {/* Fill bar */}
                                     <div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
@@ -3799,6 +3820,7 @@ function PrinterCard({
                                       </FilamentHoverCard>
                                     ) : (
                                       <EmptySlotHoverCard
+                                        kind={emptyKind ?? undefined}
                                         configureSlot={{
                                           enabled: hasPermission('printers:control'),
                                           onConfigure: () => setConfigureSlotModal({
@@ -3847,6 +3869,7 @@ function PrinterCard({
                         const tray = ams.tray[0];
                         const hasFillLevel = tray?.tray_type && tray.remain >= 0;
                         const isEmpty = !tray?.tray_type;
+                        const emptyKind = getEmptySlotKind(tray);
                         // Check if this is the currently loaded tray
                         const globalTrayId = getGlobalTrayId(ams.id, tray?.id ?? 0, false);
                         const isActive = effectiveTrayNow === globalTrayId;
@@ -3914,10 +3937,11 @@ function PrinterCard({
                               trayColor={tray?.tray_color}
                               trayType={tray?.tray_type}
                               isEmpty={isEmpty}
+                              emptyKind={emptyKind}
                               slotNumber={1}
                             />
                             <div className="text-[9px] text-white font-bold truncate">
-                              {tray?.tray_type || '—'}
+                              {tray?.tray_type || t('ams.slotEmpty')}
                             </div>
                             {/* Fill bar */}
                             <div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
@@ -4194,6 +4218,7 @@ function PrinterCard({
                                   </FilamentHoverCard>
                                 ) : (
                                   <EmptySlotHoverCard
+                                    kind={emptyKind ?? undefined}
                                     configureSlot={{
                                       enabled: hasPermission('printers:control'),
                                       onConfigure: () => setConfigureSlotModal({
@@ -4322,6 +4347,7 @@ function PrinterCard({
                               };
 
                               const isEmpty = !extTray.tray_type;
+                              const emptyKind = getEmptySlotKind(extTray);
                               const extSlotContent = (
                                 <div className={`bg-bambu-dark-tertiary rounded p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${isExtActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}>
                                   {/* Filament color circle with 1-based slot number centered inside */}
@@ -4329,10 +4355,11 @@ function PrinterCard({
                                     trayColor={extTray.tray_color}
                                     trayType={extTray.tray_type}
                                     isEmpty={isEmpty}
+                                    emptyKind={emptyKind}
                                     slotNumber={slotTrayId + 1}
                                   />
                                   <div className={`text-[9px] font-bold truncate ${isEmpty ? 'text-white/40' : 'text-white'}`}>
-                                    {extTray.tray_type || '—'}
+                                    {extTray.tray_type || t('ams.slotEmpty')}
                                   </div>
                                   <div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
                                     {extEffectiveFill !== null && extEffectiveFill >= 0 && !isEmpty && (
@@ -4506,6 +4533,7 @@ function PrinterCard({
                                     </FilamentHoverCard>
                                   ) : (
                                     <EmptySlotHoverCard
+                                      kind={emptyKind ?? undefined}
                                       configureSlot={{
                                         enabled: hasPermission('printers:control'),
                                         onConfigure: () => setConfigureSlotModal({

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-CcGEQ2Qx.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-CDuyQ_oR.js"></script>
+    <script type="module" crossorigin src="/assets/index-CcGEQ2Qx.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-KYwGxnG9.css">
   </head>
   <body>

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio