Просмотр исходного кода

Broadcast AMS slot changes that keep the same material

Configuring a slot from the printer card left the card showing the old
filament until a reload or the 30s fallback poll. The command reached the
printer and the printer applied it; the update just never got broadcast.

on_printer_status_change deduplicates WebSocket pushes against a status_key
whose AMS part carried id, tray_type and state. Configure Slot writes none of
those -- it writes tray_info_idx, tray_color, tray_sub_brands and cali_idx. So
PLA to another brand or colour of PLA produced an identical key and was
dropped, while PLA to PETG came through. Reset always worked because it clears
tray_type.

Those four fields only move when someone configures a slot or swaps a spool,
so this costs no broadcasts mid-print. remain stays out of the key for the
opposite reason.
maziggy 1 месяц назад
Родитель
Сommit
684a328d6f
3 измененных файлов с 269 добавлено и 1 удалено
  1. 1 0
      CHANGELOG.md
  2. 21 1
      backend/app/main.py
  3. 247 0
      backend/tests/unit/test_status_broadcast_ams_slot_config.py

Разница между файлами не показана из-за своего большого размера
+ 1 - 0
CHANGELOG.md


+ 21 - 1
backend/app/main.py

@@ -1279,9 +1279,29 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
     # 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)
+    #
+    # The filament identity fields are here because Configure Slot writes
+    # exactly those and nothing else. Re-configuring a slot from PLA to another
+    # brand or colour of PLA leaves id/tray_type/state identical, so the key
+    # matched, this function returned before broadcasting, and the card kept
+    # showing the old filament until the 30s fallback poll or a page reload —
+    # even though the configure route asks the printer for a fresh pushall and
+    # that push does carry the new values. Reset always worked, because it
+    # clears tray_type.
+    #
+    # These fields only change when someone configures a slot or swaps a spool,
+    # so unlike temperature or progress they add no broadcast traffic mid-print.
     ams_tray_key = (
         tuple(
-            (t.get("id"), t.get("tray_type", ""), t.get("state"))
+            (
+                t.get("id"),
+                t.get("tray_type", ""),
+                t.get("state"),
+                t.get("tray_color", ""),
+                t.get("tray_info_idx", ""),
+                t.get("tray_sub_brands", ""),
+                t.get("cali_idx"),
+            )
             for a in (state.raw_data.get("ams") or [])
             for t in a.get("tray", [])
         )

+ 247 - 0
backend/tests/unit/test_status_broadcast_ams_slot_config.py

@@ -0,0 +1,247 @@
+"""Configuring an AMS slot must reach the printer card without a page reload.
+
+`on_printer_status_change` deduplicates WebSocket broadcasts against a
+`status_key`. Its AMS component used to carry only id / tray_type / state, so
+re-configuring a slot to a different brand or colour of the SAME material
+produced an identical key: the printer's pushall arrived with the new values,
+the handler compared, found no change, and returned without broadcasting. The
+card then showed the old filament until the 30s fallback poll or an F5.
+
+Reset never had the bug — it clears tray_type, which was always in the key.
+That asymmetry is what these tests pin: every field Configure Slot writes has
+to move the key, and the fields that churn every second still must not.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app import main as main_module
+
+
+def _spawn_patch():
+    """Close the reconcile coroutine the handler builds as a call argument.
+
+    Same reason as test_printer_offline_notification.py: a bare MagicMock keeps
+    it alive in call_args and it finalises unawaited during a later test's GC.
+    """
+    return patch(
+        "backend.app.main.spawn_background_task",
+        side_effect=lambda coro, **kwargs: coro.close(),
+    )
+
+
+def _tray(**overrides) -> dict:
+    """One AMS tray as the firmware reports it, mid-way through a print job.
+
+    Defaults describe a configured slot: Bambu PLA Basic in black, bound to
+    calibration slot 3.
+    """
+    tray = {
+        "id": "0",
+        "tray_type": "PLA",
+        "state": 10,
+        "tray_color": "000000FF",
+        "tray_info_idx": "GFA00",
+        "tray_sub_brands": "PLA Basic",
+        "cali_idx": 3,
+        "remain": 42,
+    }
+    tray.update(overrides)
+    return tray
+
+
+def _state(trays: list[dict]) -> SimpleNamespace:
+    """Minimal PrinterState stub carrying one AMS unit.
+
+    Idle and unheated, so the handler runs straight from the dedup check to the
+    broadcast without touching progress milestones, HMS notifications or the DB.
+    """
+    return SimpleNamespace(
+        connected=True,
+        state="IDLE",
+        progress=0,
+        layer_num=0,
+        temperatures={},
+        raw_data={"ams": [{"id": "0", "dry_time": 0, "tray": trays}]},
+        stg_cur=0,
+        cooling_fan_speed=0,
+        big_fan1_speed=0,
+        big_fan2_speed=0,
+        chamber_light="",
+        active_extruder=0,
+        tray_now=0,
+        door_open=False,
+        subtask_name="",
+        gcode_file="",
+        remaining_time=None,
+        hms_errors=[],
+        ams_filament_backup=None,
+    )
+
+
+@pytest.fixture(autouse=True)
+def _reset_edge_state():
+    main_module._last_status_broadcast.clear()
+    main_module._printer_last_connected.clear()
+    main_module._printer_reconciled_since_connect.clear()
+    yield
+    main_module._last_status_broadcast.clear()
+    main_module._printer_last_connected.clear()
+    main_module._printer_reconciled_since_connect.clear()
+
+
+async def _push(ws_mgr, trays: list[dict]) -> None:
+    """Deliver one status push to the handler."""
+    relay = MagicMock()
+    relay.on_printer_status = AsyncMock()
+    pm = MagicMock()
+    pm.get_printer.return_value = None  # Skip the relay payload branch.
+    pm.get_model.return_value = ""
+
+    with (
+        patch("backend.app.main.ws_manager", ws_mgr),
+        patch("backend.app.main.mqtt_relay", relay),
+        patch("backend.app.main.printer_manager", pm),
+        _spawn_patch(),
+        patch("backend.app.main.printer_state_to_dict", return_value={}),
+    ):
+        await main_module.on_printer_status_change(1, _state(trays))
+
+
+@pytest.fixture
+def ws_mgr():
+    mgr = MagicMock()
+    mgr.send_printer_status = AsyncMock()
+    return mgr
+
+
+class TestConfigureSlotBroadcasts:
+    """Each field Configure Slot writes must break the dedup on its own —
+    the user may change only the colour, or only the K-profile."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        "field,new_value",
+        [
+            ("tray_color", "FF0000FF"),
+            ("tray_info_idx", "GFA01"),
+            ("tray_sub_brands", "PLA Matte"),
+            ("cali_idx", 7),
+        ],
+    )
+    async def test_a_changed_filament_field_broadcasts(self, ws_mgr, field, new_value):
+        await _push(ws_mgr, [_tray()])
+        assert ws_mgr.send_printer_status.await_count == 1
+
+        await _push(ws_mgr, [_tray(**{field: new_value})])
+
+        assert ws_mgr.send_printer_status.await_count == 2, (
+            f"changing {field} did not reach the frontend — the card would keep "
+            "showing the old filament until the fallback poll"
+        )
+
+    @pytest.mark.asyncio
+    async def test_the_realistic_reconfigure_broadcasts(self, ws_mgr):
+        """Black Bambu PLA Basic → red eSUN PLA+ with its own K-profile.
+
+        The whole point of the report: same material, so every field the old key
+        looked at is unchanged.
+        """
+        await _push(ws_mgr, [_tray()])
+
+        await _push(
+            ws_mgr,
+            [
+                _tray(
+                    tray_color="C1121FFF",
+                    tray_info_idx="GFL99",
+                    tray_sub_brands="eSUN PLA+",
+                    cali_idx=5,
+                )
+            ],
+        )
+
+        assert ws_mgr.send_printer_status.await_count == 2
+
+    @pytest.mark.asyncio
+    async def test_a_second_slot_is_watched_too(self, ws_mgr):
+        """The key spans every tray, so configuring slot 2 must broadcast even
+        though slot 1 is untouched."""
+        trays = [_tray(id="0"), _tray(id="1", tray_type="PETG", tray_info_idx="GFG00")]
+        await _push(ws_mgr, trays)
+
+        changed = [_tray(id="0"), _tray(id="1", tray_type="PETG", tray_info_idx="GFG01")]
+        await _push(ws_mgr, changed)
+
+        assert ws_mgr.send_printer_status.await_count == 2
+
+
+class TestDedupStillHolds:
+    """The dedup exists to keep a printing machine from flooding the socket.
+    Widening the key must not have cost that."""
+
+    @pytest.mark.asyncio
+    async def test_an_identical_push_is_still_suppressed(self, ws_mgr):
+        await _push(ws_mgr, [_tray()])
+        await _push(ws_mgr, [_tray()])
+
+        assert ws_mgr.send_printer_status.await_count == 1
+
+    @pytest.mark.asyncio
+    async def test_remaining_filament_does_not_broadcast(self, ws_mgr):
+        """`remain` ticks down throughout a print and is deliberately absent
+        from the key. It sits in the same tray dict as the fields we added, so
+        this pins that we widened the key rather than hashing the whole tray."""
+        await _push(ws_mgr, [_tray(remain=42)])
+        await _push(ws_mgr, [_tray(remain=41)])
+
+        assert ws_mgr.send_printer_status.await_count == 1
+
+
+class TestExistingBehaviourUnchanged:
+    """The cases that already worked, kept working."""
+
+    @pytest.mark.asyncio
+    async def test_a_load_unload_transition_still_broadcasts(self, ws_mgr):
+        """#784 — tray state 11→10."""
+        await _push(ws_mgr, [_tray(state=11)])
+        await _push(ws_mgr, [_tray(state=10)])
+
+        assert ws_mgr.send_printer_status.await_count == 2
+
+    @pytest.mark.asyncio
+    async def test_resetting_a_slot_still_broadcasts(self, ws_mgr):
+        """Reset clears the filament identity outright."""
+        await _push(ws_mgr, [_tray()])
+        await _push(
+            ws_mgr,
+            [_tray(tray_type="", tray_color="", tray_info_idx="", tray_sub_brands="", cali_idx=-1)],
+        )
+
+        assert ws_mgr.send_printer_status.await_count == 2
+
+    @pytest.mark.asyncio
+    async def test_a_printer_with_no_ams_still_broadcasts_once(self, ws_mgr):
+        """The `else ()` branch — an AMS-less printer must not crash or
+        double-broadcast."""
+        relay = MagicMock()
+        relay.on_printer_status = AsyncMock()
+        pm = MagicMock()
+        pm.get_printer.return_value = None
+        pm.get_model.return_value = ""
+        state = _state([])
+        state.raw_data = {}
+
+        for _ in range(2):
+            with (
+                patch("backend.app.main.ws_manager", ws_mgr),
+                patch("backend.app.main.mqtt_relay", relay),
+                patch("backend.app.main.printer_manager", pm),
+                _spawn_patch(),
+                patch("backend.app.main.printer_state_to_dict", return_value={}),
+            ):
+                await main_module.on_printer_status_change(1, state)
+
+        assert ws_mgr.send_printer_status.await_count == 1

Некоторые файлы не были показаны из-за большого количества измененных файлов