board_settings.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  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 threading
  23. import time
  24. from modules.core.state import state
  25. logger = logging.getLogger(__name__)
  26. # Host slot day names (state.scheduled_pause_time_slots) <-> firmware 3-letter codes.
  27. _DAY_TO_BOARD = {
  28. "sunday": "sun", "monday": "mon", "tuesday": "tue", "wednesday": "wed",
  29. "thursday": "thu", "friday": "fri", "saturday": "sat",
  30. }
  31. _BOARD_TO_DAY = {v: k for k, v in _DAY_TO_BOARD.items()}
  32. _BOARD_DAY_ORDER = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]
  33. CLEAR_MODES = ("none", "adaptive", "in", "out", "sideway", "random")
  34. def _is_on(value) -> bool:
  35. return str(value or "").upper() in ("ON", "1", "TRUE")
  36. # ---------------------------------------------------------------------------
  37. # Still Sands slot conversion: host dicts <-> "$Sands/Slots" spec string
  38. # ---------------------------------------------------------------------------
  39. def slots_to_board(time_slots: list) -> str:
  40. """Host slot dicts -> 'HH:MM-HH:MM@days,...' ($Sands/Slots syntax)."""
  41. parts = []
  42. for slot in time_slots or []:
  43. start = slot.get("start_time")
  44. end = slot.get("end_time")
  45. if not start or not end:
  46. continue
  47. days = slot.get("days", "daily")
  48. if days == "custom":
  49. codes = sorted(
  50. {_DAY_TO_BOARD[d] for d in slot.get("custom_days", []) if d in _DAY_TO_BOARD},
  51. key=_BOARD_DAY_ORDER.index,
  52. )
  53. days = "+".join(codes) if codes else "daily"
  54. parts.append(f"{start}-{end}@{days}")
  55. return ",".join(parts)
  56. def _normalize_time(value: str) -> str | None:
  57. """'9:5' -> '09:05'; None when not a valid HH:MM."""
  58. hours, sep, minutes = value.strip().partition(":")
  59. if not sep or not hours.isdigit() or not minutes.isdigit():
  60. return None
  61. h, m = int(hours), int(minutes)
  62. if h > 23 or m > 59:
  63. return None
  64. return f"{h:02d}:{m:02d}"
  65. def board_to_slots(spec: str) -> list:
  66. """'$Sands/Slots' spec string -> host slot dicts (invalid entries dropped)."""
  67. slots = []
  68. for part in (spec or "").split(","):
  69. part = part.strip()
  70. if not part:
  71. continue
  72. times, _, days_spec = part.partition("@")
  73. start, dash, end = times.strip().partition("-")
  74. start, end = _normalize_time(start), _normalize_time(end or "")
  75. if not dash or not start or not end:
  76. continue
  77. days_spec = (days_spec or "daily").strip().lower()
  78. if days_spec in ("", "daily", "weekdays", "weekends"):
  79. days, custom_days = (days_spec or "daily"), []
  80. else:
  81. custom_days = [_BOARD_TO_DAY[c.strip()] for c in days_spec.split("+") if c.strip() in _BOARD_TO_DAY]
  82. days = "custom" if custom_days else "daily"
  83. slots.append({
  84. "start_time": start,
  85. "end_time": end,
  86. "days": days,
  87. "custom_days": custom_days,
  88. })
  89. return slots
  90. # ---------------------------------------------------------------------------
  91. # Clock sync
  92. # ---------------------------------------------------------------------------
  93. def posix_tz(iana_name: str | None = None) -> str | None:
  94. """POSIX TZ rule string for an IANA zone (or the system zone when None).
  95. Modern TZif files (v2+) end with a footer line holding exactly this string
  96. (e.g. 'EST5EDT,M3.2.0,M11.1.0'), which is what the firmware's $Time/Zone
  97. wants. Returns None when the zone file can't be read.
  98. """
  99. path = f"/usr/share/zoneinfo/{iana_name}" if iana_name else "/etc/localtime"
  100. try:
  101. with open(path, "rb") as f:
  102. data = f.read()
  103. if not data.startswith(b"TZif"):
  104. return None
  105. # Footer = text between the last two newlines of the file.
  106. end = data.rfind(b"\n")
  107. if end <= 0:
  108. return None
  109. begin = data.rfind(b"\n", 0, end)
  110. footer = data[begin + 1:end].decode("ascii").strip()
  111. return footer or None
  112. except Exception as e:
  113. logger.debug(f"Could not derive POSIX tz for {iana_name or 'system'}: {e}")
  114. return None
  115. def sync_board_time(conn=None) -> dict:
  116. """Push the host's clock (and effective quiet-hours timezone) to the board.
  117. Quiet hours are computed host-side in state.scheduled_pause_timezone (or the
  118. system zone); pushing the same zone keeps board-side schedules (autostart,
  119. firmware playlists) aligned with what the user sees in the UI.
  120. """
  121. conn = conn or state.conn
  122. if not conn:
  123. raise RuntimeError("No board connection")
  124. tz = posix_tz(state.scheduled_pause_timezone)
  125. result = conn.set_time(epoch=int(time.time()), tz=tz)
  126. logger.info(f"Synced board clock (tz={tz or 'unchanged'}): {result}")
  127. return result
  128. # ---------------------------------------------------------------------------
  129. # Still Sands push / adopt
  130. # ---------------------------------------------------------------------------
  131. def push_still_sands(conn=None) -> None:
  132. """Write the host's Still Sands settings to the board's $Sands/* NVS keys."""
  133. conn = conn or state.conn
  134. if not conn:
  135. return
  136. conn.set_setting("Sands/Enabled", "ON" if state.scheduled_pause_enabled else "OFF")
  137. slots = slots_to_board(state.scheduled_pause_time_slots)
  138. if slots:
  139. conn.set_setting("Sands/Slots", slots)
  140. conn.set_setting("Sands/FinishPattern", "ON" if state.scheduled_pause_finish_pattern else "OFF")
  141. # One UI toggle drives both LED systems: host WLED and the board's own ring.
  142. conn.set_setting("Sands/LedOff", "ON" if state.scheduled_pause_control_wled else "OFF")
  143. logger.info("Pushed Still Sands settings to board")
  144. def adopt_still_sands(settings_map: dict) -> bool:
  145. """Adopt the board's $Sands/* values into host state (mobile-app edits win).
  146. Returns True when anything changed. The timezone is not adopted: the board
  147. stores a POSIX rule derived from the host's IANA zone, which isn't
  148. reversible — the host zone selection stays authoritative.
  149. """
  150. enabled = _is_on(settings_map.get("Sands/Enabled"))
  151. finish = _is_on(settings_map.get("Sands/FinishPattern", "ON"))
  152. led_off = _is_on(settings_map.get("Sands/LedOff"))
  153. slots = board_to_slots(settings_map.get("Sands/Slots", ""))
  154. changed = (
  155. enabled != state.scheduled_pause_enabled
  156. or finish != state.scheduled_pause_finish_pattern
  157. or led_off != state.scheduled_pause_control_wled
  158. or slots != state.scheduled_pause_time_slots
  159. )
  160. if changed:
  161. state.scheduled_pause_enabled = enabled
  162. state.scheduled_pause_finish_pattern = finish
  163. state.scheduled_pause_control_wled = led_off
  164. state.scheduled_pause_time_slots = slots
  165. state.save()
  166. logger.info("Adopted Still Sands settings from board")
  167. return changed
  168. # ---------------------------------------------------------------------------
  169. # Auto-home cadence ($Playlist/AutoHome) — mirrors the host auto_home settings
  170. # so firmware-sequenced playlists (autostart) drift-correct the same way.
  171. # ---------------------------------------------------------------------------
  172. def push_auto_home(conn=None) -> None:
  173. conn = conn or state.conn
  174. if not conn:
  175. return
  176. every = state.auto_home_after_patterns if state.auto_home_enabled else 0
  177. conn.set_setting("Playlist/AutoHome", max(0, int(every or 0)))
  178. # ---------------------------------------------------------------------------
  179. # Auto-play on boot ($Playlist/Autostart*) — board-only, proxied for the web UI
  180. # ---------------------------------------------------------------------------
  181. def get_board_settings(conn=None) -> dict:
  182. """Shape the board's /sand_settings + clock into the web UI's structure."""
  183. conn = conn or state.conn
  184. if not conn:
  185. raise RuntimeError("No board connection")
  186. s = conn.get_settings()
  187. status = conn.get_status()
  188. return {
  189. "reachable": True,
  190. "firmware_version": status.get("fw"),
  191. "state": status.get("state"),
  192. "time": status.get("time") or conn.get_time(),
  193. "autostart": {
  194. "playlist": s.get("Playlist/Autostart", ""),
  195. "run_mode": "single" if (s.get("Playlist/AutostartMode", "loop").lower() == "single") else "loop",
  196. "shuffle": _is_on(s.get("Playlist/AutostartShuffle")),
  197. "pause_seconds": int(float(s.get("Playlist/AutostartPause", 0) or 0)),
  198. "pause_from_start": _is_on(s.get("Playlist/AutostartPauseFromStart")),
  199. "clear_pattern": s.get("Playlist/AutostartClear", "none") or "none",
  200. },
  201. "homing_mode": (s.get("Sand/HomingMode") or "sensor").lower(),
  202. "theta_offset": float(s.get("Sand/ThetaOffset", 0) or 0),
  203. "auto_home_every": int(float(s.get("Playlist/AutoHome", 0) or 0)),
  204. }
  205. def apply_autostart(update: dict, conn=None) -> None:
  206. """Write autostart fields to the board. `update` uses the UI field names."""
  207. conn = conn or state.conn
  208. if not conn:
  209. raise RuntimeError("No board connection")
  210. if "playlist" in update:
  211. # Empty value disables auto-play on boot.
  212. conn.set_setting("Playlist/Autostart", update["playlist"] or "")
  213. if "run_mode" in update:
  214. mode = "single" if update["run_mode"] == "single" else "loop"
  215. conn.set_setting("Playlist/AutostartMode", mode)
  216. if "shuffle" in update:
  217. conn.set_setting("Playlist/AutostartShuffle", "ON" if update["shuffle"] else "OFF")
  218. if "pause_seconds" in update:
  219. conn.set_setting("Playlist/AutostartPause", max(0, int(update["pause_seconds"] or 0)))
  220. if "pause_from_start" in update:
  221. conn.set_setting("Playlist/AutostartPauseFromStart", "ON" if update["pause_from_start"] else "OFF")
  222. if "clear_pattern" in update:
  223. clear = update["clear_pattern"] if update["clear_pattern"] in CLEAR_MODES else "none"
  224. conn.set_setting("Playlist/AutostartClear", clear)
  225. # ---------------------------------------------------------------------------
  226. # Playlist mirroring — firmware playlists are /playlists/<name>.txt on the SD,
  227. # one SD pattern path per line. Autostart runs these, so host playlist CRUD is
  228. # mirrored (and the selected playlist's patterns are ensured on the board).
  229. # ---------------------------------------------------------------------------
  230. def _playlist_sd_content(files: list) -> str:
  231. from modules.core.pattern_manager import _to_sd_path
  232. lines = [_to_sd_path(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) -> None:
  235. """Write a host playlist to the board as /playlists/<name>.txt (best-effort)."""
  236. conn = conn or state.conn
  237. if not conn:
  238. return
  239. try:
  240. sd_path = f"/playlists/{name}.txt"
  241. data = _playlist_sd_content(files).encode("utf-8")
  242. conn.upload_file(sd_path, data, "/playlists")
  243. logger.info(f"Mirrored playlist '{name}' to board ({len(files or [])} patterns)")
  244. except Exception as e:
  245. logger.warning(f"Could not mirror playlist '{name}' to board: {e}")
  246. return
  247. if ensure_patterns:
  248. from modules.core.pattern_manager import _ensure_on_board, _to_sd_path
  249. for f in files or []:
  250. _ensure_on_board(f, _to_sd_path(f))
  251. def mirror_playlist_async(name: str, files: list) -> None:
  252. """Fire-and-forget mirror from sync code paths (never blocks the caller)."""
  253. threading.Thread(target=mirror_playlist, args=(name, files), daemon=True).start()
  254. def unmirror_playlist_async(name: str) -> None:
  255. threading.Thread(target=unmirror_playlist, args=(name,), daemon=True).start()
  256. def unmirror_playlist(name: str, conn=None) -> None:
  257. """Delete a playlist's mirror from the board SD (best-effort)."""
  258. conn = conn or state.conn
  259. if not conn:
  260. return
  261. try:
  262. conn.delete_file("/playlists", f"{name}.txt")
  263. logger.info(f"Removed playlist '{name}' from board")
  264. except Exception as e:
  265. logger.debug(f"Could not remove playlist '{name}' from board: {e}")
  266. def mirror_all_playlists(conn=None) -> None:
  267. """Mirror every host playlist to the board (run on connect, best-effort)."""
  268. from modules.core import playlist_manager
  269. conn = conn or state.conn
  270. if not conn:
  271. return
  272. try:
  273. playlists = playlist_manager.load_playlists()
  274. except Exception as e:
  275. logger.warning(f"Could not load playlists for mirroring: {e}")
  276. return
  277. for name, entry in playlists.items():
  278. files = entry.get("files", entry) if isinstance(entry, dict) else entry
  279. mirror_playlist(name, files, conn=conn)
  280. # ---------------------------------------------------------------------------
  281. # Custom clear patterns — the firmware picks and runs its own clear files
  282. # (/patterns/clear_from_in.thr, clear_from_out.thr per its playlist: config).
  283. # A "custom" clear is implemented by uploading the chosen pattern's content
  284. # over those fixed paths (and restoring the stock file when cleared).
  285. # ---------------------------------------------------------------------------
  286. def push_custom_clears(conn=None) -> None:
  287. """Upload the effective clear files to the board (best effort)."""
  288. conn = conn or state.conn
  289. if not conn:
  290. return
  291. from modules.core.pattern_manager import THETA_RHO_DIR
  292. import os
  293. for board_name, custom in (
  294. ("clear_from_in.thr", state.custom_clear_from_in),
  295. ("clear_from_out.thr", state.custom_clear_from_out),
  296. ):
  297. source = os.path.join(THETA_RHO_DIR, custom) if custom \
  298. else os.path.join(THETA_RHO_DIR, board_name)
  299. if not os.path.exists(source):
  300. logger.warning(f"Clear source missing, not mirrored: {source}")
  301. continue
  302. try:
  303. with open(source, "rb") as f:
  304. conn.upload_file(f"/patterns/{board_name}", f.read(), "/patterns")
  305. logger.info(f"Board clear file {board_name} <- {os.path.basename(source)}")
  306. except Exception as e:
  307. logger.warning(f"Could not push clear file {board_name}: {e}")
  308. def push_custom_clears_async() -> None:
  309. threading.Thread(target=push_custom_clears, daemon=True).start()
  310. # ---------------------------------------------------------------------------
  311. # Connect-time reconciliation, called after the board connection is up.
  312. # ---------------------------------------------------------------------------
  313. def sync_on_connect(conn=None) -> None:
  314. """Clock push + Still Sands adopt + AutoHome push + playlist mirror."""
  315. conn = conn or state.conn
  316. if not conn:
  317. return
  318. try:
  319. sync_board_time(conn)
  320. except Exception as e:
  321. logger.warning(f"Board clock sync failed: {e}")
  322. try:
  323. adopt_still_sands(conn.get_settings())
  324. except Exception as e:
  325. logger.warning(f"Could not adopt Still Sands settings from board: {e}")
  326. try:
  327. push_auto_home(conn)
  328. except Exception as e:
  329. logger.warning(f"Could not push auto-home cadence to board: {e}")
  330. mirror_all_playlists(conn)