fix(notifications): wire on_printer_offline dispatch on disconnect edge (#1752)

The provider toggle, schema, template, and NotificationService.on_printer_offline
      all shipped, but no caller invoked the dispatcher — the offline event was an
      orphan toggle. Edge detection in on_printer_status_change now schedules a
      debounced (60s) background task on the connected→disconnected transition;
      reconnect before the window elapses cancels it. Covers both upstream paths
      (smart-plug power-off via mark_printer_offline, and MQTT staleness via
      check_staleness), both of which already route through the status callback.
      The "back online" channel is the existing print-failure notification on
      firmware FAILED report — no symmetric on_printer_online needed.
This commit is contained in:
maziggy
2026-06-28 12:25:25 +02:00
3 changed files with 350 additions and 0 deletions
+2
View File
@@ -22,6 +22,8 @@ All notable changes to Bambuddy will be documented in this file.
- **VP bridge-synthesised reply trace (#1622 round 3)** — The round-2 cmd.jsonl from shaddowlink's P1S vs H2D capture proves the actual failure mode: on P1S in archive mode the slicer issues `extrusion_cali_set` (push K/n directly) and the printer responds `fail`, on H2D and on the P1S second round the slicer takes the `extrusion_cali_sel` flow (select by `filament_id` / `cali_idx`) and the printer responds `success`. Both flows traverse the bridge cleanly — `ams_filament_setting` round-trips with `result=success` and the cached push_status carries `tray_info_idx=GFA11`, `tray_type=PLA-AERO`, K/n, and `cali_idx=-1` intact. So the bridge is innocent on every layer the dump can see, and the open question becomes: what makes the slicer pick `_set` vs `_sel`? Likely candidates are the `info.get_version` answer Bambuddy synthesises (slicer fingerprints on `sw_ver` / `hw_ver` / `module` to decide its command flow) or the first cached `pushall` response the slicer reads to bootstrap its UI. Round 2 captured neither — the JSONL had `slicer_to_bridge` and `printer_to_slicer` directions but no `bridge_to_slicer` direction for the bridge's own synthesised replies. Same env flag (`BAMBUDDY_VP_DUMP_WIRE=1`) now also appends every bridge-synthesised reply (info.get_version answer, project_file ack, on-demand pushall response) to `<log_dir>/vp_wire/<vp_name>_cmd.jsonl` under direction `bridge_to_slicer`. Capture lives in `mqtt_server.py::_publish_to_report` — the single chokepoint every synthesised reply already passes through — gated on a new `log_event: bool = True` parameter; the 1Hz periodic-push path threads `log_event=False` so the JSONL isn't flooded with ~60 lines/min per VP (snapshot dump already covers cache shape). The on-demand pushall response from `_send_status_report` IS logged because that's the bootstrap-fingerprint reply the slicer reads on first connect. Two additional unit tests in `test_vp_mqtt_bridge.py::TestWireFormat` pin the event-on-default and skip-when-`log_event=False` posture; `test_vp_wire_dump.py` already covers the underlying `append_event` shape and the new direction is documented in `_debug.py`'s docstring. Diagnostic-only — does not change the publish data path; the new param defaults preserve every existing call site's behaviour.
### Fixed
- **Push notification for "Printer offline" now actually fires (#1752, reported by @saint-hh)** — The notification provider's `on_printer_offline` toggle has shipped since the notifications feature landed: schema field, DB column, `notification_template.py` entry, and the dispatcher `NotificationService.on_printer_offline(printer_id, printer_name, db)` are all in place. What was missing was the caller — nothing in the codebase actually invoked the dispatcher when a printer went offline. The reporter (P2S, smart-plug-cuts-power scenario) confirmed turning the toggle on did nothing; only the print-failure notification fired when power was restored, via the firmware's `gcode_state=FAILED` report on MQTT reconnect. **Why the toggle was orphan:** every other provider event (`on_print_start`, `on_print_complete`, `on_print_progress`, `on_printer_error`, etc.) has a clear call site under `main.py::on_printer_status_change` or alongside the print-lifecycle hooks. The offline event was the only edge-triggered toggle without one — the dispatcher and template predated the wiring step and were silently shipped. Both upstream offline-trigger paths (`smart_plug_manager` → `printer_manager.mark_printer_offline()` and `bambu_mqtt.py::check_staleness` after the 30s STALE_RECONNECT_COOLDOWN) route through `_on_status_change` already and reach `on_printer_status_change`; the handler just didn't act on the disconnect edge. **Fix:** edge detection in `on_printer_status_change` watches `state.connected` against the previous observation per printer (`_printer_last_connected: dict[int, bool]`). On the True → False transition it schedules `_maybe_notify_printer_offline(printer_id)` as a background asyncio task; on the next True observation it cancels any pending task. The helper sleeps `_PRINTER_OFFLINE_NOTIFY_DEBOUNCE_SECONDS = 60.0` then re-checks `printer_manager.is_connected(printer_id)` — only fires the notification if the printer is still offline. **Why 60s debounce:** sized against `bambu_mqtt.py::STALE_RECONNECT_COOLDOWN = 30s` — a single stale-trigger + reconnect cycle isn't enough to fire, only a real outage that survives one full cooldown notifies. Transient MQTT blips (WiFi roam, broker reload, brief packet loss) recover within the window and the cancellation path kicks in. **Edge-case handling:** initial observation with no prior connected state doesn't fire (covers Bambuddy startup with an already-offline printer); a False → False repeat doesn't reschedule (the in-flight task stays in place rather than resetting the clock on every status callback, which would otherwise mean the notification never fires); the task entry pops from `_printer_offline_notify_tasks` in the finally block whether the notification fired, the printer reconnected, or the task was cancelled mid-await. **No symmetric `on_printer_online` event:** the reporter explicitly noted the "printer lost power and interrupted the print" notification already fires when power is restored — that's the print-failure notification, triggered by the firmware reporting `gcode_state=FAILED` for the interrupted print on MQTT reconnect. That covers the "printer is back" channel without a new toggle. If the user then resumes the print, no print_start notification fires (Bambuddy's `bambu_mqtt.py:3039` explicitly suppresses `is_new_print` for PAUSE → RUNNING to prevent duplicates when resuming from pause), but that's a separate scope from offline-detection. **Tests:** 9 new cases in `test_printer_offline_notification.py` split across two classes. `TestMaybeNotifyPrinterOffline` pins the debounced helper: fires notification when still offline at end of window, doesn't fire when printer reconnected during debounce, doesn't fire when the printer disappeared from the DB (uninstall mid-window), clears `_printer_offline_notify_tasks[printer_id]` after run. `TestOfflineEdgeDetection` pins the edge logic inside `on_printer_status_change`: first observation (connected) doesn't schedule, first observation (disconnected) doesn't schedule (the no-prior-True case — important for startup), True → False schedules a task, reconnect cancels the pending task, repeated False observations don't replace the in-flight task. Full backend suite still green; ruff clean.
- **Virtual Printer queue mode: multi-plate "Send All" now enqueues one queue item per plate** — BambuStudio / OrcaSlicer's "Send All" packs every plate of the project into a SINGLE 3MF and uploads it with one FTP STOR — `slice_info.config` inside the file carries N `<plate>` blocks (one per plate), each with its own `<metadata key="index" value="N"/>` and its own `Metadata/plate_N.gcode` payload. Previously the VP queue path only ever extracted the FIRST plate's index via `_extract_plate_id` and created exactly ONE PrintQueueItem with that single `plate_id`; plates 2..N silently dropped on the floor. Indistinguishable from the user's perspective from "Send" of a single plate — except they expected 3 items in the queue and got 1, with no log line to explain why. **Confirmed against the wire** on the live H2D-1 Proxy VP: `Cube.gcode.3mf` carrying three `<plate>` blocks (indices 1, 2, 3) + three per-plate gcode payloads in the same zip, identical filename whether "Send" or "Send All" was clicked — the only signal of intent is the count of `<plate>` blocks inside the file. **Fix:** replaced `_extract_plate_id` (returning `int | None`) with `_extract_plate_ids` (returning `list[int]`). The list contains every `<plate>` block's `index` metadata, in order; falls back to `[1]` for files missing `slice_info.config` or with no parseable plates so the single-plate path is preserved. `_add_to_print_queue` now loops over the list — each iteration calls `extract_filament_requirements(file_path, plate_id)` per-plate (the plate-aware path was already there from the #1697 work) and creates a PrintQueueItem with that plate's filament types / overrides, plate-specific position = `MAX(position) + iteration`. Single-plate "Send" hits the loop once → exactly today's behaviour (one queue item, plate_id from the slicer, same archive). Multi-plate "Send All" of a 3-plate file → 3 queue items, plate_id 1/2/3, consecutive positions, all pointing at the same backing archive (one upload = one archive). **What stays the same:** the single archive row per upload (the archive backs the queue items via `archive_id`); the `auto_dispatch=False` / `manual_start=true` posture inherited from the VP config (so multi-plate items still require manual start); the `queue_force_color_match` per-VP toggle (now applies per-plate). **What this also fixed downstream:** the `required_filament_types` / `filament_overrides` JSON on each queue item now reflects THAT plate's filaments, not the file's first plate — so the scheduler's per-printer "Any X" matching dispatches each plate onto a printer with the right colours loaded for THAT plate, not for plate 1's filament set. **Tests:** 1 new regression case in `test_virtual_printer.py::TestVirtualPrinterInstance::test_add_to_print_queue_multi_plate_send_all_enqueues_one_per_plate` — builds a 3-plate 3MF (writes the per-plate `<plate>` blocks into `slice_info.config` and the per-plate gcode payloads), runs `_add_to_print_queue`, asserts 3 PrintQueueItems with `plate_id == [1, 2, 3]`, `position == [1, 2, 3]`, shared `archive_id`, all `manual_start=True`. 126 existing single-plate VP tests stay green (loop runs once when input has one plate). Full backend suite 5962/5962 green; ruff clean; frontend untouched. **Live-verified** on the H2D-1 Proxy VP — a Send All of the 3-plate Cube project now produces 3 queue items + 1 archive instead of 1 queue item + 1 archive.
- **Archive delete now removes related queue items instead of leaving "cancelled" rows behind** — Previously the soft-delete path (the default — what the trash-can button does) called `_cancel_pending_queue_items`, which only flipped queue rows with `status='pending'` to `status='cancelled'` while leaving every other status alone AND leaving every row in the DB. The Send All multi-plate work above made this much more visible: deleting an archive backed by N queue items now had to clean up N rows, and what users saw instead was N "cancelled" rows lingering in the queue history. **Fix (backend):** replaced `_cancel_pending_queue_items` with `_delete_related_queue_items(db, archive_id) -> int` that DELETEs every queue row where `archive_id = X` regardless of status. Behavior now matches what the hard-delete path already did via the `ON DELETE CASCADE` FK on `print_queue.archive_id` — both paths produce the same end state. Print history lives in `PrintLogEntry` (FK `ON DELETE SET NULL`) and is untouched, so stats / Quick Stats / accuracy bands are preserved across both delete paths. **New guard:** the route at `archives.py::delete_archive` now 409s when any related queue item is currently in `status='printing'` — both soft and hard delete are gated by the same precondition, because deleting the archive while a print is live would strip the dispatcher's metadata trail (filament / plate / ams_mapping) out from under the running print. The 409 surfaces a clear "Stop the print first, then retry" message. **Pre-flight count for the UI:** new endpoint `GET /archives/{id}/delete-impact` returns `{related_queue_items: N, currently_printing: M}` — cheap, single endpoint, not folded into the archive list response so the much larger list endpoint isn't forced to run the same query per row. Frontend ArchivesPage delete-confirm modal queries this when the modal opens (`useQuery({queryKey: ['archive', id, 'delete-impact'], enabled: showDeleteConfirm})`) and renders: an amber warning "**N queue item(s) linked to this archive will also be removed.**" when total > 0 AND printing = 0, OR a red warning "**Cannot delete — M queue item(s) are currently printing. Stop the print first, then retry.**" when printing > 0 (with the confirm button disabled in that case so the user can't bonk the 409 on submit). **ConfirmModal extension:** added optional `confirmDisabled?: boolean` prop. Existing `isLoading` was the only disable knob; this adds an external-precondition path that disables the confirm without the loading spinner. **Tests:** rewrote `test_print_queue_api.py::test_soft_delete_archive_cancels_pending_queue_items` → `test_soft_delete_archive_deletes_all_related_queue_items` to pin the new contract (both pending AND completed rows are gone post-soft-delete). 2 new integration cases in `test_archives_api.py`: `test_delete_archive_blocked_when_related_queue_item_printing` (both soft and hard paths return 409 with "printing" in detail message) + `test_archive_delete_impact_reports_counts` (3 mixed-status related rows + 1 unrelated row → endpoint reports `related_queue_items=3, currently_printing=1`, unrelated row doesn't bleed in). **i18n:** 2 new keys (`archives.modal.deleteQueueItemsWarning`, `archives.modal.deleteBlockedByPrinting`) translated across all 11 locales per `feedback_translate_dont_fallback` — no English fallbacks. **Verification:** full backend suite 5964/5964 green with `-n 30`; ruff clean; ESLint clean; `npm run build` clean; vitest 2118/2118 green; i18n parity 5109 × 11 locales green. No DB migration — the CASCADE FK was already in place; only the helper's semantics changed.
+91
View File
@@ -395,6 +395,22 @@ _bed_cool_waiters: dict[int, dict] = {}
# as "cancelled" (stopped by user) so the correct notification email is sent.
_user_stopped_printers: set[int] = set()
# Offline-notification edge state (#1752): fire `on_printer_offline` exactly
# once when a printer transitions connected → disconnected. `_printer_last_connected`
# holds the previous observation so we only fire on the True → False edge (a
# False → False repeat doesn't notify; an initial False at startup doesn't
# notify either, since there's no prior True). `_printer_offline_notify_tasks`
# holds the per-printer pending asyncio task that fires the notification
# after a debounce window — cancelled if the printer reconnects before the
# window elapses, so transient MQTT blips don't flood the user.
_printer_last_connected: dict[int, bool] = {}
_printer_offline_notify_tasks: dict[int, asyncio.Task] = {}
# Debounce: a printer must stay offline this long before we notify. Sized
# against the staleness path (`bambu_mqtt.py::STALE_RECONNECT_COOLDOWN = 30s`)
# so a single stale-trigger cooldown isn't enough to fire — only a real
# offline that survives one reconnect attempt notifies.
_PRINTER_OFFLINE_NOTIFY_DEBOUNCE_SECONDS = 60.0
# HMS short-code → human-readable failure reason. Used by _dispatch_archive_update
# when status="failed" to label the print's failure_reason in archives.
@@ -843,6 +859,53 @@ _last_status_broadcast: dict[int, str] = {}
_nozzle_count_updated: set[int] = set()
async def _maybe_notify_printer_offline(printer_id: int) -> None:
"""Wait the debounce window then fire `on_printer_offline` if the printer
is still offline.
Scheduled by `on_printer_status_change` on the connected → disconnected
edge (#1752). Cancelled by the same handler if the printer reconnects
before the window elapses, so a single MQTT blip + recovery doesn't
notify. Both the staleness-detector path (`bambu_mqtt.py::check_staleness`)
and the smart-plug power-off path (`printer_manager.mark_printer_offline`)
route through the same status-change callback, so this covers both.
"""
logger = logging.getLogger(__name__)
try:
await asyncio.sleep(_PRINTER_OFFLINE_NOTIFY_DEBOUNCE_SECONDS)
still_offline = not printer_manager.is_connected(printer_id)
logger.info(
"[#1752] Printer %s offline debounce elapsed: still_offline=%s",
printer_id,
still_offline,
)
if not still_offline:
return
async with async_session() as db:
from backend.app.models.printer import Printer
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
if not printer:
logger.warning(
"[#1752] Printer %s missing from DB at offline-notify time; skipping",
printer_id,
)
return
logger.info(
"[#1752] Dispatching on_printer_offline for printer %s (%s)",
printer_id,
printer.name,
)
await notification_service.on_printer_offline(printer_id, printer.name, db)
except asyncio.CancelledError:
raise
except Exception as e:
logger.warning("Printer offline notification failed for printer %s: %s", printer_id, e)
finally:
_printer_offline_notify_tasks.pop(printer_id, None)
async def on_printer_status_change(printer_id: int, state: PrinterState):
"""Handle printer status changes - broadcast via WebSocket."""
# Connected-edge reconciliation (#1542 follow-up). When the printer
@@ -883,6 +946,34 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
# Re-arm so the next reconnect triggers reconciliation again.
_printer_reconciled_since_connect[printer_id] = False
# Offline-notification edge (#1752): schedule `on_printer_offline` on
# connected → disconnected. The "back online" channel is already covered
# by the print-failure notification (firmware reports gcode_state=FAILED
# on reconnect of an interrupted print), so we don't add a symmetric
# online event here.
prev_connected = _printer_last_connected.get(printer_id)
_printer_last_connected[printer_id] = state.connected
if prev_connected is True and not state.connected:
existing = _printer_offline_notify_tasks.get(printer_id)
if existing is None or existing.done():
logging.getLogger(__name__).info(
"[#1752] Printer %s connected→disconnected edge; scheduling offline notification in %.0fs",
printer_id,
_PRINTER_OFFLINE_NOTIFY_DEBOUNCE_SECONDS,
)
_printer_offline_notify_tasks[printer_id] = asyncio.create_task(
_maybe_notify_printer_offline(printer_id),
name=f"printer-offline-notify-{printer_id}",
)
elif state.connected:
pending = _printer_offline_notify_tasks.pop(printer_id, None)
if pending is not None and not pending.done():
logging.getLogger(__name__).info(
"[#1752] Printer %s reconnected before debounce; cancelling pending offline notification",
printer_id,
)
pending.cancel()
# Only broadcast if something meaningful changed (reduce WebSocket spam)
# Include rounded temperatures to detect meaningful temp changes (within 1 degree)
temps = state.temperatures or {}
@@ -0,0 +1,257 @@
"""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
def _state(connected: bool, state: str = "IDLE") -> SimpleNamespace:
"""Minimal PrinterState stub. `state="IDLE"` keeps the reconcile-edge
branch quiescent (it only fires on `connected=True` with a non-unknown
state-string, which we exercise separately) but otherwise lets the
handler thread through without doing extra DB / WS work."""
return SimpleNamespace(
connected=connected,
state=state,
progress=0,
layer_num=0,
temperatures={},
raw_data={},
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="",
)
@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),
patch("backend.app.main.spawn_background_task"),
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),
patch("backend.app.main.spawn_background_task"),
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),
patch("backend.app.main.spawn_background_task"),
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),
patch("backend.app.main.spawn_background_task"),
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),
patch("backend.app.main.spawn_background_task"),
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()