fluidnc_client.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  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 requests
  18. logger = logging.getLogger(__name__)
  19. class FluidNCClient:
  20. """Stateless HTTP handle to one FluidNC sand-table board."""
  21. def __init__(self, base_url: str, timeout: float = 10.0):
  22. # base_url like "http://192.168.68.160"
  23. self.base_url = base_url.rstrip("/")
  24. self.timeout = timeout
  25. # Mirrors the BaseConnection contract main.py relies on (is_connected/close).
  26. self._connected = False
  27. # -- low-level -----------------------------------------------------------
  28. def _get(self, path: str, params: dict | None = None, timeout: float | None = None):
  29. r = requests.get(self.base_url + path, params=params, timeout=timeout or self.timeout)
  30. r.raise_for_status()
  31. return r
  32. # -- connection lifecycle (BaseConnection-compatible) --------------------
  33. def is_connected(self) -> bool:
  34. return self._connected
  35. def close(self) -> None:
  36. self._connected = False
  37. def reachable(self) -> bool:
  38. """True if the board answers a status read. Also updates is_connected()."""
  39. try:
  40. self._get("/sand_status", timeout=3.0)
  41. self._connected = True
  42. except Exception as e:
  43. logger.debug(f"Board unreachable at {self.base_url}: {e}")
  44. self._connected = False
  45. return self._connected
  46. # -- reads ---------------------------------------------------------------
  47. def get_status(self) -> dict:
  48. """The board's /sand_status object (schema in API.md)."""
  49. return self._get("/sand_status", timeout=3.0).json()
  50. def get_settings(self) -> dict:
  51. """Flat string map of board settings (keys like 'Sand/HomingMode')."""
  52. return self._get("/sand_settings").json()
  53. def list_patterns(self) -> list:
  54. return self._get("/sand_patterns").json()
  55. def list_playlists(self) -> list:
  56. return self._get("/sand_playlists").json()
  57. def fetch_file(self, sd_path: str) -> bytes:
  58. """Fetch raw SD file bytes, e.g. fetch_file('/patterns/star.thr')."""
  59. return self._get("/sd" + sd_path, timeout=15.0).content
  60. def file_exists(self, sd_path: str) -> bool:
  61. try:
  62. r = requests.get(self.base_url + "/sd" + sd_path, stream=True, timeout=5.0)
  63. ok = r.status_code == 200
  64. r.close()
  65. return ok
  66. except Exception:
  67. return False
  68. # -- clock ----------------------------------------------------------------
  69. def get_time(self) -> dict:
  70. """The board's wall clock: {epoch, synced, local, tz}."""
  71. return self._get("/sand_time").json()
  72. def set_time(self, epoch: int | None = None, tz: str | None = None) -> dict:
  73. """Push the wall clock (unix epoch) and/or a POSIX timezone to the board."""
  74. params: dict = {}
  75. if epoch is not None:
  76. params["epoch"] = int(epoch)
  77. if tz is not None:
  78. params["tz"] = tz
  79. return self._get("/sand_time", params=params).json()
  80. # -- commands / actions --------------------------------------------------
  81. def run_command(self, plain: str) -> str:
  82. """Fire a FluidNC command via the /command gateway (fire-and-forget)."""
  83. return self._get("/command", params={"plain": plain}).text
  84. def set_setting(self, key: str, value) -> str:
  85. """Write one NVS-persisted board setting, e.g. set_setting('Playlist/Autostart', 'evening').
  86. The command response contains 'error' on rejection (e.g. idle-gated
  87. settings while running); raise so callers can surface it.
  88. """
  89. resp = self.run_command(f"${key}={value}")
  90. if "error" in resp.lower():
  91. raise RuntimeError(f"Board rejected ${key}={value}: {resp.strip()}")
  92. return resp
  93. def run_pattern(self, sd_path: str, clear: str | None = None) -> None:
  94. """
  95. Start a pattern. With a clear mode, uses $Sand/Run (which sequences
  96. clear->pattern and aborts any running job first); otherwise $SD/Run.
  97. Asynchronous — poll get_status() for progress/completion.
  98. """
  99. if clear and clear != "none":
  100. self.run_command(f"$Sand/Run={sd_path} clear={clear}")
  101. else:
  102. self.run_command(f"$SD/Run={sd_path}")
  103. def stop(self) -> str:
  104. return self._get("/sand_stop").text
  105. def pause(self) -> str:
  106. return self._get("/sand_pause").text
  107. def resume(self) -> str:
  108. return self._get("/sand_resume").text
  109. def skip(self) -> str:
  110. return self.run_command("$Playlist/Skip")
  111. def home(self) -> str:
  112. """Home honoring the board's $Sand/HomingMode; safe over HTTP."""
  113. return self._get("/sand_home").text
  114. def set_feed(self, mm: int | None = None, pct: int | None = None, d: str | None = None) -> str:
  115. """Set base feed (mm/min), an override percentage, or coarse up/down/reset."""
  116. params: dict = {}
  117. if mm is not None:
  118. params["mm"] = int(mm)
  119. if pct is not None:
  120. params["pct"] = int(pct)
  121. if d is not None:
  122. params["d"] = d
  123. return self._get("/sand_feed", params=params).text
  124. def goto(self, theta: float | None = None, rho: float | None = None) -> str:
  125. """Jog to an absolute theta (radians) and/or rho (0..1). Requires Idle."""
  126. params: dict = {}
  127. if theta is not None:
  128. params["theta"] = theta
  129. if rho is not None:
  130. params["rho"] = rho
  131. return self._get("/sand_goto", params=params).text
  132. def set_led(self, **keys) -> str:
  133. """Live LED control via /sand_led (keys: effect/palette/color/brightness/...)."""
  134. return self._get("/sand_led", params=keys).text
  135. def set_homing_mode(self, mode: str) -> str:
  136. return self.run_command(f"$Sand/HomingMode={mode}") # sensor | crash
  137. def set_theta_offset(self, degrees: float) -> str:
  138. return self.run_command(f"$Sand/ThetaOffset={degrees}")
  139. def soft_reset(self) -> str:
  140. """Reboot the controller (loses position) — host re-homes afterward."""
  141. return self.run_command("$Bye")
  142. # -- SD file management (ESP3D /upload protocol) -------------------------
  143. def upload_file(self, sd_path: str, data: bytes, directory: str) -> dict:
  144. """
  145. Upload bytes to an SD path. Per the firmware's ESP3D handler the multipart
  146. file part is named by the full SD path, plus a '<path>S' size field; the
  147. ?path= query selects the directory used for the post-upload listing.
  148. """
  149. form = {f"{sd_path}S": str(len(data))}
  150. files = {sd_path: (sd_path, data, "application/octet-stream")}
  151. r = requests.post(
  152. self.base_url + "/upload",
  153. params={"path": directory},
  154. data=form,
  155. files=files,
  156. timeout=30.0,
  157. )
  158. r.raise_for_status()
  159. return r.json() if r.text else {}
  160. def delete_file(self, directory: str, filename: str) -> dict:
  161. return self._get(
  162. "/upload",
  163. params={"path": directory, "action": "delete", "filename": filename, "dontlist": "yes"},
  164. ).json()
  165. def rename_file(self, directory: str, old: str, new: str) -> dict:
  166. return self._get(
  167. "/upload",
  168. params={"path": directory, "action": "rename", "filename": old, "newname": new, "dontlist": "yes"},
  169. ).json()