Procházet zdrojové kódy

fix: persist direction pin changes via config file rewrite + XModem

FluidNC v3.7+ rejects runtime $/path writes to Pin objects but still
replies "ok", so direction toggles were silently dropped and $CD then
persisted the unchanged config. Direction fixes now read config.yaml
from flash ($LocalFS/Show with a raw, indentation-preserving reader),
toggle :low host-side, and upload the edited file back over XModem,
verifying the result. The connection lock is held across the whole
sequence so status polls or pattern traffic can't corrupt the
transfer, and failures now surface as errors instead of saved:true.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tuanchris před 1 měsícem
rodič
revize
40148b198f
2 změnil soubory, kde provedl 272 přidání a 21 odebrání
  1. 21 6
      main.py
  2. 251 15
      modules/connection/fluidnc_config.py

+ 21 - 6
main.py

@@ -4254,6 +4254,7 @@ async def fluidnc_config_write(update: FluidNCConfigUpdate):
 
     def apply_changes():
         nonlocal restart_required
+        direction_axes: list[str] = []
         # Apply axis settings
         if update.axes:
             for axis in ("x", "y"):
@@ -4266,11 +4267,10 @@ async def fluidnc_config_write(update: FluidNCConfigUpdate):
                         continue
 
                     if key == "direction_inverted":
-                        # Toggle :low on direction pin
-                        success, new_state = fluidnc_config.toggle_direction_pin(axis)
-                        if success:
-                            changes_applied.append(f"{axis}/direction_pin")
-                            restart_required = True
+                        # Pin changes can't be made at runtime — handled below
+                        # via a config file rewrite, after the $CD save so the
+                        # RAM dump doesn't overwrite the edited file
+                        direction_axes.append(axis)
                         continue
 
                     path_template = _AXIS_KEY_TO_PATH.get(key)
@@ -4299,8 +4299,23 @@ async def fluidnc_config_write(update: FluidNCConfigUpdate):
             if x_steps is not None or y_steps is not None:
                 state.save()
 
-        # Persist to flash
+        # Persist runtime-writable settings to flash via $CD dump
         saved = fluidnc_config.save_config() if changes_applied else False
+
+        # Direction pin toggles rewrite the config file directly (must come
+        # after save_config so the RAM dump doesn't clobber the file edit)
+        if direction_axes:
+            toggled = fluidnc_config.toggle_direction_pins(direction_axes)
+            failed = [a for a in direction_axes if a not in toggled]
+            if failed:
+                raise RuntimeError(
+                    f"Failed to update direction pin for axes: {', '.join(failed)}"
+                )
+            for axis in toggled:
+                changes_applied.append(f"{axis}/direction_pin")
+            restart_required = True
+            saved = True
+
         return saved
 
     try:

+ 251 - 15
modules/connection/fluidnc_config.py

@@ -292,6 +292,11 @@ def write_setting(path: str, value: str) -> bool:
         responses = send_command(f"$/{path}={value}")
     except ConnectionError:
         return False
+    # FluidNC rejects runtime changes to Pin objects with a message but
+    # still replies "ok" — treat that as failure, not success
+    if any("not supported" in r.lower() for r in responses):
+        logger.warning(f"FluidNC rejected write to {path}: {responses}")
+        return False
     return any("ok" in r.lower() for r in responses)
 
 
@@ -318,20 +323,251 @@ def save_config() -> bool:
     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.
+###############################################################################
+# Direction pin toggling via config file rewrite
+#
+# FluidNC (v3.7+) rejects runtime changes to Pin objects — it prints
+# "Runtime setting of Pin objects is not supported" but still replies "ok",
+# so $/path=value writes silently do nothing. The only way to change a pin
+# is to edit the YAML config file on the controller's flash and reboot.
+# We read the file with $LocalFS/Show, toggle the :low flag host-side, and
+# upload the edited file back over XModem ($Xmodem/Receive).
+###############################################################################
+
+# XModem protocol bytes
+_SOH, _EOT, _ACK, _NAK, _CAN, _CTRLZ = 0x01, 0x04, 0x06, 0x15, 0x18, 0x1A
+
+
+def _get_serial():
+    """Return the underlying pyserial object, or raise for non-serial connections."""
+    conn = state.conn
+    if not conn or not conn.is_connected():
+        raise ConnectionError("Not connected to controller")
+    ser = getattr(conn, "ser", None)
+    if ser is None:
+        raise ConnectionError(
+            "Direction pin changes require a serial connection "
+            "(file upload over WebSocket is not supported)"
+        )
+    return conn, ser
+
+
+def _read_raw(ser, timeout: float = 10.0, silence: float = 1.0) -> str:
+    """Read raw bytes until 'ok'/'error', a silence gap, or timeout.
 
-    Returns (success, new_inverted_state).
+    Unlike send_command(), preserves leading whitespace — required when
+    reading YAML file content where indentation is structure.
+    """
+    buf = bytearray()
+    start = time.time()
+    last_data = start
+    while time.time() - start < timeout:
+        n = ser.in_waiting
+        if n:
+            buf += ser.read(n)
+            last_data = time.time()
+            tail = bytes(buf[-16:])
+            if tail.rstrip().endswith(b"ok") or b"error:" in tail:
+                break
+        else:
+            if buf and time.time() - last_data > silence:
+                break
+            time.sleep(0.02)
+    return buf.decode("utf-8", errors="replace")
+
+
+def read_config_file() -> str | None:
+    """Read the active config file's raw text from the controller's flash."""
+    conn, ser = _get_serial()
+    filename = get_config_filename()
+    with conn.lock:
+        ser.reset_input_buffer()
+        ser.write(f"$LocalFS/Show=/littlefs/{filename}\n".encode())
+        ser.flush()
+        text = _read_raw(ser, timeout=15.0, silence=1.0)
+
+    lines = []
+    for line in text.splitlines():
+        stripped = line.strip()
+        # Drop protocol/status noise; keep file content (incl. indentation)
+        if stripped.lower() == "ok" or stripped.startswith(("[", "<")) or stripped.startswith("error:"):
+            continue
+        lines.append(line.rstrip("\r"))
+    content = "\n".join(lines).strip("\n")
+    if not content:
+        return None
+    return content + "\n"
+
+
+def _toggle_direction_in_text(text: str, axes: list[str]) -> tuple[str, dict[str, bool]]:
+    """Toggle :low on direction_pin lines for the given axes in YAML text.
+
+    Walks the YAML line by line with an indentation-based path stack so the
+    edit is targeted (axes/<axis>/motor0/*/direction_pin) while preserving
+    comments and formatting everywhere else.
+
+    Returns (new_text, {axis: new_inverted_state}).
+    """
+    lines = text.split("\n")
+    stack: list[tuple[int, str]] = []
+    toggled: dict[str, bool] = {}
+
+    for i, line in enumerate(lines):
+        stripped = line.strip()
+        if not stripped or stripped.startswith("#") or ":" not in stripped:
+            continue
+        indent = len(line) - len(line.lstrip(" "))
+        key = stripped.split(":", 1)[0].strip()
+        while stack and stack[-1][0] >= indent:
+            stack.pop()
+        stack.append((indent, key))
+
+        parts = [k for _, k in stack]
+        # Match axes/<axis>/motor0/<driver>/direction_pin for any driver type
+        if (
+            len(parts) == 5
+            and parts[0] == "axes"
+            and parts[1] in axes
+            and parts[2] == "motor0"
+            and parts[4] == "direction_pin"
+        ):
+            value = stripped.split(":", 1)[1].split("#", 1)[0].strip().strip("'\"")
+            if not value:
+                continue
+            new_val = value.replace(":low", "") if ":low" in value else value + ":low"
+            # Quote values containing ':' so the YAML stays valid
+            rendered = f"'{new_val}'" if ":" in new_val else new_val
+            lines[i] = " " * indent + f"direction_pin: {rendered}"
+            toggled[parts[1]] = ":low" in new_val
+
+    return "\n".join(lines), toggled
+
+
+def _crc16_xmodem(data: bytes) -> int:
+    crc = 0
+    for byte in data:
+        crc ^= byte << 8
+        for _ in range(8):
+            crc = ((crc << 1) ^ 0x1021) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF
+    return crc
+
+
+def upload_config_file(text: str) -> bool:
+    """Upload config file text to the controller's flash via XModem."""
+    conn, ser = _get_serial()
+    filename = get_config_filename()
+    data = text.encode()
+
+    with conn.lock:
+        ser.reset_input_buffer()
+        ser.write(f"$Xmodem/Receive=/littlefs/{filename}\n".encode())
+        ser.flush()
+
+        # Wait for receiver handshake: 'C' = CRC mode, NAK = checksum mode
+        crc_mode = None
+        start = time.time()
+        while time.time() - start < 15:
+            b = ser.read(1)
+            if not b:
+                continue
+            if b == b"C":
+                crc_mode = True
+                break
+            if b[0] == _NAK:
+                crc_mode = False
+                break
+        if crc_mode is None:
+            logger.error("XModem: no handshake from controller")
+            return False
+
+        seq = 1
+        for offset in range(0, len(data), 128):
+            block = data[offset : offset + 128].ljust(128, bytes([_CTRLZ]))
+            packet = bytes([_SOH, seq & 0xFF, 0xFF - (seq & 0xFF)]) + block
+            if crc_mode:
+                crc = _crc16_xmodem(block)
+                packet += bytes([crc >> 8, crc & 0xFF])
+            else:
+                packet += bytes([sum(block) & 0xFF])
+
+            for _attempt in range(10):
+                ser.write(packet)
+                ser.flush()
+                resp = ser.read(1)
+                # Ignore leftover handshake chars from before the first packet
+                while resp == b"C":
+                    resp = ser.read(1)
+                if resp and resp[0] == _ACK:
+                    break
+                if resp and resp[0] == _CAN:
+                    logger.error("XModem: transfer cancelled by controller")
+                    return False
+            else:
+                logger.error(f"XModem: packet {seq} never acknowledged")
+                ser.write(bytes([_CAN, _CAN]))
+                ser.flush()
+                return False
+            seq += 1
+
+        for _attempt in range(10):
+            ser.write(bytes([_EOT]))
+            ser.flush()
+            resp = ser.read(1)
+            if resp and resp[0] == _ACK:
+                break
+        else:
+            logger.error("XModem: EOT not acknowledged")
+            return False
+
+        # Drain trailing log lines ("[MSG:INFO: Received N bytes...]")
+        tail = _read_raw(ser, timeout=3.0, silence=0.5)
+
+    logger.info(f"XModem upload complete ({len(data)} bytes): {tail.strip()}")
+    return True
+
+
+def toggle_direction_pins(axes: list[str]) -> dict[str, bool]:
+    """Toggle :low on the direction pins by rewriting the config file.
+
+    Reads the active config from flash, flips the :low flag on each axis's
+    direction_pin line, uploads the edited file back, and verifies the
+    write by re-reading. Changes take effect after a controller restart.
+
+    Returns {axis: new_inverted_state} for axes successfully toggled.
+    Raises ConnectionError if no serial connection is available.
     """
-    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)
+    conn, _ser = _get_serial()
+    # Hold the connection lock across the whole read→edit→upload→verify
+    # sequence so status polls or pattern traffic can't interleave and
+    # corrupt the file transfer (the lock is reentrant, so the nested
+    # send_command/raw IO calls below are fine)
+    with conn.lock:
+        text = read_config_file()
+        if not text:
+            logger.error("Could not read config file from controller")
+            return {}
+
+        new_text, toggled = _toggle_direction_in_text(text, axes)
+        if not toggled:
+            logger.error(
+                f"No direction_pin lines found for axes {axes} in config file "
+                f"({len(text)} bytes read)"
+            )
+            return {}
+
+        if not upload_config_file(new_text):
+            return {}
+
+        # Verify: re-read and confirm the toggled lines are on flash
+        verify_text = read_config_file()
+        if verify_text:
+            _, would_toggle = _toggle_direction_in_text(verify_text, list(toggled.keys()))
+            # Toggling the verified file should produce the OLD state — i.e. the
+            # file currently holds the NEW state for every axis we changed
+            for axis, new_state in toggled.items():
+                if would_toggle.get(axis) == new_state:
+                    logger.error(f"Verification failed: {axis} direction pin not updated on flash")
+                    return {}
+
+    logger.info(f"Direction pins toggled via config file: {toggled}")
+    return toggled