fluidnc_config.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573
  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. # FluidNC rejects runtime changes to Pin objects with a message but
  245. # still replies "ok" — treat that as failure, not success
  246. if any("not supported" in r.lower() for r in responses):
  247. logger.warning(f"FluidNC rejected write to {path}: {responses}")
  248. return False
  249. return any("ok" in r.lower() for r in responses)
  250. def get_config_filename() -> str:
  251. """Get the active config filename from FluidNC."""
  252. try:
  253. responses = send_command("$Config/Filename")
  254. except ConnectionError:
  255. return "config.yaml"
  256. for line in responses:
  257. if "=" in line and "Filename" in line:
  258. return line.split("=", 1)[1].strip()
  259. return "config.yaml"
  260. def save_config() -> bool:
  261. """Persist current in-RAM config to flash using the active config filename."""
  262. filename = get_config_filename()
  263. try:
  264. responses = send_command(f"$CD=/littlefs/{filename}", timeout=5.0)
  265. except ConnectionError:
  266. return False
  267. return any("ok" in r.lower() for r in responses)
  268. ###############################################################################
  269. # Direction pin toggling via config file rewrite
  270. #
  271. # FluidNC (v3.7+) rejects runtime changes to Pin objects — it prints
  272. # "Runtime setting of Pin objects is not supported" but still replies "ok",
  273. # so $/path=value writes silently do nothing. The only way to change a pin
  274. # is to edit the YAML config file on the controller's flash and reboot.
  275. # We read the file with $LocalFS/Show, toggle the :low flag host-side, and
  276. # upload the edited file back over XModem ($Xmodem/Receive).
  277. ###############################################################################
  278. # XModem protocol bytes
  279. _SOH, _EOT, _ACK, _NAK, _CAN, _CTRLZ = 0x01, 0x04, 0x06, 0x15, 0x18, 0x1A
  280. def _get_serial():
  281. """Return the underlying pyserial object, or raise for non-serial connections."""
  282. conn = state.conn
  283. if not conn or not conn.is_connected():
  284. raise ConnectionError("Not connected to controller")
  285. ser = getattr(conn, "ser", None)
  286. if ser is None:
  287. raise ConnectionError(
  288. "Direction pin changes require a serial connection "
  289. "(file upload over WebSocket is not supported)"
  290. )
  291. return conn, ser
  292. def _read_raw(ser, timeout: float = 10.0, silence: float = 1.0) -> str:
  293. """Read raw bytes until 'ok'/'error', a silence gap, or timeout.
  294. Unlike send_command(), preserves leading whitespace — required when
  295. reading YAML file content where indentation is structure.
  296. """
  297. buf = bytearray()
  298. start = time.time()
  299. last_data = start
  300. while time.time() - start < timeout:
  301. n = ser.in_waiting
  302. if n:
  303. buf += ser.read(n)
  304. last_data = time.time()
  305. tail = bytes(buf[-16:])
  306. if tail.rstrip().endswith(b"ok") or b"error:" in tail:
  307. break
  308. else:
  309. if buf and time.time() - last_data > silence:
  310. break
  311. time.sleep(0.02)
  312. return buf.decode("utf-8", errors="replace")
  313. def read_config_file() -> str | None:
  314. """Read the active config file's raw text from the controller's flash."""
  315. conn, ser = _get_serial()
  316. filename = get_config_filename()
  317. with conn.lock:
  318. ser.reset_input_buffer()
  319. ser.write(f"$LocalFS/Show=/littlefs/{filename}\n".encode())
  320. ser.flush()
  321. text = _read_raw(ser, timeout=15.0, silence=1.0)
  322. lines = []
  323. for line in text.splitlines():
  324. stripped = line.strip()
  325. # Drop protocol/status noise; keep file content (incl. indentation)
  326. if stripped.lower() == "ok" or stripped.startswith(("[", "<")) or stripped.startswith("error:"):
  327. continue
  328. lines.append(line.rstrip("\r"))
  329. content = "\n".join(lines).strip("\n")
  330. if not content:
  331. return None
  332. return content + "\n"
  333. def _toggle_direction_in_text(text: str, axes: list[str]) -> tuple[str, dict[str, bool]]:
  334. """Toggle :low on direction_pin lines for the given axes in YAML text.
  335. Walks the YAML line by line with an indentation-based path stack so the
  336. edit is targeted (axes/<axis>/motor0/*/direction_pin) while preserving
  337. comments and formatting everywhere else.
  338. Returns (new_text, {axis: new_inverted_state}).
  339. """
  340. lines = text.split("\n")
  341. stack: list[tuple[int, str]] = []
  342. toggled: dict[str, bool] = {}
  343. for i, line in enumerate(lines):
  344. stripped = line.strip()
  345. if not stripped or stripped.startswith("#") or ":" not in stripped:
  346. continue
  347. indent = len(line) - len(line.lstrip(" "))
  348. key = stripped.split(":", 1)[0].strip()
  349. while stack and stack[-1][0] >= indent:
  350. stack.pop()
  351. stack.append((indent, key))
  352. parts = [k for _, k in stack]
  353. # Match axes/<axis>/motor0/<driver>/direction_pin for any driver type
  354. if (
  355. len(parts) == 5
  356. and parts[0] == "axes"
  357. and parts[1] in axes
  358. and parts[2] == "motor0"
  359. and parts[4] == "direction_pin"
  360. ):
  361. value = stripped.split(":", 1)[1].split("#", 1)[0].strip().strip("'\"")
  362. if not value:
  363. continue
  364. new_val = value.replace(":low", "") if ":low" in value else value + ":low"
  365. # Quote values containing ':' so the YAML stays valid
  366. rendered = f"'{new_val}'" if ":" in new_val else new_val
  367. lines[i] = " " * indent + f"direction_pin: {rendered}"
  368. toggled[parts[1]] = ":low" in new_val
  369. return "\n".join(lines), toggled
  370. def _crc16_xmodem(data: bytes) -> int:
  371. crc = 0
  372. for byte in data:
  373. crc ^= byte << 8
  374. for _ in range(8):
  375. crc = ((crc << 1) ^ 0x1021) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF
  376. return crc
  377. def upload_config_file(text: str) -> bool:
  378. """Upload config file text to the controller's flash via XModem."""
  379. conn, ser = _get_serial()
  380. filename = get_config_filename()
  381. data = text.encode()
  382. with conn.lock:
  383. ser.reset_input_buffer()
  384. ser.write(f"$Xmodem/Receive=/littlefs/{filename}\n".encode())
  385. ser.flush()
  386. # Wait for receiver handshake: 'C' = CRC mode, NAK = checksum mode
  387. crc_mode = None
  388. start = time.time()
  389. while time.time() - start < 15:
  390. b = ser.read(1)
  391. if not b:
  392. continue
  393. if b == b"C":
  394. crc_mode = True
  395. break
  396. if b[0] == _NAK:
  397. crc_mode = False
  398. break
  399. if crc_mode is None:
  400. logger.error("XModem: no handshake from controller")
  401. return False
  402. seq = 1
  403. for offset in range(0, len(data), 128):
  404. block = data[offset : offset + 128].ljust(128, bytes([_CTRLZ]))
  405. packet = bytes([_SOH, seq & 0xFF, 0xFF - (seq & 0xFF)]) + block
  406. if crc_mode:
  407. crc = _crc16_xmodem(block)
  408. packet += bytes([crc >> 8, crc & 0xFF])
  409. else:
  410. packet += bytes([sum(block) & 0xFF])
  411. for _attempt in range(10):
  412. ser.write(packet)
  413. ser.flush()
  414. resp = ser.read(1)
  415. # Ignore leftover handshake chars from before the first packet
  416. while resp == b"C":
  417. resp = ser.read(1)
  418. if resp and resp[0] == _ACK:
  419. break
  420. if resp and resp[0] == _CAN:
  421. logger.error("XModem: transfer cancelled by controller")
  422. return False
  423. else:
  424. logger.error(f"XModem: packet {seq} never acknowledged")
  425. ser.write(bytes([_CAN, _CAN]))
  426. ser.flush()
  427. return False
  428. seq += 1
  429. for _attempt in range(10):
  430. ser.write(bytes([_EOT]))
  431. ser.flush()
  432. resp = ser.read(1)
  433. if resp and resp[0] == _ACK:
  434. break
  435. else:
  436. logger.error("XModem: EOT not acknowledged")
  437. return False
  438. # Drain trailing log lines ("[MSG:INFO: Received N bytes...]")
  439. tail = _read_raw(ser, timeout=3.0, silence=0.5)
  440. logger.info(f"XModem upload complete ({len(data)} bytes): {tail.strip()}")
  441. return True
  442. def toggle_direction_pins(axes: list[str]) -> dict[str, bool]:
  443. """Toggle :low on the direction pins by rewriting the config file.
  444. Reads the active config from flash, flips the :low flag on each axis's
  445. direction_pin line, uploads the edited file back, and verifies the
  446. write by re-reading. Changes take effect after a controller restart.
  447. Returns {axis: new_inverted_state} for axes successfully toggled.
  448. Raises ConnectionError if no serial connection is available.
  449. """
  450. conn, _ser = _get_serial()
  451. # Hold the connection lock across the whole read→edit→upload→verify
  452. # sequence so status polls or pattern traffic can't interleave and
  453. # corrupt the file transfer (the lock is reentrant, so the nested
  454. # send_command/raw IO calls below are fine)
  455. with conn.lock:
  456. text = read_config_file()
  457. if not text:
  458. logger.error("Could not read config file from controller")
  459. return {}
  460. new_text, toggled = _toggle_direction_in_text(text, axes)
  461. if not toggled:
  462. logger.error(
  463. f"No direction_pin lines found for axes {axes} in config file "
  464. f"({len(text)} bytes read)"
  465. )
  466. return {}
  467. if not upload_config_file(new_text):
  468. return {}
  469. # Verify: re-read and confirm the toggled lines are on flash
  470. verify_text = read_config_file()
  471. if verify_text:
  472. _, would_toggle = _toggle_direction_in_text(verify_text, list(toggled.keys()))
  473. # Toggling the verified file should produce the OLD state — i.e. the
  474. # file currently holds the NEW state for every axis we changed
  475. for axis, new_state in toggled.items():
  476. if would_toggle.get(axis) == new_state:
  477. logger.error(f"Verification failed: {axis} direction pin not updated on flash")
  478. return {}
  479. logger.info(f"Direction pins toggled via config file: {toggled}")
  480. return toggled