Browse Source

fix(inventory): assign to AMS slot on firmwares that never report state=11 (#1322)

  A1 Mini BMCU (01.07.02.00) and P1S Standard AMS (00.00.06.75) always
  report tray.state=3, even for loaded configured slots. The empty-slot
  detection preferred state==11 with tray_type as a fallback only when
  state was absent, so every assign was classified as empty and MQTT
  was skipped — both for "assign to unconfigured slot" and the secondary
  "PETG over a PLA-configured slot won't reconfigure" symptom.

  Empty-slot detection in the assign route and the on_ams_change replay
  now treats the slot as loaded when EITHER state==11 OR tray_type is
  non-empty. Reset-slot case (state=11 + tray_type="") still works
  through the first clause; configured slots on these firmwares now
  work through the second.

  Truly empty unconfigured slots (state!=11 + tray_type="") still hit
  the pending-config path, and the deferred publish now fires when the
  user later configures the slot in Bambu Studio (tray_type goes
  non-empty), since the replay uses the same disjunction.
maziggy 1 week ago
parent
commit
f45aaea97c

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


+ 17 - 11
backend/app/api/routes/inventory.py

@@ -1255,17 +1255,23 @@ async def assign_spool(
     # inserted later, on_ams_change re-fires the full configuration. This is
     # the SpoolBuddy primary workflow: weigh-then-assign before insertion.
     #
-    # Empty-detection: prefer the printer's tray.state when it's reported
-    # (11=loaded, 9=empty, 10=spool present but filament not in feeder).
-    # tray_type alone is wrong post-"Reset slot" — that flow clears tray_type
-    # to "" while leaving filament physically loaded, and the old check
-    # would then mark it as a pending-config SpoolBuddy assignment, skip
-    # MQTT, and the slot would stay unconfigured forever because the
-    # on_ams_change replay only fires on an empty→loaded transition that
-    # never comes (the slot is already loaded). When state is not reported
-    # (older firmware), fall back to the tray_type heuristic.
-    if tray_state is not None:
-        slot_is_empty = tray_state != 11
+    # Empty-detection: priority order is the firmware's explicit signals
+    # first, then a tray_type fallback for firmwares that don't use the full
+    # state enum meaningfully.
+    #   - state == 9  → empty (firmware says "no spool")
+    #   - state == 10 → empty (firmware says "spool present but no feed")
+    #   - state == 11 → loaded (firmware says "filament in extruder")
+    #   - any other state value, including 3 (A1 Mini BMCU / P1S Standard AMS
+    #     always report 3) or None (older firmwares omit the field), falls
+    #     back to tray_type: configured ⇒ loaded, empty ⇒ empty (#1322).
+    # The tray_type fallback for state==3 fixes the reported bug. The 9/10
+    # branch keeps existing behavior intact even if the MQTT relay's
+    # auto-clearing of tray_type ever fails to fire — the firmware's
+    # explicit "empty" signal stays authoritative over stale metadata.
+    if tray_state == 9 or tray_state == 10:
+        slot_is_empty = True
+    elif tray_state == 11:
+        slot_is_empty = False
     else:
         slot_is_empty = not (fingerprint_type and fingerprint_type.strip())
     configured = False

+ 12 - 6
backend/app/main.py

@@ -1018,12 +1018,18 @@ async def on_ams_change(printer_id: int, ams_data: list):
                     # MQTT was deferred). The moment any filament gets inserted
                     # — Bambu RFID, 3rd-party, or even an existing-but-now-
                     # reconfigured spool — fire the deferred configuration.
-                    # The "loaded" signal is `state == 11` (Bambu's "filament fed
-                    # to extruder" code), NOT tray_type — 3rd-party spools without
-                    # readable RFID report state=11 but tray_type="" because the
-                    # AMS sensor reads no filament metadata. Requiring a non-empty
-                    # tray_type would lock out the exact users this feature targets.
-                    if not fp_type.strip() and cur_state == 11 and assignment.spool:
+                    # The "loaded" signal is state == 11 (Bambu's "filament fed to
+                    # extruder" code) OR, on firmwares that don't use the state
+                    # enum meaningfully, a non-empty tray_type when state is
+                    # NOT one of the firmware's explicit empty signals (9, 10).
+                    # state-only was wrong for firmwares that never set 11 — A1
+                    # Mini BMCU 01.07.02.00 and P1S Standard AMS 00.00.06.75 both
+                    # always report state=3 — so the replay never fired for them
+                    # (#1322). The state ∉ {9,10} guard keeps the firmware's
+                    # explicit "empty" signals authoritative over any stale
+                    # tray_type that might survive the relay's auto-clearing.
+                    loaded = cur_state == 11 or (cur_state not in (9, 10) and cur_type.strip())
+                    if not fp_type.strip() and loaded and assignment.spool:
                         try:
                             from backend.app.api.routes.inventory import (
                                 apply_spool_to_slot_via_mqtt,

+ 137 - 0
backend/tests/integration/test_inventory_assign.py

@@ -797,6 +797,75 @@ class TestAssignSpoolEmptySlotPreConfig:
         # Fingerprint was already set — re-fire path skipped
         mock_client.ams_set_filament_setting.assert_not_called()
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_on_ams_change_fires_replay_when_tray_type_appears_without_state_11(
+        self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
+    ):
+        """A1 Mini / P1S firmware variant of the SpoolBuddy pre-config replay
+        (#1322). The user pre-assigned via SpoolBuddy (fingerprint empty), then
+        configured the slot manually in Bambu Studio so tray_type went from ''
+        to 'PLA' — but state stays at 3 because these firmwares never set it
+        to 11. With state-only detection the replay never fired."""
+        from unittest.mock import AsyncMock
+
+        from backend.app.main import on_ams_change
+        from backend.app.models.spool_assignment import SpoolAssignment
+
+        printer = await printer_factory(name="A1 mini")
+        spool = await spool_factory(slicer_filament="GFL05", material="PLA")
+
+        pre_assignment = SpoolAssignment(
+            spool_id=spool.id,
+            printer_id=printer.id,
+            ams_id=0,
+            tray_id=3,
+            fingerprint_color=None,
+            fingerprint_type=None,
+        )
+        db_session.add(pre_assignment)
+        await db_session.commit()
+
+        # state=3 (never goes to 11 on A1 Mini BMCU 01.07.02.00) but tray_type
+        # is now configured — the replay must fire on this transition too.
+        ams_data = [
+            {
+                "id": 0,
+                "tray": [{"id": 3, "tray_type": "PLA", "tray_color": "FF0000FF", "state": 3, "tray_info_idx": "GFL05"}],
+            }
+        ]
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+
+        status = _make_mock_status(ams_data=ams_data)
+        printer_info = MagicMock(name="A1 mini", serial_number="0309CA391800999")
+
+        with (
+            patch("backend.app.main.printer_manager") as mock_pm_main,
+            patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
+            patch("backend.app.main.mqtt_relay") as mock_relay,
+            patch("backend.app.main.ws_manager") as mock_ws,
+        ):
+            mock_pm_main.get_printer.return_value = printer_info
+            mock_pm_main.get_status.return_value = status
+            mock_pm_main.get_client.return_value = mock_client
+            mock_pm_main.get_model.return_value = "A1 mini"
+            mock_pm_inv.get_client.return_value = mock_client
+            mock_pm_inv.get_status.return_value = status
+            mock_relay.on_ams_change = AsyncMock()
+            mock_ws.send_printer_status = AsyncMock()
+            mock_ws.broadcast = AsyncMock()
+
+            await on_ams_change(printer.id, ams_data)
+
+        # Replay fired despite state never being 11 — the disjunction picked
+        # up tray_type going non-empty.
+        mock_client.ams_set_filament_setting.assert_called_once()
+        await db_session.refresh(pre_assignment)
+        assert pre_assignment.fingerprint_type == "PLA"
+
 
 class TestAssignSpoolEmptyDetection:
     """Bambu firmware reports tray.state — 11=loaded, 9=empty, 10=spool present
@@ -938,6 +1007,74 @@ class TestAssignSpoolEmptyDetection:
         body = response.json()
         assert body["pending_config"] is True
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_state_never_eleven_firmware_with_loaded_tray_fires_mqtt(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """A1 Mini BMCU 01.07.02.00 and P1S Standard AMS 00.00.06.75 always
+        report tray.state=3, never 11 — even for fully-loaded configured slots.
+        A state-only check classified those as empty and skipped MQTT (#1322).
+        With the disjunctive check, tray_type='PLA' alone is enough to fire."""
+        printer = await printer_factory()
+        spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+
+        # state=3, tray_type non-empty — A1 Mini / P1S configured slot.
+        tray_data = {"id": 3, "state": 3, "tray_type": "PLA", "tray_color": "FF0000FF", "tray_info_idx": "GFL99"}
+        status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
+
+        with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.post(
+                "/api/v1/inventory/assignments",
+                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
+            )
+
+        assert response.status_code == 200
+        mock_client.ams_set_filament_setting.assert_called_once()
+        body = response.json()
+        assert body["pending_config"] is False
+        assert body["configured"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_state_never_eleven_firmware_with_empty_tray_marks_pending(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """Same firmwares as above, but the slot is truly unconfigured
+        (tray_type=''). Neither signal points to 'loaded', so this should
+        still pending-config — the user has to configure or insert filament
+        before MQTT can fire. Pins that the disjunction didn't accidentally
+        flip empty slots into the loaded branch."""
+        printer = await printer_factory()
+        spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+
+        tray_data = {"id": 3, "state": 3, "tray_type": "", "tray_color": "00000000", "tray_info_idx": ""}
+        status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
+
+        with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.post(
+                "/api/v1/inventory/assignments",
+                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
+            )
+
+        assert response.status_code == 200
+        mock_client.ams_set_filament_setting.assert_not_called()
+        body = response.json()
+        assert body["pending_config"] is True
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_external_slot_state_loaded_with_empty_tray_type_fires_mqtt(

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