Files
bambuddy/backend/tests/unit/test_printer_manager_status_broadcast.py
T
Gabe 9ee162d51a feat(printers): expose P2S/X2D accessory fans (left aux + exhaust)
The P2S/X2D have two fans bambuddy didn't fully handle. On the P2S both are
add-on kits; on the X2D they ship from the factory.

1. Left auxiliary part cooling fan — not shown or controllable. It is reported
   ONLY as device.airduct part id 10 (raw id 160 >> 4; FAN_REMOTE_COOLING_1 in
   Bambu Studio's DevFan::ParseV3_0) and is never mirrored into a flat
   big_fanX_speed field, which is why it was invisible. This is the gap
   identified in #2576, where the single 'Auxiliary' fan (big_fan1 / M106 P2)
   only reaches the right-hand aux fan.

2. Chamber exhaust fan — shown on every P2S labelled 'Chamber Fan'. On P2S/X2D
   Bambu's firmware/UI (and Bambu Studio's FAN_CHAMBER_0_IDX) call it 'Exhaust',
   and it is a kit on the P2S rather than built in.

Both are now detected from device.airduct.parts, which lists only the fans that
physically exist, so each tile appears only when the hardware is present.

- bambu_mqtt: parse airduct part 10 -> left_aux_fan_speed (None when absent) and
  part 3 presence -> exhaust_fan_present; set_fan_speed() accepts index 10 plus a
  set_left_aux_fan() helper
- schema / status route / printer_manager broadcast / mqtt_relay expose both fields
- POST /printers/{id}/fan-speed accepts fan=aux2 -> M106 P10, the command Bambu's
  official P2S machine profiles use
- frontend: 'Left Auxiliary Fan' tile shown when reported; big_fan2 tile labelled
  'Exhaust' and presence-gated on P2S/X2D, unchanged 'Chamber Fan' elsewhere
- i18n: leftAuxiliary + exhaust for all 12 locales

Verified fan -> field map on a live P2S (fw 01.02.00.00), stable across cooling
and heating airduct modes:
  Part cooling -> cooling_fan_speed / airduct id 1  (built in)
  Aux          -> big_fan1_speed    / airduct id 2  (built in)
  Exhaust      -> big_fan2_speed    / airduct id 3  (kit)
  Left aux     -> airduct id 10 only (kit; forced off in heating by mode config)

Tests: airduct id-10 parsing (raw 160 -> id 10, not literal 160), id-3 presence,
base-P2S absence, diff-push survival, clamping, malformed entries, M106 P10
emission, invalid-index rejection; fan-speed API aux2->10 mapping; frontend tile
presence and labelling per model/kit.
2026-07-28 11:16:22 -05:00

304 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,
}
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