firmware_client.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. """Async HTTP client for the Dune Weaver FluidNC firmware.
  2. The table is a headless ESP32 (FluidNC ``sandtable`` build). It exposes a
  3. stateless HTTP/JSON API (see the firmware's ``API.md``): status is *polled*
  4. from ``/sand_status``, actions go out as ``$...`` commands via ``/command`` and
  5. the dedicated ``/sand_*`` routes, and pattern/playlist files are fetched from
  6. ``/sd/...``.
  7. This module is the single place that knows how to talk to the board. It is a
  8. process-wide ``QObject`` singleton so the ``Backend`` controller and the
  9. ``PatternModel`` / ``PlaylistModel`` list models all share one aiohttp session
  10. and one notion of "which table are we pointed at".
  11. """
  12. from __future__ import annotations
  13. import asyncio
  14. import logging
  15. import random
  16. import socket
  17. from typing import Optional
  18. from urllib.parse import quote
  19. import aiohttp
  20. from PySide6.QtCore import QObject, Signal
  21. logger = logging.getLogger("DuneWeaver.Firmware")
  22. # Firmware LED capability is a fixed catalogue of named effects/palettes
  23. # (API.md -> "LEDs"). The QML LED page works in terms of integer ids, so we
  24. # expose these lists and map id <-> name by index.
  25. LED_EFFECTS = [
  26. "off", "static", "rainbow", "breathe", "colorloop", "theater", "scan",
  27. "running", "sine", "gradient", "sinelon", "twinkle", "sparkle", "fire",
  28. "candle", "meteor", "bouncing", "wipe", "dualscan", "juggle", "multicomet",
  29. "glitter", "dissolve", "ripple", "drip", "lightning", "fireworks", "plasma",
  30. "heartbeat", "strobe", "police", "chase", "railway", "pacifica", "aurora",
  31. "pride", "colorwaves", "bpm", "ball",
  32. ]
  33. LED_PALETTES = [
  34. "rainbow", "ocean", "lava", "forest", "party", "cloud", "heat", "sunset",
  35. ]
  36. # Pattern "pre-execution" clear modes as used by the touch UI mapped to the
  37. # firmware's clear= modes ($Sand/Run ... clear=<mode>). The touch UI speaks in
  38. # center/perimeter terms; the firmware ships clear-from-in / clear-from-out
  39. # templates.
  40. CLEAR_MODE_MAP = {
  41. "adaptive": "adaptive",
  42. "clear_center": "in", # start at the center, clear outward
  43. "clear_perimeter": "out", # start at the perimeter, clear inward
  44. "none": "none",
  45. # pass-through for firmware-native names so callers may use them directly
  46. "in": "in", "out": "out", "sideway": "sideway", "random": "random",
  47. }
  48. DEFAULT_HTTP_TIMEOUT = 6 # seconds, for normal requests
  49. # 503 low-memory load-shedding retry, mirroring the backend's FluidNCClient.
  50. _RETRY_503_ATTEMPTS = 3 # total tries (1 initial + 2 retries)
  51. _RETRY_503_BASE = 0.3 # seconds; base for exponential backoff + jitter
  52. # The board's web server serializes requests, so a request behind a big file
  53. # transfer can legitimately time out once and succeed a moment later.
  54. _TRANSIENT_RETRY_DELAY = 0.5 # seconds between transient-error retries
  55. # Status poll budget. The board's web server serializes requests, so a status
  56. # read legitimately waits several seconds behind a file transfer — a tight
  57. # timeout here makes a busy board look dead.
  58. STATUS_TIMEOUT = 5 # seconds, for the ~1 Hz status poll
  59. def friendly_error(exc: BaseException) -> str:
  60. """Human-readable message for a request failure.
  61. ``str(asyncio.TimeoutError())`` is an EMPTY string — surfacing raw
  62. exceptions produced blank error dialogs. Every user-facing error goes
  63. through here instead.
  64. """
  65. if isinstance(exc, asyncio.TimeoutError):
  66. return "The table didn't respond in time — it may be busy. Try again."
  67. if isinstance(exc, aiohttp.ClientResponseError) and exc.status == 401:
  68. return "The table rejected the password. Set it under Table connection."
  69. if isinstance(exc, (aiohttp.ClientConnectionError, aiohttp.ClientError)):
  70. return "Can't reach the table. Check that it's powered on and on your network."
  71. return str(exc) or type(exc).__name__
  72. def _raise_file_error(status: int, body) -> None:
  73. """Raise a helpful error from a file-op JSON body on HTTP failure."""
  74. if status < 400:
  75. return
  76. detail = ""
  77. if isinstance(body, dict):
  78. err = body.get("error")
  79. if isinstance(err, dict):
  80. detail = err.get("message", "")
  81. detail = detail or body.get("status", "")
  82. raise RuntimeError(detail or f"HTTP {status}")
  83. def posix_tz() -> Optional[str]:
  84. """POSIX TZ rule for the system zone (from the TZif v2+ footer line).
  85. Same derivation as the backend's board_settings.posix_tz(): modern TZif
  86. files end with a footer holding exactly the rule string the firmware's
  87. $Time/Zone wants (e.g. 'EST5EDT,M3.2.0,M11.1.0'). None if unreadable.
  88. """
  89. try:
  90. with open("/etc/localtime", "rb") as f:
  91. data = f.read()
  92. if not data.startswith(b"TZif"):
  93. return None
  94. end = data.rfind(b"\n")
  95. if end <= 0:
  96. return None
  97. begin = data.rfind(b"\n", 0, end)
  98. footer = data[begin + 1:end].decode("ascii").strip()
  99. return footer or None
  100. except Exception as exc:
  101. logger.debug(f"Could not derive POSIX tz: {exc}")
  102. return None
  103. def normalize_base_url(value: str) -> str:
  104. """Turn a user/mDNS supplied host into a ``http://host[:port]`` base URL."""
  105. value = (value or "").strip().rstrip("/")
  106. if not value:
  107. return ""
  108. if not value.startswith(("http://", "https://")):
  109. value = "http://" + value
  110. return value
  111. class FirmwareClient(QObject):
  112. """Process-wide async client for one FluidNC sand table."""
  113. # Emitted whenever the target table changes (empty string = no table).
  114. baseUrlChanged = Signal(str)
  115. # Emitted when reachability changes based on request success/failure.
  116. reachabilityChanged = Signal(bool)
  117. _instance: Optional["FirmwareClient"] = None
  118. @classmethod
  119. def instance(cls) -> "FirmwareClient":
  120. if cls._instance is None:
  121. cls._instance = FirmwareClient()
  122. return cls._instance
  123. def __init__(self):
  124. super().__init__()
  125. self._base_url = ""
  126. self._session: Optional[aiohttp.ClientSession] = None
  127. self._reachable = False
  128. # $Sand/Password key (fw >= v0.1.11), sent as X-Sand-Key on every
  129. # request — same contract as the backend's FluidNCClient.
  130. self._api_key: Optional[str] = None
  131. # True when the board rejected us with 401 (locked, wrong/missing key).
  132. self.locked = False
  133. def set_api_key(self, key: Optional[str]) -> None:
  134. self._api_key = key or None
  135. self.locked = False
  136. def _headers(self) -> dict:
  137. return {"X-Sand-Key": self._api_key} if self._api_key else {}
  138. # ------------------------------------------------------------------ target
  139. @property
  140. def base_url(self) -> str:
  141. return self._base_url
  142. @property
  143. def reachable(self) -> bool:
  144. return self._reachable
  145. def set_base_url(self, value: str) -> None:
  146. normalized = normalize_base_url(value)
  147. if normalized == self._base_url:
  148. return
  149. logger.info(f"Target table set to: {normalized or '(none)'}")
  150. self._base_url = normalized
  151. self._set_reachable(False)
  152. self.baseUrlChanged.emit(self._base_url)
  153. def _set_reachable(self, value: bool) -> None:
  154. if value != self._reachable:
  155. self._reachable = value
  156. self.reachabilityChanged.emit(value)
  157. # ----------------------------------------------------------------- session
  158. async def _ensure_session(self) -> aiohttp.ClientSession:
  159. if self._session is None or self._session.closed:
  160. # IPv4 only: the boards publish no AAAA record over mDNS, and a
  161. # dual-stack getaddrinfo stalls ~5s waiting for it (longer than the
  162. # 3s status timeout, so every poll would die in name resolution).
  163. connector = aiohttp.TCPConnector(
  164. ssl=False, limit=8, family=socket.AF_INET, ttl_dns_cache=300
  165. )
  166. self._session = aiohttp.ClientSession(connector=connector)
  167. return self._session
  168. async def close(self) -> None:
  169. if self._session and not self._session.closed:
  170. await self._session.close()
  171. # ------------------------------------------------------------- HTTP helpers
  172. async def _fetch(self, path: str, parse: str, *, timeout: float,
  173. transient_retries: int = 1):
  174. """GET ``path`` and return the parsed body ("json" | "text" | "bytes").
  175. Retries the firmware's ``503 busy: low memory`` load-shedding with
  176. exponential backoff, like the backend's FluidNCClient (the board sheds
  177. every route except the status/stop/pause/resume lifeline when free
  178. heap drops below ~10 KB; these resolve in a few seconds). Tracks 401s
  179. in ``self.locked`` so the UI can prompt for the table password.
  180. ``transient_retries`` additionally retries timeouts and connection
  181. errors (the board's serialized web server queues requests behind file
  182. transfers, so a one-off timeout is normal). Callers whose requests are
  183. NOT safe to re-send (``$...`` commands) pass 0.
  184. """
  185. if not self._base_url:
  186. raise RuntimeError("No table selected")
  187. session = await self._ensure_session()
  188. client_timeout = aiohttp.ClientTimeout(total=timeout)
  189. url = f"{self._base_url}{path}"
  190. attempt_503 = 0
  191. transient_left = transient_retries
  192. while True:
  193. try:
  194. async with session.get(url, timeout=client_timeout,
  195. headers=self._headers()) as resp:
  196. if resp.status == 503 and attempt_503 < _RETRY_503_ATTEMPTS - 1:
  197. attempt_503 += 1
  198. delay = _RETRY_503_BASE * (2 ** attempt_503) + random.uniform(0, _RETRY_503_BASE)
  199. logger.debug(f"Board 503 (low memory) on {path}; retrying in {delay:.2f}s")
  200. await asyncio.sleep(delay)
  201. continue
  202. self.locked = resp.status == 401
  203. resp.raise_for_status()
  204. if parse == "json":
  205. data = await resp.json(content_type=None)
  206. elif parse == "text":
  207. data = await resp.text()
  208. else:
  209. data = await resp.read()
  210. self._set_reachable(True)
  211. return data
  212. except (asyncio.TimeoutError, aiohttp.ClientConnectionError) as exc:
  213. if transient_left <= 0:
  214. raise
  215. transient_left -= 1
  216. logger.debug(f"Transient failure on {path} ({exc!r}); retrying")
  217. await asyncio.sleep(_TRANSIENT_RETRY_DELAY)
  218. async def get_json(self, path: str, *, timeout: float = DEFAULT_HTTP_TIMEOUT,
  219. transient_retries: int = 1):
  220. return await self._fetch(path, "json", timeout=timeout,
  221. transient_retries=transient_retries)
  222. async def get_text(self, path: str, *, timeout: float = DEFAULT_HTTP_TIMEOUT,
  223. transient_retries: int = 1) -> str:
  224. return await self._fetch(path, "text", timeout=timeout,
  225. transient_retries=transient_retries)
  226. async def get_bytes(self, path: str, *, timeout: float = DEFAULT_HTTP_TIMEOUT,
  227. transient_retries: int = 1) -> bytes:
  228. return await self._fetch(path, "bytes", timeout=timeout,
  229. transient_retries=transient_retries)
  230. # -------------------------------------------------------------- status/read
  231. async def status(self) -> dict:
  232. """Poll ``/sand_status`` (fast, works during motion)."""
  233. return await self.get_json("/sand_status", timeout=STATUS_TIMEOUT)
  234. async def patterns(self) -> list:
  235. """``/sand_patterns`` -> list of ``.thr`` paths relative to /patterns."""
  236. data = await self.get_json("/sand_patterns")
  237. return data if isinstance(data, list) else []
  238. async def playlists(self) -> list:
  239. """``/sand_playlists`` -> list of ``.txt`` file names."""
  240. data = await self.get_json("/sand_playlists")
  241. return data if isinstance(data, list) else []
  242. async def settings(self) -> dict:
  243. """``/sand_settings`` -> flat dict of setting name -> string value."""
  244. data = await self.get_json("/sand_settings")
  245. return data if isinstance(data, dict) else {}
  246. async def fetch_sd_file(self, sd_path: str, *, timeout: float = 45) -> bytes:
  247. """Fetch a file from the SD card, e.g. ``/patterns/star.thr``.
  248. Default timeout is generous: the board serves large .thr files slowly
  249. (measured up to ~26s for 500KB when it is busy), and the default 6s
  250. budget made every big pattern's preview fetch fail.
  251. """
  252. sd_path = "/" + sd_path.lstrip("/")
  253. return await self.get_bytes(f"/sd{sd_path}", timeout=timeout)
  254. # ------------------------------------------------------------------ actions
  255. async def command(self, cmd: str, *, timeout: float = DEFAULT_HTTP_TIMEOUT) -> str:
  256. """Fire a ``$...`` command via ``/command?plain=`` (fire-and-forget).
  257. Output routing over ``/command`` is racy for anything but ``$/`` reads,
  258. so callers that need a value should poll a ``/sand_*`` route instead.
  259. No transient retry: a timed-out command may still execute on the board
  260. once its queue drains, and re-sending e.g. ``$Playlist/Run`` or ``$Bye``
  261. would double-fire it.
  262. """
  263. return await self.get_text(f"/command?plain={quote(cmd)}",
  264. timeout=timeout, transient_retries=0)
  265. async def run_pattern(self, rel_path: str, clear: str = "none") -> None:
  266. """Run ``/patterns/<rel_path>`` with an optional pre-execution clear."""
  267. path = "/patterns/" + rel_path.lstrip("/")
  268. mode = CLEAR_MODE_MAP.get(clear, "none")
  269. if mode == "none":
  270. await self.command(f"$SD/Run={path}")
  271. else:
  272. await self.command(f"$Sand/Run={path} clear={mode}")
  273. async def _wait_for_idle(self, timeout_s: float) -> bool:
  274. loop = asyncio.get_event_loop()
  275. deadline = loop.time() + timeout_s
  276. while loop.time() < deadline:
  277. try:
  278. st = await self.status()
  279. state = (st.get("state") or "").split(":", 1)[0]
  280. if state == "Idle" and not st.get("running"):
  281. return True
  282. except Exception as exc:
  283. logger.debug(f"Idle wait poll failed: {exc}")
  284. await asyncio.sleep(0.5)
  285. return False
  286. async def run_playlist(self, name: str, *, pause_time=None, clear_pattern=None,
  287. run_mode=None, shuffle=None) -> None:
  288. """Apply the run parameters (NVS) then start the playlist.
  289. NVS writes are idle-gated on the firmware (rejected mid-motion), so a
  290. run-while-running stops the board first — same as the backend's
  291. execution.start_playlist.
  292. """
  293. try:
  294. st = await self.status()
  295. except Exception:
  296. st = None
  297. state = ((st or {}).get("state") or "").split(":", 1)[0]
  298. if st and (st.get("running") or state not in ("Idle", "Alarm")):
  299. await self.stop()
  300. if not await self._wait_for_idle(15.0):
  301. raise RuntimeError("Table is busy and did not stop in time")
  302. if run_mode in ("single", "loop"):
  303. await self.command(f"$Playlist/Mode={run_mode}")
  304. if shuffle is not None:
  305. await self.command(f"$Playlist/Shuffle={'ON' if shuffle else 'OFF'}")
  306. if pause_time is not None:
  307. await self.command(f"$Playlist/PauseTime={int(pause_time)}")
  308. if clear_pattern is not None:
  309. mode = CLEAR_MODE_MAP.get(clear_pattern, clear_pattern)
  310. await self.command(f"$Playlist/ClearPattern={mode}")
  311. await self.command(f"$Playlist/Run={name}")
  312. async def playlist_stop(self) -> None:
  313. await self.command("$Playlist/Stop")
  314. async def playlist_skip(self) -> None:
  315. await self.command("$Playlist/Skip")
  316. async def stop(self) -> None:
  317. """Stop the whole sequence (clear + pattern + playlist)."""
  318. await self.get_text("/sand_stop")
  319. async def pause(self) -> None:
  320. await self.get_text("/sand_pause")
  321. async def resume(self) -> None:
  322. await self.get_text("/sand_resume")
  323. async def home(self) -> None:
  324. # Homing can take a while; give it room. Runs in the main loop (safe).
  325. await self.get_text("/sand_home", timeout=95)
  326. async def goto(self, *, theta=None, rho=None) -> None:
  327. params = []
  328. if theta is not None:
  329. params.append(f"theta={theta}")
  330. if rho is not None:
  331. params.append(f"rho={rho}")
  332. await self.get_text("/sand_goto?" + "&".join(params), timeout=95)
  333. async def set_feed_mm(self, mm: int) -> None:
  334. """Set the base feed rate (motor mm/min); works mid-pattern."""
  335. await self.get_text(f"/sand_feed?mm={int(mm)}")
  336. async def set_led(self, **kwargs) -> None:
  337. """Live LED control via ``/sand_led?...`` (applies immediately)."""
  338. params = "&".join(f"{k}={quote(str(v))}" for k, v in kwargs.items() if v is not None)
  339. await self.get_text(f"/sand_led?{params}")
  340. async def set_autostart(self, name: str) -> None:
  341. """Set (or clear, with empty name) the boot auto-play playlist."""
  342. await self.command(f"$Playlist/Autostart={name}")
  343. async def reboot(self) -> None:
  344. await self.command("$Bye")
  345. async def sync_time(self, epoch: int, tz: Optional[str] = None) -> None:
  346. """Push the wall clock and (optionally) a POSIX timezone rule.
  347. The tz matters: board-side quiet-hours/autostart schedules run on the
  348. board's local time. The backend always sends both; so do we.
  349. """
  350. query = f"epoch={int(epoch)}"
  351. if tz:
  352. query += f"&tz={quote(tz)}"
  353. await self.get_text(f"/sand_time?{query}")
  354. # --------------------------------------------------------------- file ops
  355. async def upload_file(self, name: str, data: bytes, path: str = "/patterns") -> dict:
  356. """Upload ``data`` as ``<path>/<name>`` (multipart, firmware contract)."""
  357. if not self._base_url:
  358. raise RuntimeError("No table selected")
  359. session = await self._ensure_session()
  360. form = aiohttp.FormData()
  361. form.add_field(f"{name}S", str(len(data)))
  362. form.add_field(name, data, filename=name,
  363. content_type="application/octet-stream")
  364. url = f"{self._base_url}/upload?path={quote(path)}"
  365. timeout = aiohttp.ClientTimeout(total=60)
  366. async with session.post(url, data=form, timeout=timeout,
  367. headers=self._headers()) as resp:
  368. body = await resp.json(content_type=None)
  369. self._set_reachable(True)
  370. _raise_file_error(resp.status, body)
  371. return body
  372. async def _file_action(self, action: str, filename: str, path: str, **extra) -> dict:
  373. if not self._base_url:
  374. raise RuntimeError("No table selected")
  375. session = await self._ensure_session()
  376. params = {"action": action, "filename": filename, "path": path, "dontlist": "yes"}
  377. params.update(extra)
  378. query = "&".join(f"{k}={quote(str(v))}" for k, v in params.items())
  379. url = f"{self._base_url}/upload?{query}"
  380. timeout = aiohttp.ClientTimeout(total=DEFAULT_HTTP_TIMEOUT)
  381. async with session.get(url, timeout=timeout,
  382. headers=self._headers()) as resp:
  383. body = await resp.json(content_type=None)
  384. self._set_reachable(True)
  385. _raise_file_error(resp.status, body)
  386. return body
  387. async def delete_file(self, filename: str, path: str = "/playlists") -> dict:
  388. return await self._file_action("delete", filename, path)
  389. async def create_dir(self, filename: str, path: str = "/") -> dict:
  390. return await self._file_action("createdir", filename, path)