mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-03 12:46:49 +02:00
With a switch fitted, an AMS is not wired to a nozzle any more. It is plumbed into one of the switch's two inlets and reaches both nozzles through it, so every unit reports its extruder as "not fixed" (0xE) and ams_extruder_map comes back empty on these machines. The printer card had nothing to fall back on but the AMS unit number, so AMS-A was badged R and AMS-B was badged L purely because their unit ids are 0 and 1, a third unit got no badge at all, and every one of those labels was wrong. The SpoolBuddy assign modal had the same fallback in a worse form, mapping anything that was not extruder 1 to R. The binding turned out to need no new telemetry. BambuStudio reads it out of bits 24-27 of the same AMS info string we already parse for the AMS type and the extruder id -- 0 is In-B, 1 is In-A -- and it is only meaningful when a switch is installed, because without one 0xE really does mean an uninitialised unit and those bits carry nothing. That gates the read, which in turn forced the switch block to be parsed before the AMS block: _handle_ams_data runs early in _process_message and _update_state only much later, so the binding was lost on every frame that carried both. _parse_fila_switch is split out and called first, and left in _update_state as well so that stays a complete absorb step. The badge keeps L and R rather than A and B, because the lettering is familiar and matches the physical layout. It is a different colour from the plain nozzle badge, and its tooltip names the inlet in full, since the letter is the inlet's position and not a claim about which nozzle that AMS feeds -- the switch can route either inlet to either outlet. An AMS still reporting a real extruder id keeps its ordinary badge, which BambuStudio also treats as authoritative over any switch binding, and a switch that has been fitted but not yet set up on the printer shows nothing rather than a guess. The print dialog's slot dropdown gets the same label. It replaces a left/right hint that never once rendered: ftsExtruderForSlot compared snow-encoded in[] values against global tray ids and could not match. Decoding it correctly would not have saved it -- the firmware reports which slot sits in each inlet and which nozzle each outlet feeds, but never which inlet is currently paired with which outlet, so no per-slot nozzle can be derived. That function is gone rather than fixed. The dialog also points out when every filament a print needs sits behind one inlet. Bambu's own guidance is that this is legal but slow: a change between two filaments on the same inlet retracts the outgoing spool all the way back to its AMS before the next can be fed up the shared tube, where a change across the two inlets only retracts as far as the switch. All on one inlet means every change in the job takes the slow path, and moving a single spool fixes it. So it advises, it does not block. Both views update live. Two things were stopping that. fila_switch and ams_switch_inlet were absent from printer_state_to_dict, and the frontend shallow-merges each WebSocket push over its cached status, so a field the push omits keeps whatever the last full fetch left behind. And the broadcast dedup key had no term for either, so "Join IN-B" on the printer screen moved nothing: the binding is not in the tray component of that key, and it is not in the AMS change-hash either, which covers tray fields only and must stay that way because it drives Spoolman sync. Assigning an AMS to an inlet remains printer-side. BambuStudio can read the binding and has no command to write it -- its switch class is parse and getters only, and the recommended-arrangement popup draws and publishes nothing -- so there is no wire format for us to copy. Adding the two fields to PrinterState broke four test modules whose SimpleNamespace stubs predate them. The stubs are fixed rather than the production reads made defensive: the real dataclass always carries both, and a getattr in the dedup key would silently stop tracking the field if it were ever renamed.
308 lines
13 KiB
Python
308 lines
13 KiB
Python
"""Regression tests for ``PrinterManager._broadcast_status_change`` and
|
|
its wiring from ``set_awaiting_plate_clear`` (#1128).
|
|
|
|
The bug: ``awaiting_plate_clear`` is a Bambuddy-side flag, so toggling it
|
|
doesn't produce an MQTT push from the printer. Before the fix,
|
|
``set_awaiting_plate_clear()`` mutated state and persisted to DB but never
|
|
notified WebSocket subscribers. The plate-clear button on the printer card
|
|
disappeared "immediately" only because of an optimistic React Query cache
|
|
update on the click path; any other caller (admin script, second tab, an
|
|
automation that hits ``POST /printers/{id}/clear-plate``) silently left
|
|
the UI stale until the next coincidental status refresh.
|
|
|
|
These tests pin the contract: every flip of the flag must schedule a
|
|
``printer_status`` broadcast, and the broadcast must carry the new flag
|
|
value so subscribers see the right state without polling.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from backend.app.services.printer_manager import PrinterManager
|
|
|
|
|
|
@pytest.fixture
|
|
def manager():
|
|
"""Fresh manager per test; the awaiting-plate-clear set is per-instance."""
|
|
return PrinterManager()
|
|
|
|
|
|
def _close_unawaited(coro):
|
|
"""Side effect for mocked ``_schedule_async``.
|
|
|
|
``set_awaiting_plate_clear`` evaluates the coroutine expressions
|
|
``self._persist_awaiting_plate_clear(...)`` and
|
|
``self._broadcast_status_change(...)`` before passing them to
|
|
``_schedule_async``. When that target is patched, the coroutine objects
|
|
leak — Python's ``__del__`` then emits ``coroutine was never awaited``
|
|
during GC, and when GC runs late enough that warning hits the interpreter
|
|
shutdown path with ``KeyError: '__import__'``. Closing the coroutine here
|
|
prevents both. Returns ``None`` so the mock's call signature is unchanged.
|
|
"""
|
|
if asyncio.iscoroutine(coro):
|
|
coro.close()
|
|
return None
|
|
|
|
|
|
def _fake_state(**overrides):
|
|
"""Stand-in for a ``PrinterState``.
|
|
|
|
The tests below patch ``printer_state_to_dict`` so the fake doesn't need
|
|
to satisfy every attribute access — but the patch was observed to race on
|
|
parallel CI runners (pytest-xdist), and when it didn't catch the call the
|
|
real ``printer_state_to_dict`` ran against this fake and ``AttributeError``'d
|
|
on ``.kprofiles``. The fake now carries every attribute the real function
|
|
reads, so it remains correct even if the patch is somehow bypassed — the
|
|
test no longer depends on a fragile monkeypatch landing in time.
|
|
|
|
Iterables (``kprofiles``, ``printable_objects``, ``hms_errors``,
|
|
``temperatures``, etc.) default to empty so the function's loops are
|
|
no-ops; scalars default to ``None`` so any "if state.x is None" guard
|
|
falls through cleanly.
|
|
"""
|
|
base = {
|
|
# State the existing test bodies explicitly set / read
|
|
"connected": True,
|
|
"state": "FINISH",
|
|
"raw_data": {},
|
|
"progress": 100.0,
|
|
# Iterables — must be iterable for the loops inside printer_state_to_dict
|
|
"kprofiles": [],
|
|
"printable_objects": [],
|
|
"hms_errors": [],
|
|
"temperatures": {},
|
|
"nozzle_rack": [],
|
|
# Nullable scalars — printer_state_to_dict tolerates None for these
|
|
"active_extruder": None,
|
|
"ams_status_main": None,
|
|
"ams_status_sub": None,
|
|
"big_fan1_speed": None,
|
|
"big_fan2_speed": None,
|
|
"chamber_light": None,
|
|
"cooling_fan_speed": None,
|
|
"current_print": None,
|
|
"door_open": None,
|
|
"firmware_version": None,
|
|
"gcode_file": None,
|
|
"heatbreak_fan_speed": None,
|
|
"left_aux_fan_speed": None,
|
|
"exhaust_fan_present": False,
|
|
"layer_num": None,
|
|
"remaining_time": None,
|
|
"speed_level": None,
|
|
"stg_cur": 0, # get_derived_status_name does ``0 <= state.stg_cur < 255``
|
|
"subtask_name": None,
|
|
"total_layers": None,
|
|
"tray_now": None,
|
|
"wifi_signal": None,
|
|
"wired_network": None,
|
|
"ams_filament_backup": None,
|
|
# Filament Track Switch. None means "no accessory", which is what
|
|
# printer_state_to_dict gates both of these on.
|
|
"fila_switch": None,
|
|
"ams_switch_inlet": {},
|
|
}
|
|
base.update(overrides)
|
|
return SimpleNamespace(**base)
|
|
|
|
|
|
def _scheduled_names(mock) -> list[str]:
|
|
"""Coroutine names passed to the patched ``_schedule_async``.
|
|
|
|
Asserting on names rather than a bare call count keeps this file pinned to
|
|
#1128's contract (persist + broadcast on every flag mutation) without
|
|
breaking every time another emission is hung off the same setter — #2525
|
|
added an edge-triggered MQTT/notification relay, which is covered by its
|
|
own test module.
|
|
"""
|
|
return [call.args[0].__qualname__.rsplit(".", 1)[-1] for call in mock.call_args_list]
|
|
|
|
|
|
class TestSchedulingFromSetAwaitingPlateClear:
|
|
"""The hook from the public flag-mutation method into the broadcast."""
|
|
|
|
def test_schedules_broadcast_when_loop_running(self, manager):
|
|
"""When a real event loop is attached, every call to
|
|
``set_awaiting_plate_clear`` must enqueue both the persistence
|
|
coroutine and the broadcast coroutine. Both are needed: persist
|
|
survives restarts, broadcast notifies live subscribers."""
|
|
manager._loop = MagicMock()
|
|
manager._loop.is_running.return_value = True
|
|
|
|
with patch.object(manager, "_schedule_async", side_effect=_close_unawaited) as scheduled:
|
|
manager.set_awaiting_plate_clear(7, True)
|
|
|
|
# Persist + broadcast, in either order.
|
|
names = _scheduled_names(scheduled)
|
|
assert "_persist_awaiting_plate_clear" in names
|
|
assert "_broadcast_status_change" in names
|
|
|
|
def test_does_not_schedule_when_no_loop_attached(self, manager):
|
|
"""Sync unit-test path (no loop attached): nothing must be
|
|
scheduled, otherwise Python emits 'coroutine was never awaited'
|
|
runtime warnings and the test suite goes red on harmless flag
|
|
twiddling."""
|
|
manager._loop = None
|
|
|
|
with patch.object(manager, "_schedule_async") as scheduled:
|
|
manager.set_awaiting_plate_clear(7, True)
|
|
|
|
scheduled.assert_not_called()
|
|
|
|
def test_does_not_schedule_when_loop_not_running(self, manager):
|
|
"""A loop attached-but-stopped is the same situation as no loop —
|
|
scheduling onto a dead loop would never fire."""
|
|
manager._loop = MagicMock()
|
|
manager._loop.is_running.return_value = False
|
|
|
|
with patch.object(manager, "_schedule_async") as scheduled:
|
|
manager.set_awaiting_plate_clear(7, True)
|
|
|
|
scheduled.assert_not_called()
|
|
|
|
def test_both_true_and_false_flips_schedule_broadcast(self, manager):
|
|
"""The bug only became visible on ``False`` flips (clear), but a
|
|
regression that broadcasts only on ``True`` would re-introduce
|
|
the original symptom for any future flag mutation that goes
|
|
``False → True`` outside the printer-card optimistic-update
|
|
path. Make both directions a contract."""
|
|
manager._loop = MagicMock()
|
|
manager._loop.is_running.return_value = True
|
|
|
|
with patch.object(manager, "_schedule_async", side_effect=_close_unawaited) as scheduled:
|
|
manager.set_awaiting_plate_clear(7, True)
|
|
scheduled.reset_mock()
|
|
manager.set_awaiting_plate_clear(7, False)
|
|
|
|
# The False flip persists and broadcasts just like the True flip did.
|
|
names = _scheduled_names(scheduled)
|
|
assert "_persist_awaiting_plate_clear" in names
|
|
assert "_broadcast_status_change" in names
|
|
|
|
|
|
class TestBroadcastStatusChange:
|
|
"""The broadcast coroutine itself."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_emits_ws_update_when_state_present(self, manager):
|
|
"""Happy path: printer has a known status, broadcast goes out
|
|
with the dict produced by ``printer_state_to_dict``.
|
|
|
|
Note: we deliberately don't patch ``printer_state_to_dict`` here.
|
|
The patch was observed to race on parallel xdist runners — when it
|
|
didn't catch the call the real function ran, leaving the test
|
|
comparing the patched return value against the real dict shape.
|
|
Letting the real function run (against a complete ``_fake_state``)
|
|
makes the test deterministic; we assert structural shape, not the
|
|
exact ~36 keys, because pinning those couples the test to the
|
|
evolving ``printer_state_to_dict`` body and adds zero value over
|
|
what ``test_printer_manager.py`` already covers."""
|
|
state = _fake_state()
|
|
with (
|
|
patch.object(manager, "get_status", return_value=state),
|
|
patch.object(manager, "get_model", return_value="P1S"),
|
|
patch(
|
|
"backend.app.core.websocket.ws_manager.send_printer_status",
|
|
new_callable=AsyncMock,
|
|
) as send_status,
|
|
):
|
|
await manager._broadcast_status_change(7)
|
|
|
|
send_status.assert_awaited_once()
|
|
printer_id_arg, payload_arg = send_status.await_args.args
|
|
assert printer_id_arg == 7
|
|
assert isinstance(payload_arg, dict)
|
|
# The ``awaiting_plate_clear`` key is the whole point of this broadcast
|
|
# path (#1128). Any future restructuring that drops it from the dict
|
|
# would silently break the UI; pin its presence.
|
|
assert "awaiting_plate_clear" in payload_arg
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skips_when_status_unknown(self, manager):
|
|
"""Printer not connected / unknown ID → no point broadcasting a
|
|
snapshot we don't have. A future reconnect will produce a fresh
|
|
status push anyway, so we'd only be forcing a stale or bogus
|
|
payload onto subscribers right now."""
|
|
with (
|
|
patch.object(manager, "get_status", return_value=None),
|
|
patch(
|
|
"backend.app.core.websocket.ws_manager.send_printer_status",
|
|
new_callable=AsyncMock,
|
|
) as send_status,
|
|
):
|
|
await manager._broadcast_status_change(999)
|
|
|
|
send_status.assert_not_awaited()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_swallows_websocket_errors(self, manager):
|
|
"""The broadcast is a courtesy, not a correctness path — if the
|
|
WS layer is down, the flag is already mutated in-memory and
|
|
persisted. Letting an exception bubble out of
|
|
``_broadcast_status_change`` would surface as an
|
|
``Exception in scheduled callback`` traceback in the log AND
|
|
prevent the persistence coroutine from completing if both were
|
|
gathered together. Swallow + warn instead."""
|
|
with (
|
|
patch.object(manager, "get_status", return_value=_fake_state()),
|
|
patch.object(manager, "get_model", return_value="P1S"),
|
|
patch(
|
|
"backend.app.core.websocket.ws_manager.send_printer_status",
|
|
new_callable=AsyncMock,
|
|
side_effect=RuntimeError("websocket layer unavailable"),
|
|
),
|
|
):
|
|
# Must not raise.
|
|
await manager._broadcast_status_change(7)
|
|
|
|
|
|
class TestEndToEndUnderRunningLoop:
|
|
"""Verify the full flow under a real running event loop — schedule
|
|
→ broadcast → ws_manager.send_printer_status — without mocking
|
|
``_schedule_async``. Catches regressions where individual pieces
|
|
pass but the wiring breaks (e.g. ``_schedule_async`` swallowing the
|
|
broadcast coroutine)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_false_eventually_emits_broadcast(self, manager):
|
|
"""Reproduces the #1128 fix path end-to-end: set the flag to
|
|
False under a live loop, give the scheduler a tick, the
|
|
ws broadcast must have fired with the new payload."""
|
|
loop = asyncio.get_running_loop()
|
|
manager._loop = loop
|
|
# Pretend the printer has been seen — without a state present
|
|
# the broadcast short-circuits before reaching ws_manager.
|
|
manager._awaiting_plate_clear.add(7)
|
|
|
|
# _fake_state defaults awaiting_plate_clear=False via printer_state_to_dict's
|
|
# is_awaiting_plate_clear(printer_id) lookup, which reads from
|
|
# manager._awaiting_plate_clear (the in-memory set). Since we just
|
|
# removed 7 from that set by calling set_awaiting_plate_clear(7, False),
|
|
# the broadcast payload's awaiting_plate_clear field will be False.
|
|
with (
|
|
patch.object(manager, "get_status", return_value=_fake_state()),
|
|
patch.object(manager, "get_model", return_value="P1S"),
|
|
patch(
|
|
"backend.app.core.websocket.ws_manager.send_printer_status",
|
|
new_callable=AsyncMock,
|
|
) as send_status,
|
|
# Persistence path opens a DB session; stub it out so this
|
|
# stays a pure unit test.
|
|
patch.object(manager, "_persist_awaiting_plate_clear", new_callable=AsyncMock),
|
|
):
|
|
manager.set_awaiting_plate_clear(7, False)
|
|
# Yield repeatedly so run_coroutine_threadsafe has a chance
|
|
# to land its scheduled coroutine on this loop.
|
|
for _ in range(10):
|
|
await asyncio.sleep(0)
|
|
|
|
send_status.assert_awaited()
|
|
printer_id_arg, payload_arg = send_status.await_args.args
|
|
assert printer_id_arg == 7
|
|
assert payload_arg["awaiting_plate_clear"] is False
|