fix(inventory): break the Reset-Slot deadlock on A1 Mini BMCU / P1S Standard AMS (#1322)

The original #1322 fix widened empty-slot detection to (state == 11 OR
  tray_type != ""), which closed the configured-slot reconfig case but
  didn't help the "Reset Slot on printer screen with spool still inserted"
  flow. On these firmwares the AMS reports state=3, tray_type="" after a
  Reset Slot regardless of whether a spool is physically present, so the
  empty-detection still decided "empty", skipped MQTT, marked pending —
  and on_ams_change replay never re-fired because the AMS never reported
  any state change either.

  RosdasHH traced the path: tray_state=3 falls into the else: branch,
  slot_is_empty = not (fingerprint_type and fingerprint_type.strip()),
  fingerprint_type is "", so slot_is_empty=True, MQTT is skipped, and the
  slot stays unconfigured forever. He verified empirically that removing
  the gate makes the firmware accept the push when a spool is physically
  present.

  Drop the tray_type fallback entirely. Only state in {9, 10} (firmware's
  explicit "no spool" / "spool present but no feed") short-circuits the
  MQTT publish. Every other state — including 3 (default-idle, ambiguous)
  and missing-state (older firmwares) — attempts the publish. Bambu's
  "firmware silently drops on empty slots" behavior makes the worst case
  a no-op for a truly-empty slot, and on_ams_change replay still serves
  as the safety net for state=9/10 slots whose spools get inserted later.

  pending_config is now (slot_is_definitely_empty OR not configured) so a
  printer-offline / no-client publish failure correctly flags the
  assignment for replay instead of falsely showing "configured".
This commit is contained in:
maziggy
2026-05-15 10:33:38 +02:00
parent 405dd1525b
commit dca05ce6b1
3 changed files with 94 additions and 63 deletions
@@ -590,21 +590,25 @@ class TestAssignSpoolLiveCaliIdx:
class TestAssignSpoolEmptySlotPreConfig:
"""SpoolBuddy primary workflow: weigh-then-assign before the spool is in the AMS.
"""Assign path under ambiguous / explicit-empty AMS state.
Bambu firmware silently drops ams_filament_setting / extrusion_cali_sel for
unloaded slots — there's no filament context for the cali_idx to attach to.
The endpoint persists the SpoolAssignment row with an empty fingerprint_type
(the "pending config" marker) and skips the MQTT publish; on_ams_change
re-fires the full configuration when filament is later inserted.
Updated for the #1322 follow-up: only the firmware's *explicit* empty
signal (state ∈ {9, 10}) skips MQTT. Anything else — including the
SpoolBuddy weigh-then-assign-before-insert case where state/tray_type
can't tell us whether a spool is loaded — attempts MQTT. The deferred-
config workflow still works because on_ams_change at main.py:1031-1054
re-fires when an AMS push eventually reports the loaded slot.
"""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_empty_slot_skips_mqtt_but_persists_assignment(
async def test_empty_tray_type_without_state_still_fires_mqtt(
self, async_client: AsyncClient, printer_factory, spool_factory
):
"""Assigning to an empty slot skips MQTT and returns pending_config=True."""
"""tray_type='' with no state field: AMS can't tell us whether a
spool is loaded. Trust the user's Assign click and fire MQTT —
firmware accepts it when a spool is physically there, drops it
silently otherwise (no harm)."""
printer = await printer_factory(name="H2D")
spool = await spool_factory(slicer_filament="GFL05", material="PLA")
@@ -612,7 +616,6 @@ class TestAssignSpoolEmptySlotPreConfig:
mock_client.ams_set_filament_setting.return_value = True
mock_client.extrusion_cali_sel.return_value = True
# Slot found but empty (tray_type=""): the SpoolBuddy scenario
status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "tray_type": ""}]}])
with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
@@ -625,27 +628,27 @@ class TestAssignSpoolEmptySlotPreConfig:
)
assert response.status_code == 200
mock_client.ams_set_filament_setting.assert_called_once()
body = response.json()
assert body["pending_config"] is True
assert body["configured"] is False
# Critical: no MQTT was published (firmware would drop it)
mock_client.ams_set_filament_setting.assert_not_called()
mock_client.extrusion_cali_sel.assert_not_called()
assert body["pending_config"] is False
assert body["configured"] is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_empty_slot_no_ams_data_skips_mqtt(self, async_client: AsyncClient, printer_factory, spool_factory):
"""No AMS data at all (printer offline, no telemetry yet) → still pre-config."""
async def test_no_ams_data_with_no_client_marks_pending(
self, async_client: AsyncClient, printer_factory, spool_factory
):
"""No AMS data + no MQTT client (printer offline, no telemetry):
publish can't happen, so configured=False and pending_config=True so
on_ams_change replay picks it up when the printer comes online."""
printer = await printer_factory(name="X1C")
spool = await spool_factory(slicer_filament="GFL05", material="PLA")
mock_client = MagicMock()
# No AMS data — fingerprint_type stays None, treated as empty
# No AMS data — fingerprint_type stays None.
status = _make_mock_status(ams_data=[])
with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
mock_pm.get_client.return_value = mock_client
mock_pm.get_client.return_value = None # Printer offline, no MQTT client.
mock_pm.get_status.return_value = status
response = await async_client.post(
@@ -654,8 +657,9 @@ class TestAssignSpoolEmptySlotPreConfig:
)
assert response.status_code == 200
assert response.json()["pending_config"] is True
mock_client.ams_set_filament_setting.assert_not_called()
body = response.json()
assert body["pending_config"] is True
assert body["configured"] is False
@pytest.mark.asyncio
@pytest.mark.integration
@@ -994,15 +998,23 @@ class TestAssignSpoolEmptyDetection:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_state_missing_falls_back_to_tray_type_empty(
async def test_state_missing_with_empty_tray_type_still_fires_mqtt(
self, async_client: AsyncClient, printer_factory, spool_factory
):
"""Older firmware without state field + empty tray_type → pending."""
"""Older firmware without state field + empty tray_type still fires MQTT.
The AMS doesn't tell us whether a spool is physically loaded in this
case (no state, no tray_type), so the assign click is the user's
assertion that a spool is there. Firmware silently drops the push on
a truly empty slot — no harm done, and on_ams_change replay handles
the deferred-config case (#1322 follow-up).
"""
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
tray_data = {"id": 3, "tray_type": "", "tray_color": ""}
status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
@@ -1017,10 +1029,10 @@ class TestAssignSpoolEmptyDetection:
)
assert response.status_code == 200
# Legacy fallback: empty tray_type + no state → treated as empty.
mock_client.ams_set_filament_setting.assert_not_called()
mock_client.ams_set_filament_setting.assert_called_once()
body = response.json()
assert body["pending_config"] is True
assert body["pending_config"] is False
assert body["configured"] is True
@pytest.mark.asyncio
@pytest.mark.integration
@@ -1059,19 +1071,26 @@ class TestAssignSpoolEmptyDetection:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_state_never_eleven_firmware_with_empty_tray_marks_pending(
async def test_post_reset_slot_with_state_3_still_fires_mqtt(
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."""
"""A1 Mini BMCU / P1S Standard AMS post-"Reset Slot" with spool still
inserted: state=3, tray_type="". The AMS gives us no signal to tell
this apart from a truly-empty slot. We trust the user's Assign click
and fire MQTT — firmware accepts the push because a spool is
physically there (#1322 follow-up by @RosdasHH).
Replaces the previous "marks_pending" assertion which was the bug:
that gate created a deadlock because the AMS would never report a
state change (nothing physically changed), so on_ams_change replay
never re-fired the deferred config either.
"""
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
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]}])
@@ -1086,9 +1105,10 @@ class TestAssignSpoolEmptyDetection:
)
assert response.status_code == 200
mock_client.ams_set_filament_setting.assert_not_called()
mock_client.ams_set_filament_setting.assert_called_once()
body = response.json()
assert body["pending_config"] is True
assert body["pending_config"] is False
assert body["configured"] is True
@pytest.mark.asyncio
@pytest.mark.integration