notify_live_activities.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759
  1. """Persistent Notify! Live Activities, independent of push and digest selection.
  2. Only the worker performs network I/O. MQTT callbacks copy their mutable state and
  3. wake it; database sessions are closed before every HTTP call. A durable intent is
  4. written *before* starting a tile: after a crash or an ambiguous network failure we
  5. never blindly repeat a push-to-start and consume another of the five device slots.
  6. """
  7. import asyncio
  8. import hashlib
  9. import json
  10. import logging
  11. from dataclasses import dataclass, replace
  12. from datetime import datetime, timedelta, timezone
  13. from uuid import uuid4
  14. import httpx
  15. from sqlalchemy import delete, select, update
  16. from backend.app.core.database import async_session
  17. from backend.app.models.notification import NotificationProvider
  18. from backend.app.models.notification_live_activity import NotificationLiveActivity
  19. from backend.app.models.printer import Printer
  20. from backend.app.services.notify_client import NotifyClient, NotifyError, _https_url
  21. from backend.app.services.notify_worker import NotifyWorkerLifecycle
  22. logger = logging.getLogger(__name__)
  23. _RUNNING = {"RUNNING", "PRINTING", "PAUSE", "PREPARE", "SLICING"}
  24. _TERMINAL = {"IDLE", "FINISH", "FAILED", "COMPLETED", "CANCELLED", "ABORTED", "STOPPED"}
  25. _STOPPED = {"ended", "suppressed"}
  26. _ROLLOVER = {"expired", "overdue", "abandoned"}
  27. _INTERVAL = 60
  28. _STARTUP_GRACE = timedelta(minutes=2)
  29. def _now() -> datetime:
  30. return datetime.now(timezone.utc).replace(tzinfo=None)
  31. def _date(value) -> datetime | None:
  32. if not value:
  33. return None
  34. try:
  35. return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc).replace(tzinfo=None)
  36. except (ValueError, AttributeError):
  37. return None
  38. def _config(provider) -> dict:
  39. return json.loads(provider.config) if isinstance(provider.config, str) else provider.config or {}
  40. def _credential_key(config: dict) -> str:
  41. return hashlib.sha256(f"{config.get('device_id', '')}:{config.get('token', '')}".encode()).hexdigest()
  42. def _identity(state) -> str:
  43. job_id = str(getattr(state, "subtask_id", "") or "")
  44. if job_id and job_id != "0":
  45. return f"job:{job_id}"[:160]
  46. # raw_data contains MQTT deltas, so gcode_start_time may disappear on the
  47. # next push. The stable job ID is preferred; the filename fallback is given
  48. # a fresh generation by real start callbacks and adopted on restart.
  49. filename = getattr(state, "subtask_name", None) or getattr(state, "current_print", None) or ""
  50. digest = hashlib.sha256(str(filename).encode()).hexdigest()[:32]
  51. return f"file:{digest}"
  52. def _printer_fault(state) -> str | None:
  53. """Match the printer card's actionable HMS filtering, including print_error.
  54. Bambu's MQTT parser folds print_error into hms_errors. Level-3 sixteen-digit
  55. HMS notices without actions (for example an open cover) are informational;
  56. eight-digit task-stopping print_error prompts at that level still count.
  57. """
  58. for error in getattr(state, "hms_errors", None) or []:
  59. severity = getattr(error, "severity", 0)
  60. description = str(getattr(error, "description", "") or "")
  61. actions = bool(getattr(error, "actions", None))
  62. notice = severity == 3 and len(str(getattr(error, "full_code", "") or "")) == 16
  63. if severity >= 1 and (actions or (description and not notice)):
  64. code = getattr(error, "full_code", "") or getattr(error, "code", "")
  65. return (description or f"Printer needs attention ({code})").replace("\x00", "")[:170]
  66. return None
  67. @dataclass(frozen=True)
  68. class PrintSnapshot:
  69. key: str
  70. connected: bool
  71. state: str
  72. filename: str
  73. progress: float
  74. remaining_seconds: int
  75. layer: int
  76. layers: int
  77. job_key: str | None = None
  78. fault: str | None = None
  79. temperatures: tuple = ()
  80. stage: int = -1
  81. @classmethod
  82. def from_state(cls, state):
  83. return cls(
  84. key=_identity(state),
  85. job_key=_identity(state) if _identity(state).startswith("job:") else None,
  86. connected=bool(state.connected),
  87. state=str(state.state or "").upper(),
  88. filename=str(getattr(state, "subtask_name", None) or getattr(state, "current_print", None) or "Print"),
  89. progress=max(0, min(100, float(state.progress or 0))),
  90. remaining_seconds=max(0, int(getattr(state, "remaining_time", 0) or 0) * 60),
  91. layer=int(state.layer_num or 0),
  92. layers=int(state.total_layers or 0),
  93. temperatures=tuple((getattr(state, "temperatures", {}) or {}).items()),
  94. stage=getattr(state, "stg_cur", -1),
  95. fault=_printer_fault(state),
  96. )
  97. def content(self, printer_name: str, config: dict | None = None) -> dict:
  98. config = config or {}
  99. phase = "Printing"
  100. if not self.connected:
  101. phase = "Printer offline"
  102. elif self.state == "PAUSE":
  103. phase = "Paused"
  104. elif self.state in ("PREPARE", "SLICING") or (
  105. self.state in {"RUNNING", "PRINTING"}
  106. and self.layer < 1
  107. and (self.layers > 0 or self.stage not in {0, -1, 255})
  108. ):
  109. phase = "Preparing"
  110. elif self.state in _TERMINAL:
  111. phase = {
  112. "FINISH": "Complete",
  113. "COMPLETED": "Complete",
  114. "FAILED": "Failed",
  115. "CANCELLED": "Stopped",
  116. "ABORTED": "Stopped",
  117. "STOPPED": "Stopped",
  118. "IDLE": "Finished",
  119. }[self.state]
  120. if self.connected and self.fault and self.state in _RUNNING:
  121. lower = self.fault.lower()
  122. runout = "filament" in lower and any(
  123. word in lower for word in ("run out", "ran out", "runout", "exhaust", "empty")
  124. )
  125. phase = "Filament runout" if runout else "Printer error"
  126. # Character and UTF-8 limits leave ample room for server-owned fields
  127. # within Notify's 2048-byte merged-content budget, including emoji names.
  128. filename = (
  129. "Print in progress"
  130. if config.get("live_activity_privacy") is True
  131. else self.filename.replace("\x00", "")[:120]
  132. )
  133. layer = f" · Layer {self.layer}/{self.layers}" if self.layers else ""
  134. progress = 0 if phase == "Preparing" else self.progress
  135. content = {
  136. "title": printer_name.replace("\x00", "")[:80] or "Bambuddy",
  137. "body": self.fault
  138. if self.connected and self.fault and self.state in _RUNNING
  139. else f"{filename}{layer}"[:170],
  140. "symbol": str(config.get("live_activity_symbol") or "printer.fill")[:64],
  141. "tint": config.get("live_activity_tint") or "#00AE42",
  142. "progress": 100 if phase == "Complete" else progress,
  143. "status": phase,
  144. "endsIn": self.remaining_seconds
  145. if self.connected
  146. and not self.fault
  147. and self.state in {"RUNNING", "PRINTING"}
  148. and 0 < self.remaining_seconds <= 86400
  149. else None,
  150. }
  151. style = config.get("live_activity_style", "bar")
  152. content.update(
  153. steps=10 if style == "segments" else None, step=int(progress // 10) if style == "segments" else None
  154. )
  155. if style == "none":
  156. content["progress"] = None
  157. if (
  158. config.get("live_activity_stage") is True
  159. and self.connected
  160. and not self.fault
  161. and self.state in {"RUNNING", "PRINTING"}
  162. ):
  163. from backend.app.services.bambu_mqtt import get_stage_name
  164. content["status"] = get_stage_name(self.stage)[:40] if self.stage >= 0 else phase
  165. eta = f"{self.remaining_seconds // 3600}h {(self.remaining_seconds // 60) % 60}m"
  166. content["trailing"] = eta if self.remaining_seconds > 86400 and phase == "Printing" else None
  167. # The default tile uses the native countdown. Metric chips are opt-in;
  168. # send null when disabled so merge-patch clears previously selected chips.
  169. metrics = []
  170. temperatures = dict(self.temperatures)
  171. for field in (config.get("live_activity_metrics") or [])[:6]:
  172. label, value, unit = "", None, ""
  173. if field == "progress":
  174. label, value = "Progress", f"{progress:.0f}%"
  175. elif field == "eta" and phase == "Printing" and self.remaining_seconds > 0:
  176. label, value = "Remaining", eta
  177. elif field == "layers" and self.layers:
  178. label, value = "Layer", f"{self.layer}/{self.layers}"
  179. elif field in {"nozzle", "bed", "chamber"} and temperatures.get(field) is not None:
  180. label, value, unit = field.title(), f"{temperatures[field]:.0f}", "°C"
  181. if value is not None:
  182. metrics.append({"label": label, "value": value[:16], "unit": unit})
  183. content["metrics"] = None if self.fault else metrics or None
  184. button = _https_url(config.get("live_activity_button_url"))
  185. content["button"] = (
  186. {"title": "Open Bambuddy", "url": button, "open": True} if button and len(button) <= 512 else None
  187. )
  188. # Notify adds timestamps when merging. Reserve 200 bytes for those;
  189. # shorten display text first, then drop optional cells/button if needed.
  190. def size():
  191. return len(json.dumps(content, ensure_ascii=False, separators=(",", ":")).encode())
  192. while size() > 1800 and len(content["body"]) > 20:
  193. content["body"] = content["body"][:-10]
  194. if size() > 1800:
  195. content["button"] = None
  196. while size() > 1800 and content["metrics"]:
  197. content["metrics"].pop()
  198. return content
  199. class NotifyLiveActivityService(NotifyWorkerLifecycle):
  200. ownership_model = NotificationLiveActivity
  201. feature_field = "live_activities"
  202. worker_interval = 30
  203. worker_name = "notify-live-activities"
  204. stopped_states = ("ended", "suppressed")
  205. _cleanup_key = staticmethod(_credential_key)
  206. def __init__(self, session_factory=None, client=None):
  207. self._session = session_factory or async_session
  208. self._client = client
  209. self._http: httpx.AsyncClient | None = None
  210. self._snapshots: dict[int, PrintSnapshot] = {}
  211. self._new_fallbacks: dict[int, str] = {}
  212. self._started_at: dict[int, datetime] = {}
  213. self._progress_resets: dict[int, tuple[float, datetime]] = {}
  214. self._finished: list[tuple[int, str | None, str]] = []
  215. self._lock = asyncio.Lock()
  216. self._wake = asyncio.Event()
  217. self._task: asyncio.Task | None = None
  218. self._init_worker()
  219. self._status_grace_until: datetime | None = None
  220. def forget_printer(self, printer_id: int) -> None:
  221. """Drop a deleted printer's print, and end its tiles now: the next tick
  222. ends them (the printer is gone), before a new printer is given the id."""
  223. for state in (self._snapshots, self._new_fallbacks, self._started_at, self._progress_resets):
  224. state.pop(printer_id, None)
  225. self._wake.set()
  226. def _on_activated(self):
  227. # No MQTT observations are accepted while dormant. Anything retained
  228. # from before opt-out is stale until a real status arrives after opt-in.
  229. self._snapshots.clear()
  230. self._finished.clear()
  231. self._new_fallbacks.clear()
  232. self._started_at.clear()
  233. self._progress_resets.clear()
  234. self._status_grace_until = None
  235. def observe(self, printer_id: int, state) -> None:
  236. if self._provider_enabled is False:
  237. return
  238. snapshot = PrintSnapshot.from_state(state)
  239. if snapshot.state in {"", "UNKNOWN"}:
  240. previous = self._snapshots.get(printer_id)
  241. if previous is None or snapshot.connected:
  242. return # Broker connection alone is not a real printer status.
  243. snapshot = replace(previous, connected=False)
  244. reset = self._progress_resets.get(printer_id)
  245. if reset:
  246. initial, until = reset
  247. if (
  248. snapshot.state not in _RUNNING
  249. or snapshot.progress < initial
  250. or snapshot.progress <= 5
  251. or _now() >= until
  252. ):
  253. self._progress_resets.pop(printer_id, None)
  254. else:
  255. snapshot = replace(snapshot, progress=0, remaining_seconds=0)
  256. previous = self._snapshots.get(printer_id)
  257. if (
  258. previous
  259. and previous.state in _RUNNING
  260. and snapshot.state in _RUNNING
  261. and previous.filename == snapshot.filename
  262. ):
  263. # A delta may omit or belatedly supply a job ID. Retain the print's
  264. # established identity until an explicit start or a different ID.
  265. if snapshot.key.startswith("file:") or previous.key.startswith("file:"):
  266. snapshot = replace(snapshot, key=previous.key)
  267. self._snapshots[printer_id] = snapshot
  268. if self._provider_enabled and (
  269. previous is None
  270. or (previous.key, previous.state, previous.connected, previous.fault)
  271. != (
  272. snapshot.key,
  273. snapshot.state,
  274. snapshot.connected,
  275. snapshot.fault,
  276. )
  277. ):
  278. self._wake.set()
  279. def print_started(self, printer_id: int, state, data: dict | None = None) -> None:
  280. if state is None or self._provider_enabled is False:
  281. return
  282. snapshot = PrintSnapshot.from_state(state)
  283. job_id = (data or {}).get("subtask_id") or ((data or {}).get("raw_data") or {}).get("subtask_id")
  284. if job_id and str(job_id) != "0":
  285. snapshot = replace(snapshot, key=f"job:{job_id}", job_key=f"job:{job_id}")
  286. self._progress_resets[printer_id] = (snapshot.progress, _now() + timedelta(minutes=2))
  287. snapshot = replace(snapshot, progress=0, remaining_seconds=0)
  288. self._started_at[printer_id] = _now()
  289. self._new_fallbacks.pop(printer_id, None)
  290. if snapshot.key.startswith("file:"):
  291. self._new_fallbacks[printer_id] = f"{snapshot.key}:{uuid4().hex}"
  292. self._snapshots[printer_id] = snapshot
  293. self._wake.set()
  294. def print_finished(self, printer_id: int, data: dict) -> None:
  295. if self._provider_enabled is False:
  296. return
  297. job = data.get("subtask_id") or (data.get("raw_data") or {}).get("subtask_id")
  298. snapshot = self._snapshots.get(printer_id)
  299. filename = data.get("subtask_name") or data.get("filename")
  300. key = f"job:{job}" if job and str(job) != "0" else None
  301. if key is None and snapshot and (not filename or snapshot.filename == filename):
  302. key = (
  303. self._new_fallbacks.get(printer_id, snapshot.key) if snapshot.key.startswith("file:") else snapshot.key
  304. )
  305. if key is None:
  306. # No unambiguous ownership: the real terminal state can reconcile
  307. # it, but a delayed event must never end the printer's next job.
  308. return
  309. self._finished.append((printer_id, key, str(data.get("status", "completed")).upper()))
  310. snapshot = self._snapshots.get(printer_id)
  311. if snapshot and key in (snapshot.key, snapshot.job_key):
  312. self._snapshots[printer_id] = replace(snapshot, state=self._finished[-1][2], remaining_seconds=0)
  313. self._wake.set()
  314. async def _api(self):
  315. if self._client is None:
  316. self._http = httpx.AsyncClient(
  317. timeout=httpx.Timeout(30, connect=5), follow_redirects=False, headers={"User-Agent": "Bambuddy/1.0"}
  318. )
  319. self._client = NotifyClient(self._http)
  320. return self._client
  321. async def _save(self, row) -> None:
  322. async with self._session() as db:
  323. values = {column.name: getattr(row, column.name) for column in row.__table__.columns if column.name != "id"}
  324. # Never resurrect ownership after provider deletion. A queued cleanup
  325. # still holds this object's newly returned remote ID.
  326. await db.execute(
  327. update(NotificationLiveActivity).where(NotificationLiveActivity.id == row.id).values(**values)
  328. )
  329. await db.commit()
  330. async def tick(self) -> None:
  331. async with self._lock:
  332. async with self._session() as db:
  333. providers = (
  334. await db.scalars(select(NotificationProvider).where(NotificationProvider.provider_type == "notify"))
  335. ).all()
  336. any_enabled = any(self._eligible(provider) for provider in providers)
  337. previously_enabled = self._provider_enabled
  338. self._provider_enabled = any_enabled
  339. if any_enabled and previously_enabled is False:
  340. self._on_activated()
  341. if any_enabled and self._status_grace_until is None:
  342. # Start when reconciliation begins, not at module import.
  343. self._status_grace_until = _now() + _STARTUP_GRACE
  344. if not any_enabled and not previously_enabled and not self._cleanup_once and not self._cleanup_pending:
  345. return
  346. rows = (await db.scalars(select(NotificationLiveActivity))).all()
  347. self._working_rows = rows
  348. names = dict((await db.execute(select(Printer.id, Printer.name))).all()) if any_enabled else {}
  349. finished, self._finished = self._finished, []
  350. snapshots = self._snapshots.copy()
  351. for provider in providers:
  352. config = _config(provider)
  353. owned = [r for r in rows if r.provider_id == provider.id]
  354. enabled = bool(
  355. provider.enabled
  356. and config.get("live_activities") is True
  357. and not str(config.get("device_id", "")).upper().startswith(("GRP", "MC", "WB"))
  358. )
  359. for row in owned:
  360. snapshot = snapshots.get(row.printer_id)
  361. selected = provider.printer_id is None or provider.printer_id == row.printer_id
  362. if (
  363. row.state == "ended"
  364. and row.end_reason == "disabled"
  365. and enabled
  366. and selected
  367. and snapshot
  368. and snapshot.connected
  369. and snapshot.state in _RUNNING
  370. and self._matches(row, snapshot, row.printer_id)
  371. ):
  372. # An explicit re-enable may resume this job's tile. A
  373. # user-dismissed/failed-to-appear tile remains suppressed.
  374. row.state, row.activity_id, row.end_reason = "pending", None, None
  375. row.failures, row.next_attempt_at, row.last_sent_at = 0, None, None
  376. await self._save(row)
  377. if (
  378. snapshot
  379. and snapshot.job_key
  380. and not row.job_key
  381. and self._matches(row, snapshot, row.printer_id)
  382. ):
  383. row.job_key = snapshot.job_key
  384. await self._save(row)
  385. if self._retired_row(row):
  386. continue
  387. if row.state in _STOPPED:
  388. continue
  389. if row.credential_key != _credential_key(config):
  390. # The edit hook owns cleanup using the old credentials.
  391. continue
  392. if row.state == "ending":
  393. await self._end(row, config, row.end_reason or "STOPPED")
  394. continue
  395. snapshot = snapshots.get(row.printer_id)
  396. final = next(
  397. (
  398. status
  399. for pid, key, status in finished
  400. if pid == row.printer_id and key in (row.print_key, row.job_key)
  401. ),
  402. None,
  403. )
  404. selected = provider.printer_id is None or provider.printer_id == row.printer_id
  405. same_print = snapshot and self._matches(row, snapshot, row.printer_id)
  406. if final or not enabled or not selected or row.printer_id not in names:
  407. await self._end(
  408. row, config, final or ("DISABLED" if not enabled or not selected else "STOPPED")
  409. )
  410. elif snapshot and snapshot.connected and snapshot.state in _TERMINAL:
  411. await self._end(row, config, snapshot.state)
  412. elif snapshot and snapshot.connected and snapshot.state in _RUNNING and not same_print:
  413. await self._end(row, config, "STOPPED")
  414. elif snapshot is None and self._status_grace_until and _now() < self._status_grace_until:
  415. # Allow reconnect time without leaving a switched-off
  416. # printer's saved countdown running indefinitely.
  417. continue
  418. else:
  419. # A missing/offline printer is not evidence the print ended.
  420. # Clear its countdown and keep its last known progress.
  421. content = (
  422. snapshot.content(names[row.printer_id], config) if snapshot else json.loads(row.content)
  423. )
  424. if snapshot is None or not snapshot.connected:
  425. content.update(status="Printer offline", endsIn=None, trailing=None)
  426. if snapshot is None:
  427. content["metrics"] = None # Saved ETA/temperature chips are stale after a restart.
  428. await self._sync(
  429. row,
  430. config,
  431. content,
  432. can_start=bool(
  433. snapshot
  434. and snapshot.connected
  435. and snapshot.state in _RUNNING
  436. and enabled
  437. and not self._quiet(provider)
  438. ),
  439. )
  440. if not enabled or self._quiet(provider):
  441. continue
  442. for printer_id, snapshot in snapshots.items():
  443. if (
  444. not snapshot.connected
  445. or snapshot.state not in _RUNNING
  446. or printer_id not in names
  447. or provider.printer_id not in (None, printer_id)
  448. ):
  449. continue
  450. # Finish the old tile before using another device slot for
  451. # this printer. A failed DELETE remains durable and retries.
  452. if any(r.printer_id == printer_id and r.state == "ending" and r.activity_id for r in owned):
  453. continue
  454. if any(self._matches(r, snapshot, printer_id) for r in owned):
  455. continue
  456. key = (
  457. self._new_fallbacks.get(printer_id, snapshot.key)
  458. if snapshot.key.startswith("file:")
  459. else snapshot.key
  460. )
  461. row = NotificationLiveActivity(
  462. provider_id=provider.id,
  463. printer_id=printer_id,
  464. print_key=key,
  465. print_name=snapshot.filename[:255],
  466. job_key=snapshot.job_key,
  467. credential_key=_credential_key(config),
  468. state="pending",
  469. content=json.dumps(snapshot.content(names[printer_id], config)),
  470. created_at=_now(),
  471. )
  472. if self._retired_row(row):
  473. continue
  474. self._working_rows.append(row)
  475. async with self._session() as db:
  476. db.add(row)
  477. await db.commit()
  478. await self._sync(row, config, json.loads(row.content), can_start=True)
  479. self._cleanup_pending = any(
  480. row.state == "ending" and row.provider_id in {p.id for p in providers} for row in rows
  481. )
  482. # Tombstones need no maintenance while the integration is dormant.
  483. if not any_enabled:
  484. return
  485. # Tombstones must outlive any plausible print, but need not grow forever.
  486. async with self._session() as db:
  487. await db.execute(
  488. delete(NotificationLiveActivity).where(
  489. NotificationLiveActivity.state.in_(_STOPPED),
  490. NotificationLiveActivity.created_at < _now() - timedelta(days=30),
  491. NotificationLiveActivity.printer_id.not_in(
  492. [pid for pid, snapshot in snapshots.items() if snapshot.state in _RUNNING]
  493. ),
  494. )
  495. )
  496. await db.commit()
  497. def _matches(self, row, snapshot, printer_id):
  498. if row.printer_id != printer_id:
  499. return False
  500. if row.job_key and snapshot.job_key:
  501. return row.job_key == snapshot.job_key
  502. fallback = self._new_fallbacks.get(printer_id)
  503. if fallback and snapshot.key.startswith("file:"):
  504. return row.print_key == fallback
  505. if (
  506. row.print_key.startswith("file:")
  507. and snapshot.key.startswith("job:")
  508. and row.print_name == snapshot.filename[:255]
  509. and row.job_key is None
  510. and self._started_at.get(printer_id, row.created_at) <= row.created_at
  511. ):
  512. return True # Restart after firmware finally supplied its job ID.
  513. return row.print_key == snapshot.key or (
  514. snapshot.key.startswith("file:") and row.print_key.startswith(snapshot.key + ":")
  515. )
  516. @staticmethod
  517. def _quiet(provider) -> bool:
  518. # Share Bambuddy's configured/local-time quiet-hours semantics. Digest
  519. # and event toggles deliberately never participate in this lifecycle.
  520. from backend.app.services.notification_service import notification_service
  521. return notification_service._is_in_quiet_hours(provider)
  522. async def _sync(self, row, config, content, *, can_start):
  523. if self._retired_row(row):
  524. return
  525. now = _now()
  526. urgent = json.loads(row.content).get("status") != content.get("status")
  527. if row.next_attempt_at and row.next_attempt_at > now and not (urgent and row.last_sent_at and not row.failures):
  528. return
  529. # Store an absolute deadline rather than repeatedly resetting a frozen
  530. # MQTT minute estimate. Refresh only when that estimate or phase changes.
  531. seconds = content.get("endsIn")
  532. if seconds is None:
  533. row.eta_seconds, row.eta_deadline = None, None
  534. elif seconds != row.eta_seconds or row.eta_deadline is None:
  535. row.eta_seconds = seconds
  536. row.eta_deadline = now + timedelta(seconds=seconds)
  537. if row.eta_deadline:
  538. remaining = int((row.eta_deadline - now).total_seconds())
  539. content["endsIn"] = remaining if remaining > 0 else None
  540. api = await self._api()
  541. token = config.get("token", "")
  542. try:
  543. if row.activity_id:
  544. remote = await api.get_activity(row.activity_id, token)
  545. state, reason = remote.get("state"), remote.get("endReason")
  546. if state in {"ended", "dismissed"}:
  547. if reason not in _ROLLOVER or not can_start:
  548. row.state = "suppressed" if reason not in _ROLLOVER else "pending"
  549. row.end_reason = reason or state
  550. if row.state == "pending":
  551. row.activity_id = None
  552. await self._save(row)
  553. return
  554. row.activity_id = None
  555. row.state = "pending"
  556. await self._save(row)
  557. else:
  558. row.state = state or "active"
  559. row.expires_at = _date(remote.get("expiresAt")) or row.expires_at
  560. if can_start and row.expires_at and row.expires_at <= now:
  561. # Poll first: a dismissed tile must never be rolled over.
  562. await api.end_activity(row.activity_id, token)
  563. row.activity_id = None
  564. row.state = "pending"
  565. await self._save(row)
  566. else:
  567. if self._retired_row(row):
  568. return
  569. await api.update_activity(row.activity_id, token, content)
  570. row.content = json.dumps(content)
  571. row.failures = 0
  572. row.last_sent_at = now
  573. row.next_attempt_at = now + timedelta(seconds=_INTERVAL)
  574. await self._save(row)
  575. return
  576. if row.state == "uncertain":
  577. # No usable ID after a timeout/crash. The API has no client
  578. # idempotency key; do not adopt an unrelated device tile.
  579. row.state = "suppressed"
  580. row.end_reason = "unconfirmed-start"
  581. await self._save(row)
  582. return
  583. if not can_start or row.state in _STOPPED:
  584. return
  585. row.state = "uncertain"
  586. row.content = json.dumps(content)
  587. await self._save(row)
  588. if self._retired_row(row):
  589. return
  590. result = await api.start_activity(config.get("device_id", ""), token, content)
  591. row.activity_id = result["activityId"]
  592. row.state = "starting"
  593. row.failures = 0
  594. row.expires_at = _date(result.get("expiresAt")) or now + timedelta(hours=8)
  595. row.last_sent_at = now
  596. row.next_attempt_at = now + timedelta(seconds=_INTERVAL)
  597. await self._save(row)
  598. except NotifyError as error:
  599. await self._failure(row, error)
  600. async def _failure(self, row, error):
  601. ending = row.state == "ending"
  602. if error.activity_id:
  603. row.activity_id = error.activity_id
  604. row.state = "starting"
  605. elif not row.activity_id and error.delivery_state == "unknown":
  606. row.state = "suppressed"
  607. row.end_reason = "unconfirmed-start"
  608. elif error.status_code in (401, 403):
  609. row.state = "suppressed"
  610. row.end_reason = "credentials"
  611. elif error.status_code == 410 and row.activity_id:
  612. # GET on the next pass determines whether this was a dismissal or
  613. # the eight-hour ceiling; never infer rollover from HTTP 410 alone.
  614. pass
  615. elif not row.activity_id:
  616. if error.status_code in (409, 429, 503) or error.retry_after_seconds is not None:
  617. row.state = "pending"
  618. elif error.status_code == 400 and "5" in str(error.payload.get("message", "")):
  619. row.state = "pending" # Device cap: another printer may free a slot.
  620. else:
  621. row.state = "suppressed"
  622. row.end_reason = "start-rejected"
  623. if ending:
  624. row.state = "ending"
  625. row.failures = min((row.failures or 0) + 1, 5)
  626. row.next_attempt_at = _now() + timedelta(
  627. seconds=max(60 * 2 ** (row.failures - 1), error.retry_after_seconds or 0)
  628. )
  629. # Do not log upstream bodies, URLs, or credentials.
  630. logger.warning(
  631. "Notify Live Activity request failed for provider %s (HTTP %s)", row.provider_id, error.status_code
  632. )
  633. await self._save(row)
  634. message = str(error)
  635. if error.retry_after_seconds:
  636. message += f" Retry after {error.retry_after_seconds} seconds."
  637. async with self._session() as db:
  638. await db.execute(
  639. update(NotificationProvider)
  640. .where(NotificationProvider.id == row.provider_id)
  641. .values(
  642. last_error=message,
  643. last_error_at=_now(),
  644. )
  645. )
  646. await db.commit()
  647. async def _end(self, row, config, status):
  648. content = json.loads(row.content)
  649. content.update(
  650. status={"COMPLETED": "Complete", "FINISH": "Complete", "FAILED": "Failed"}.get(status, "Stopped"),
  651. endsIn=None,
  652. )
  653. if status in {"COMPLETED", "FINISH"}:
  654. content["progress"] = 100
  655. # Persist the terminal decision before I/O. A failed end must still
  656. # finish this exact job after restart or after the next job begins.
  657. if row.state != "ending":
  658. row.state = "ending"
  659. row.end_reason = status
  660. row.content = json.dumps(content)
  661. await self._save(row)
  662. if row.failures and row.next_attempt_at and row.next_attempt_at > _now():
  663. return
  664. if row.activity_id:
  665. try:
  666. await (await self._api()).end_activity(row.activity_id, config.get("token", ""), content)
  667. except NotifyError as error:
  668. if error.status_code not in (403, 404, 410):
  669. await self._failure(row, error)
  670. return
  671. row.state = "ended"
  672. row.end_reason = status.lower()
  673. row.content = json.dumps(content)
  674. await self._save(row)
  675. async def cleanup_provider(self, provider_id: int, old_config: dict, *, captured_rows=None) -> None:
  676. """Called after saving a credential/scope change or deleting a provider.
  677. Cleanup is best effort (the remote eight-hour ceiling bounds an outage).
  678. No old credential is retained in a second database location.
  679. """
  680. async with self._lock:
  681. async with self._session() as db:
  682. rows = (
  683. await db.scalars(
  684. select(NotificationLiveActivity).where(
  685. NotificationLiveActivity.provider_id == provider_id,
  686. NotificationLiveActivity.credential_key == _credential_key(old_config),
  687. )
  688. )
  689. ).all()
  690. if captured_rows is not None:
  691. merged = {row.id: row for row in rows}
  692. merged.update(
  693. {row.id: row for row in captured_rows if row.credential_key == _credential_key(old_config)}
  694. )
  695. rows = list(merged.values())
  696. for row in rows:
  697. if row.activity_id and row.state not in _STOPPED:
  698. try:
  699. await (await self._api()).end_activity(row.activity_id, old_config.get("token", ""))
  700. except NotifyError:
  701. logger.warning("Notify Live Activity cleanup failed for provider %s", provider_id)
  702. async with self._session() as db:
  703. await db.execute(
  704. delete(NotificationLiveActivity).where(
  705. NotificationLiveActivity.provider_id == provider_id,
  706. NotificationLiveActivity.credential_key == _credential_key(old_config),
  707. )
  708. )
  709. await db.commit()
  710. for row in rows:
  711. row.state = "ended"
  712. self._wake.set()
  713. notify_live_activities = NotifyLiveActivityService()