execution.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652
  1. """
  2. Firmware-delegated execution: the board runs everything, the host proxies
  3. commands and observes status.
  4. The FluidNC firmware owns kinematics, `.thr` playback, playlist sequencing,
  5. pre-execution clears, quiet hours and auto-home. This module is the host's
  6. entire runtime layer on top of that:
  7. - Commands: each user action is one or a few HTTP calls to the board
  8. ($Sand/Run, $Playlist/Run, /sand_stop, /sand_pause, /sand_resume,
  9. $Playlist/Skip, /sand_feed, /sand_goto).
  10. - Truth: /sand_status is the single source of runtime state. One observer
  11. task polls it, translates it into the frontend's /ws/status contract,
  12. detects edges (file transitions -> play history, Hold -> pause accounting,
  13. clearing -> clear-speed shim, run end -> state reset) and broadcasts.
  14. - Context: the host remembers what run *it* started (RunContext) to fill the
  15. contract fields the board doesn't report (playlist files, run mode).
  16. No sequencing happens on the host. If the board reboots or the backend
  17. restarts mid-run, the observer resynchronizes from /sand_status alone.
  18. """
  19. import asyncio
  20. import logging
  21. import os
  22. import time
  23. from dataclasses import dataclass, field
  24. from typing import Callable, Literal, Optional
  25. from modules.connection import connection_manager
  26. from modules.core.state import state
  27. logger = logging.getLogger(__name__)
  28. # Firmware clear modes; legacy host values are mapped onto them.
  29. CLEAR_MODES = ("none", "adaptive", "in", "out", "sideway", "random")
  30. _LEGACY_CLEAR = {
  31. "clear_from_in": "in",
  32. "clear_from_out": "out",
  33. "clear_sideway": "sideway",
  34. "clear_in": "in",
  35. "clear_out": "out",
  36. }
  37. class ExecutionError(Exception):
  38. """Raised when the board rejects or times out on an execution command."""
  39. @dataclass
  40. class RunContext:
  41. """Host-side knowledge about the run we started (not board truth)."""
  42. kind: Literal["pattern", "playlist"]
  43. playlist_name: Optional[str] = None
  44. files: Optional[list] = None # host paths, unshuffled mirror order
  45. run_mode: str = "single" # frontend value: 'single' | 'indefinite'
  46. shuffle: bool = False
  47. clear_pattern: str = "none"
  48. started_at: float = field(default_factory=time.time)
  49. current_run: Optional[RunContext] = None
  50. def map_clear_mode(value) -> str:
  51. """Map a frontend/legacy clear value onto the firmware's clear modes."""
  52. if not value:
  53. return "none"
  54. value = str(value).lower()
  55. if value in CLEAR_MODES:
  56. return value
  57. if value in _LEGACY_CLEAR:
  58. return _LEGACY_CLEAR[value]
  59. logger.warning(f"Unknown clear mode '{value}', using none")
  60. return "none"
  61. def _state(st: Optional[dict]) -> str:
  62. """Machine state without the GRBL substate suffix ('Hold:0' -> 'Hold')."""
  63. return ((st or {}).get("state") or "").split(":", 1)[0]
  64. def _from_sd_path(sd_path: str) -> Optional[str]:
  65. """Map a board SD path ('/patterns/x.thr', '/sd/patterns/x.thr') to the
  66. host-relative path ('./patterns/x.thr') the frontend/history expect."""
  67. if not sd_path:
  68. return None
  69. p = sd_path.replace("\\", "/")
  70. if p.startswith("/sd/"):
  71. p = p[3:]
  72. if not p.startswith("/"):
  73. p = "/" + p
  74. return "." + p
  75. # ---------------------------------------------------------------------------
  76. # Commands
  77. # ---------------------------------------------------------------------------
  78. def _require_conn():
  79. if not state.conn or not state.conn.is_connected():
  80. raise ExecutionError("Connection not established")
  81. return state.conn
  82. async def _wait_for_idle(timeout: float = 15.0) -> bool:
  83. """Poll the board until it reports Idle (used before idle-gated NVS writes)."""
  84. deadline = time.time() + timeout
  85. while time.time() < deadline:
  86. try:
  87. st = await asyncio.to_thread(state.conn.get_status)
  88. if _state(st) == "Idle" and not st.get("running"):
  89. return True
  90. except Exception as e:
  91. logger.debug(f"Idle wait poll failed: {e}")
  92. await asyncio.sleep(0.5)
  93. return False
  94. async def run_pattern(file_path: str, clear_pattern: str = "none") -> None:
  95. """Run one pattern via $Sand/Run (firmware sequences clear -> pattern and
  96. aborts any current job first)."""
  97. global current_run
  98. conn = _require_conn()
  99. from modules.core.pattern_manager import _ensure_on_board, _to_sd_path
  100. sd_path = _to_sd_path(file_path)
  101. await asyncio.to_thread(_ensure_on_board, file_path, sd_path)
  102. try:
  103. await asyncio.to_thread(conn.set_feed, int(state.speed))
  104. except Exception as e:
  105. logger.warning(f"Could not set feed before run: {e}")
  106. mode = map_clear_mode(clear_pattern)
  107. await asyncio.to_thread(conn.run_pattern, sd_path, mode)
  108. current_run = RunContext(kind="pattern", clear_pattern=mode)
  109. state.current_playlist = None
  110. state.current_playlist_name = None
  111. # Optimistic; the observer confirms/corrects from /sand_status.
  112. state.current_playing_file = file_path
  113. observer.on_run_started()
  114. async def start_playlist(playlist_name: str, run_mode: str = "single",
  115. pause_time: float = 0, clear_pattern: str = "none",
  116. shuffle: bool = False) -> None:
  117. """Run a playlist on the board via $Playlist/Run.
  118. The run options are the firmware's NVS $Playlist/* globals; NVS writes are
  119. idle-gated, so a run-while-running stops the board first.
  120. """
  121. global current_run
  122. conn = _require_conn()
  123. from modules.core import playlist_manager, board_settings
  124. from modules.core.pattern_manager import THETA_RHO_DIR, _ensure_on_board, _to_sd_path
  125. playlist = playlist_manager.get_playlist(playlist_name)
  126. if not playlist:
  127. raise ExecutionError(f"Playlist '{playlist_name}' not found")
  128. files = playlist["files"]
  129. if not files:
  130. raise ExecutionError(f"Playlist '{playlist_name}' is empty")
  131. host_paths = [os.path.join(THETA_RHO_DIR, f) for f in files]
  132. # Idle-gate: NVS settings writes are rejected mid-motion.
  133. st = None
  134. try:
  135. st = await asyncio.to_thread(conn.get_status)
  136. except Exception:
  137. pass
  138. if st and (st.get("running") or _state(st) not in ("Idle", "Alarm")):
  139. await asyncio.to_thread(conn.stop)
  140. if not await _wait_for_idle(15.0):
  141. raise ExecutionError("Table is busy and did not stop in time")
  142. mode = map_clear_mode(clear_pattern)
  143. for key, value in (
  144. ("Playlist/Mode", "loop" if run_mode == "indefinite" else "single"),
  145. ("Playlist/Shuffle", "ON" if shuffle else "OFF"),
  146. ("Playlist/PauseTime", max(0, int(pause_time or 0))),
  147. ("Playlist/ClearPattern", mode),
  148. ):
  149. await asyncio.to_thread(conn.set_setting, key, value)
  150. # The board needs the playlist file before Run; patterns can trail behind
  151. # (each one plays for minutes) except the first, which is ensured now.
  152. await asyncio.to_thread(board_settings.mirror_playlist, playlist_name, files, conn)
  153. await asyncio.to_thread(_ensure_on_board, host_paths[0], _to_sd_path(host_paths[0]))
  154. def _mirror_rest():
  155. for p in host_paths[1:]:
  156. _ensure_on_board(p, _to_sd_path(p))
  157. import threading
  158. threading.Thread(target=_mirror_rest, daemon=True).start()
  159. try:
  160. await asyncio.to_thread(conn.set_feed, int(state.speed))
  161. except Exception as e:
  162. logger.warning(f"Could not set feed before playlist: {e}")
  163. await asyncio.to_thread(conn.run_command, f"$Playlist/Run={playlist_name}")
  164. current_run = RunContext(
  165. kind="playlist", playlist_name=playlist_name, files=host_paths,
  166. run_mode=run_mode, shuffle=shuffle, clear_pattern=mode,
  167. )
  168. state.current_playlist = host_paths
  169. state.current_playlist_name = playlist_name
  170. state.playlist_mode = run_mode
  171. observer.on_run_started()
  172. logger.info(f"Started playlist '{playlist_name}' on the board "
  173. f"(mode={run_mode}, shuffle={shuffle}, pause={pause_time}s, clear={mode})")
  174. async def stop(force: bool = False) -> bool:
  175. """Clean stop. Returns True once the board is Idle (always True for force)."""
  176. try:
  177. conn = _require_conn()
  178. await asyncio.to_thread(conn.stop)
  179. except Exception as e:
  180. if not force:
  181. raise ExecutionError(f"Stop failed: {e}")
  182. logger.warning(f"Force stop: board stop failed ({e}), resetting host state anyway")
  183. if force:
  184. _reset_run_state()
  185. observer.reset()
  186. return True
  187. return await _wait_for_idle(10.0)
  188. async def pause() -> None:
  189. conn = _require_conn()
  190. await asyncio.to_thread(conn.pause)
  191. async def resume() -> None:
  192. conn = _require_conn()
  193. await asyncio.to_thread(conn.resume)
  194. async def skip() -> bool:
  195. """Skip the current pattern: $Playlist/Skip for playlists (also overrides
  196. quiet hours for one pattern); stopping is the 'skip' of a single pattern."""
  197. conn = _require_conn()
  198. st = observer.last_raw or {}
  199. pl = st.get("playlist") or {}
  200. if pl.get("active"):
  201. await asyncio.to_thread(conn.skip)
  202. return True
  203. if st.get("running"):
  204. await asyncio.to_thread(conn.stop)
  205. return True
  206. return False
  207. async def set_speed(speed: float) -> None:
  208. conn = _require_conn()
  209. state.speed = speed
  210. await asyncio.to_thread(conn.set_feed, int(speed))
  211. def _reset_run_state() -> None:
  212. global current_run
  213. current_run = None
  214. state.current_playing_file = None
  215. state.current_playlist = None
  216. state.current_playlist_name = None
  217. state.pause_requested = False
  218. state.pause_time_remaining = 0
  219. state.execution_progress = None
  220. # ---------------------------------------------------------------------------
  221. # Status translation: /sand_status -> the frontend's /ws/status contract
  222. # ---------------------------------------------------------------------------
  223. def translate_status(st: Optional[dict], obs: Optional["BoardObserver"] = None,
  224. now: Optional[float] = None) -> dict:
  225. """Translate a raw board status into the /ws/status data object.
  226. Pure given (st, observer timing, state); unit-testable with fixtures.
  227. """
  228. obs = obs or observer
  229. now = now if now is not None else time.time()
  230. connected = bool(state.conn and state.conn.is_connected())
  231. if st is None:
  232. return {
  233. "connection_status": connected,
  234. "current_file": None,
  235. "is_running": False,
  236. "is_paused": False,
  237. "is_homing": state.is_homing,
  238. "sensor_homing_failed": state.sensor_homing_failed,
  239. "is_clearing": False,
  240. "speed": state.speed,
  241. "pause_time_remaining": 0,
  242. "original_pause_time": None,
  243. "progress": None,
  244. "playlist": None,
  245. "current_theta": state.current_theta,
  246. "current_rho": state.current_rho,
  247. "firmware_version": state.firmware_version,
  248. "table_type": None,
  249. }
  250. pl = st.get("playlist") or {}
  251. running = bool(st.get("running"))
  252. # GRBL states can carry a substate suffix (e.g. "Hold:0", "Door:1").
  253. machine_state = _state(st)
  254. clearing = bool(pl.get("clearing"))
  255. pause_remaining = pl.get("pause_remaining", -1)
  256. pause_total = pl.get("pause_total", -1)
  257. current_file = _from_sd_path(st.get("file")) if running else None
  258. # --- progress ---
  259. progress_obj = None
  260. if running:
  261. fraction = st.get("progress", -1)
  262. fraction = fraction if isinstance(fraction, (int, float)) and fraction >= 0 else 0
  263. elapsed = max(0.0, now - obs.file_started_at - obs.hold_accumulated
  264. - (now - obs.hold_started_at if obs.hold_started_at else 0))
  265. if fraction > 0.02:
  266. remaining = max(0.0, elapsed / fraction - elapsed)
  267. elif obs.cached_history and obs.cached_history.get("actual_time_seconds"):
  268. remaining = max(0.0, obs.cached_history["actual_time_seconds"] - elapsed)
  269. else:
  270. remaining = None
  271. progress_obj = {
  272. "current": int(fraction * 1000),
  273. "total": 1000,
  274. "percentage": round(fraction * 100, 1),
  275. "elapsed_time": elapsed,
  276. "remaining_time": remaining,
  277. }
  278. if obs.cached_history:
  279. progress_obj["last_completed_time"] = obs.cached_history
  280. # --- playlist ---
  281. playlist_obj = None
  282. playlist_active = bool(pl.get("active"))
  283. ctx = current_run
  284. if (playlist_active and pl.get("total", 0) > 0) or (ctx and ctx.kind == "playlist"):
  285. files = [f for f in (state.current_playlist or [])]
  286. index = int(pl.get("index", 0) or 0)
  287. total = int(pl.get("total", 0) or 0) or len(files)
  288. shuffled = bool(ctx.shuffle) if ctx else False
  289. next_file = None
  290. if files and not shuffled:
  291. # While clearing, the "next" thing is the pattern the clear precedes.
  292. next_idx = index if clearing else index + 1
  293. if 0 <= next_idx < len(files):
  294. next_file = files[next_idx]
  295. playlist_obj = {
  296. "name": pl.get("name") or (ctx.playlist_name if ctx else None),
  297. "current_index": index,
  298. "total_files": total,
  299. "mode": (ctx.run_mode if ctx else "indefinite"),
  300. "files": files,
  301. "next_file": next_file,
  302. "shuffled": shuffled,
  303. }
  304. return {
  305. "connection_status": connected,
  306. "current_file": current_file,
  307. "is_running": running,
  308. "is_paused": machine_state == "Hold",
  309. "is_homing": machine_state == "Home" or state.is_homing,
  310. "sensor_homing_failed": state.sensor_homing_failed,
  311. "is_clearing": clearing,
  312. "speed": state.speed,
  313. "pause_time_remaining": pause_remaining if pause_remaining >= 0 else 0,
  314. "original_pause_time": pause_total if pause_total >= 0 else None,
  315. "progress": progress_obj,
  316. "playlist": playlist_obj,
  317. "current_theta": st.get("theta", state.current_theta),
  318. "current_rho": st.get("rho", state.current_rho),
  319. "firmware_version": st.get("fw") or state.firmware_version,
  320. "table_type": None,
  321. }
  322. # ---------------------------------------------------------------------------
  323. # Observer: single poll loop — edges, history, shims, broadcast
  324. # ---------------------------------------------------------------------------
  325. class BoardObserver:
  326. """Polls /sand_status and turns transitions into host behavior."""
  327. POLL_ACTIVE = 1.0
  328. POLL_IDLE = 2.0
  329. OFFLINE_GRACE = 3 # consecutive failures before a run is declared over
  330. def __init__(self):
  331. self.prev: Optional[dict] = None
  332. self.last_raw: Optional[dict] = None
  333. self.last_translated: dict = {}
  334. self.file_started_at: float = 0.0
  335. self.hold_started_at: Optional[float] = None
  336. self.hold_accumulated: float = 0.0
  337. self.last_progress: float = -1.0
  338. self.cached_history: Optional[dict] = None
  339. self._clear_speed_saved: Optional[float] = None
  340. self._was_quiet_wled_off = False
  341. self._fail_count = 0
  342. self._tick = 0
  343. self._task: Optional[asyncio.Task] = None
  344. # Set by main.py to fan out to /ws/status clients (avoids circular import).
  345. self.on_status: Optional[Callable] = None
  346. def reset(self) -> None:
  347. self.prev = None
  348. self.file_started_at = 0.0
  349. self.hold_started_at = None
  350. self.hold_accumulated = 0.0
  351. self.last_progress = -1.0
  352. self.cached_history = None
  353. self._clear_speed_saved = None
  354. def on_run_started(self) -> None:
  355. """Called by the command layer so the first poll attributes time correctly."""
  356. self.file_started_at = time.time()
  357. self.hold_accumulated = 0.0
  358. self.hold_started_at = None
  359. self.last_progress = -1.0
  360. # -- lifecycle ---------------------------------------------------------
  361. def start(self) -> None:
  362. self._task = asyncio.create_task(self._run())
  363. async def astop(self) -> None:
  364. if self._task:
  365. self._task.cancel()
  366. try:
  367. await self._task
  368. except asyncio.CancelledError:
  369. pass
  370. async def _run(self) -> None:
  371. while True:
  372. interval = self.POLL_IDLE
  373. try:
  374. interval = await self._tick_once()
  375. except asyncio.CancelledError:
  376. raise
  377. except Exception as e:
  378. logger.warning(f"Status observer tick failed: {e}")
  379. await asyncio.sleep(interval)
  380. async def _tick_once(self) -> float:
  381. st = None
  382. if state.conn and state.conn.is_connected():
  383. try:
  384. st = await asyncio.to_thread(connection_manager.poll_status_once)
  385. except Exception as e:
  386. logger.debug(f"Status poll failed: {e}")
  387. await self.process(st)
  388. active = bool(st and (st.get("running") or (st.get("playlist") or {}).get("active")
  389. or _state(st) in ("Hold", "Home", "Jog")))
  390. return self.POLL_ACTIVE if active else self.POLL_IDLE
  391. # -- core (sync-friendly for tests; only I/O bits are awaited) ----------
  392. async def process(self, st: Optional[dict], now: Optional[float] = None) -> dict:
  393. """One observation step: edge detection + translation + broadcast."""
  394. now = now if now is not None else time.time()
  395. if st is None:
  396. self._fail_count += 1
  397. if self._fail_count == self.OFFLINE_GRACE and self.prev is not None:
  398. # Board gone: close out the run without claiming completion.
  399. logger.warning("Board unreachable — closing out the observed run")
  400. self._close_file(self.prev, now, aborted=True)
  401. _reset_run_state()
  402. self.reset()
  403. else:
  404. self._fail_count = 0
  405. if self.prev is not None and st.get("uptime", 0) < self.prev.get("uptime", 0):
  406. logger.warning("Board rebooted mid-observation — resetting run context")
  407. _reset_run_state()
  408. self.reset()
  409. self._detect_edges(st, now)
  410. self.prev = st
  411. self.last_raw = st
  412. await self._quiet_hours_wled(now)
  413. self._tick += 1
  414. if self._tick % 30 == 0 and state.conn and state.conn.is_connected():
  415. try:
  416. from modules.core import board_settings
  417. settings_map = await asyncio.to_thread(state.conn.get_settings)
  418. board_settings.adopt_still_sands(settings_map)
  419. except Exception as e:
  420. logger.debug(f"Board settings adopt failed: {e}")
  421. self.last_translated = translate_status(st, self, now)
  422. # Mirror the progress 4-tuple for the MQTT handler, which unpacks it.
  423. prog = self.last_translated.get("progress")
  424. state.execution_progress = (
  425. (prog["current"], prog["total"], prog["remaining_time"], prog["elapsed_time"])
  426. if prog else None
  427. )
  428. if self.on_status:
  429. try:
  430. await self.on_status(self.last_translated)
  431. except Exception as e:
  432. logger.debug(f"Status broadcast failed: {e}")
  433. return self.last_translated
  434. def _detect_edges(self, st: dict, now: float) -> None:
  435. prev = self.prev or {}
  436. prev_pl = prev.get("playlist") or {}
  437. pl = st.get("playlist") or {}
  438. prev_file = prev.get("file") or ""
  439. cur_file = st.get("file") or ""
  440. prev_running = bool(prev.get("running"))
  441. running = bool(st.get("running"))
  442. # File end: the file changed while running, or playback stopped.
  443. if prev_running and prev_file and (cur_file != prev_file or not running):
  444. self._close_file(prev, now, aborted=False)
  445. # File start.
  446. if running and cur_file and (not prev_running or cur_file != prev_file):
  447. self.file_started_at = now
  448. self.hold_accumulated = 0.0
  449. self.hold_started_at = None
  450. self.last_progress = -1.0
  451. host_path = _from_sd_path(cur_file)
  452. state.current_playing_file = host_path
  453. self._cache_history(host_path)
  454. self._on_playing_leds()
  455. if running and isinstance(st.get("progress"), (int, float)) and st["progress"] >= 0:
  456. self.last_progress = st["progress"]
  457. # Hold (pause) edges — for pause accounting and the MQTT mirror.
  458. prev_hold = _state(prev) == "Hold"
  459. hold = _state(st) == "Hold"
  460. if hold and not prev_hold:
  461. self.hold_started_at = now
  462. state.pause_requested = True
  463. elif prev_hold and not hold:
  464. if self.hold_started_at:
  465. self.hold_accumulated += now - self.hold_started_at
  466. self.hold_started_at = None
  467. state.pause_requested = False
  468. # Clearing edges — clear-speed shim.
  469. prev_clearing = bool(prev_pl.get("clearing"))
  470. clearing = bool(pl.get("clearing"))
  471. if clearing and not prev_clearing and state.clear_pattern_speed:
  472. self._clear_speed_saved = state.speed
  473. self._set_feed_safe(state.clear_pattern_speed)
  474. elif prev_clearing and not clearing and self._clear_speed_saved:
  475. self._set_feed_safe(self._clear_speed_saved)
  476. self._clear_speed_saved = None
  477. # Run end: nothing running, no active playlist, machine idle.
  478. was_active = prev_running or bool(prev_pl.get("active"))
  479. is_active = running or bool(pl.get("active"))
  480. if was_active and not is_active and _state(st) in ("Idle", "Alarm"):
  481. logger.info("Observed run end on the board")
  482. _reset_run_state()
  483. state.save()
  484. self._on_idle_leds()
  485. def _close_file(self, prev_st: dict, now: float, aborted: bool) -> None:
  486. """Log history for the file that just finished/stopped."""
  487. prev_pl = prev_st.get("playlist") or {}
  488. prev_file = prev_st.get("file") or ""
  489. if not prev_file or prev_pl.get("clearing"):
  490. return # clears are not history-worthy (matches old semantics)
  491. from modules.core.pattern_manager import log_execution_time
  492. hold_extra = (now - self.hold_started_at) if self.hold_started_at else 0.0
  493. actual = max(0.0, now - self.file_started_at - self.hold_accumulated - hold_extra)
  494. completed = (not aborted) and self.last_progress >= 0.98
  495. try:
  496. log_execution_time(
  497. pattern_name=os.path.basename(prev_file),
  498. table_type="fluidnc",
  499. speed=int(state.speed or 0),
  500. actual_time=actual,
  501. total_coordinates=0,
  502. was_completed=completed,
  503. )
  504. except Exception as e:
  505. logger.warning(f"Could not log execution history: {e}")
  506. def _cache_history(self, host_path: Optional[str]) -> None:
  507. self.cached_history = None
  508. if not host_path:
  509. return
  510. try:
  511. from modules.core.pattern_manager import get_last_completed_execution_time
  512. self.cached_history = get_last_completed_execution_time(
  513. os.path.basename(host_path), state.speed)
  514. except Exception as e:
  515. logger.debug(f"History lookup failed: {e}")
  516. def _set_feed_safe(self, mm: float) -> None:
  517. try:
  518. if state.conn:
  519. state.conn.set_feed(int(mm))
  520. except Exception as e:
  521. logger.warning(f"Clear-speed feed change failed: {e}")
  522. def _on_playing_leds(self) -> None:
  523. if state.led_controller and state.led_automation_enabled:
  524. try:
  525. asyncio.get_running_loop().create_task(
  526. state.led_controller.effect_playing_async(None))
  527. except RuntimeError:
  528. pass
  529. def _on_idle_leds(self) -> None:
  530. if state.led_controller and state.led_automation_enabled:
  531. try:
  532. asyncio.get_running_loop().create_task(
  533. state.led_controller.effect_idle_async(None))
  534. except RuntimeError:
  535. pass
  536. async def _quiet_hours_wled(self, now: float) -> None:
  537. """The one surviving host Still Sands behavior: switch WLED off during
  538. quiet hours (the board handles its own ring via $Sands/LedOff)."""
  539. if state.led_provider != "wled" or not state.led_controller:
  540. return
  541. from modules.core.pattern_manager import is_in_scheduled_pause_period
  542. in_quiet = bool(state.scheduled_pause_control_wled and is_in_scheduled_pause_period())
  543. if in_quiet and not self._was_quiet_wled_off:
  544. self._was_quiet_wled_off = True
  545. await state.led_controller.set_power_async(0)
  546. logger.info("Still Sands: WLED off")
  547. elif not in_quiet and self._was_quiet_wled_off:
  548. self._was_quiet_wled_off = False
  549. await state.led_controller.set_power_async(1)
  550. await state.led_controller.effect_idle_async(None)
  551. logger.info("Still Sands: WLED restored")
  552. observer = BoardObserver()
  553. def get_cached_status() -> dict:
  554. """Last translated status (what /ws/status clients get on connect)."""
  555. if observer.last_translated:
  556. return observer.last_translated
  557. return translate_status(observer.last_raw, observer)