fluidnc_client.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. """
  2. FluidNC HTTP client — the backend's transport to the headless board firmware.
  3. The board runs a FluidNC fork that owns kinematics, `.thr` playback, progress
  4. reporting and homing, and exposes an HTTP API (contract: the firmware repo's
  5. API.md). This client is the single seam the rest of the backend uses to talk to
  6. hardware; it replaces the old serial / websocket GRBL transport.
  7. Design notes:
  8. - Calls are synchronous (``requests``). Callers that need async wrap them in
  9. ``asyncio.to_thread`` (the codebase already does this for blocking I/O).
  10. - "Actions" are fire-and-forget: the board applies them and the caller
  11. confirms the effect by polling ``get_status()``. This matches the firmware's
  12. own model (API.md: use ``/command`` + ``/sand_*`` to act, poll to confirm).
  13. - It is multi-client-safe: status/listing reads and action routes are stateless
  14. HTTP and keep working during playback.
  15. """
  16. import logging
  17. import random
  18. import socket
  19. import time
  20. from urllib.parse import urlparse
  21. import requests
  22. logger = logging.getLogger(__name__)
  23. # The board's single-threaded web server answers ``503 busy: low memory`` for
  24. # every route except the status/stop/pause/resume lifeline when free heap drops
  25. # below ~10 KB (firmware API.md, load-shedding). These transient sheds — often
  26. # triggered by an app's launch-burst of concurrent reads — resolve in a few
  27. # seconds, so idempotent GETs back off and retry rather than failing hard.
  28. _RETRY_503_ATTEMPTS = 3 # total tries (1 initial + 2 retries)
  29. _RETRY_503_BASE = 0.3 # seconds; base for exponential backoff + jitter
  30. def _pin_ipv4(base_url: str) -> str:
  31. """Rewrite a hostname base URL to its numeric IPv4 address.
  32. The boards publish no AAAA record over mDNS, and a dual-stack getaddrinfo
  33. for a ``.local`` name stalls ~5s waiting for the IPv6 answer — longer than
  34. the 3s status timeout, so every poll would die in name resolution. Resolve
  35. the A record once here (milliseconds) and pin it; clients are rebuilt on
  36. every connect/relocate, so a DHCP move is still handled by the watchdog.
  37. Unresolvable hosts pass through unchanged for reachable() to report.
  38. """
  39. parsed = urlparse(base_url)
  40. host = parsed.hostname or ""
  41. if not host:
  42. return base_url
  43. try:
  44. socket.inet_aton(host)
  45. return base_url # already a numeric IPv4 address
  46. except OSError:
  47. pass
  48. try:
  49. info = socket.getaddrinfo(host, parsed.port or 80,
  50. socket.AF_INET, socket.SOCK_STREAM)
  51. except OSError:
  52. return base_url
  53. address = info[0][4][0]
  54. port = f":{parsed.port}" if parsed.port else ""
  55. return f"{parsed.scheme}://{address}{port}"
  56. class FluidNCClient:
  57. """Stateless HTTP handle to one FluidNC sand-table board."""
  58. def __init__(self, base_url: str, timeout: float = 10.0, api_key: str | None = None):
  59. # base_url like "http://192.168.68.160"
  60. self.base_url = _pin_ipv4(base_url.rstrip("/"))
  61. self.timeout = timeout
  62. # $Sand/Password key (fw >= v0.1.11), sent as X-Sand-Key on every request.
  63. self.api_key = api_key or None
  64. # Mirrors the BaseConnection contract main.py relies on (is_connected/close).
  65. self._connected = False
  66. # True when the board rejected us with 401 (locked, wrong/missing key).
  67. self.locked = False
  68. # -- low-level -----------------------------------------------------------
  69. def _headers(self) -> dict:
  70. return {"X-Sand-Key": self.api_key} if self.api_key else {}
  71. def _send_with_retry(self, method: str, url: str, **kwargs):
  72. """Issue an HTTP request, retrying on the firmware's ``503 low memory``.
  73. Only used for idempotent GETs (never for uploads/OTA/wifi writes, which
  74. are non-retryable). Backs off with exponential + jittered delay; the
  75. final 503 is returned to the caller like any other response.
  76. """
  77. send = getattr(requests, method.lower()) # requests.get / requests.post
  78. for attempt in range(_RETRY_503_ATTEMPTS):
  79. r = send(url, **kwargs)
  80. if r.status_code != 503 or attempt == _RETRY_503_ATTEMPTS - 1:
  81. return r
  82. delay = _RETRY_503_BASE * (2 ** attempt) + random.uniform(0, _RETRY_503_BASE)
  83. logger.debug(
  84. f"Board 503 (low memory) on {url}; retry {attempt + 1}/"
  85. f"{_RETRY_503_ATTEMPTS - 1} in {delay:.2f}s")
  86. time.sleep(delay)
  87. return r # unreachable, but keeps type-checkers happy
  88. def _get(self, path: str, params: dict | None = None, timeout: float | None = None):
  89. r = self._send_with_retry("GET", self.base_url + path, params=params,
  90. timeout=timeout or self.timeout, headers=self._headers())
  91. if r.status_code == 401:
  92. self.locked = True
  93. r.raise_for_status()
  94. return r
  95. # -- connection lifecycle (BaseConnection-compatible) --------------------
  96. def is_connected(self) -> bool:
  97. return self._connected
  98. def close(self) -> None:
  99. self._connected = False
  100. def reachable(self) -> bool:
  101. """True if the board answers a status read. Also updates is_connected()."""
  102. try:
  103. self._get("/sand_status", timeout=3.0)
  104. self._connected = True
  105. self.locked = False
  106. except Exception as e:
  107. logger.debug(f"Board unreachable at {self.base_url}: {e}")
  108. self._connected = False
  109. return self._connected
  110. # -- API password ($Sand/Password, fw >= v0.1.11) --------------------------
  111. def test_key(self, key: str | None) -> bool:
  112. """Probe whether `key` unlocks the board (a cheap $G through /command).
  113. True = accepted (or the board isn't locked); False = 401 rejected.
  114. Other errors (unreachable, etc.) propagate.
  115. """
  116. headers = {"X-Sand-Key": key} if key else {}
  117. r = self._send_with_retry("GET", self.base_url + "/command", params={"plain": "$G"},
  118. timeout=6.0, headers=headers)
  119. if r.status_code == 401:
  120. return False
  121. r.raise_for_status()
  122. return True
  123. def set_password(self, password: str) -> str:
  124. """Set (non-empty) or remove (empty) the board's API password."""
  125. return self.run_command(f"$Sand/Password={password or ''}")
  126. # -- reads ---------------------------------------------------------------
  127. def get_status(self) -> dict:
  128. """The board's /sand_status object (schema in API.md)."""
  129. return self._get("/sand_status", timeout=3.0).json()
  130. def get_bootlog(self) -> str:
  131. """Plain-text boot log ($SS startup log). After a panic reset it still
  132. holds the *previous* boot's log — the on-device crash breadcrumb."""
  133. return self._get("/sand_bootlog", timeout=6.0).text
  134. def get_coredump(self) -> dict:
  135. """JSON crash report from the coredump flash partition, written on any
  136. panic (incl. task-WDT hang). {present, task, pc, backtrace, ...}."""
  137. return self._get("/sand_coredump", timeout=6.0).json()
  138. def erase_coredump(self) -> None:
  139. """Clear the stored coredump (?erase=1)."""
  140. self._get("/sand_coredump", params={"erase": "1"}, timeout=6.0)
  141. def get_settings(self) -> dict:
  142. """Flat string map of board settings (keys like 'Sand/HomingMode')."""
  143. return self._get("/sand_settings").json()
  144. def list_patterns(self) -> list:
  145. return self._get("/sand_patterns").json()
  146. def list_playlists(self) -> list:
  147. return self._get("/sand_playlists").json()
  148. def fetch_file(self, sd_path: str) -> bytes:
  149. """Fetch raw SD file bytes, e.g. fetch_file('/patterns/star.thr')."""
  150. return self._get("/sd" + sd_path, timeout=15.0).content
  151. def file_exists(self, sd_path: str) -> bool:
  152. try:
  153. r = self._send_with_retry("GET", self.base_url + "/sd" + sd_path, stream=True,
  154. timeout=5.0, headers=self._headers())
  155. ok = r.status_code == 200
  156. r.close()
  157. return ok
  158. except Exception:
  159. return False
  160. # -- clock ----------------------------------------------------------------
  161. def get_time(self) -> dict:
  162. """The board's wall clock: {epoch, synced, local, tz}."""
  163. return self._get("/sand_time").json()
  164. def set_time(self, epoch: int | None = None, tz: str | None = None) -> dict:
  165. """Push the wall clock (unix epoch) and/or a POSIX timezone to the board."""
  166. params: dict = {}
  167. if epoch is not None:
  168. params["epoch"] = int(epoch)
  169. if tz is not None:
  170. params["tz"] = tz
  171. return self._get("/sand_time", params=params).json()
  172. # -- commands / actions --------------------------------------------------
  173. def run_command(self, plain: str) -> str:
  174. """Fire a FluidNC command via the /command gateway (fire-and-forget)."""
  175. return self._get("/command", params={"plain": plain}).text
  176. def set_setting(self, key: str, value) -> str:
  177. """Write one NVS-persisted board setting, e.g. set_setting('Playlist/Autostart', 'evening').
  178. The command response contains 'error' on rejection (e.g. idle-gated
  179. settings while running); raise so callers can surface it.
  180. """
  181. resp = self.run_command(f"${key}={value}")
  182. if "error" in resp.lower():
  183. raise RuntimeError(f"Board rejected ${key}={value}: {resp.strip()}")
  184. return resp
  185. def run_pattern(self, sd_path: str, clear: str | None = None) -> None:
  186. """
  187. Start a pattern. With a clear mode, uses $Sand/Run (which sequences
  188. clear->pattern and aborts any running job first); otherwise $SD/Run.
  189. Asynchronous — poll get_status() for progress/completion.
  190. """
  191. if clear and clear != "none":
  192. self.run_command(f"$Sand/Run={sd_path} clear={clear}")
  193. else:
  194. self.run_command(f"$SD/Run={sd_path}")
  195. def stop(self) -> str:
  196. return self._get("/sand_stop").text
  197. def pause(self) -> str:
  198. return self._get("/sand_pause").text
  199. def resume(self) -> str:
  200. return self._get("/sand_resume").text
  201. def skip(self) -> str:
  202. return self.run_command("$Playlist/Skip")
  203. def home(self) -> str:
  204. """Home honoring the board's $Sand/HomingMode; safe over HTTP."""
  205. return self._get("/sand_home").text
  206. def set_feed(self, mm: int | None = None, pct: int | None = None, d: str | None = None) -> str:
  207. """Set base feed (mm/min), an override percentage, or coarse up/down/reset."""
  208. params: dict = {}
  209. if mm is not None:
  210. params["mm"] = int(mm)
  211. if pct is not None:
  212. params["pct"] = int(pct)
  213. if d is not None:
  214. params["d"] = d
  215. return self._get("/sand_feed", params=params).text
  216. def goto(self, theta: float | None = None, rho: float | None = None) -> str:
  217. """Jog to an absolute theta (radians) and/or rho (0..1). Requires Idle."""
  218. params: dict = {}
  219. if theta is not None:
  220. params["theta"] = theta
  221. if rho is not None:
  222. params["rho"] = rho
  223. return self._get("/sand_goto", params=params).text
  224. def set_led(self, **keys) -> str:
  225. """Live LED control via /sand_led (keys: effect/palette/color/brightness/...)."""
  226. return self._get("/sand_led", params=keys).text
  227. def set_homing_mode(self, mode: str) -> str:
  228. return self.run_command(f"$Sand/HomingMode={mode}") # sensor | crash
  229. def set_theta_offset(self, degrees: float) -> str:
  230. return self.run_command(f"$Sand/ThetaOffset={degrees}")
  231. def soft_reset(self) -> str:
  232. """Reboot the controller (loses position) — host re-homes afterward."""
  233. return self.run_command("$Bye")
  234. # -- board Wi-Fi (fw >= v0.1.8) -------------------------------------------
  235. def wifi_status(self) -> dict:
  236. """{mode: sta|fallback|standalone, sta_ssid, ap_ssid, fail}. Older
  237. firmware 404s (propagates as HTTPError)."""
  238. return self._get("/wifi_status", timeout=6.0).json()
  239. def wifi_scan(self, rescan: bool = False) -> dict:
  240. """{status:'scanning'} while the async scan runs; then {status:'ok', aps:[...]}"""
  241. return self._get("/wifi_scan", params={"rescan": "1"} if rescan else None,
  242. timeout=8.0).json()
  243. def _post_wifi(self, path: str, form: dict | None = None) -> dict:
  244. """Wi-Fi writes are form-urlencoded POSTs answering {status, reboot, message?}.
  245. 'busy' = idle-gated (boot auto-home / running pattern); on 'ok' with
  246. reboot the table restarts ~0.5s later and the link drops."""
  247. r = requests.post(self.base_url + path, data=form or {}, timeout=10.0,
  248. headers=self._headers())
  249. return r.json()
  250. def wifi_save(self, ssid: str, password: str) -> dict:
  251. return self._post_wifi("/wifi_save", {"ssid": ssid, "password": password})
  252. def wifi_standalone(self) -> dict:
  253. return self._post_wifi("/wifi_standalone")
  254. # -- firmware OTA (/updatefw) ---------------------------------------------
  255. def update_probe(self) -> dict:
  256. """Is the board willing to take an OTA right now? Returns the parsed
  257. JSON on any HTTP status ('ready'/'busy'; legacy firmware returns an
  258. older shape = too old for OTA)."""
  259. r = self._send_with_retry("GET", self.base_url + "/updatefw", timeout=6.0,
  260. headers=self._headers())
  261. return r.json()
  262. def upload_firmware(self, image: bytes) -> dict:
  263. """Flash an app image over OTA. Same multipart shape as /upload
  264. (a 'firmware.binS' size field + the binary part). On 'ok' the board
  265. reboots ~1s later; poll get_status() until it's back."""
  266. r = requests.post(
  267. self.base_url + "/updatefw",
  268. data={"firmware.binS": str(len(image))},
  269. files={"firmware.bin": ("firmware.bin", image, "application/octet-stream")},
  270. timeout=180.0,
  271. headers=self._headers(),
  272. )
  273. return r.json()
  274. # -- SD file management (ESP3D /upload protocol) -------------------------
  275. def upload_file(self, sd_path: str, data: bytes, directory: str) -> dict:
  276. """
  277. Upload bytes to an SD path. Per the firmware's ESP3D handler the multipart
  278. file part is named by the full SD path, plus a '<path>S' size field; the
  279. ?path= query selects the directory used for the post-upload listing.
  280. """
  281. form = {f"{sd_path}S": str(len(data))}
  282. files = {sd_path: (sd_path, data, "application/octet-stream")}
  283. r = requests.post(
  284. self.base_url + "/upload",
  285. params={"path": directory},
  286. data=form,
  287. files=files,
  288. timeout=30.0,
  289. headers=self._headers(),
  290. )
  291. r.raise_for_status()
  292. return r.json() if r.text else {}
  293. def delete_file(self, directory: str, filename: str) -> dict:
  294. return self._get(
  295. "/upload",
  296. params={"path": directory, "action": "delete", "filename": filename, "dontlist": "yes"},
  297. ).json()
  298. def rename_file(self, directory: str, old: str, new: str) -> dict:
  299. return self._get(
  300. "/upload",
  301. params={"path": directory, "action": "rename", "filename": old, "newname": new, "dontlist": "yes"},
  302. ).json()