firmware_client.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  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. from typing import Optional
  16. from urllib.parse import quote
  17. import aiohttp
  18. from PySide6.QtCore import QObject, Signal
  19. logger = logging.getLogger("DuneWeaver.Firmware")
  20. # Firmware LED capability is a fixed catalogue of named effects/palettes
  21. # (API.md -> "LEDs"). The QML LED page works in terms of integer ids, so we
  22. # expose these lists and map id <-> name by index.
  23. LED_EFFECTS = [
  24. "off", "static", "rainbow", "breathe", "colorloop", "theater", "scan",
  25. "running", "sine", "gradient", "sinelon", "twinkle", "sparkle", "fire",
  26. "candle", "meteor", "bouncing", "wipe", "dualscan", "juggle", "multicomet",
  27. "glitter", "dissolve", "ripple", "drip", "lightning", "fireworks", "plasma",
  28. "heartbeat", "strobe", "police", "chase", "railway", "pacifica", "aurora",
  29. "pride", "colorwaves", "bpm", "ball",
  30. ]
  31. LED_PALETTES = [
  32. "rainbow", "ocean", "lava", "forest", "party", "cloud", "heat", "sunset",
  33. ]
  34. # Pattern "pre-execution" clear modes as used by the touch UI mapped to the
  35. # firmware's clear= modes ($Sand/Run ... clear=<mode>). The touch UI speaks in
  36. # center/perimeter terms; the firmware ships clear-from-in / clear-from-out
  37. # templates.
  38. CLEAR_MODE_MAP = {
  39. "adaptive": "adaptive",
  40. "clear_center": "in", # start at the center, clear outward
  41. "clear_perimeter": "out", # start at the perimeter, clear inward
  42. "none": "none",
  43. # pass-through for firmware-native names so callers may use them directly
  44. "in": "in", "out": "out", "sideway": "sideway", "random": "random",
  45. }
  46. DEFAULT_HTTP_TIMEOUT = 6 # seconds, for normal requests
  47. STATUS_TIMEOUT = 3 # seconds, for the ~1 Hz status poll
  48. def _raise_file_error(status: int, body) -> None:
  49. """Raise a helpful error from a file-op JSON body on HTTP failure."""
  50. if status < 400:
  51. return
  52. detail = ""
  53. if isinstance(body, dict):
  54. err = body.get("error")
  55. if isinstance(err, dict):
  56. detail = err.get("message", "")
  57. detail = detail or body.get("status", "")
  58. raise RuntimeError(detail or f"HTTP {status}")
  59. def normalize_base_url(value: str) -> str:
  60. """Turn a user/mDNS supplied host into a ``http://host[:port]`` base URL."""
  61. value = (value or "").strip().rstrip("/")
  62. if not value:
  63. return ""
  64. if not value.startswith(("http://", "https://")):
  65. value = "http://" + value
  66. return value
  67. class FirmwareClient(QObject):
  68. """Process-wide async client for one FluidNC sand table."""
  69. # Emitted whenever the target table changes (empty string = no table).
  70. baseUrlChanged = Signal(str)
  71. # Emitted when reachability changes based on request success/failure.
  72. reachabilityChanged = Signal(bool)
  73. _instance: Optional["FirmwareClient"] = None
  74. @classmethod
  75. def instance(cls) -> "FirmwareClient":
  76. if cls._instance is None:
  77. cls._instance = FirmwareClient()
  78. return cls._instance
  79. def __init__(self):
  80. super().__init__()
  81. self._base_url = ""
  82. self._session: Optional[aiohttp.ClientSession] = None
  83. self._reachable = False
  84. # ------------------------------------------------------------------ target
  85. @property
  86. def base_url(self) -> str:
  87. return self._base_url
  88. @property
  89. def reachable(self) -> bool:
  90. return self._reachable
  91. def set_base_url(self, value: str) -> None:
  92. normalized = normalize_base_url(value)
  93. if normalized == self._base_url:
  94. return
  95. logger.info(f"Target table set to: {normalized or '(none)'}")
  96. self._base_url = normalized
  97. self._set_reachable(False)
  98. self.baseUrlChanged.emit(self._base_url)
  99. def _set_reachable(self, value: bool) -> None:
  100. if value != self._reachable:
  101. self._reachable = value
  102. self.reachabilityChanged.emit(value)
  103. # ----------------------------------------------------------------- session
  104. async def _ensure_session(self) -> aiohttp.ClientSession:
  105. if self._session is None or self._session.closed:
  106. connector = aiohttp.TCPConnector(ssl=False, limit=8)
  107. self._session = aiohttp.ClientSession(connector=connector)
  108. return self._session
  109. async def close(self) -> None:
  110. if self._session and not self._session.closed:
  111. await self._session.close()
  112. # ------------------------------------------------------------- HTTP helpers
  113. async def _get(self, path: str, *, timeout: float = DEFAULT_HTTP_TIMEOUT):
  114. """GET ``path`` (leading-slash relative). Returns the aiohttp response
  115. inside a context manager caller. Raises on transport error."""
  116. if not self._base_url:
  117. raise RuntimeError("No table selected")
  118. session = await self._ensure_session()
  119. client_timeout = aiohttp.ClientTimeout(total=timeout)
  120. return session.get(f"{self._base_url}{path}", timeout=client_timeout)
  121. async def get_json(self, path: str, *, timeout: float = DEFAULT_HTTP_TIMEOUT):
  122. async with await self._get(path, timeout=timeout) as resp:
  123. resp.raise_for_status()
  124. data = await resp.json(content_type=None)
  125. self._set_reachable(True)
  126. return data
  127. async def get_text(self, path: str, *, timeout: float = DEFAULT_HTTP_TIMEOUT) -> str:
  128. async with await self._get(path, timeout=timeout) as resp:
  129. resp.raise_for_status()
  130. text = await resp.text()
  131. self._set_reachable(True)
  132. return text
  133. async def get_bytes(self, path: str, *, timeout: float = DEFAULT_HTTP_TIMEOUT) -> bytes:
  134. async with await self._get(path, timeout=timeout) as resp:
  135. resp.raise_for_status()
  136. data = await resp.read()
  137. self._set_reachable(True)
  138. return data
  139. # -------------------------------------------------------------- status/read
  140. async def status(self) -> dict:
  141. """Poll ``/sand_status`` (fast, works during motion)."""
  142. return await self.get_json("/sand_status", timeout=STATUS_TIMEOUT)
  143. async def patterns(self) -> list:
  144. """``/sand_patterns`` -> list of ``.thr`` paths relative to /patterns."""
  145. data = await self.get_json("/sand_patterns")
  146. return data if isinstance(data, list) else []
  147. async def playlists(self) -> list:
  148. """``/sand_playlists`` -> list of ``.txt`` file names."""
  149. data = await self.get_json("/sand_playlists")
  150. return data if isinstance(data, list) else []
  151. async def settings(self) -> dict:
  152. """``/sand_settings`` -> flat dict of setting name -> string value."""
  153. data = await self.get_json("/sand_settings")
  154. return data if isinstance(data, dict) else {}
  155. async def fetch_sd_file(self, sd_path: str) -> bytes:
  156. """Fetch a file from the SD card, e.g. ``/patterns/star.thr``."""
  157. sd_path = "/" + sd_path.lstrip("/")
  158. return await self.get_bytes(f"/sd{sd_path}")
  159. # ------------------------------------------------------------------ actions
  160. async def command(self, cmd: str, *, timeout: float = DEFAULT_HTTP_TIMEOUT) -> str:
  161. """Fire a ``$...`` command via ``/command?plain=`` (fire-and-forget).
  162. Output routing over ``/command`` is racy for anything but ``$/`` reads,
  163. so callers that need a value should poll a ``/sand_*`` route instead.
  164. """
  165. return await self.get_text(f"/command?plain={quote(cmd)}", timeout=timeout)
  166. async def run_pattern(self, rel_path: str, clear: str = "none") -> None:
  167. """Run ``/patterns/<rel_path>`` with an optional pre-execution clear."""
  168. path = "/patterns/" + rel_path.lstrip("/")
  169. mode = CLEAR_MODE_MAP.get(clear, "none")
  170. if mode == "none":
  171. await self.command(f"$SD/Run={path}")
  172. else:
  173. await self.command(f"$Sand/Run={path} clear={mode}")
  174. async def run_playlist(self, name: str, *, pause_time=None, clear_pattern=None,
  175. run_mode=None, shuffle=None) -> None:
  176. """Apply the run parameters (NVS) then start the playlist."""
  177. if run_mode in ("single", "loop"):
  178. await self.command(f"$Playlist/Mode={run_mode}")
  179. if shuffle is not None:
  180. await self.command(f"$Playlist/Shuffle={'ON' if shuffle else 'OFF'}")
  181. if pause_time is not None:
  182. await self.command(f"$Playlist/PauseTime={int(pause_time)}")
  183. if clear_pattern is not None:
  184. mode = CLEAR_MODE_MAP.get(clear_pattern, clear_pattern)
  185. await self.command(f"$Playlist/ClearPattern={mode}")
  186. await self.command(f"$Playlist/Run={name}")
  187. async def playlist_stop(self) -> None:
  188. await self.command("$Playlist/Stop")
  189. async def playlist_skip(self) -> None:
  190. await self.command("$Playlist/Skip")
  191. async def stop(self) -> None:
  192. """Stop the whole sequence (clear + pattern + playlist)."""
  193. await self.get_text("/sand_stop")
  194. async def pause(self) -> None:
  195. await self.get_text("/sand_pause")
  196. async def resume(self) -> None:
  197. await self.get_text("/sand_resume")
  198. async def home(self) -> None:
  199. # Homing can take a while; give it room. Runs in the main loop (safe).
  200. await self.get_text("/sand_home", timeout=95)
  201. async def goto(self, *, theta=None, rho=None) -> None:
  202. params = []
  203. if theta is not None:
  204. params.append(f"theta={theta}")
  205. if rho is not None:
  206. params.append(f"rho={rho}")
  207. await self.get_text("/sand_goto?" + "&".join(params), timeout=95)
  208. async def set_feed_mm(self, mm: int) -> None:
  209. """Set the base feed rate (motor mm/min); works mid-pattern."""
  210. await self.get_text(f"/sand_feed?mm={int(mm)}")
  211. async def set_led(self, **kwargs) -> None:
  212. """Live LED control via ``/sand_led?...`` (applies immediately)."""
  213. params = "&".join(f"{k}={quote(str(v))}" for k, v in kwargs.items() if v is not None)
  214. await self.get_text(f"/sand_led?{params}")
  215. async def set_autostart(self, name: str) -> None:
  216. """Set (or clear, with empty name) the boot auto-play playlist."""
  217. await self.command(f"$Playlist/Autostart={name}")
  218. async def reboot(self) -> None:
  219. await self.command("$Bye")
  220. async def sync_time(self, epoch: int) -> None:
  221. await self.get_text(f"/sand_time?epoch={int(epoch)}")
  222. # --------------------------------------------------------------- file ops
  223. async def upload_file(self, name: str, data: bytes, path: str = "/patterns") -> dict:
  224. """Upload ``data`` as ``<path>/<name>`` (multipart, firmware contract)."""
  225. if not self._base_url:
  226. raise RuntimeError("No table selected")
  227. session = await self._ensure_session()
  228. form = aiohttp.FormData()
  229. form.add_field(f"{name}S", str(len(data)))
  230. form.add_field(name, data, filename=name,
  231. content_type="application/octet-stream")
  232. url = f"{self._base_url}/upload?path={quote(path)}"
  233. timeout = aiohttp.ClientTimeout(total=60)
  234. async with session.post(url, data=form, timeout=timeout) as resp:
  235. body = await resp.json(content_type=None)
  236. self._set_reachable(True)
  237. _raise_file_error(resp.status, body)
  238. return body
  239. async def _file_action(self, action: str, filename: str, path: str, **extra) -> dict:
  240. if not self._base_url:
  241. raise RuntimeError("No table selected")
  242. session = await self._ensure_session()
  243. params = {"action": action, "filename": filename, "path": path, "dontlist": "yes"}
  244. params.update(extra)
  245. query = "&".join(f"{k}={quote(str(v))}" for k, v in params.items())
  246. url = f"{self._base_url}/upload?{query}"
  247. timeout = aiohttp.ClientTimeout(total=DEFAULT_HTTP_TIMEOUT)
  248. async with session.get(url, timeout=timeout) as resp:
  249. body = await resp.json(content_type=None)
  250. self._set_reachable(True)
  251. _raise_file_error(resp.status, body)
  252. return body
  253. async def delete_file(self, filename: str, path: str = "/playlists") -> dict:
  254. return await self._file_action("delete", filename, path)
  255. async def create_dir(self, filename: str, path: str = "/") -> dict:
  256. return await self._file_action("createdir", filename, path)