board_settings.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. """
  2. Board-owned settings sync — the host side of the "board NVS is canonical" rule.
  3. The FluidNC firmware persists table behavior settings in NVS ($Playlist/*,
  4. $Sands/*, $Sand/*) and the native mobile apps edit them directly on the board.
  5. This module keeps the web backend in agreement:
  6. - Auto-play on boot ($Playlist/Autostart*) lives ONLY on the board — it fires
  7. when the *table* powers on, whether or not this backend is running. The
  8. backend proxies reads/writes for the web UI and mirrors host playlists to
  9. the board SD so autostart has something to run.
  10. - Still Sands quiet hours ($Sands/*) are stored on the board, but the firmware
  11. only enforces them for its own playlist sequencing — an explicit $Sand/Run
  12. (how this backend plays each pattern) deliberately bypasses them. So the
  13. host keeps enforcing quiet hours itself via state.scheduled_pause_* in
  14. pattern_manager; this module pushes UI edits to the board and adopts board
  15. values (mobile app edits) back into host state.
  16. - The board clock must be set for quiet hours / autostart schedules; the host
  17. pushes its epoch + POSIX timezone on connect and whenever the tz changes.
  18. All pushes are best-effort: the board being unreachable never blocks a host-side
  19. settings save (host enforcement still works without the board's copy).
  20. """
  21. import logging
  22. import os
  23. import threading
  24. import time
  25. from modules.core.state import state
  26. logger = logging.getLogger(__name__)
  27. # Host slot day names (state.scheduled_pause_time_slots) <-> firmware 3-letter codes.
  28. _DAY_TO_BOARD = {
  29. "sunday": "sun", "monday": "mon", "tuesday": "tue", "wednesday": "wed",
  30. "thursday": "thu", "friday": "fri", "saturday": "sat",
  31. }
  32. _BOARD_TO_DAY = {v: k for k, v in _DAY_TO_BOARD.items()}
  33. _BOARD_DAY_ORDER = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]
  34. CLEAR_MODES = ("none", "adaptive", "in", "out", "sideway", "random")
  35. def _is_on(value) -> bool:
  36. return str(value or "").upper() in ("ON", "1", "TRUE")
  37. # ---------------------------------------------------------------------------
  38. # Still Sands slot conversion: host dicts <-> "$Sands/Slots" spec string
  39. # ---------------------------------------------------------------------------
  40. def slots_to_board(time_slots: list) -> str:
  41. """Host slot dicts -> 'HH:MM-HH:MM@days,...' ($Sands/Slots syntax)."""
  42. parts = []
  43. for slot in time_slots or []:
  44. start = slot.get("start_time")
  45. end = slot.get("end_time")
  46. if not start or not end:
  47. continue
  48. days = slot.get("days", "daily")
  49. if days == "custom":
  50. codes = sorted(
  51. {_DAY_TO_BOARD[d] for d in slot.get("custom_days", []) if d in _DAY_TO_BOARD},
  52. key=_BOARD_DAY_ORDER.index,
  53. )
  54. days = "+".join(codes) if codes else "daily"
  55. parts.append(f"{start}-{end}@{days}")
  56. return ",".join(parts)
  57. def _normalize_time(value: str) -> str | None:
  58. """'9:5' -> '09:05'; None when not a valid HH:MM."""
  59. hours, sep, minutes = value.strip().partition(":")
  60. if not sep or not hours.isdigit() or not minutes.isdigit():
  61. return None
  62. h, m = int(hours), int(minutes)
  63. if h > 23 or m > 59:
  64. return None
  65. return f"{h:02d}:{m:02d}"
  66. def board_to_slots(spec: str) -> list:
  67. """'$Sands/Slots' spec string -> host slot dicts (invalid entries dropped)."""
  68. slots = []
  69. for part in (spec or "").split(","):
  70. part = part.strip()
  71. if not part:
  72. continue
  73. times, _, days_spec = part.partition("@")
  74. start, dash, end = times.strip().partition("-")
  75. start, end = _normalize_time(start), _normalize_time(end or "")
  76. if not dash or not start or not end:
  77. continue
  78. days_spec = (days_spec or "daily").strip().lower()
  79. if days_spec in ("", "daily", "weekdays", "weekends"):
  80. days, custom_days = (days_spec or "daily"), []
  81. else:
  82. custom_days = [_BOARD_TO_DAY[c.strip()] for c in days_spec.split("+") if c.strip() in _BOARD_TO_DAY]
  83. days = "custom" if custom_days else "daily"
  84. slots.append({
  85. "start_time": start,
  86. "end_time": end,
  87. "days": days,
  88. "custom_days": custom_days,
  89. })
  90. return slots
  91. # ---------------------------------------------------------------------------
  92. # Clock sync
  93. # ---------------------------------------------------------------------------
  94. def posix_tz(iana_name: str | None = None) -> str | None:
  95. """POSIX TZ rule string for an IANA zone (or the system zone when None).
  96. Modern TZif files (v2+) end with a footer line holding exactly this string
  97. (e.g. 'EST5EDT,M3.2.0,M11.1.0'), which is what the firmware's $Time/Zone
  98. wants. Returns None when the zone file can't be read.
  99. """
  100. path = f"/usr/share/zoneinfo/{iana_name}" if iana_name else "/etc/localtime"
  101. try:
  102. with open(path, "rb") as f:
  103. data = f.read()
  104. if not data.startswith(b"TZif"):
  105. return None
  106. # Footer = text between the last two newlines of the file.
  107. end = data.rfind(b"\n")
  108. if end <= 0:
  109. return None
  110. begin = data.rfind(b"\n", 0, end)
  111. footer = data[begin + 1:end].decode("ascii").strip()
  112. return footer or None
  113. except Exception as e:
  114. logger.debug(f"Could not derive POSIX tz for {iana_name or 'system'}: {e}")
  115. return None
  116. def sync_board_time(conn=None) -> dict:
  117. """Push the host's clock (and effective quiet-hours timezone) to the board.
  118. Quiet hours are computed host-side in state.scheduled_pause_timezone (or the
  119. system zone); pushing the same zone keeps board-side schedules (autostart,
  120. firmware playlists) aligned with what the user sees in the UI.
  121. """
  122. conn = conn or state.conn
  123. if not conn:
  124. raise RuntimeError("No board connection")
  125. tz = posix_tz(state.scheduled_pause_timezone)
  126. result = conn.set_time(epoch=int(time.time()), tz=tz)
  127. logger.info(f"Synced board clock (tz={tz or 'unchanged'}): {result}")
  128. return result
  129. # ---------------------------------------------------------------------------
  130. # Still Sands push / adopt
  131. # ---------------------------------------------------------------------------
  132. def push_still_sands(conn=None) -> None:
  133. """Write the host's Still Sands settings to the board's $Sands/* NVS keys."""
  134. conn = conn or state.conn
  135. if not conn:
  136. return
  137. conn.set_setting("Sands/Enabled", "ON" if state.scheduled_pause_enabled else "OFF")
  138. slots = slots_to_board(state.scheduled_pause_time_slots)
  139. if slots:
  140. conn.set_setting("Sands/Slots", slots)
  141. conn.set_setting("Sands/FinishPattern", "ON" if state.scheduled_pause_finish_pattern else "OFF")
  142. # One UI toggle drives both LED systems: host WLED and the board's own ring.
  143. conn.set_setting("Sands/LedOff", "ON" if state.scheduled_pause_control_wled else "OFF")
  144. logger.info("Pushed Still Sands settings to board")
  145. def adopt_still_sands(settings_map: dict) -> bool:
  146. """Adopt the board's $Sands/* values into host state (mobile-app edits win).
  147. Returns True when anything changed. The timezone is not adopted: the board
  148. stores a POSIX rule derived from the host's IANA zone, which isn't
  149. reversible — the host zone selection stays authoritative.
  150. """
  151. enabled = _is_on(settings_map.get("Sands/Enabled"))
  152. finish = _is_on(settings_map.get("Sands/FinishPattern", "ON"))
  153. led_off = _is_on(settings_map.get("Sands/LedOff"))
  154. slots = board_to_slots(settings_map.get("Sands/Slots", ""))
  155. changed = (
  156. enabled != state.scheduled_pause_enabled
  157. or finish != state.scheduled_pause_finish_pattern
  158. or led_off != state.scheduled_pause_control_wled
  159. or slots != state.scheduled_pause_time_slots
  160. )
  161. if changed:
  162. state.scheduled_pause_enabled = enabled
  163. state.scheduled_pause_finish_pattern = finish
  164. state.scheduled_pause_control_wled = led_off
  165. state.scheduled_pause_time_slots = slots
  166. state.save()
  167. logger.info("Adopted Still Sands settings from board")
  168. return changed
  169. # ---------------------------------------------------------------------------
  170. # Auto-home cadence ($Playlist/AutoHome) — mirrors the host auto_home settings
  171. # so firmware-sequenced playlists (autostart) drift-correct the same way.
  172. # ---------------------------------------------------------------------------
  173. def push_auto_home(conn=None) -> None:
  174. conn = conn or state.conn
  175. if not conn:
  176. return
  177. every = state.auto_home_after_patterns if state.auto_home_enabled else 0
  178. conn.set_setting("Playlist/AutoHome", max(0, int(every or 0)))
  179. # ---------------------------------------------------------------------------
  180. # Auto-play on boot ($Playlist/Autostart*) — board-only, proxied for the web UI
  181. # ---------------------------------------------------------------------------
  182. def get_board_settings(conn=None) -> dict:
  183. """Shape the board's /sand_settings + clock into the web UI's structure."""
  184. conn = conn or state.conn
  185. if not conn:
  186. raise RuntimeError("No board connection")
  187. s = conn.get_settings()
  188. status = conn.get_status()
  189. return {
  190. "reachable": True,
  191. "firmware_version": status.get("fw"),
  192. "state": status.get("state"),
  193. "time": status.get("time") or conn.get_time(),
  194. "autostart": {
  195. "playlist": s.get("Playlist/Autostart", ""),
  196. "run_mode": "single" if (s.get("Playlist/AutostartMode", "loop").lower() == "single") else "loop",
  197. "shuffle": _is_on(s.get("Playlist/AutostartShuffle")),
  198. "pause_seconds": int(float(s.get("Playlist/AutostartPause", 0) or 0)),
  199. "pause_from_start": _is_on(s.get("Playlist/AutostartPauseFromStart")),
  200. "clear_pattern": s.get("Playlist/AutostartClear", "none") or "none",
  201. },
  202. "homing_mode": (s.get("Sand/HomingMode") or "sensor").lower(),
  203. "theta_offset": float(s.get("Sand/ThetaOffset", 0) or 0),
  204. "auto_home_every": int(float(s.get("Playlist/AutoHome", 0) or 0)),
  205. }
  206. def apply_autostart(update: dict, conn=None) -> None:
  207. """Write autostart fields to the board. `update` uses the UI field names."""
  208. conn = conn or state.conn
  209. if not conn:
  210. raise RuntimeError("No board connection")
  211. if "playlist" in update:
  212. # Empty value disables auto-play on boot.
  213. conn.set_setting("Playlist/Autostart", update["playlist"] or "")
  214. if "run_mode" in update:
  215. mode = "single" if update["run_mode"] == "single" else "loop"
  216. conn.set_setting("Playlist/AutostartMode", mode)
  217. if "shuffle" in update:
  218. conn.set_setting("Playlist/AutostartShuffle", "ON" if update["shuffle"] else "OFF")
  219. if "pause_seconds" in update:
  220. conn.set_setting("Playlist/AutostartPause", max(0, int(update["pause_seconds"] or 0)))
  221. if "pause_from_start" in update:
  222. conn.set_setting("Playlist/AutostartPauseFromStart", "ON" if update["pause_from_start"] else "OFF")
  223. if "clear_pattern" in update:
  224. clear = update["clear_pattern"] if update["clear_pattern"] in CLEAR_MODES else "none"
  225. conn.set_setting("Playlist/AutostartClear", clear)
  226. # ---------------------------------------------------------------------------
  227. # Playlist mirroring — firmware playlists are /playlists/<name>.txt on the SD,
  228. # one SD pattern path per line. Autostart runs these, so host playlist CRUD is
  229. # mirrored (and the selected playlist's patterns are ensured on the board).
  230. # ---------------------------------------------------------------------------
  231. def _playlist_sd_content(files: list, resolve) -> str:
  232. lines = [resolve(f) for f in files or []]
  233. return "\n".join(lines) + "\n"
  234. def mirror_playlist(name: str, files: list, conn=None, ensure_patterns: bool = False,
  235. resolve=None) -> None:
  236. """Write a host playlist to the board as /playlists/<name>.txt (best-effort).
  237. `resolve` maps host entries to SD paths (make_sd_path_resolver); pass the
  238. caller's resolver when patterns are ensured separately so the playlist
  239. lines and the uploads land on the same paths."""
  240. from modules.core.pattern_manager import _ensure_on_board, make_sd_path_resolver
  241. conn = conn or state.conn
  242. if not conn:
  243. return
  244. resolve = resolve or make_sd_path_resolver(conn)
  245. try:
  246. sd_path = f"/playlists/{name}.txt"
  247. data = _playlist_sd_content(files, resolve).encode("utf-8")
  248. conn.upload_file(sd_path, data, "/playlists")
  249. logger.info(f"Mirrored playlist '{name}' to board ({len(files or [])} patterns)")
  250. except Exception as e:
  251. logger.warning(f"Could not mirror playlist '{name}' to board: {e}")
  252. return
  253. if ensure_patterns:
  254. for f in files or []:
  255. _ensure_on_board(f, resolve(f))
  256. def mirror_playlist_async(name: str, files: list) -> None:
  257. """Fire-and-forget mirror from sync code paths (never blocks the caller)."""
  258. threading.Thread(target=mirror_playlist, args=(name, files), daemon=True).start()
  259. def unmirror_playlist_async(name: str) -> None:
  260. threading.Thread(target=unmirror_playlist, args=(name,), daemon=True).start()
  261. def unmirror_playlist(name: str, conn=None) -> None:
  262. """Delete a playlist's mirror from the board SD (best-effort)."""
  263. conn = conn or state.conn
  264. if not conn:
  265. return
  266. try:
  267. conn.delete_file("/playlists", f"{name}.txt")
  268. logger.info(f"Removed playlist '{name}' from board")
  269. except Exception as e:
  270. logger.debug(f"Could not remove playlist '{name}' from board: {e}")
  271. def _from_playlist_sd_line(line: str) -> str:
  272. """Invert _to_sd_path for playlist lines: '/patterns/x.thr' -> 'x.thr'
  273. (host playlist entries are relative to the patterns/ dir)."""
  274. p = line.replace("\\", "/").strip()
  275. if p.startswith("/sd/"):
  276. p = p[3:]
  277. if p.startswith("/patterns/"):
  278. return p[len("/patterns/"):]
  279. return p.rsplit("/", 1)[-1]
  280. def _make_host_path_resolver():
  281. """Board SD paths don't always mirror the host patterns/ tree: files can
  282. reach the board via the mobile app or a copied SD card while the host holds
  283. the same pattern elsewhere (e.g. under custom_patterns/). Adopted playlist
  284. entries must point at real host files or previews and play both 404.
  285. Returns a resolve(rel) -> rel function: exact path if it exists, else a
  286. unique catalog suffix match, else a unique basename match, else the raw
  287. path unchanged (the pattern genuinely isn't on the host). The catalog is
  288. scanned lazily once per adoption pass and results are memoized, so the
  289. resolution is deterministic and repeat adoptions stay stable."""
  290. from modules.core import pattern_manager
  291. cache: dict = {}
  292. catalog: list = []
  293. scanned = False
  294. def resolve(rel: str) -> str:
  295. nonlocal catalog, scanned
  296. if rel in cache:
  297. return cache[rel]
  298. result = rel
  299. if not os.path.exists(os.path.join(pattern_manager.THETA_RHO_DIR, rel)):
  300. if not scanned:
  301. scanned = True
  302. try:
  303. catalog = pattern_manager.list_theta_rho_files()
  304. except Exception as e:
  305. logger.warning(f"Could not scan pattern catalog: {e}")
  306. suffix = "/" + rel
  307. matches = [f for f in catalog if f.endswith(suffix)]
  308. if not matches:
  309. basename = rel.rsplit("/", 1)[-1]
  310. matches = [f for f in catalog if f.rsplit("/", 1)[-1] == basename]
  311. if len(matches) == 1:
  312. result = matches[0]
  313. cache[rel] = result
  314. return result
  315. return resolve
  316. def adopt_board_playlists(conn=None) -> None:
  317. """Read the board's /playlists/*.txt into the host catalog.
  318. The board is the source of truth for playlists (mobile apps edit it
  319. directly); the host only WRITES on deliberate user actions (playlist CRUD,
  320. pressing play, selecting an autostart playlist) — never automatically.
  321. Board copies win for names that exist on the board; host-only playlists
  322. are kept (they reach the board the next time the user edits or plays
  323. them). Reads only — no SD writes, no flash wear.
  324. """
  325. from modules.core import playlist_manager
  326. conn = conn or state.conn
  327. if not conn:
  328. return
  329. try:
  330. names = conn.list_playlists()
  331. except Exception as e:
  332. logger.warning(f"Could not list board playlists: {e}")
  333. return
  334. if not isinstance(names, list):
  335. return
  336. try:
  337. playlists = playlist_manager.load_playlists()
  338. except Exception:
  339. playlists = {}
  340. changed = []
  341. resolve_host_path = _make_host_path_resolver()
  342. for board_name in names:
  343. fname = board_name if board_name.endswith(".txt") else f"{board_name}.txt"
  344. name = fname[:-4]
  345. if not name:
  346. continue
  347. try:
  348. data = conn.fetch_file(f"/playlists/{fname}")
  349. except Exception:
  350. continue # listed but unreadable (e.g. deleted mid-scan) — skip
  351. files = [resolve_host_path(_from_playlist_sd_line(l))
  352. for l in data.decode("utf-8", "replace").splitlines() if l.strip()]
  353. entry = playlists.get(name)
  354. current = entry.get("files", entry) if isinstance(entry, dict) else entry
  355. if current != files:
  356. playlists[name] = {**entry, "files": files} if isinstance(entry, dict) else files
  357. changed.append(name)
  358. if changed:
  359. playlist_manager.save_playlists(playlists)
  360. logger.info(f"Adopted board playlists: {', '.join(changed)}")
  361. def adopt_auto_home(settings_map: dict) -> None:
  362. """Adopt the board's $Playlist/AutoHome cadence (0 = disabled). The host
  363. pushes it only when the user edits the setting in the web UI."""
  364. raw = settings_map.get("Playlist/AutoHome")
  365. if raw is None:
  366. return
  367. try:
  368. every = int(float(raw))
  369. except (TypeError, ValueError):
  370. return
  371. enabled = every > 0
  372. if enabled == state.auto_home_enabled and (
  373. not enabled or every == state.auto_home_after_patterns):
  374. return
  375. state.auto_home_enabled = enabled
  376. if enabled:
  377. state.auto_home_after_patterns = every
  378. state.save_debounced()
  379. logger.info(f"Adopted board auto-home cadence: {every or 'disabled'}")
  380. # ---------------------------------------------------------------------------
  381. # Custom clear patterns — the firmware picks and runs its own clear files
  382. # (/patterns/clear_from_in.thr, clear_from_out.thr per its playlist: config).
  383. # A "custom" clear is implemented by uploading the chosen pattern's content
  384. # over those fixed paths (and restoring the stock file when cleared).
  385. # ---------------------------------------------------------------------------
  386. def push_custom_clears(conn=None) -> None:
  387. """Upload the effective clear files to the board (best effort)."""
  388. conn = conn or state.conn
  389. if not conn:
  390. return
  391. from modules.core.pattern_manager import THETA_RHO_DIR
  392. import os
  393. for board_name, custom in (
  394. ("clear_from_in.thr", state.custom_clear_from_in),
  395. ("clear_from_out.thr", state.custom_clear_from_out),
  396. ):
  397. source = os.path.join(THETA_RHO_DIR, custom) if custom \
  398. else os.path.join(THETA_RHO_DIR, board_name)
  399. if not os.path.exists(source):
  400. logger.warning(f"Clear source missing, not mirrored: {source}")
  401. continue
  402. try:
  403. with open(source, "rb") as f:
  404. conn.upload_file(f"/patterns/{board_name}", f.read(), "/patterns")
  405. logger.info(f"Board clear file {board_name} <- {os.path.basename(source)}")
  406. except Exception as e:
  407. logger.warning(f"Could not push clear file {board_name}: {e}")
  408. def push_custom_clears_async() -> None:
  409. threading.Thread(target=push_custom_clears, daemon=True).start()
  410. # ---------------------------------------------------------------------------
  411. # Connect-time reconciliation, called after the board connection is up.
  412. # ---------------------------------------------------------------------------
  413. def sync_on_connect(conn=None) -> None:
  414. """Connect-time reconciliation: clock push + adopt board-owned state.
  415. Read-only toward the board's SD/NVS (except the clock, which is RAM/RTC):
  416. Still Sands, auto-home cadence and playlists are ADOPTED from the board.
  417. The host never auto-pushes content — uploads happen only on deliberate
  418. user actions (play, playlist CRUD, autostart selection, settings edits).
  419. """
  420. conn = conn or state.conn
  421. if not conn:
  422. return
  423. try:
  424. sync_board_time(conn)
  425. except Exception as e:
  426. logger.warning(f"Board clock sync failed: {e}")
  427. try:
  428. settings_map = conn.get_settings()
  429. adopt_still_sands(settings_map)
  430. adopt_auto_home(settings_map)
  431. except Exception as e:
  432. logger.warning(f"Could not adopt board settings: {e}")
  433. adopt_board_playlists(conn)