1
0

execution.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912
  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. # ---------------------------------------------------------------------------
  29. # Board log harvesting (/sand_log)
  30. #
  31. # The board keeps only a small RAM ring buffer (~8 KB) that is lost on reboot.
  32. # The observer periodically pulls it and merges new lines (keyed by the
  33. # "[+<uptime>]" prefix) into a persistent host-side file, so users get table
  34. # history that outlives reboots — same idea as the mobile app's table log.
  35. # ---------------------------------------------------------------------------
  36. BOARD_LOG_FILE = "board_log.txt"
  37. BOARD_LOG_MAX_LINES = 4000
  38. BOARD_LOG_HARVEST_EVERY = 300.0 # seconds
  39. def _log_uptime(line: str) -> Optional[float]:
  40. """'[+1104] [MSG:...]' -> 1104.0; None for lines without the prefix."""
  41. if not line.startswith("[+"):
  42. return None
  43. end = line.find("]")
  44. if end < 2:
  45. return None
  46. try:
  47. return float(line[2:end])
  48. except ValueError:
  49. return None
  50. def merge_board_log(fetched: list[str], last_uptime: float, last_count: int) -> tuple[list[str], float, int, bool]:
  51. """Return (new_lines, new_last_uptime, new_last_count, restarted).
  52. Lines strictly newer than last_uptime are new; at exactly last_uptime we
  53. skip the `last_count` occurrences already stored (the ring can hold several
  54. lines within the same second). A drop in max uptime means the board
  55. rebooted — everything is new again.
  56. """
  57. parsed = [(u, ln) for ln in fetched if ln.strip() and (u := _log_uptime(ln)) is not None]
  58. if not parsed:
  59. return [], last_uptime, last_count, False
  60. max_uptime = max(u for u, _ in parsed)
  61. restarted = max_uptime < last_uptime
  62. if restarted:
  63. last_uptime, last_count = -1.0, 0
  64. new_lines: list[str] = []
  65. seen_at_last = 0
  66. for u, ln in parsed:
  67. if u > last_uptime:
  68. new_lines.append(ln)
  69. elif u == last_uptime:
  70. seen_at_last += 1
  71. if seen_at_last > last_count:
  72. new_lines.append(ln)
  73. new_count = sum(1 for u, _ in parsed if u == max_uptime)
  74. return new_lines, max_uptime, new_count, restarted
  75. # Firmware clear modes; legacy host values are mapped onto them.
  76. CLEAR_MODES = ("none", "adaptive", "in", "out", "sideway", "random")
  77. _LEGACY_CLEAR = {
  78. "clear_from_in": "in",
  79. "clear_from_out": "out",
  80. "clear_sideway": "sideway",
  81. "clear_in": "in",
  82. "clear_out": "out",
  83. }
  84. class ExecutionError(Exception):
  85. """Raised when the board rejects or times out on an execution command."""
  86. @dataclass
  87. class RunContext:
  88. """Host-side knowledge about the run we started (not board truth)."""
  89. kind: Literal["pattern", "playlist"]
  90. playlist_name: Optional[str] = None
  91. files: Optional[list] = None # host paths, unshuffled mirror order
  92. run_mode: str = "single" # frontend value: 'single' | 'indefinite'
  93. shuffle: bool = False
  94. clear_pattern: str = "none"
  95. started_at: float = field(default_factory=time.time)
  96. current_run: Optional[RunContext] = None
  97. def map_clear_mode(value) -> str:
  98. """Map a frontend/legacy clear value onto the firmware's clear modes."""
  99. if not value:
  100. return "none"
  101. value = str(value).lower()
  102. if value in CLEAR_MODES:
  103. return value
  104. if value in _LEGACY_CLEAR:
  105. return _LEGACY_CLEAR[value]
  106. logger.warning(f"Unknown clear mode '{value}', using none")
  107. return "none"
  108. def _state(st: Optional[dict]) -> str:
  109. """Machine state without the GRBL substate suffix ('Hold:0' -> 'Hold')."""
  110. return ((st or {}).get("state") or "").split(":", 1)[0]
  111. _sd_path_cache: dict = {}
  112. def _from_sd_path(sd_path: str) -> Optional[str]:
  113. """Map a board SD path ('/patterns/x.thr', '/sd/patterns/x.thr') to the
  114. host-relative path ('./patterns/x.thr') the frontend/history expect.
  115. The board's SD layout can differ from the host catalog (a host
  116. 'custom_patterns/x.thr' uploads to SD 'patterns/x.thr'), so when the
  117. literal mapping doesn't exist locally, re-find the pattern in the host
  118. catalog by path suffix, then unique basename — the reverse of
  119. make_sd_path_resolver's matching. Otherwise previews/history for a
  120. playing custom pattern point at a path the host doesn't have."""
  121. if not sd_path:
  122. return None
  123. cached = _sd_path_cache.get(sd_path)
  124. if cached:
  125. return cached
  126. p = sd_path.replace("\\", "/")
  127. if p.startswith("/sd/"):
  128. p = p[3:]
  129. p = p.lstrip("/")
  130. result = "./" + p
  131. if not os.path.exists(result):
  132. from modules.core.pattern_manager import list_theta_rho_files
  133. rel = p[len("patterns/"):] if p.startswith("patterns/") else p
  134. catalog = list_theta_rho_files()
  135. hits = [r for r in catalog if r == rel or r.endswith("/" + rel)]
  136. if not hits:
  137. base = rel.rsplit("/", 1)[-1]
  138. hits = [r for r in catalog if r.rsplit("/", 1)[-1] == base]
  139. if len(hits) == 1:
  140. result = "./patterns/" + hits[0]
  141. if len(_sd_path_cache) > 512:
  142. _sd_path_cache.clear()
  143. _sd_path_cache[sd_path] = result
  144. return result
  145. # ---------------------------------------------------------------------------
  146. # Commands
  147. # ---------------------------------------------------------------------------
  148. def _require_conn():
  149. if not state.conn or not state.conn.is_connected():
  150. raise ExecutionError("Connection not established")
  151. return state.conn
  152. async def _wait_for_idle(timeout: float = 15.0) -> bool:
  153. """Poll the board until it reports Idle (used before idle-gated NVS writes)."""
  154. deadline = time.time() + timeout
  155. while time.time() < deadline:
  156. try:
  157. st = await asyncio.to_thread(state.conn.get_status)
  158. if _state(st) == "Idle" and not st.get("running"):
  159. return True
  160. except Exception as e:
  161. logger.debug(f"Idle wait poll failed: {e}")
  162. await asyncio.sleep(0.5)
  163. return False
  164. async def run_pattern(file_path: str, clear_pattern: str = "none") -> None:
  165. """Run one pattern via $Sand/Run (firmware sequences clear -> pattern and
  166. aborts any current job first)."""
  167. global current_run
  168. conn = _require_conn()
  169. from modules.core.pattern_manager import _ensure_on_board, make_sd_path_resolver
  170. # Prefer a copy already on the board SD over uploading a duplicate.
  171. resolve_sd = make_sd_path_resolver(conn)
  172. sd_path = await asyncio.to_thread(resolve_sd, file_path)
  173. await asyncio.to_thread(_ensure_on_board, file_path, sd_path)
  174. try:
  175. await asyncio.to_thread(conn.set_feed, int(state.speed))
  176. except Exception as e:
  177. logger.warning(f"Could not set feed before run: {e}")
  178. mode = map_clear_mode(clear_pattern)
  179. await asyncio.to_thread(conn.run_pattern, sd_path, mode)
  180. current_run = RunContext(kind="pattern", clear_pattern=mode)
  181. state.current_playlist = None
  182. state.current_playlist_name = None
  183. # Optimistic; the observer confirms/corrects from /sand_status.
  184. state.current_playing_file = file_path
  185. observer.on_run_started()
  186. async def start_playlist(playlist_name: str, run_mode: str = "single",
  187. pause_time: float = 0, clear_pattern: str = "none",
  188. shuffle: bool = False) -> None:
  189. """Run a playlist on the board via $Playlist/Run.
  190. The run options are the firmware's NVS $Playlist/* globals; NVS writes are
  191. idle-gated, so a run-while-running stops the board first.
  192. """
  193. global current_run
  194. conn = _require_conn()
  195. from modules.core import playlist_manager, board_settings
  196. from modules.core.pattern_manager import THETA_RHO_DIR, _ensure_on_board, make_sd_path_resolver
  197. playlist = playlist_manager.get_playlist(playlist_name)
  198. if not playlist:
  199. raise ExecutionError(f"Playlist '{playlist_name}' not found")
  200. files = playlist["files"]
  201. if not files:
  202. raise ExecutionError(f"Playlist '{playlist_name}' is empty")
  203. host_paths = [os.path.join(THETA_RHO_DIR, f) for f in files]
  204. # Idle-gate: NVS settings writes are rejected mid-motion.
  205. st = None
  206. try:
  207. st = await asyncio.to_thread(conn.get_status)
  208. except Exception:
  209. pass
  210. if st and (st.get("running") or _state(st) not in ("Idle", "Alarm")):
  211. await asyncio.to_thread(conn.stop)
  212. if not await _wait_for_idle(15.0):
  213. raise ExecutionError("Table is busy and did not stop in time")
  214. mode = map_clear_mode(clear_pattern)
  215. for key, value in (
  216. ("Playlist/Mode", "loop" if run_mode == "indefinite" else "single"),
  217. ("Playlist/Shuffle", "ON" if shuffle else "OFF"),
  218. ("Playlist/PauseTime", max(0, int(pause_time or 0))),
  219. ("Playlist/ClearPattern", mode),
  220. ):
  221. await asyncio.to_thread(conn.set_setting, key, value)
  222. # The board needs the playlist file before Run; patterns can trail behind
  223. # (each one plays for minutes) except the first, which is ensured now.
  224. # One shared resolver so the playlist lines and the uploads agree on
  225. # paths, and existing board copies are reused instead of duplicated.
  226. resolve_sd = make_sd_path_resolver(conn)
  227. await asyncio.to_thread(
  228. board_settings.mirror_playlist, playlist_name, files, conn, False, resolve_sd)
  229. first_sd = await asyncio.to_thread(resolve_sd, host_paths[0])
  230. await asyncio.to_thread(_ensure_on_board, host_paths[0], first_sd)
  231. def _mirror_rest():
  232. for p in host_paths[1:]:
  233. _ensure_on_board(p, resolve_sd(p))
  234. import threading
  235. threading.Thread(target=_mirror_rest, daemon=True).start()
  236. try:
  237. await asyncio.to_thread(conn.set_feed, int(state.speed))
  238. except Exception as e:
  239. logger.warning(f"Could not set feed before playlist: {e}")
  240. await asyncio.to_thread(conn.run_command, f"$Playlist/Run={playlist_name}")
  241. current_run = RunContext(
  242. kind="playlist", playlist_name=playlist_name, files=host_paths,
  243. run_mode=run_mode, shuffle=shuffle, clear_pattern=mode,
  244. )
  245. state.current_playlist = host_paths
  246. state.current_playlist_name = playlist_name
  247. state.playlist_mode = run_mode
  248. observer.on_run_started()
  249. logger.info(f"Started playlist '{playlist_name}' on the board "
  250. f"(mode={run_mode}, shuffle={shuffle}, pause={pause_time}s, clear={mode})")
  251. async def stop(force: bool = False) -> bool:
  252. """Clean stop. Returns True once the board is Idle (always True for force)."""
  253. try:
  254. conn = _require_conn()
  255. await asyncio.to_thread(conn.stop)
  256. except Exception as e:
  257. if not force:
  258. raise ExecutionError(f"Stop failed: {e}")
  259. logger.warning(f"Force stop: board stop failed ({e}), resetting host state anyway")
  260. if force:
  261. _reset_run_state()
  262. observer.reset()
  263. return True
  264. return await _wait_for_idle(10.0)
  265. async def pause() -> None:
  266. conn = _require_conn()
  267. await asyncio.to_thread(conn.pause)
  268. async def resume() -> None:
  269. conn = _require_conn()
  270. await asyncio.to_thread(conn.resume)
  271. async def skip() -> bool:
  272. """Skip the current pattern: $Playlist/Skip for playlists (also overrides
  273. quiet hours for one pattern); stopping is the 'skip' of a single pattern."""
  274. conn = _require_conn()
  275. st = observer.last_raw or {}
  276. pl = st.get("playlist") or {}
  277. if pl.get("active"):
  278. await asyncio.to_thread(conn.skip)
  279. return True
  280. if st.get("running"):
  281. await asyncio.to_thread(conn.stop)
  282. return True
  283. return False
  284. async def set_speed(speed: float) -> None:
  285. conn = _require_conn()
  286. state.speed = speed
  287. await asyncio.to_thread(conn.set_feed, int(speed))
  288. def _reset_run_state() -> None:
  289. global current_run
  290. current_run = None
  291. state.current_playing_file = None
  292. state.current_playlist = None
  293. state.current_playlist_name = None
  294. state.pause_requested = False
  295. state.pause_time_remaining = 0
  296. state.execution_progress = None
  297. # ---------------------------------------------------------------------------
  298. # Status translation: /sand_status -> the frontend's /ws/status contract
  299. # ---------------------------------------------------------------------------
  300. def _board_health(st: Optional[dict]) -> dict:
  301. """Board health telemetry for the /ws/status payload (firmware API.md).
  302. Prefers the live /sand_status values; falls back to the last-known values
  303. mirrored into state so the UI keeps a reading across a dropped poll. Older
  304. firmware omits these keys — they surface as null.
  305. """
  306. def pick(key: str, attr: str):
  307. if st and key in st:
  308. return st[key]
  309. return getattr(state, attr, None)
  310. return {
  311. "heap": pick("heap", "board_heap"),
  312. "heap_min": pick("heap_min", "board_heap_min"),
  313. "heap_largest": pick("heap_largest", "board_heap_largest"),
  314. "last_reset": pick("last_reset", "board_last_reset"),
  315. "sd_ok": pick("sd_ok", "board_sd_ok"),
  316. "uptime": pick("uptime", "board_uptime"),
  317. }
  318. def translate_status(st: Optional[dict], obs: Optional["BoardObserver"] = None,
  319. now: Optional[float] = None) -> dict:
  320. """Translate a raw board status into the /ws/status data object.
  321. Pure given (st, observer timing, state); unit-testable with fixtures.
  322. """
  323. obs = obs or observer
  324. now = now if now is not None else time.time()
  325. connected = bool(state.conn and state.conn.is_connected())
  326. if st is None:
  327. return {
  328. "connection_status": connected,
  329. "current_file": None,
  330. "is_running": False,
  331. "is_paused": False,
  332. "is_alarm": False,
  333. "is_homing": state.is_homing,
  334. "sensor_homing_failed": state.sensor_homing_failed,
  335. "is_clearing": False,
  336. "speed": state.speed,
  337. "pause_time_remaining": 0,
  338. "original_pause_time": None,
  339. "progress": None,
  340. "playlist": None,
  341. "current_theta": state.current_theta,
  342. "current_rho": state.current_rho,
  343. "firmware_version": state.firmware_version,
  344. "table_type": None,
  345. "health": _board_health(None),
  346. }
  347. pl = st.get("playlist") or {}
  348. running = bool(st.get("running"))
  349. # GRBL states can carry a substate suffix (e.g. "Hold:0", "Door:1").
  350. machine_state = _state(st)
  351. clearing = bool(pl.get("clearing"))
  352. pause_remaining = pl.get("pause_remaining", -1)
  353. pause_total = pl.get("pause_total", -1)
  354. current_file = _from_sd_path(st.get("file")) if running else None
  355. # --- progress ---
  356. progress_obj = None
  357. if running:
  358. fraction = st.get("progress", -1)
  359. fraction = fraction if isinstance(fraction, (int, float)) and fraction >= 0 else 0
  360. elapsed = max(0.0, now - obs.file_started_at - obs.hold_accumulated
  361. - (now - obs.hold_started_at if obs.hold_started_at else 0))
  362. if fraction > 0.02:
  363. remaining = max(0.0, elapsed / fraction - elapsed)
  364. elif obs.cached_history and obs.cached_history.get("actual_time_seconds"):
  365. remaining = max(0.0, obs.cached_history["actual_time_seconds"] - elapsed)
  366. else:
  367. remaining = None
  368. progress_obj = {
  369. "current": int(fraction * 1000),
  370. "total": 1000,
  371. "percentage": round(fraction * 100, 1),
  372. "elapsed_time": elapsed,
  373. "remaining_time": remaining,
  374. }
  375. if obs.cached_history:
  376. progress_obj["last_completed_time"] = obs.cached_history
  377. # --- playlist ---
  378. playlist_obj = None
  379. playlist_active = bool(pl.get("active"))
  380. ctx = current_run
  381. if (playlist_active and pl.get("total", 0) > 0) or (ctx and ctx.kind == "playlist"):
  382. files = [f for f in (state.current_playlist or [])]
  383. index = int(pl.get("index", 0) or 0)
  384. total = int(pl.get("total", 0) or 0) or len(files)
  385. shuffled = bool(ctx.shuffle) if ctx else False
  386. next_file = None
  387. if files and not shuffled:
  388. # While clearing, the "next" thing is the pattern the clear precedes.
  389. next_idx = index if clearing else index + 1
  390. if 0 <= next_idx < len(files):
  391. next_file = files[next_idx]
  392. playlist_obj = {
  393. "name": pl.get("name") or (ctx.playlist_name if ctx else None),
  394. "current_index": index,
  395. "total_files": total,
  396. "mode": (ctx.run_mode if ctx else "indefinite"),
  397. "files": files,
  398. "next_file": next_file,
  399. "shuffled": shuffled,
  400. }
  401. return {
  402. "connection_status": connected,
  403. "current_file": current_file,
  404. "is_running": running,
  405. "is_paused": machine_state == "Hold",
  406. "is_alarm": machine_state == "Alarm",
  407. "is_homing": machine_state == "Home" or state.is_homing,
  408. "sensor_homing_failed": state.sensor_homing_failed,
  409. "is_clearing": clearing,
  410. "speed": state.speed,
  411. "pause_time_remaining": pause_remaining if pause_remaining >= 0 else 0,
  412. "original_pause_time": pause_total if pause_total >= 0 else None,
  413. "progress": progress_obj,
  414. "playlist": playlist_obj,
  415. "current_theta": st.get("theta", state.current_theta),
  416. "current_rho": st.get("rho", state.current_rho),
  417. "firmware_version": st.get("fw") or state.firmware_version,
  418. "table_type": None,
  419. "health": _board_health(st),
  420. }
  421. # ---------------------------------------------------------------------------
  422. # Observer: single poll loop — edges, history, shims, broadcast
  423. # ---------------------------------------------------------------------------
  424. class BoardObserver:
  425. """Polls /sand_status and turns transitions into host behavior."""
  426. POLL_ACTIVE = 1.0
  427. POLL_IDLE = 2.0
  428. OFFLINE_GRACE = 3 # consecutive failures before a run is declared over
  429. RECONNECT_EVERY = 15.0 # seconds between reconnect/relocate attempts while offline
  430. def __init__(self):
  431. self.prev: Optional[dict] = None
  432. self.last_raw: Optional[dict] = None
  433. self.last_translated: dict = {}
  434. self.file_started_at: float = 0.0
  435. self.hold_started_at: Optional[float] = None
  436. self.hold_accumulated: float = 0.0
  437. self.last_progress: float = -1.0
  438. self.cached_history: Optional[dict] = None
  439. self._clear_speed_saved: Optional[float] = None
  440. self._was_quiet_wled_off = False
  441. self._fail_count = 0
  442. self._tick = 0
  443. self._last_reconnect = 0.0
  444. self._task: Optional[asyncio.Task] = None
  445. # True while a firmware OTA is in flight: the board's web server is
  446. # single-threaded, so concurrent polls would wedge the flash upload.
  447. self.suspended = False
  448. # Board log harvest cursor (see merge_board_log)
  449. self._log_last_uptime = -1.0
  450. self._log_last_count = 0
  451. self._last_log_harvest = 0.0
  452. self._log_cursor_loaded = False
  453. # Set by main.py to fan out to /ws/status clients (avoids circular import).
  454. self.on_status: Optional[Callable] = None
  455. def reset(self) -> None:
  456. self.prev = None
  457. self.file_started_at = 0.0
  458. self.hold_started_at = None
  459. self.hold_accumulated = 0.0
  460. self.last_progress = -1.0
  461. self.cached_history = None
  462. self._clear_speed_saved = None
  463. def on_run_started(self) -> None:
  464. """Called by the command layer so the first poll attributes time correctly."""
  465. self.file_started_at = time.time()
  466. self.hold_accumulated = 0.0
  467. self.hold_started_at = None
  468. self.last_progress = -1.0
  469. # -- lifecycle ---------------------------------------------------------
  470. def start(self) -> None:
  471. self._task = asyncio.create_task(self._run())
  472. async def astop(self) -> None:
  473. if self._task:
  474. self._task.cancel()
  475. try:
  476. await self._task
  477. except asyncio.CancelledError:
  478. pass
  479. async def _run(self) -> None:
  480. while True:
  481. interval = self.POLL_IDLE
  482. try:
  483. interval = await self._tick_once()
  484. except asyncio.CancelledError:
  485. raise
  486. except Exception as e:
  487. logger.warning(f"Status observer tick failed: {e}")
  488. await asyncio.sleep(interval)
  489. async def _tick_once(self) -> float:
  490. if self.suspended:
  491. return self.POLL_IDLE
  492. st = None
  493. if state.conn and state.conn.is_connected():
  494. try:
  495. st = await asyncio.to_thread(connection_manager.poll_status_once)
  496. except Exception as e:
  497. logger.debug(f"Status poll failed: {e}")
  498. await self.process(st)
  499. if st is None:
  500. await self._maybe_reconnect()
  501. active = bool(st and (st.get("running") or (st.get("playlist") or {}).get("active")
  502. or _state(st) in ("Hold", "Home", "Jog")))
  503. return self.POLL_ACTIVE if active else self.POLL_IDLE
  504. # -- reconnect / DHCP relocate -------------------------------------------
  505. async def _maybe_reconnect(self) -> None:
  506. """While the board is unreachable, periodically retry the saved address
  507. and — when DHCP has moved the board — relocate to the mDNS entry with
  508. the same MAC (fallback: same hostname). Never homes; the board kept its
  509. own position, only its IP changed."""
  510. if state.user_disconnected or state.is_homing or state.board_locked:
  511. return
  512. if state.conn and state.conn.is_connected() and self._fail_count < self.OFFLINE_GRACE:
  513. return
  514. now = time.time()
  515. if now - self._last_reconnect < self.RECONNECT_EVERY:
  516. return
  517. self._last_reconnect = now
  518. try:
  519. await asyncio.to_thread(self._reconnect_sync)
  520. except Exception as e:
  521. logger.debug(f"Reconnect attempt failed: {e}")
  522. def _reconnect_sync(self) -> None:
  523. from modules.connection.fluidnc_client import FluidNCClient
  524. from modules.core.mdns_discovery import discovery
  525. url = connection_manager.board_url()
  526. probe = FluidNCClient(url, api_key=state.board_api_key)
  527. if probe.reachable():
  528. logger.info(f"Board back online at {url} — reconnecting")
  529. state.conn = probe
  530. state.board_locked = False
  531. state.port = url
  532. connection_manager.device_init(False)
  533. return
  534. if probe.locked:
  535. state.board_locked = True
  536. logger.warning(f"Board at {url} now rejects us (401) — password required")
  537. return
  538. # Saved address is dead: look for the same hardware elsewhere on the LAN.
  539. target = None
  540. boards = discovery.get_boards()
  541. if state.board_mac:
  542. target = next((b for b in boards if b.get("mac") == state.board_mac), None)
  543. if target is None and state.board_hostname:
  544. hn = state.board_hostname.lower()
  545. target = next(
  546. (b for b in boards if (b.get("hostname") or "").lower() == hn), None)
  547. if target is None or target["url"] == url:
  548. return
  549. probe = FluidNCClient(target["url"], api_key=state.board_api_key)
  550. if not probe.reachable():
  551. return
  552. logger.info(f"Board moved (DHCP): relocating from {url} to {target['url']} "
  553. f"(matched by {'mac' if state.board_mac else 'hostname'})")
  554. state.board_url = target["url"]
  555. state.port = target["url"]
  556. state.conn = probe
  557. state.board_locked = False
  558. state.save()
  559. connection_manager.device_init(False)
  560. # -- core (sync-friendly for tests; only I/O bits are awaited) ----------
  561. async def process(self, st: Optional[dict], now: Optional[float] = None) -> dict:
  562. """One observation step: edge detection + translation + broadcast."""
  563. now = now if now is not None else time.time()
  564. if st is None:
  565. self._fail_count += 1
  566. if self._fail_count == self.OFFLINE_GRACE and self.prev is not None:
  567. # Board gone: close out the run without claiming completion.
  568. logger.warning("Board unreachable — closing out the observed run")
  569. self._close_file(self.prev, now, aborted=True)
  570. _reset_run_state()
  571. self.reset()
  572. else:
  573. if self._fail_count >= self.OFFLINE_GRACE:
  574. # Just recovered: pull the board log promptly (it may hold the
  575. # reboot/crash story we missed while offline).
  576. self._last_log_harvest = 0.0
  577. self._fail_count = 0
  578. if self.prev is not None and st.get("uptime", 0) < self.prev.get("uptime", 0):
  579. logger.warning("Board rebooted mid-observation — resetting run context")
  580. _reset_run_state()
  581. self.reset()
  582. self._detect_edges(st, now)
  583. self.prev = st
  584. self.last_raw = st
  585. await self._quiet_hours_wled(now)
  586. if (st is not None and state.conn and state.conn.is_connected()
  587. and now - self._last_log_harvest > BOARD_LOG_HARVEST_EVERY):
  588. self._last_log_harvest = now
  589. asyncio.ensure_future(asyncio.to_thread(self._harvest_board_log_sync))
  590. self._tick += 1
  591. if self._tick % 30 == 0 and state.conn and state.conn.is_connected():
  592. from modules.core import board_settings
  593. try:
  594. settings_map = await asyncio.to_thread(state.conn.get_settings)
  595. board_settings.adopt_still_sands(settings_map)
  596. board_settings.adopt_auto_home(settings_map)
  597. except Exception as e:
  598. logger.debug(f"Board settings adopt failed: {e}")
  599. # Playlists change rarely and adopting reads every file — slower cadence.
  600. if self._tick % 300 == 0:
  601. asyncio.ensure_future(asyncio.to_thread(board_settings.adopt_board_playlists))
  602. self.last_translated = translate_status(st, self, now)
  603. # Mirror the progress 4-tuple for the MQTT handler, which unpacks it.
  604. prog = self.last_translated.get("progress")
  605. state.execution_progress = (
  606. (prog["current"], prog["total"], prog["remaining_time"], prog["elapsed_time"])
  607. if prog else None
  608. )
  609. if self.on_status:
  610. try:
  611. await self.on_status(self.last_translated)
  612. except Exception as e:
  613. logger.debug(f"Status broadcast failed: {e}")
  614. return self.last_translated
  615. # -- board log harvest ---------------------------------------------------
  616. def _load_log_cursor(self) -> None:
  617. """Recover the merge cursor from the stored file after a backend
  618. restart, so we don't re-append the board's whole ring buffer."""
  619. self._log_cursor_loaded = True
  620. try:
  621. with open(BOARD_LOG_FILE, "r", encoding="utf-8", errors="replace") as f:
  622. tail = f.readlines()[-200:]
  623. except FileNotFoundError:
  624. return
  625. last_uptime, count = -1.0, 0
  626. for line in tail:
  627. # Stored lines look like "2026-07-12 17:00:03 [+1104] [MSG:...]"
  628. idx = line.find("[+")
  629. u = _log_uptime(line[idx:]) if idx >= 0 else None
  630. if u is None:
  631. continue
  632. if u == last_uptime:
  633. count += 1
  634. else:
  635. # Any change (up OR down — the tail may span a board reboot)
  636. # moves the cursor: later lines are newer.
  637. last_uptime, count = u, 1
  638. self._log_last_uptime, self._log_last_count = last_uptime, count
  639. def _harvest_board_log_sync(self) -> None:
  640. try:
  641. if not self._log_cursor_loaded:
  642. self._load_log_cursor()
  643. text = state.conn._get("/sand_log", timeout=5.0).text
  644. new, self._log_last_uptime, self._log_last_count, restarted = merge_board_log(
  645. text.splitlines(), self._log_last_uptime, self._log_last_count)
  646. if not new:
  647. return
  648. now = time.time()
  649. max_uptime = self._log_last_uptime
  650. out = []
  651. if restarted:
  652. out.append(f"--- table restarted ({time.strftime('%Y-%m-%d %H:%M:%S')}) ---")
  653. for ln in new:
  654. # Estimated wall-clock time: now minus how long ago the line was
  655. # logged (per its uptime prefix).
  656. u = _log_uptime(ln) or max_uptime
  657. stamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(now - (max_uptime - u)))
  658. out.append(f"{stamp} {ln}")
  659. try:
  660. with open(BOARD_LOG_FILE, "r", encoding="utf-8", errors="replace") as f:
  661. existing = f.read().splitlines()
  662. except FileNotFoundError:
  663. existing = []
  664. merged = (existing + out)[-BOARD_LOG_MAX_LINES:]
  665. with open(BOARD_LOG_FILE, "w", encoding="utf-8") as f:
  666. f.write("\n".join(merged) + "\n")
  667. except Exception as e:
  668. logger.debug(f"Board log harvest failed: {e}")
  669. def _detect_edges(self, st: dict, now: float) -> None:
  670. prev = self.prev or {}
  671. prev_pl = prev.get("playlist") or {}
  672. pl = st.get("playlist") or {}
  673. prev_file = prev.get("file") or ""
  674. cur_file = st.get("file") or ""
  675. prev_running = bool(prev.get("running"))
  676. running = bool(st.get("running"))
  677. # File end: the file changed while running, or playback stopped.
  678. if prev_running and prev_file and (cur_file != prev_file or not running):
  679. self._close_file(prev, now, aborted=False)
  680. # File start.
  681. if running and cur_file and (not prev_running or cur_file != prev_file):
  682. self.file_started_at = now
  683. self.hold_accumulated = 0.0
  684. self.hold_started_at = None
  685. self.last_progress = -1.0
  686. host_path = _from_sd_path(cur_file)
  687. state.current_playing_file = host_path
  688. self._cache_history(host_path)
  689. self._on_playing_leds()
  690. if running and isinstance(st.get("progress"), (int, float)) and st["progress"] >= 0:
  691. self.last_progress = st["progress"]
  692. # Hold (pause) edges — for pause accounting and the MQTT mirror.
  693. prev_hold = _state(prev) == "Hold"
  694. hold = _state(st) == "Hold"
  695. if hold and not prev_hold:
  696. self.hold_started_at = now
  697. state.pause_requested = True
  698. elif prev_hold and not hold:
  699. if self.hold_started_at:
  700. self.hold_accumulated += now - self.hold_started_at
  701. self.hold_started_at = None
  702. state.pause_requested = False
  703. # Clearing edges — clear-speed shim.
  704. prev_clearing = bool(prev_pl.get("clearing"))
  705. clearing = bool(pl.get("clearing"))
  706. if clearing and not prev_clearing and state.clear_pattern_speed:
  707. self._clear_speed_saved = state.speed
  708. self._set_feed_safe(state.clear_pattern_speed)
  709. elif prev_clearing and not clearing and self._clear_speed_saved:
  710. self._set_feed_safe(self._clear_speed_saved)
  711. self._clear_speed_saved = None
  712. # Run end: nothing running, no active playlist, machine idle.
  713. was_active = prev_running or bool(prev_pl.get("active"))
  714. is_active = running or bool(pl.get("active"))
  715. if was_active and not is_active and _state(st) in ("Idle", "Alarm"):
  716. logger.info("Observed run end on the board")
  717. _reset_run_state()
  718. state.save()
  719. self._on_idle_leds()
  720. def _close_file(self, prev_st: dict, now: float, aborted: bool) -> None:
  721. """Log history for the file that just finished/stopped."""
  722. prev_pl = prev_st.get("playlist") or {}
  723. prev_file = prev_st.get("file") or ""
  724. if not prev_file or prev_pl.get("clearing"):
  725. return # clears are not history-worthy (matches old semantics)
  726. from modules.core.pattern_manager import log_execution_time
  727. hold_extra = (now - self.hold_started_at) if self.hold_started_at else 0.0
  728. actual = max(0.0, now - self.file_started_at - self.hold_accumulated - hold_extra)
  729. completed = (not aborted) and self.last_progress >= 0.98
  730. try:
  731. log_execution_time(
  732. pattern_name=os.path.basename(prev_file),
  733. table_type="fluidnc",
  734. speed=int(state.speed or 0),
  735. actual_time=actual,
  736. total_coordinates=0,
  737. was_completed=completed,
  738. )
  739. except Exception as e:
  740. logger.warning(f"Could not log execution history: {e}")
  741. def _cache_history(self, host_path: Optional[str]) -> None:
  742. self.cached_history = None
  743. if not host_path:
  744. return
  745. try:
  746. from modules.core.pattern_manager import get_last_completed_execution_time
  747. self.cached_history = get_last_completed_execution_time(
  748. os.path.basename(host_path), state.speed)
  749. except Exception as e:
  750. logger.debug(f"History lookup failed: {e}")
  751. def _set_feed_safe(self, mm: float) -> None:
  752. try:
  753. if state.conn:
  754. state.conn.set_feed(int(mm))
  755. except Exception as e:
  756. logger.warning(f"Clear-speed feed change failed: {e}")
  757. def _on_playing_leds(self) -> None:
  758. if state.led_controller and state.led_automation_enabled:
  759. try:
  760. asyncio.get_running_loop().create_task(
  761. state.led_controller.effect_playing_async(None))
  762. except RuntimeError:
  763. pass
  764. def _on_idle_leds(self) -> None:
  765. if state.led_controller and state.led_automation_enabled:
  766. try:
  767. asyncio.get_running_loop().create_task(
  768. state.led_controller.effect_idle_async(None))
  769. except RuntimeError:
  770. pass
  771. async def _quiet_hours_wled(self, now: float) -> None:
  772. """The one surviving host Still Sands behavior: switch WLED off during
  773. quiet hours (the board handles its own ring via $Sands/LedOff)."""
  774. if state.led_provider != "wled" or not state.led_controller:
  775. return
  776. from modules.core.pattern_manager import is_in_scheduled_pause_period
  777. in_quiet = bool(state.scheduled_pause_control_wled and is_in_scheduled_pause_period())
  778. if in_quiet and not self._was_quiet_wled_off:
  779. self._was_quiet_wled_off = True
  780. await state.led_controller.set_power_async(0)
  781. logger.info("Still Sands: WLED off")
  782. elif not in_quiet and self._was_quiet_wled_off:
  783. self._was_quiet_wled_off = False
  784. await state.led_controller.set_power_async(1)
  785. await state.led_controller.effect_idle_async(None)
  786. logger.info("Still Sands: WLED restored")
  787. observer = BoardObserver()
  788. def get_cached_status() -> dict:
  789. """Last translated status (what /ws/status clients get on connect)."""
  790. if observer.last_translated:
  791. return observer.last_translated
  792. return translate_status(observer.last_raw, observer)