mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-08 07:01:40 +02:00
The K value on an AMS slot card went blank after a while and came back after a backend restart. It is not the MQTT merge: that preserves a tray's k correctly. H2-series trays have no k to preserve. Verified against the H2 wire capture in logs/vp_wire -- every tray reports cali_idx and nothing else -- so the number on the card is resolved from that index against the printer's calibration table in state.kprofiles, and that table was a single global list. An extrusion_cali_get response is the complete table for one nozzle diameter, and the printer answers whoever asks; BambuStudio's queries arrive on the same report topic we subscribe to. Every response was assigned straight to state.kprofiles, so any one answer stood for the whole printer. The nightly GitHub backup asks for 0.2, 0.4, 0.6 and 0.8 in turn and finishes on 0.8, which holds nothing on a 0.4+0.6 machine: logs/bambuddy.log records exactly that at 17:15 on 2026-08-25, and the table was empty from then until something refilled it. Responses are now bucketed by the diameter they describe, so an empty answer for a size the printer does not have clears only that size. The three assign paths that look an index up by nozzle_diameter get the same fix for free -- they were quietly finding nothing whenever the last response was for another nozzle, which is what spoolman_inventory has been logging as a stale kp. Bucketing makes state.kprofiles a union, and cali_idx is numbered per nozzle, so the index alone no longer identifies a profile. The REST serializer has keyed on (extruder, cali_idx) since c5e005586; the WebSocket one still keyed on the index alone, which meant the first render of a card could be right and every update after it wrong. Both now share one resolver. It goes through the extruder the slot feeds, and where that does not single out one profile -- a single-nozzle printer that has been swapped, so both its tables sit under extruder 0 -- it falls back to which diameters are actually fitted. Where neither settles it the card shows nothing, because a blank space is a smaller error than confidently printing the other nozzle's number. Deliberately no loosening to a bare cali_idx lookup on a miss: that is the cross-nozzle bleed the extruder keying was added to stop. Nothing read the table on connect, which is the other half of the report. It arrived by luck -- a visit to Profiles or Configure Slot, a backup, or the printer answering someone else -- so a Bambuddy nobody had opened showed a card with no K values at all, and "restart and they come back" was the printer happening to broadcast rather than anything we did. It is now read once per connection, on the same latch the stale-print reconcile uses. Only the fitted diameters are asked for, one request on a single-nozzle printer and two on a dual; probing the four sizes blind is what the backup does and what blanked the table. The edge is gated on a nozzle diameter being known as well as on the state being known, because the first push_status is what makes the state known and does not always carry the nozzle fields -- latching there would spend the connection's one attempt on a printer that could not yet say what was fitted. Adopting an unsolicited table now logs at debug. It was the quietest way for the card to change underneath us and there was no way to see it in a support bundle. Three test files gained nozzles=[] on their PrinterState stubs. The field has always been on the dataclass; the connect edge is simply the first thing on that path to read it.
361 lines
15 KiB
Python
361 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={},
|
|
nozzles=[],
|
|
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()
|