fluidnc_config.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. """FluidNC configuration utilities.
  2. Provides functions to read/write FluidNC settings via the main serial/WebSocket
  3. connection. Uses $Config/Dump for bulk reads (single round-trip) and individual
  4. $/path=value writes.
  5. Targets FluidNC-based boards with bipolar stepper motors (DLC32, MKS boards).
  6. """
  7. import time
  8. import logging
  9. import yaml
  10. from modules.core.state import state
  11. logger = logging.getLogger(__name__)
  12. # Curated settings exposed in the Setup UI.
  13. # Keys are FluidNC config tree paths queried via $/path.
  14. CURATED_SETTINGS = {
  15. "x": [
  16. "axes/x/steps_per_mm",
  17. "axes/x/max_rate_mm_per_min",
  18. "axes/x/acceleration_mm_per_sec2",
  19. "axes/x/motor0/stepstick/direction_pin",
  20. "axes/x/homing/cycle",
  21. "axes/x/homing/positive_direction",
  22. "axes/x/homing/mpos_mm",
  23. "axes/x/homing/feed_mm_per_min",
  24. "axes/x/homing/seek_mm_per_min",
  25. "axes/x/homing/settle_ms",
  26. "axes/x/homing/seek_scaler",
  27. "axes/x/homing/feed_scaler",
  28. "axes/x/motor0/pulloff_mm",
  29. ],
  30. "y": [
  31. "axes/y/steps_per_mm",
  32. "axes/y/max_rate_mm_per_min",
  33. "axes/y/acceleration_mm_per_sec2",
  34. "axes/y/motor0/stepstick/direction_pin",
  35. "axes/y/homing/cycle",
  36. "axes/y/homing/positive_direction",
  37. "axes/y/homing/mpos_mm",
  38. "axes/y/homing/feed_mm_per_min",
  39. "axes/y/homing/seek_mm_per_min",
  40. "axes/y/homing/settle_ms",
  41. "axes/y/homing/seek_scaler",
  42. "axes/y/homing/feed_scaler",
  43. "axes/y/motor0/pulloff_mm",
  44. ],
  45. "global": [],
  46. }
  47. def send_command(command: str, timeout: float = 3.0, silence: float = 1.0) -> list[str]:
  48. """Send a command via the main connection and return response lines.
  49. Clears the input buffer, sends the command, then reads lines until
  50. 'ok', 'error', silence gap, or timeout is reached.
  51. Args:
  52. command: The FluidNC command string.
  53. timeout: Absolute max wait time in seconds.
  54. silence: After receiving data, if no new data arrives for this many
  55. seconds, consider the response complete. Handles commands
  56. like $CD that may not end with 'ok'.
  57. """
  58. if not state.conn or not state.conn.is_connected():
  59. raise ConnectionError("Not connected to controller")
  60. # Clear input buffer
  61. try:
  62. while state.conn.in_waiting() > 0:
  63. state.conn.readline()
  64. except Exception:
  65. pass
  66. # Send command
  67. state.conn.send(command + "\n")
  68. time.sleep(0.2)
  69. # Read response lines
  70. lines: list[str] = []
  71. start_time = time.time()
  72. last_data_time = start_time
  73. got_data = False
  74. while time.time() - start_time < timeout:
  75. try:
  76. if state.conn.in_waiting() > 0:
  77. response = state.conn.readline()
  78. if response:
  79. line = response.strip() if isinstance(response, str) else response.decode("utf-8", errors="replace").strip()
  80. if line:
  81. lines.append(line)
  82. got_data = True
  83. last_data_time = time.time()
  84. if line.lower() == "ok" or line.lower().startswith("error"):
  85. break
  86. else:
  87. # If data was flowing but has gone silent, we're done
  88. if got_data and (time.time() - last_data_time > silence):
  89. break
  90. time.sleep(0.05)
  91. except Exception as e:
  92. logger.warning(f"Error reading response: {e}")
  93. break
  94. return lines
  95. def read_setting(path: str) -> str | None:
  96. """Query a single FluidNC setting by config-tree path.
  97. Returns the value string, or None if the setting doesn't exist
  98. (e.g. stepstick path on a unipolar board).
  99. """
  100. try:
  101. responses = send_command(f"$/{path}")
  102. except ConnectionError:
  103. return None
  104. # Response format: "/axes/x/steps_per_mm=200.000" or similar
  105. leaf = path.split("/")[-1]
  106. for line in responses:
  107. if "=" in line and leaf in line:
  108. return line.split("=", 1)[1].strip()
  109. if line.lower().startswith("error"):
  110. return None
  111. return None
  112. def _parse_value(raw: str | None, path: str) -> object:
  113. """Parse a raw FluidNC value string into a typed Python value."""
  114. if raw is None:
  115. return None
  116. # Direction pin — keep raw string but also derive inverted flag
  117. if "direction_pin" in path:
  118. return raw
  119. # Boolean-ish
  120. lower = raw.lower()
  121. if lower in ("true", "false"):
  122. return lower == "true"
  123. # Numeric
  124. try:
  125. if "." in raw:
  126. return float(raw)
  127. return int(raw)
  128. except ValueError:
  129. return raw
  130. def _resolve_yaml_path(data: dict, path: str):
  131. """Walk a nested dict by slash-separated path. Returns None if any key is missing."""
  132. node = data
  133. for key in path.split("/"):
  134. if not isinstance(node, dict) or key not in node:
  135. return None
  136. node = node[key]
  137. return node
  138. def _make_key(path: str) -> str:
  139. """Convert a FluidNC config path to a flat UI key.
  140. e.g. "axes/x/motor0/stepstick/direction_pin" → "direction_pin"
  141. "axes/x/homing/feed_mm_per_min" → "homing_feed_mm_per_min"
  142. "axes/x/motor0/hard_limits" → "hard_limits"
  143. """
  144. parts = path.split("/")
  145. remaining = parts[2:] # drop "axes/x" prefix
  146. remaining = [p for p in remaining if p not in ("motor0", "stepstick")]
  147. return "_".join(remaining)
  148. def read_all_settings() -> dict:
  149. """Read all curated settings from the controller via $Config/Dump.
  150. Issues a single $Config/Dump command, parses the YAML response, and
  151. extracts curated settings. Much faster than individual queries
  152. (~1s vs ~9s for 30 settings).
  153. Returns a structured dict:
  154. {
  155. "axes": {
  156. "x": { "steps_per_mm": 320.0, "direction_inverted": True, "direction_pin": "i2so.2:low", ... },
  157. "y": { ... }
  158. },
  159. "start": { "must_home": False }
  160. }
  161. """
  162. lines = send_command("$CD", timeout=10.0, silence=1.5)
  163. logger.info(f"$CD returned {len(lines)} lines")
  164. # Filter out non-YAML lines (status messages, 'ok', etc.)
  165. yaml_lines = []
  166. for line in lines:
  167. if line.lower() == "ok" or line.lower().startswith("error"):
  168. continue
  169. # Skip FluidNC status messages like [MSG:...]
  170. if line.startswith("["):
  171. continue
  172. yaml_lines.append(line)
  173. yaml_text = "\n".join(yaml_lines)
  174. if not yaml_text.strip():
  175. logger.warning("$CD returned no YAML content, falling back to individual queries")
  176. return _read_all_settings_individual()
  177. try:
  178. config = yaml.safe_load(yaml_text)
  179. except yaml.YAMLError as e:
  180. logger.error(f"Failed to parse $CD YAML ({len(yaml_lines)} lines): {e}")
  181. return _read_all_settings_individual()
  182. if not isinstance(config, dict):
  183. logger.warning(f"$CD parsed as {type(config).__name__}, falling back to individual queries")
  184. return _read_all_settings_individual()
  185. logger.info(f"$CD YAML top-level keys: {list(config.keys())}")
  186. result: dict = {"axes": {"x": {}, "y": {}}, "start": {}}
  187. resolved_count = 0
  188. for axis in ("x", "y"):
  189. for path in CURATED_SETTINGS[axis]:
  190. raw = _resolve_yaml_path(config, path)
  191. key = _make_key(path)
  192. if raw is not None:
  193. resolved_count += 1
  194. if "direction_pin" in path:
  195. raw_str = str(raw) if raw is not None else None
  196. result["axes"][axis][key] = raw_str
  197. result["axes"][axis]["direction_inverted"] = (
  198. ":low" in raw_str if raw_str else None
  199. )
  200. else:
  201. result["axes"][axis][key] = raw
  202. for path in CURATED_SETTINGS["global"]:
  203. raw = _resolve_yaml_path(config, path)
  204. if raw is not None:
  205. resolved_count += 1
  206. parts = path.split("/")
  207. key = "_".join(parts[1:])
  208. result["start"][key] = raw
  209. # If $CD parsed but we couldn't resolve any of our settings,
  210. # the YAML structure doesn't match — fall back to individual queries
  211. if resolved_count == 0:
  212. logger.warning(
  213. f"$CD YAML parsed ({len(config)} keys) but no curated settings resolved. "
  214. f"Falling back to individual queries."
  215. )
  216. return _read_all_settings_individual()
  217. logger.info(f"$CD resolved {resolved_count}/{len(CURATED_SETTINGS['x']) * 2 + len(CURATED_SETTINGS['global'])} settings")
  218. return result
  219. def _read_all_settings_individual() -> dict:
  220. """Fallback: read curated settings one by one via $/path queries."""
  221. result: dict = {"axes": {"x": {}, "y": {}}, "start": {}}
  222. for axis in ("x", "y"):
  223. for path in CURATED_SETTINGS[axis]:
  224. raw = read_setting(path)
  225. key = _make_key(path)
  226. parsed = _parse_value(raw, path)
  227. result["axes"][axis][key] = parsed
  228. if "direction_pin" in path:
  229. result["axes"][axis]["direction_inverted"] = (
  230. ":low" in raw if raw else None
  231. )
  232. for path in CURATED_SETTINGS["global"]:
  233. raw = read_setting(path)
  234. parts = path.split("/")
  235. key = "_".join(parts[1:])
  236. result["start"][key] = _parse_value(raw, path)
  237. return result
  238. def write_setting(path: str, value: str) -> bool:
  239. """Write a single FluidNC setting. Returns True on success."""
  240. try:
  241. responses = send_command(f"$/{path}={value}")
  242. except ConnectionError:
  243. return False
  244. return any("ok" in r.lower() for r in responses)
  245. def get_config_filename() -> str:
  246. """Get the active config filename from FluidNC."""
  247. try:
  248. responses = send_command("$Config/Filename")
  249. except ConnectionError:
  250. return "config.yaml"
  251. for line in responses:
  252. if "=" in line and "Filename" in line:
  253. return line.split("=", 1)[1].strip()
  254. return "config.yaml"
  255. def save_config() -> bool:
  256. """Persist current in-RAM config to flash using the active config filename."""
  257. filename = get_config_filename()
  258. try:
  259. responses = send_command(f"$CD=/littlefs/{filename}", timeout=5.0)
  260. except ConnectionError:
  261. return False
  262. return any("ok" in r.lower() for r in responses)
  263. def toggle_direction_pin(axis: str) -> tuple[bool, bool]:
  264. """Toggle :low on the direction pin for the given axis.
  265. Returns (success, new_inverted_state).
  266. """
  267. path = f"axes/{axis}/motor0/stepstick/direction_pin"
  268. current = read_setting(path)
  269. if current is None:
  270. return (False, False)
  271. if ":low" in current:
  272. new_val = current.replace(":low", "")
  273. else:
  274. new_val = current + ":low"
  275. success = write_setting(path, new_val)
  276. return (success, ":low" in new_val)