| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337 |
- """FluidNC configuration utilities.
- Provides functions to read/write FluidNC settings via the main serial/WebSocket
- connection. Uses $Config/Dump for bulk reads (single round-trip) and individual
- $/path=value writes.
- Targets FluidNC-based boards with bipolar stepper motors (DLC32, MKS boards).
- """
- import time
- import logging
- import yaml
- from modules.core.state import state
- logger = logging.getLogger(__name__)
- # Curated settings exposed in the Setup UI.
- # Keys are FluidNC config tree paths queried via $/path.
- CURATED_SETTINGS = {
- "x": [
- "axes/x/steps_per_mm",
- "axes/x/max_rate_mm_per_min",
- "axes/x/acceleration_mm_per_sec2",
- "axes/x/motor0/stepstick/direction_pin",
- "axes/x/homing/cycle",
- "axes/x/homing/positive_direction",
- "axes/x/homing/mpos_mm",
- "axes/x/homing/feed_mm_per_min",
- "axes/x/homing/seek_mm_per_min",
- "axes/x/homing/settle_ms",
- "axes/x/homing/seek_scaler",
- "axes/x/homing/feed_scaler",
- "axes/x/motor0/pulloff_mm",
- ],
- "y": [
- "axes/y/steps_per_mm",
- "axes/y/max_rate_mm_per_min",
- "axes/y/acceleration_mm_per_sec2",
- "axes/y/motor0/stepstick/direction_pin",
- "axes/y/homing/cycle",
- "axes/y/homing/positive_direction",
- "axes/y/homing/mpos_mm",
- "axes/y/homing/feed_mm_per_min",
- "axes/y/homing/seek_mm_per_min",
- "axes/y/homing/settle_ms",
- "axes/y/homing/seek_scaler",
- "axes/y/homing/feed_scaler",
- "axes/y/motor0/pulloff_mm",
- ],
- "global": [],
- }
- def send_command(command: str, timeout: float = 3.0, silence: float = 1.0) -> list[str]:
- """Send a command via the main connection and return response lines.
- Clears the input buffer, sends the command, then reads lines until
- 'ok', 'error', silence gap, or timeout is reached.
- Args:
- command: The FluidNC command string.
- timeout: Absolute max wait time in seconds.
- silence: After receiving data, if no new data arrives for this many
- seconds, consider the response complete. Handles commands
- like $CD that may not end with 'ok'.
- """
- if not state.conn or not state.conn.is_connected():
- raise ConnectionError("Not connected to controller")
- # Clear input buffer
- try:
- while state.conn.in_waiting() > 0:
- state.conn.readline()
- except Exception:
- pass
- # Send command
- state.conn.send(command + "\n")
- time.sleep(0.2)
- # Read response lines
- lines: list[str] = []
- start_time = time.time()
- last_data_time = start_time
- got_data = False
- while time.time() - start_time < timeout:
- try:
- if state.conn.in_waiting() > 0:
- response = state.conn.readline()
- if response:
- line = response.strip() if isinstance(response, str) else response.decode("utf-8", errors="replace").strip()
- if line:
- lines.append(line)
- got_data = True
- last_data_time = time.time()
- if line.lower() == "ok" or line.lower().startswith("error"):
- break
- else:
- # If data was flowing but has gone silent, we're done
- if got_data and (time.time() - last_data_time > silence):
- break
- time.sleep(0.05)
- except Exception as e:
- logger.warning(f"Error reading response: {e}")
- break
- return lines
- def read_setting(path: str) -> str | None:
- """Query a single FluidNC setting by config-tree path.
- Returns the value string, or None if the setting doesn't exist
- (e.g. stepstick path on a unipolar board).
- """
- try:
- responses = send_command(f"$/{path}")
- except ConnectionError:
- return None
- # Response format: "/axes/x/steps_per_mm=200.000" or similar
- leaf = path.split("/")[-1]
- for line in responses:
- if "=" in line and leaf in line:
- return line.split("=", 1)[1].strip()
- if line.lower().startswith("error"):
- return None
- return None
- def _parse_value(raw: str | None, path: str) -> object:
- """Parse a raw FluidNC value string into a typed Python value."""
- if raw is None:
- return None
- # Direction pin — keep raw string but also derive inverted flag
- if "direction_pin" in path:
- return raw
- # Boolean-ish
- lower = raw.lower()
- if lower in ("true", "false"):
- return lower == "true"
- # Numeric
- try:
- if "." in raw:
- return float(raw)
- return int(raw)
- except ValueError:
- return raw
- def _resolve_yaml_path(data: dict, path: str):
- """Walk a nested dict by slash-separated path. Returns None if any key is missing."""
- node = data
- for key in path.split("/"):
- if not isinstance(node, dict) or key not in node:
- return None
- node = node[key]
- return node
- def _make_key(path: str) -> str:
- """Convert a FluidNC config path to a flat UI key.
- e.g. "axes/x/motor0/stepstick/direction_pin" → "direction_pin"
- "axes/x/homing/feed_mm_per_min" → "homing_feed_mm_per_min"
- "axes/x/motor0/hard_limits" → "hard_limits"
- """
- parts = path.split("/")
- remaining = parts[2:] # drop "axes/x" prefix
- remaining = [p for p in remaining if p not in ("motor0", "stepstick")]
- return "_".join(remaining)
- def read_all_settings() -> dict:
- """Read all curated settings from the controller via $Config/Dump.
- Issues a single $Config/Dump command, parses the YAML response, and
- extracts curated settings. Much faster than individual queries
- (~1s vs ~9s for 30 settings).
- Returns a structured dict:
- {
- "axes": {
- "x": { "steps_per_mm": 320.0, "direction_inverted": True, "direction_pin": "i2so.2:low", ... },
- "y": { ... }
- },
- "start": { "must_home": False }
- }
- """
- lines = send_command("$CD", timeout=10.0, silence=1.5)
- logger.info(f"$CD returned {len(lines)} lines")
- # Filter out non-YAML lines (status messages, 'ok', etc.)
- yaml_lines = []
- for line in lines:
- if line.lower() == "ok" or line.lower().startswith("error"):
- continue
- # Skip FluidNC status messages like [MSG:...]
- if line.startswith("["):
- continue
- yaml_lines.append(line)
- yaml_text = "\n".join(yaml_lines)
- if not yaml_text.strip():
- logger.warning("$CD returned no YAML content, falling back to individual queries")
- return _read_all_settings_individual()
- try:
- config = yaml.safe_load(yaml_text)
- except yaml.YAMLError as e:
- logger.error(f"Failed to parse $CD YAML ({len(yaml_lines)} lines): {e}")
- return _read_all_settings_individual()
- if not isinstance(config, dict):
- logger.warning(f"$CD parsed as {type(config).__name__}, falling back to individual queries")
- return _read_all_settings_individual()
- logger.info(f"$CD YAML top-level keys: {list(config.keys())}")
- result: dict = {"axes": {"x": {}, "y": {}}, "start": {}}
- resolved_count = 0
- for axis in ("x", "y"):
- for path in CURATED_SETTINGS[axis]:
- raw = _resolve_yaml_path(config, path)
- key = _make_key(path)
- if raw is not None:
- resolved_count += 1
- if "direction_pin" in path:
- raw_str = str(raw) if raw is not None else None
- result["axes"][axis][key] = raw_str
- result["axes"][axis]["direction_inverted"] = (
- ":low" in raw_str if raw_str else None
- )
- else:
- result["axes"][axis][key] = raw
- for path in CURATED_SETTINGS["global"]:
- raw = _resolve_yaml_path(config, path)
- if raw is not None:
- resolved_count += 1
- parts = path.split("/")
- key = "_".join(parts[1:])
- result["start"][key] = raw
- # If $CD parsed but we couldn't resolve any of our settings,
- # the YAML structure doesn't match — fall back to individual queries
- if resolved_count == 0:
- logger.warning(
- f"$CD YAML parsed ({len(config)} keys) but no curated settings resolved. "
- f"Falling back to individual queries."
- )
- return _read_all_settings_individual()
- logger.info(f"$CD resolved {resolved_count}/{len(CURATED_SETTINGS['x']) * 2 + len(CURATED_SETTINGS['global'])} settings")
- return result
- def _read_all_settings_individual() -> dict:
- """Fallback: read curated settings one by one via $/path queries."""
- result: dict = {"axes": {"x": {}, "y": {}}, "start": {}}
- for axis in ("x", "y"):
- for path in CURATED_SETTINGS[axis]:
- raw = read_setting(path)
- key = _make_key(path)
- parsed = _parse_value(raw, path)
- result["axes"][axis][key] = parsed
- if "direction_pin" in path:
- result["axes"][axis]["direction_inverted"] = (
- ":low" in raw if raw else None
- )
- for path in CURATED_SETTINGS["global"]:
- raw = read_setting(path)
- parts = path.split("/")
- key = "_".join(parts[1:])
- result["start"][key] = _parse_value(raw, path)
- return result
- def write_setting(path: str, value: str) -> bool:
- """Write a single FluidNC setting. Returns True on success."""
- try:
- responses = send_command(f"$/{path}={value}")
- except ConnectionError:
- return False
- return any("ok" in r.lower() for r in responses)
- def get_config_filename() -> str:
- """Get the active config filename from FluidNC."""
- try:
- responses = send_command("$Config/Filename")
- except ConnectionError:
- return "config.yaml"
- for line in responses:
- if "=" in line and "Filename" in line:
- return line.split("=", 1)[1].strip()
- return "config.yaml"
- def save_config() -> bool:
- """Persist current in-RAM config to flash using the active config filename."""
- filename = get_config_filename()
- try:
- responses = send_command(f"$CD=/littlefs/{filename}", timeout=5.0)
- except ConnectionError:
- return False
- return any("ok" in r.lower() for r in responses)
- def toggle_direction_pin(axis: str) -> tuple[bool, bool]:
- """Toggle :low on the direction pin for the given axis.
- Returns (success, new_inverted_state).
- """
- path = f"axes/{axis}/motor0/stepstick/direction_pin"
- current = read_setting(path)
- if current is None:
- return (False, False)
- if ":low" in current:
- new_val = current.replace(":low", "")
- else:
- new_val = current + ":low"
- success = write_setting(path, new_val)
- return (success, ":low" in new_val)
|