wled.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396
  1. """Optional per-printer WLED preset integration."""
  2. import asyncio
  3. import logging
  4. import time
  5. from dataclasses import dataclass
  6. import httpx
  7. from backend.app.core.tasks import spawn_background_task
  8. from backend.app.schemas.printer import WLEDConfig
  9. from backend.app.services.bambu_mqtt import PrinterState
  10. from backend.app.services.hms_errors import hms_fault_counts
  11. logger = logging.getLogger(__name__)
  12. # While WLED does not answer, the same preset is retried at most this often
  13. # instead of on every printer status update.
  14. WLED_RETRY_SECONDS = 30.0
  15. class WLEDResponseError(ValueError):
  16. """WLED returned JSON that does not match the expected API shape."""
  17. def effective_wled_status(
  18. state: PrinterState, *, awaiting_plate_clear: bool = False, require_plate_clear: bool = False
  19. ) -> str | None:
  20. """Map Bambuddy's existing printer state to a WLED mapping key."""
  21. statuses = _applicable_wled_statuses(
  22. state, awaiting_plate_clear=awaiting_plate_clear, require_plate_clear=require_plate_clear
  23. )
  24. return statuses[0] if statuses else None
  25. def _applicable_wled_statuses(
  26. state: PrinterState, *, awaiting_plate_clear: bool = False, require_plate_clear: bool = False
  27. ) -> list[str]:
  28. """Return applicable states in priority order for mapped-preset fallback."""
  29. if not state.connected:
  30. return ["offline"]
  31. printer_state = (state.state or "").upper()
  32. statuses = []
  33. if printer_state == "PAUSE" and state.stg_cur == 6:
  34. statuses.append("filament_problem")
  35. if any(hms_fault_counts(error) for error in state.hms_errors):
  36. statuses.append("hms_error")
  37. if require_plate_clear and awaiting_plate_clear and printer_state in {"IDLE", "FINISH", "FAILED"}:
  38. statuses.append("queue_waiting")
  39. if printer_state == "FAILED":
  40. statuses.append("error")
  41. elif printer_state == "PAUSE":
  42. statuses.append("paused")
  43. elif printer_state == "FINISH":
  44. statuses.append("finished")
  45. elif printer_state in {"RUNNING", "PRINTING"}:
  46. statuses.append("printing")
  47. elif printer_state in {"PREPARE", "SLICING"}:
  48. statuses.append("prepare")
  49. elif printer_state == "IDLE":
  50. statuses.append("idle")
  51. return statuses
  52. @dataclass
  53. class _WLEDRuntime:
  54. status: str | None = None
  55. preset_id: int | None = None
  56. target: tuple[str | None, int | None] | None = None
  57. generation: int = 0
  58. confirmed_generation: int | None = None
  59. finished_timeout_expired: bool = False
  60. # Monotonic time before which a failed send is not retried, and whether
  61. # the last automatic send failed (so an outage logs once, not per retry).
  62. retry_after: float = 0.0
  63. unreachable: bool = False
  64. send_task: asyncio.Task | None = None
  65. finished_task: asyncio.Task | None = None
  66. class WLEDManager:
  67. """Own WLED HTTP calls, per-printer deduplication, and finished timers."""
  68. def __init__(self, client: httpx.AsyncClient | None = None):
  69. self._client = client
  70. self._owns_client = client is None
  71. self.require_plate_clear = False
  72. self.retry_seconds = WLED_RETRY_SECONDS
  73. self._configs: dict[int, WLEDConfig] = {}
  74. self._runtime: dict[int, _WLEDRuntime] = {}
  75. @property
  76. def client(self) -> httpx.AsyncClient:
  77. if self._client is None:
  78. self._client = httpx.AsyncClient(
  79. timeout=httpx.Timeout(3.0, connect=2.0),
  80. follow_redirects=False,
  81. )
  82. return self._client
  83. def configure_printer(self, printer_id: int, config: dict | WLEDConfig | None) -> None:
  84. self._cancel_runtime(printer_id, remove=True)
  85. try:
  86. self._configs[printer_id] = WLEDConfig.model_validate(config or {})
  87. except ValueError:
  88. self._configs[printer_id] = WLEDConfig()
  89. logger.warning("Ignoring invalid WLED configuration for printer %d", printer_id)
  90. def remove_printer(self, printer_id: int) -> None:
  91. self._configs.pop(printer_id, None)
  92. self._cancel_runtime(printer_id, remove=True)
  93. def handle_status(
  94. self,
  95. printer_id: int,
  96. state: PrinterState,
  97. *,
  98. awaiting_plate_clear: bool = False,
  99. ) -> None:
  100. """Schedule any needed WLED work without delaying printer handling."""
  101. config = self._configs.get(printer_id)
  102. if not config or not config.enabled or not config.base_url:
  103. self._cancel_runtime(printer_id)
  104. return
  105. status = next(
  106. (
  107. candidate
  108. for candidate in _applicable_wled_statuses(
  109. state, awaiting_plate_clear=awaiting_plate_clear, require_plate_clear=self.require_plate_clear
  110. )
  111. if getattr(config.presets, candidate) is not None
  112. ),
  113. None,
  114. )
  115. preset_id = getattr(config.presets, status, None) if status else None
  116. if state.connected and (state.state or "").upper() in {"PREPARE", "SLICING", "RUNNING", "PRINTING"}:
  117. self._runtime.setdefault(printer_id, _WLEDRuntime()).finished_timeout_expired = False
  118. self._apply_status(printer_id, config, status, preset_id)
  119. def handle_offline(self, printer_id: int) -> None:
  120. """Apply the offline mapping for an explicit printer lifecycle transition."""
  121. config = self._configs.get(printer_id)
  122. if not config or not config.enabled or not config.base_url:
  123. return
  124. preset_id = config.presets.offline
  125. self._apply_status(printer_id, config, "offline" if preset_id is not None else None, preset_id)
  126. def _apply_status(self, printer_id: int, config: WLEDConfig, status: str | None, preset_id: int | None) -> None:
  127. base_url = config.base_url
  128. if not base_url:
  129. return
  130. idle_preset_id = config.presets.idle
  131. runtime = self._runtime.setdefault(printer_id, _WLEDRuntime())
  132. expired_finished = runtime.finished_timeout_expired and status == "finished" and idle_preset_id is not None
  133. target = (status, preset_id)
  134. if runtime.target == target:
  135. if expired_finished:
  136. if runtime.finished_task is not None and not runtime.finished_task.done():
  137. return
  138. elif (
  139. preset_id is None
  140. or runtime.confirmed_generation == runtime.generation
  141. or (runtime.send_task is not None and not runtime.send_task.done())
  142. ):
  143. return
  144. if time.monotonic() < runtime.retry_after:
  145. return
  146. else:
  147. runtime.generation += 1
  148. self._cancel_task(runtime.finished_task)
  149. runtime.finished_task = None
  150. self._cancel_task(runtime.send_task)
  151. runtime.target = target
  152. generation = runtime.generation
  153. if expired_finished and idle_preset_id is not None:
  154. if runtime.status == "idle" and runtime.preset_id == idle_preset_id:
  155. return
  156. runtime.finished_task = spawn_background_task(
  157. self._send_finished_idle(printer_id, generation, base_url, idle_preset_id),
  158. name=f"wled-finished-idle-{printer_id}",
  159. )
  160. return
  161. if preset_id is not None:
  162. runtime.send_task = spawn_background_task(
  163. self._send_status(printer_id, base_url, status, preset_id, generation),
  164. name=f"wled-preset-{printer_id}",
  165. )
  166. if (
  167. status == "finished"
  168. and config.finished_timeout_seconds
  169. and idle_preset_id is not None
  170. and runtime.finished_task is None
  171. ):
  172. runtime.finished_task = spawn_background_task(
  173. self._finish_timeout(
  174. printer_id,
  175. generation,
  176. base_url,
  177. idle_preset_id,
  178. config.finished_timeout_seconds,
  179. ),
  180. name=f"wled-finished-timeout-{printer_id}",
  181. )
  182. async def _send_status(
  183. self, printer_id: int, base_url: str, status: str | None, preset_id: int, generation: int
  184. ) -> None:
  185. ok = await self._send_automatic(printer_id, base_url, preset_id, status)
  186. if ok:
  187. runtime = self._runtime.get(printer_id)
  188. if (
  189. runtime
  190. and runtime.generation == generation
  191. and runtime.target == (status, preset_id)
  192. and runtime.send_task is asyncio.current_task()
  193. ):
  194. runtime.status = status
  195. runtime.preset_id = preset_id
  196. runtime.confirmed_generation = generation
  197. async def _send_finished_idle(self, printer_id: int, generation: int, base_url: str, idle_preset_id: int) -> None:
  198. if await self._send_automatic(printer_id, base_url, idle_preset_id, "idle"):
  199. runtime = self._runtime.get(printer_id)
  200. if (
  201. runtime
  202. and runtime.generation == generation
  203. and runtime.finished_timeout_expired
  204. and runtime.target
  205. and runtime.target[0] == "finished"
  206. ):
  207. runtime.status = "idle"
  208. runtime.preset_id = idle_preset_id
  209. runtime.confirmed_generation = generation
  210. async def _send_automatic(self, printer_id: int, base_url: str, preset_id: int, status: str | None) -> bool:
  211. """Send a mapped preset; on failure hold retries back and log once per outage."""
  212. runtime = self._runtime.get(printer_id)
  213. quiet = runtime is not None and runtime.unreachable
  214. ok = await self.send_preset(printer_id, base_url, preset_id, status=status, quiet=quiet)
  215. if runtime is None or self._runtime.get(printer_id) is not runtime:
  216. return ok
  217. if ok:
  218. runtime.retry_after = 0.0
  219. if runtime.unreachable:
  220. runtime.unreachable = False
  221. logger.info("[WLED] Printer %d: WLED answers again", printer_id)
  222. else:
  223. runtime.retry_after = time.monotonic() + self.retry_seconds
  224. if not runtime.unreachable:
  225. runtime.unreachable = True
  226. logger.info(
  227. "[WLED] Printer %d: retrying every %ds until WLED answers", printer_id, int(self.retry_seconds)
  228. )
  229. return ok
  230. async def test_preset(self, printer_id: int, base_url: str, preset_id: int) -> bool:
  231. """Test a preset while forcing the next status update to restore automatic control."""
  232. self._invalidate_automatic(printer_id)
  233. try:
  234. return await self.send_preset(printer_id, base_url, preset_id)
  235. finally:
  236. self._invalidate_automatic(printer_id)
  237. def _invalidate_automatic(self, printer_id: int) -> None:
  238. runtime = self._runtime.get(printer_id)
  239. if runtime:
  240. self._cancel_task(runtime.send_task)
  241. runtime.send_task = None
  242. runtime.status = None
  243. runtime.preset_id = None
  244. runtime.confirmed_generation = None
  245. async def send_preset(
  246. self, printer_id: int, base_url: str, preset_id: int, *, status: str | None = None, quiet: bool = False
  247. ) -> bool:
  248. """Activate a preset. Failures are isolated from all printer work.
  249. ``quiet`` logs a failure at debug level: a retry during an outage that
  250. was already reported.
  251. """
  252. log_failure = logger.debug if quiet else logger.warning
  253. try:
  254. response = await self.client.post(f"{base_url}/json/state", json={"ps": preset_id})
  255. response.raise_for_status()
  256. if not isinstance(response.json(), dict):
  257. raise WLEDResponseError("state response must be a JSON object")
  258. logger.info("[WLED] Printer %d state=%s -> preset=%d", printer_id, status or "manual", preset_id)
  259. return True
  260. except asyncio.CancelledError:
  261. raise
  262. except httpx.HTTPStatusError as exc:
  263. log_failure(
  264. "[WLED] Printer %d state=%s -> preset=%d failed (HTTP %d)",
  265. printer_id,
  266. status or "manual",
  267. preset_id,
  268. exc.response.status_code,
  269. )
  270. except (httpx.HTTPError, ValueError):
  271. log_failure("[WLED] Printer %d state=%s -> preset=%d failed", printer_id, status or "manual", preset_id)
  272. return False
  273. async def list_presets(self, base_url: str) -> list[dict[str, int | str]]:
  274. """Return WLED preset names and IDs without modifying WLED state."""
  275. response = await self.client.get(f"{base_url}/presets.json")
  276. response.raise_for_status()
  277. try:
  278. payload = response.json()
  279. except ValueError as exc:
  280. raise WLEDResponseError("presets response is not valid JSON") from exc
  281. if not isinstance(payload, dict):
  282. raise WLEDResponseError("presets response must be a JSON object")
  283. presets = []
  284. for raw_id, value in payload.items():
  285. try:
  286. preset_id = int(raw_id)
  287. except (TypeError, ValueError):
  288. continue
  289. if not 1 <= preset_id <= 250 or not isinstance(value, dict):
  290. continue
  291. name = value.get("n")
  292. presets.append(
  293. {"id": preset_id, "name": name.strip() if isinstance(name, str) and name.strip() else str(preset_id)}
  294. )
  295. return sorted(presets, key=lambda item: int(item["id"]))
  296. async def get_info(self, base_url: str) -> dict[str, str | None]:
  297. """Return the optional name and version reported by WLED."""
  298. response = await self.client.get(f"{base_url}/json/info")
  299. response.raise_for_status()
  300. try:
  301. payload = response.json()
  302. except ValueError as exc:
  303. raise WLEDResponseError("info response is not valid JSON") from exc
  304. if not isinstance(payload, dict):
  305. raise WLEDResponseError("info response must be a JSON object")
  306. def clean(value) -> str | None:
  307. return value.strip() if isinstance(value, str) and value.strip() else None
  308. return {"name": clean(payload.get("name")), "version": clean(payload.get("ver"))}
  309. async def shutdown(self) -> None:
  310. tasks: list[asyncio.Task] = []
  311. for runtime in self._runtime.values():
  312. for task in (runtime.send_task, runtime.finished_task):
  313. if task and not task.done():
  314. task.cancel()
  315. tasks.append(task)
  316. if tasks:
  317. await asyncio.gather(*tasks, return_exceptions=True)
  318. self._runtime.clear()
  319. self._configs.clear()
  320. if self._owns_client and self._client is not None:
  321. await self._client.aclose()
  322. self._client = None
  323. async def _finish_timeout(
  324. self,
  325. printer_id: int,
  326. generation: int,
  327. base_url: str,
  328. idle_preset_id: int,
  329. timeout_seconds: int,
  330. ) -> None:
  331. try:
  332. await asyncio.sleep(timeout_seconds)
  333. runtime = self._runtime.get(printer_id)
  334. if not runtime or runtime.generation != generation or not runtime.target or runtime.target[0] != "finished":
  335. return
  336. runtime.finished_timeout_expired = True
  337. await self._send_finished_idle(printer_id, generation, base_url, idle_preset_id)
  338. except asyncio.CancelledError:
  339. return
  340. def _cancel_runtime(self, printer_id: int, *, remove: bool = False) -> None:
  341. runtime = self._runtime.get(printer_id)
  342. if runtime:
  343. runtime.generation += 1
  344. self._cancel_task(runtime.send_task)
  345. self._cancel_task(runtime.finished_task)
  346. if remove:
  347. self._runtime.pop(printer_id, None)
  348. @staticmethod
  349. def _cancel_task(task: asyncio.Task | None) -> None:
  350. if task and not task.done():
  351. task.cancel()
  352. wled_manager = WLEDManager()