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
+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 {}