mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +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.
360 lines
15 KiB
Python
360 lines
15 KiB
Python
"""Tests for the connected → disconnected edge that fires the
|
|
`on_printer_offline` notification (#1752).
|
|
|
|
The provider toggle, schema, and dispatcher already existed; what was missing
|
|
was a caller that fires `notification_service.on_printer_offline` when a
|
|
printer goes offline. These tests pin both layers:
|
|
|
|
* `_maybe_notify_printer_offline` — the debounced background task. Must
|
|
fire when the printer is still offline at the end of the window, and
|
|
must NOT fire if the printer reconnected during the window.
|
|
|
|
* Edge detection inside `on_printer_status_change` — schedules the task
|
|
only on the True → False transition, cancels any pending task on
|
|
reconnect, and stays silent on startup (no prior connected state).
|
|
"""
|
|
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from backend.app import main as main_module
|
|
from backend.tests._fixtures.background_tasks import MAIN_TARGET, discarding_spawn_patch
|
|
|
|
|
|
def _spawn_patch():
|
|
"""Patch `spawn_background_task` so the coroutine handed to it is closed.
|
|
|
|
`on_printer_status_change` builds `reconcile_stale_active_prints(...)` as
|
|
a call argument, so the coroutine object is constructed whether or not the
|
|
replacement schedules it — see
|
|
`backend/tests/_fixtures/background_tasks.py` for what parking it in a
|
|
mock instead does to an unrelated test.
|
|
"""
|
|
return discarding_spawn_patch(MAIN_TARGET)
|
|
|
|
|
|
def _state(connected: bool, state: str = "IDLE") -> SimpleNamespace:
|
|
"""Minimal PrinterState stub.
|
|
|
|
`state="IDLE"` is a *known* state, so on `connected=True` this does trip
|
|
the reconcile-edge branch in `on_printer_status_change` — that is why
|
|
every handler test patches the spawn helper via `_spawn_patch()`. These
|
|
tests assert on the offline-notification edge only; reconciliation
|
|
behaviour is pinned separately in
|
|
`test_reconcile_stale_active_prints.py`. The remaining fields just let
|
|
the handler thread through without extra DB / WS work.
|
|
"""
|
|
return SimpleNamespace(
|
|
connected=connected,
|
|
state=state,
|
|
progress=0,
|
|
layer_num=0,
|
|
temperatures={},
|
|
raw_data={},
|
|
stg_cur=0,
|
|
# Real PrinterState always carries these; the status-broadcast dedup
|
|
# key reads them so a Filament Track Switch rebind reaches the card.
|
|
fila_switch=None,
|
|
ams_switch_inlet={},
|
|
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="",
|
|
ams_filament_backup=None,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_edge_state():
|
|
"""Clear the module-level edge dicts between tests so one test's
|
|
True-edge doesn't leak into the next."""
|
|
main_module._printer_last_connected.clear()
|
|
for task in list(main_module._printer_offline_notify_tasks.values()):
|
|
if not task.done():
|
|
task.cancel()
|
|
main_module._printer_offline_notify_tasks.clear()
|
|
main_module._printer_reconciled_since_connect.clear()
|
|
main_module._last_status_broadcast.clear()
|
|
yield
|
|
main_module._printer_last_connected.clear()
|
|
for task in list(main_module._printer_offline_notify_tasks.values()):
|
|
if not task.done():
|
|
task.cancel()
|
|
main_module._printer_offline_notify_tasks.clear()
|
|
|
|
|
|
class TestMaybeNotifyPrinterOffline:
|
|
"""The debounced background task — fires notification at the end of the
|
|
window only if the printer is still offline."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fires_notification_when_still_offline_after_debounce(self):
|
|
printer = SimpleNamespace(id=1, name="Workshop")
|
|
scalar = MagicMock()
|
|
scalar.scalar_one_or_none.return_value = printer
|
|
db = AsyncMock()
|
|
db.execute = AsyncMock(return_value=scalar)
|
|
session_cm = MagicMock()
|
|
session_cm.__aenter__ = AsyncMock(return_value=db)
|
|
session_cm.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
with (
|
|
patch("backend.app.main.asyncio.sleep", new=AsyncMock()),
|
|
patch("backend.app.main.printer_manager") as mock_pm,
|
|
patch("backend.app.main.async_session", return_value=session_cm),
|
|
patch("backend.app.main.notification_service") as mock_notif,
|
|
):
|
|
mock_pm.is_connected.return_value = False
|
|
mock_notif.on_printer_offline = AsyncMock()
|
|
|
|
await main_module._maybe_notify_printer_offline(printer_id=1)
|
|
|
|
mock_notif.on_printer_offline.assert_awaited_once_with(1, "Workshop", db)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_does_not_fire_when_printer_reconnected_during_debounce(self):
|
|
with (
|
|
patch("backend.app.main.asyncio.sleep", new=AsyncMock()),
|
|
patch("backend.app.main.printer_manager") as mock_pm,
|
|
patch("backend.app.main.notification_service") as mock_notif,
|
|
):
|
|
mock_pm.is_connected.return_value = True
|
|
mock_notif.on_printer_offline = AsyncMock()
|
|
|
|
await main_module._maybe_notify_printer_offline(printer_id=1)
|
|
|
|
mock_notif.on_printer_offline.assert_not_awaited()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_does_not_fire_when_printer_missing_from_db(self):
|
|
scalar = MagicMock()
|
|
scalar.scalar_one_or_none.return_value = None
|
|
db = AsyncMock()
|
|
db.execute = AsyncMock(return_value=scalar)
|
|
session_cm = MagicMock()
|
|
session_cm.__aenter__ = AsyncMock(return_value=db)
|
|
session_cm.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
with (
|
|
patch("backend.app.main.asyncio.sleep", new=AsyncMock()),
|
|
patch("backend.app.main.printer_manager") as mock_pm,
|
|
patch("backend.app.main.async_session", return_value=session_cm),
|
|
patch("backend.app.main.notification_service") as mock_notif,
|
|
):
|
|
mock_pm.is_connected.return_value = False
|
|
mock_notif.on_printer_offline = AsyncMock()
|
|
|
|
await main_module._maybe_notify_printer_offline(printer_id=1)
|
|
|
|
mock_notif.on_printer_offline.assert_not_awaited()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clears_task_entry_after_run(self):
|
|
with (
|
|
patch("backend.app.main.asyncio.sleep", new=AsyncMock()),
|
|
patch("backend.app.main.printer_manager") as mock_pm,
|
|
):
|
|
mock_pm.is_connected.return_value = True # No notification path
|
|
main_module._printer_offline_notify_tasks[1] = MagicMock()
|
|
await main_module._maybe_notify_printer_offline(printer_id=1)
|
|
assert 1 not in main_module._printer_offline_notify_tasks
|
|
|
|
|
|
class TestOfflineEdgeDetection:
|
|
"""Edge detection inside `on_printer_status_change` — only the
|
|
True → False transition schedules a task. Reconnects cancel pending
|
|
tasks. Startup-with-disconnected does not fire."""
|
|
|
|
@staticmethod
|
|
def _patch_handler_deps():
|
|
"""Patch out the heavy side-effects of `on_printer_status_change`
|
|
(MQTT relay, WebSocket broadcast, state serializer) so we can focus
|
|
on edge state."""
|
|
ws_mgr = MagicMock()
|
|
ws_mgr.send_printer_status = AsyncMock()
|
|
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 = ""
|
|
return ws_mgr, relay, pm
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_first_call_connected_does_not_schedule(self):
|
|
ws_mgr, relay, pm = self._patch_handler_deps()
|
|
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(connected=True))
|
|
assert 1 not in main_module._printer_offline_notify_tasks
|
|
assert main_module._printer_last_connected[1] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_first_call_disconnected_does_not_schedule(self):
|
|
"""Startup with an already-offline printer must not fire — there's
|
|
no prior True observation, so we have no edge to trigger on."""
|
|
ws_mgr, relay, pm = self._patch_handler_deps()
|
|
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(connected=False))
|
|
assert 1 not in main_module._printer_offline_notify_tasks
|
|
assert main_module._printer_last_connected[1] is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_connected_to_disconnected_schedules_task(self):
|
|
ws_mgr, relay, pm = self._patch_handler_deps()
|
|
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._maybe_notify_printer_offline", new=AsyncMock()),
|
|
patch("backend.app.main.printer_state_to_dict", return_value={}),
|
|
):
|
|
await main_module.on_printer_status_change(1, _state(connected=True))
|
|
await main_module.on_printer_status_change(1, _state(connected=False))
|
|
|
|
task = main_module._printer_offline_notify_tasks.get(1)
|
|
assert task is not None
|
|
task.cancel()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reconnect_cancels_pending_task(self):
|
|
ws_mgr, relay, pm = self._patch_handler_deps()
|
|
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._maybe_notify_printer_offline", new=AsyncMock()),
|
|
patch("backend.app.main.printer_state_to_dict", return_value={}),
|
|
):
|
|
await main_module.on_printer_status_change(1, _state(connected=True))
|
|
await main_module.on_printer_status_change(1, _state(connected=False))
|
|
scheduled = main_module._printer_offline_notify_tasks.get(1)
|
|
assert scheduled is not None
|
|
await main_module.on_printer_status_change(1, _state(connected=True))
|
|
# Yield so the cancellation propagates through the event loop.
|
|
await asyncio.sleep(0)
|
|
|
|
assert 1 not in main_module._printer_offline_notify_tasks
|
|
assert scheduled.cancelled() or scheduled.done()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_repeated_disconnected_does_not_reschedule(self):
|
|
"""A second False observation while a task is already pending must
|
|
not replace the in-flight task — otherwise the debounce clock
|
|
resets on every status callback and the notification never fires."""
|
|
ws_mgr, relay, pm = self._patch_handler_deps()
|
|
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._maybe_notify_printer_offline", new=AsyncMock()),
|
|
patch("backend.app.main.printer_state_to_dict", return_value={}),
|
|
):
|
|
await main_module.on_printer_status_change(1, _state(connected=True))
|
|
await main_module.on_printer_status_change(1, _state(connected=False))
|
|
first_task = main_module._printer_offline_notify_tasks.get(1)
|
|
await main_module.on_printer_status_change(1, _state(connected=False))
|
|
second_task = main_module._printer_offline_notify_tasks.get(1)
|
|
|
|
assert first_task is second_task
|
|
if first_task is not None:
|
|
first_task.cancel()
|
|
|
|
|
|
class TestProgressMilestoneSessionHygiene:
|
|
"""The progress-milestone notification path must capture the camera
|
|
snapshot WITHOUT holding a DB session (issue #2572): a ~15s RTSP grab
|
|
across an open session pinned a pooled connection per milestone, per
|
|
printer. The read (printer name) and the send (provider lookups) each get
|
|
their own short session; the snapshot happens in between with none held."""
|
|
|
|
@staticmethod
|
|
def _printing_state(progress: int):
|
|
st = _state(connected=True, state="RUNNING")
|
|
st.progress = progress
|
|
st.remaining_time = 30
|
|
st.gcode_file = "benchy.gcode"
|
|
return st
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_milestone_captures_snapshot_outside_session_and_notifies(self):
|
|
main_module._last_progress_milestone.clear()
|
|
|
|
printer = SimpleNamespace(id=1, name="Workshop")
|
|
db = AsyncMock()
|
|
db.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=printer)))
|
|
|
|
# Stateful session that tracks how many sessions are currently open.
|
|
open_sessions = {"count": 0}
|
|
|
|
class _SessionCM:
|
|
async def __aenter__(self):
|
|
open_sessions["count"] += 1
|
|
return db
|
|
|
|
async def __aexit__(self, *exc):
|
|
open_sessions["count"] -= 1
|
|
return False
|
|
|
|
snap_calls = []
|
|
|
|
async def _snap(printer_id, prn, _logger):
|
|
# The whole point of the fix (#2572): the ~15s camera grab must NOT
|
|
# run while a DB session is held. On the old code the snapshot sat
|
|
# inside the milestone session, so this would be 1.
|
|
assert open_sessions["count"] == 0, "camera snapshot ran while a DB session was held"
|
|
snap_calls.append((printer_id, prn))
|
|
return b"jpeg-bytes"
|
|
|
|
ws_mgr = MagicMock()
|
|
ws_mgr.send_printer_status = AsyncMock()
|
|
relay = MagicMock()
|
|
relay.on_printer_status = AsyncMock()
|
|
pm = MagicMock()
|
|
pm.get_printer.return_value = None
|
|
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={}),
|
|
patch("backend.app.main.async_session", side_effect=lambda: _SessionCM()),
|
|
patch("backend.app.main._capture_snapshot_for_notification", new=_snap),
|
|
patch("backend.app.main.notification_service") as mock_notif,
|
|
):
|
|
mock_notif.on_print_progress = AsyncMock()
|
|
|
|
await main_module.on_printer_status_change(1, self._printing_state(25))
|
|
|
|
# Snapshot ran (with the detached printer) and outside any session.
|
|
assert snap_calls == [(1, printer)]
|
|
# The notification fired carrying that image (send legitimately holds a session).
|
|
mock_notif.on_print_progress.assert_awaited_once()
|
|
assert mock_notif.on_print_progress.await_args.kwargs["image_data"] == b"jpeg-bytes"
|
|
# Every session opened was also closed — none leaked past the handler.
|
|
assert open_sessions["count"] == 0
|
|
|
|
main_module._last_progress_milestone.clear()
|