fluidnc_client.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  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 = 5.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. # -- commands / actions --------------------------------------------------
  69. def run_command(self, plain: str) -> str:
  70. """Fire a FluidNC command via the /command gateway (fire-and-forget)."""
  71. return self._get("/command", params={"plain": plain}).text
  72. def run_pattern(self, sd_path: str, clear: str | None = None) -> None:
  73. """
  74. Start a pattern. With a clear mode, uses $Sand/Run (which sequences
  75. clear->pattern and aborts any running job first); otherwise $SD/Run.
  76. Asynchronous — poll get_status() for progress/completion.
  77. """
  78. if clear and clear != "none":
  79. self.run_command(f"$Sand/Run={sd_path} clear={clear}")
  80. else:
  81. self.run_command(f"$SD/Run={sd_path}")
  82. def stop(self) -> str:
  83. return self._get("/sand_stop").text
  84. def pause(self) -> str:
  85. return self._get("/sand_pause").text
  86. def resume(self) -> str:
  87. return self._get("/sand_resume").text
  88. def skip(self) -> str:
  89. return self.run_command("$Playlist/Skip")
  90. def home(self) -> str:
  91. """Home honoring the board's $Sand/HomingMode; safe over HTTP."""
  92. return self._get("/sand_home").text
  93. def set_feed(self, mm: int | None = None, pct: int | None = None, d: str | None = None) -> str:
  94. """Set base feed (mm/min), an override percentage, or coarse up/down/reset."""
  95. params: dict = {}
  96. if mm is not None:
  97. params["mm"] = int(mm)
  98. if pct is not None:
  99. params["pct"] = int(pct)
  100. if d is not None:
  101. params["d"] = d
  102. return self._get("/sand_feed", params=params).text
  103. def goto(self, theta: float | None = None, rho: float | None = None) -> str:
  104. """Jog to an absolute theta (radians) and/or rho (0..1). Requires Idle."""
  105. params: dict = {}
  106. if theta is not None:
  107. params["theta"] = theta
  108. if rho is not None:
  109. params["rho"] = rho
  110. return self._get("/sand_goto", params=params).text
  111. def set_led(self, **keys) -> str:
  112. """Live LED control via /sand_led (keys: effect/palette/color/brightness/...)."""
  113. return self._get("/sand_led", params=keys).text
  114. def set_homing_mode(self, mode: str) -> str:
  115. return self.run_command(f"$Sand/HomingMode={mode}") # sensor | crash
  116. def set_theta_offset(self, degrees: float) -> str:
  117. return self.run_command(f"$Sand/ThetaOffset={degrees}")
  118. def soft_reset(self) -> str:
  119. """Reboot the controller (loses position) — host re-homes afterward."""
  120. return self.run_command("$Bye")
  121. # -- SD file management (ESP3D /upload protocol) -------------------------
  122. def upload_file(self, sd_path: str, data: bytes, directory: str) -> dict:
  123. """
  124. Upload bytes to an SD path. Per the firmware's ESP3D handler the multipart
  125. file part is named by the full SD path, plus a '<path>S' size field; the
  126. ?path= query selects the directory used for the post-upload listing.
  127. """
  128. form = {f"{sd_path}S": str(len(data))}
  129. files = {sd_path: (sd_path, data, "application/octet-stream")}
  130. r = requests.post(
  131. self.base_url + "/upload",
  132. params={"path": directory},
  133. data=form,
  134. files=files,
  135. timeout=30.0,
  136. )
  137. r.raise_for_status()
  138. return r.json() if r.text else {}
  139. def delete_file(self, directory: str, filename: str) -> dict:
  140. return self._get(
  141. "/upload",
  142. params={"path": directory, "action": "delete", "filename": filename, "dontlist": "yes"},
  143. ).json()
  144. def rename_file(self, directory: str, old: str, new: str) -> dict:
  145. return self._get(
  146. "/upload",
  147. params={"path": directory, "action": "rename", "filename": old, "newname": new, "dontlist": "yes"},
  148. ).json()