Просмотр исходного кода

feat: drive FluidNC board over HTTP instead of streaming G-code

The backend now delegates pattern execution to the FluidNC firmware:
FluidNCClient talks to the board's HTTP API ($SD/Run + /sand_status
polling) instead of streaming coordinates over serial/websocket with
Python-side kinematics. Adds a configurable board_url in state, an
idle-rate board status poller, and reworks the connection manager and
pattern manager accordingly. main.py also wires mDNS discovery into
the app lifecycle and adds GET /api/discovered-tables.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tuanchris 2 недель назад
Родитель
Сommit
4b852bcbe0

+ 58 - 12
main.py

@@ -23,6 +23,7 @@ from modules.screen.screen_controller import ScreenController
 from modules.led.idle_timeout_manager import idle_timeout_manager
 from modules.core.cache_manager import get_cache_path, generate_image_preview, get_pattern_metadata
 from modules.core.version_manager import version_manager
+from modules.core.mdns_discovery import discovery as mdns_discovery
 from modules.core.log_handler import init_memory_handler, get_memory_handler
 from modules.wifi.router import router as wifi_router, captive_portal_router
 import json
@@ -154,6 +155,20 @@ async def lifespan(app: FastAPI):
     # Start connection/homing in background - doesn't block server startup
     asyncio.create_task(connect_and_home())
 
+    # Keep app state in sync with the board. During playback the pattern
+    # executor polls /sand_status at a higher rate; this low-rate poller keeps
+    # theta/rho/state fresh while idle (after jogs, homing, board-side changes).
+    async def board_status_poller():
+        while True:
+            try:
+                if state.conn and state.conn.is_connected():
+                    await asyncio.to_thread(connection_manager.poll_status_once)
+            except Exception as e:
+                logger.debug(f"Idle status poll failed: {e}")
+            await asyncio.sleep(2.0)
+
+    asyncio.create_task(board_status_poller())
+
     # Initialize LED controller based on saved configuration
     try:
         # Auto-detect provider for backward compatibility with existing installations
@@ -345,10 +360,22 @@ async def lifespan(app: FastAPI):
 
     asyncio.create_task(still_sands_led_monitor())
 
+    # Advertise this table via mDNS and browse for peer tables (best-effort)
+    try:
+        await mdns_discovery.start(
+            table_id=state.table_id,
+            table_name=state.table_name,
+            port=8080,
+            version=await version_manager.get_current_version(),
+        )
+    except Exception as e:
+        logger.warning(f"mDNS table discovery unavailable: {e}")
+
     yield  # This separates startup from shutdown code
 
     # Shutdown
     logger.info("Shutting down Dune Weaver application...")
+    await mdns_discovery.stop()
 
 app = FastAPI(lifespan=lifespan)
 
@@ -1157,6 +1184,7 @@ async def update_table_info(update: TableInfoUpdate):
         state.table_name = update.name.strip() or "Dune Weaver"
         state.save()
         logger.info(f"Table name updated to: {state.table_name}")
+        await mdns_discovery.update_name(state.table_name)
 
     return {
         "success": True,
@@ -1164,6 +1192,17 @@ async def update_table_info(update: TableInfoUpdate):
         "name": state.table_name
     }
 
+@app.get("/api/discovered-tables", tags=["multi-table"])
+async def get_discovered_tables():
+    """
+    Get Dune Weaver tables auto-discovered via mDNS on the local network.
+
+    Unlike known-tables these are not persisted - the list reflects which
+    peer backends are currently advertising themselves. Returns an empty
+    list when mDNS is unavailable (e.g. zeroconf not installed).
+    """
+    return {"tables": mdns_discovery.get_tables()}
+
 @app.get("/api/known-tables", tags=["multi-table"])
 async def get_known_tables():
     """
@@ -1407,23 +1446,25 @@ async def list_ports():
 
 @app.post("/connect")
 async def connect(request: ConnectRequest):
-    if not request.port:
-        state.conn = connection_manager.WebSocketConnection('ws://fluidnc.local:81')
-        if not connection_manager.device_init():
-            raise HTTPException(status_code=500, detail="Failed to initialize device - could not get machine parameters")
-        logger.info('Successfully connected to websocket ws://fluidnc.local:81')
-        return {"success": True}
-
+    # `request.port` carries the board address now (a URL or bare IP). Blank =>
+    # use the configured/default board URL.
+    from modules.connection.fluidnc_client import FluidNCClient
     try:
-        state.conn = connection_manager.SerialConnection(request.port)
-        if not connection_manager.device_init():
-            raise HTTPException(status_code=500, detail="Failed to initialize device - could not get machine parameters")
-        logger.info(f'Successfully connected to serial port {request.port}')
+        url = connection_manager._normalize_board_url(request.port or "") or connection_manager.board_url()
+        state.board_url = url
+        state.save()
+        state.conn = FluidNCClient(url)
+        if not state.conn.reachable():
+            state.conn = None
+            raise HTTPException(status_code=500, detail=f"Board not reachable at {url}")
+        if not await asyncio.to_thread(connection_manager.device_init, False):
+            raise HTTPException(status_code=500, detail="Failed to initialize board")
+        logger.info(f"Successfully connected to board at {url}")
         return {"success": True}
     except HTTPException:
         raise
     except Exception as e:
-        logger.error(f'Failed to connect to serial port {request.port}: {str(e)}')
+        logger.error(f"Failed to connect to board: {str(e)}")
         raise HTTPException(status_code=500, detail=str(e))
 
 @app.post("/disconnect")
@@ -2579,6 +2620,11 @@ async def set_speed(request: SpeedRequest):
             raise HTTPException(status_code=400, detail="Invalid speed value")
 
         state.speed = request.speed
+        # Push the feed live to the board (works mid-pattern; persists across the run).
+        try:
+            await asyncio.to_thread(state.conn.set_feed, int(request.speed))
+        except Exception as e:
+            logger.warning(f"Could not push feed to board: {e}")
         return {"success": True, "speed": request.speed}
     except HTTPException:
         raise  # Re-raise HTTPException as-is

+ 208 - 1452
modules/connection/connection_manager.py

@@ -1,27 +1,53 @@
-import threading
+"""
+Connection manager — drives the headless FluidNC board over HTTP.
+
+The board firmware now owns kinematics, `.thr` playback, progress and homing
+(contract: the firmware repo's API.md). This module is the thin seam the backend
+uses to talk to it: it builds a FluidNCClient, exposes the same public functions
+the rest of the app already calls (connect_device, device_init, home,
+check_idle_async, is_machine_idle, get_machine_position, update_machine_position,
+perform_soft_reset, restart_connection, list_serial_ports), and keeps the LED
+hooks intact. The old serial/websocket GRBL transport, coordinate streaming, and
+the $H/$J homing handshake are gone — the firmware does all of that itself.
+"""
+
+import os
 import time
+import math
 import logging
-import re
-import serial
-import serial.tools.list_ports
-import websocket
 import asyncio
-import os
 
 from modules.core.state import state
+from modules.connection.fluidnc_client import FluidNCClient
 from modules.led.led_interface import LEDInterface
 from modules.led.idle_timeout_manager import idle_timeout_manager
 
 logger = logging.getLogger(__name__)
 
-IGNORE_PORTS = ['/dev/cu.debug-console', '/dev/cu.Bluetooth-Incoming-Port', '/dev/ttyS0']
+DEFAULT_BOARD_URL = "http://192.168.68.160"
+
+
+def _normalize_board_url(value: str) -> str:
+    """Accept a bare IP/host or a full URL and return a normalized base URL."""
+    value = (value or "").strip().rstrip("/")
+    if not value:
+        return ""
+    if not value.startswith(("http://", "https://")):
+        value = "http://" + value
+    return value
+
 
-# Ports to deprioritize during auto-connect (shown in UI but not auto-selected)
-DEPRIORITIZED_PORTS = ['/dev/ttyS0']
+def board_url() -> str:
+    """Resolve the board base URL: explicit setting > env > default."""
+    return (
+        _normalize_board_url(getattr(state, "board_url", None) or "")
+        or _normalize_board_url(os.environ.get("DUNE_BOARD_URL", ""))
+        or DEFAULT_BOARD_URL
+    )
 
 
 async def _check_table_is_idle() -> bool:
-    """Helper function to check if table is idle."""
+    """Helper for the idle-LED timeout: table counts as idle when nothing plays."""
     return not state.current_playing_file or state.pause_requested
 
 
@@ -29,239 +55,124 @@ def _start_idle_led_timeout():
     """Start idle LED timeout if enabled."""
     if not state.dw_led_idle_timeout_enabled or state.dw_led_idle_timeout_minutes <= 0:
         return
-
     logger.debug(f"Starting idle LED timeout: {state.dw_led_idle_timeout_minutes} minutes")
     idle_timeout_manager.start_idle_timeout(
         timeout_minutes=state.dw_led_idle_timeout_minutes,
         state=state,
-        check_idle_callback=_check_table_is_idle
+        check_idle_callback=_check_table_is_idle,
     )
 
 
 ###############################################################################
-# Connection Abstraction
+# Status helpers
 ###############################################################################
 
-class BaseConnection:
-    """Abstract base class for a connection."""
-    def send(self, data: str) -> None:
-        raise NotImplementedError
-
-    def flush(self) -> None:
-        raise NotImplementedError
-
-    def readline(self) -> str:
-        raise NotImplementedError
-
-    def in_waiting(self) -> int:
-        raise NotImplementedError
-
-    def is_connected(self) -> bool:
-        raise NotImplementedError
-
-    def close(self) -> None:
-        raise NotImplementedError
-
-###############################################################################
-# Serial Connection Implementation
-###############################################################################
-
-class SerialConnection(BaseConnection):
-    def __init__(self, port: str, baudrate: int = 115200, timeout: int = 2):
-        self.port = port
-        self.baudrate = baudrate
-        self.timeout = timeout
-        self.lock = threading.RLock()
-        logger.info(f'Connecting to Serial port {port}')
-        self.ser = serial.Serial(port, baudrate, timeout=timeout)
-        state.port = port
-        logger.info(f'Connected to Serial port {port}')
-
-    def send(self, data: str) -> None:
-        with self.lock:
-            self.ser.write(data.encode())
-            self.ser.flush()
-
-    def flush(self) -> None:
-        with self.lock:
-            self.ser.flush()
-
-    def readline(self) -> str:
-        with self.lock:
-            return self.ser.readline().decode().strip()
-
-    def in_waiting(self) -> int:
-        with self.lock:
-            return self.ser.in_waiting
+def apply_status(st: dict) -> None:
+    """Mirror the board's /sand_status fields into app state."""
+    if not st:
+        return
+    if "theta" in st:
+        state.current_theta = st["theta"]
+    if "rho" in st:
+        state.current_rho = st["rho"]
+    if st.get("feed"):
+        state.speed = st["feed"]
 
-    def reset_input_buffer(self) -> None:
-        """Clear any stale data from the serial input buffer."""
-        with self.lock:
-            if self.ser and self.ser.is_open:
-                self.ser.reset_input_buffer()
 
-    def is_connected(self) -> bool:
-        return self.ser is not None and self.ser.is_open
+def poll_status_once() -> dict | None:
+    """Read /sand_status once and mirror it into state. Returns the raw dict."""
+    if not state.conn:
+        return None
+    try:
+        st = state.conn.get_status()
+        apply_status(st)
+        return st
+    except Exception as e:
+        logger.debug(f"Status poll failed: {e}")
+        return None
 
-    def close(self) -> None:
-        # Save current state synchronously first (critical for position persistence)
-        try:
-            state.save()
-        except Exception as e:
-            logger.error(f"Error saving state on close: {e}")
 
-        # Schedule async position update if event loop exists, otherwise skip
-        # This avoids creating nested event loops which causes RuntimeError
-        try:
-            asyncio.get_running_loop()
-            # We're in async context - schedule as task (fire-and-forget)
-            asyncio.create_task(update_machine_position())
-            logger.debug("Scheduled async machine position update")
-        except RuntimeError:
-            # No running event loop - we're in sync context
-            # Position was already saved above, skip async update to avoid nested loop
-            logger.debug("No event loop running, skipping async position update")
+def get_status_response() -> str:
+    """Back-compat shim: return the board's machine state as a GRBL-like token."""
+    st = poll_status_once()
+    return st.get("state", "") if st else ""
 
-        with self.lock:
-            if self.ser.is_open:
-                self.ser.close()
 
 ###############################################################################
-# WebSocket Connection Implementation
+# Connection lifecycle
 ###############################################################################
 
-class WebSocketConnection(BaseConnection):
-    def __init__(self, url: str, timeout: int = 5):
-        self.url = url
-        self.timeout = timeout
-        self.lock = threading.RLock()
-        self.ws = None
-        self.connect()
-
-    def connect(self):
-        logger.info(f'Connecting to Websocket {self.url}')
-        self.ws = websocket.create_connection(self.url, timeout=self.timeout)
-        state.port = self.url
-        logger.info(f'Connected to Websocket {self.url}')
-        
-    def send(self, data: str) -> None:
-        with self.lock:
-            self.ws.send(data)
-
-    def flush(self) -> None:
-        # WebSocket sends immediately; nothing to flush.
-        pass
-
-    def readline(self) -> str:
-        with self.lock:
-            data = self.ws.recv()
-            # Decode bytes to string if necessary
-            if isinstance(data, bytes):
-                data = data.decode('utf-8')
-            return data.strip()
-
-    def in_waiting(self) -> int:
-        return 0  # Not applicable for WebSocket
+def list_serial_ports():
+    """
+    There are no serial ports anymore — the board is reached over HTTP. Return
+    the board URL as the single selectable "port" so the frontend's connection
+    panel keeps working unchanged.
+    """
+    return [board_url()]
 
-    def is_connected(self) -> bool:
-        return self.ws is not None
 
-    def close(self) -> None:
-        # Save current state synchronously first (critical for position persistence)
-        try:
-            state.save()
-        except Exception as e:
-            logger.error(f"Error saving state on close: {e}")
+def _push_homing_settings():
+    """Push the host's homing preferences onto the board (best effort)."""
+    try:
+        state.conn.set_homing_mode("sensor" if state.homing == 1 else "crash")
+        state.conn.set_theta_offset(state.angular_homing_offset_degrees)
+    except Exception as e:
+        logger.warning(f"Could not push homing settings to board: {e}")
 
-        # Schedule async position update if event loop exists, otherwise skip
-        # This avoids creating nested event loops which causes RuntimeError
-        try:
-            asyncio.get_running_loop()
-            # We're in async context - schedule as task (fire-and-forget)
-            asyncio.create_task(update_machine_position())
-            logger.debug("Scheduled async machine position update")
-        except RuntimeError:
-            # No running event loop - we're in sync context
-            # Position was already saved above, skip async update to avoid nested loop
-            logger.debug("No event loop running, skipping async position update")
 
-        with self.lock:
-            if self.ws:
-                self.ws.close()
-                self.ws = None
+def _sync_homing_settings():
+    """
+    Reconcile homing config between host and board. If the user has explicitly
+    chosen a homing mode (homing_user_override), the host is authoritative and we
+    push it to the board. Otherwise we *adopt* the board's configured mode/offset
+    so we never clobber a correctly-configured board with a host default.
+    """
+    if state.homing_user_override:
+        _push_homing_settings()
+        return
+    try:
+        settings = state.conn.get_settings()
+        board_mode = (settings.get("Sand/HomingMode") or "").lower()
+        if board_mode in ("sensor", "crash"):
+            state.homing = 1 if board_mode == "sensor" else 0
+        offset = settings.get("Sand/ThetaOffset")
+        if offset is not None:
+            state.angular_homing_offset_degrees = float(offset)
+        logger.info(
+            f"Adopted board homing config: mode={board_mode}, "
+            f"offset={state.angular_homing_offset_degrees}"
+        )
+    except Exception as e:
+        logger.warning(f"Could not read board homing settings: {e}")
 
-def list_serial_ports():
-    """Return a list of available serial ports."""
-    ports = serial.tools.list_ports.comports()
-    available_ports = [port.device for port in ports if port.device not in IGNORE_PORTS]
-    logger.debug(f"Available serial ports: {available_ports}")
-    return available_ports
 
 def device_init(homing=True):
-    # IMPORTANT: Query machine position BEFORE reset to determine if homing is needed
-    # If machine wasn't power cycled, it retains position and we can skip homing
-    # Reset ($Bye) zeroes position counters, so we must check BEFORE reset
-
+    """Read board status, record firmware, reconcile homing, optionally home."""
     try:
-        if get_machine_steps():
-            logger.info(f"x_steps_per_mm: {state.x_steps_per_mm}, y_steps_per_mm: {state.y_steps_per_mm}, gear_ratio: {state.gear_ratio}")
-        else:
-            logger.fatal("Failed to get machine steps")
-            state.conn.close()
-            return False
-    except Exception:
-        logger.fatal("Not GRBL firmware")
+        st = state.conn.get_status()
+    except Exception as e:
+        logger.fatal(f"Board status unreadable: {e}")
         state.conn.close()
         return False
 
-    # Check machine position BEFORE reset to decide if homing is needed
-    machine_x, machine_y = get_machine_position()
-    needs_homing = False
-
-    if machine_x != state.machine_x or machine_y != state.machine_y:
-        logger.info(f'Machine position mismatch - machine: ({machine_x}, {machine_y}), saved: ({state.machine_x}, {state.machine_y})')
-        needs_homing = homing
-    else:
-        logger.info('Machine position matches saved state, skipping home')
-        logger.info(f'Theta: {state.current_theta}, rho: {state.current_rho}')
-        logger.info(f'Position: ({machine_x}, {machine_y})')
-
-    # Now perform soft reset to ensure controller is in a clean state
-    # This clears any pending commands and resets position counters to 0
-    logger.info("Performing soft reset for clean controller state...")
-    perform_soft_reset_sync()
-    time.sleep(1)  # Extra stabilization after controller restart
+    state.firmware_type = "fluidnc"
+    state.firmware_version = state.firmware_version or "fluidnc"
+    apply_status(st)
+    _sync_homing_settings()
 
-    # Reset work coordinate offsets for a clean start
-    # This ensures we're using work coordinates (G54) starting from 0
-    reset_work_coordinates()
+    if homing and state.home_on_connect:
+        home()
 
-    # Home if position was mismatched (machine may have been power cycled)
-    if needs_homing:
-        logger.info("Homing required due to position mismatch...")
-        success = home()
-        if not success:
-            logger.error("Homing failed during device initialization")
-            # If sensor homing failed, close connection and return False
-            # This prevents auto-connection from completing until user takes action
-            if state.sensor_homing_failed:
-                logger.error("Sensor homing failed - closing connection. User must check sensor or switch to crash homing.")
-                state.conn.close()
-                state.conn = None
-                return False
-
-    time.sleep(2)  # Allow time for the connection to establish
     return True
 
 
 def connect_device(homing=True):
-    # Initialize LED interface based on configured provider
-    # Note: DW LEDs are initialized at startup in main.py, so we preserve the existing controller
+    """Initialize LEDs, connect to the board over HTTP, and init the device."""
+    # Initialize LED interface based on configured provider (unchanged from before).
     if state.led_provider == "wled" and state.wled_ip:
         state.led_controller = LEDInterface(provider="wled", ip_address=state.wled_ip)
     elif state.led_provider == "dw_leds":
-        # DW LEDs are already initialized in main.py at startup
-        # Only initialize here if not already set up (e.g., reconnection scenario)
+        # DW LEDs are initialized at startup in main.py; only set up here on reconnect.
         if not state.led_controller or not state.led_controller.is_configured:
             state.led_controller = LEDInterface(
                 provider="dw_leds",
@@ -270,1326 +181,171 @@ def connect_device(homing=True):
                 pixel_order=state.dw_led_pixel_order,
                 brightness=state.dw_led_brightness / 100.0,
                 speed=state.dw_led_speed,
-                intensity=state.dw_led_intensity
+                intensity=state.dw_led_intensity,
             )
-    elif state.led_provider == "hyperion" and state.hyperion_ip:
-        state.led_controller = LEDInterface(
-            provider="hyperion",
-            ip_address=state.hyperion_ip,
-            port=state.hyperion_port
-        )
     elif state.led_provider == "none" or not state.led_provider:
         state.led_controller = None
-    # For other cases (e.g., wled without IP), preserve existing controller
 
-    # Show loading effect
     if state.led_controller:
         state.led_controller.effect_loading()
 
-    ports = list_serial_ports()
-
-    # Check auto-connect mode: "__auto__" or None = auto, "__none__" = disabled, else specific port
-    if state.preferred_port == "__none__":
-        logger.info("Auto-connect disabled by user preference")
-        # Skip all auto-connect logic, no connection will be established
-    # Priority for auto-connect:
-    # 1. Preferred port (user's explicit choice) if available
-    # 2. Last used port if available
-    # 3. First available port as fallback
-    elif state.preferred_port and state.preferred_port not in ("__auto__", None) and state.preferred_port in ports:
-        logger.info(f"Connecting to preferred port: {state.preferred_port}")
-        state.conn = SerialConnection(state.preferred_port)
-    elif state.port and state.port in ports:
-        logger.info(f"Connecting to last used port: {state.port}")
-        state.conn = SerialConnection(state.port)
-    elif ports:
-        # Prefer non-deprioritized ports (e.g., USB serial over hardware UART)
-        preferred_ports = [p for p in ports if p not in DEPRIORITIZED_PORTS]
-        fallback_ports = [p for p in ports if p in DEPRIORITIZED_PORTS]
-
-        if preferred_ports:
-            logger.info(f"Connecting to first available port: {preferred_ports[0]}")
-            state.conn = SerialConnection(preferred_ports[0])
-        elif fallback_ports:
-            logger.info(f"Connecting to deprioritized port (no better option): {fallback_ports[0]}")
-            state.conn = SerialConnection(fallback_ports[0])
+    url = board_url()
+    logger.info(f"Connecting to FluidNC board at {url}")
+    state.conn = FluidNCClient(url)
+    if not state.conn.reachable():
+        logger.error(f"Board not reachable at {url}")
+        state.conn = None
     else:
-        logger.error("Auto connect failed: No serial ports available")
-        # state.conn = WebSocketConnection('ws://fluidnc.local:81')
-
-    if (state.conn.is_connected() if state.conn else False):
-        # Check for alarm state and unlock if needed before initializing
-        if not check_and_unlock_alarm():
-            logger.error("Failed to unlock device from alarm state")
-            # Still proceed with device_init but log the issue
-
+        state.port = url
+        logger.info(f"Connected to board at {url}")
         device_init(homing)
 
-    # Show connected effect, then transition to configured idle effect
+    # Show connected effect, then transition to the configured idle effect.
     if state.led_controller:
         logger.info("Showing LED connected effect (green flash)")
         state.led_controller.effect_connected()
-        # Set the configured idle effect after connection
         logger.info(f"Setting LED to idle effect: {state.dw_led_idle_effect}")
         state.led_controller.effect_idle(state.dw_led_idle_effect)
         _start_idle_led_timeout()
 
-def check_and_unlock_alarm():
-    """
-    Check if GRBL is in alarm state and unlock it with $X if needed.
-    Returns True if device is ready (unlocked or no alarm), False on error.
-
-    Note: If sensors are physically triggered (Pn:XY), the alarm may persist
-    but we still return True to allow homing to proceed.
-    """
-    try:
-        logger.info("Checking device status for alarm state...")
-
-        # Clear any pending data in buffer first
-        while state.conn.in_waiting() > 0:
-            state.conn.readline()
-
-        # Send status query
-        state.conn.send('?\n')
-        time.sleep(0.2)
-
-        # Read response with timeout
-        max_attempts = 10
-        response = None
-
-        for attempt in range(max_attempts):
-            if state.conn.in_waiting() > 0:
-                response = state.conn.readline()
-                logger.debug(f"Status response: {response}")
-                if response and ('<' in response or 'Alarm' in response or 'Idle' in response):
-                    break  # Got a valid status response
-            time.sleep(0.1)
-
-        if not response:
-            logger.warning("No status response received, proceeding anyway")
-            return True
-
-        # Check for alarm state
-        if "Alarm" in response:
-            logger.warning(f"Device in ALARM state: {response}")
-
-            # Send unlock command
-            logger.info("Sending $X to unlock...")
-            state.conn.send('$X\n')
-            time.sleep(1.0)  # Give more time for unlock to process
-
-            # Clear buffer before verification
-            while state.conn.in_waiting() > 0:
-                discarded = state.conn.readline()
-                logger.debug(f"Discarded response: {discarded}")
-
-            # Verify unlock succeeded
-            state.conn.send('?\n')
-            time.sleep(0.3)
 
-            verify_response = None
-            for attempt in range(max_attempts):
-                if state.conn.in_waiting() > 0:
-                    verify_response = state.conn.readline()
-                    logger.debug(f"Verification response: {verify_response}")
-                    if verify_response and '<' in verify_response:
-                        break
-                time.sleep(0.1)
-
-            if verify_response and "Alarm" in verify_response:
-                # Check if pins are physically triggered (Pn: in response)
-                if "Pn:" in verify_response:
-                    logger.warning(f"Alarm persists due to triggered sensors: {verify_response}")
-                    logger.warning("Proceeding anyway - homing may clear the sensor state")
-                    return True  # Let homing attempt to proceed
-                else:
-                    logger.error("Failed to unlock device from alarm state")
-                    return False
-            else:
-                logger.info("Device successfully unlocked")
-                return True
-        else:
-            logger.info("Device not in alarm state, proceeding normally")
-            return True
-
-    except Exception as e:
-        logger.error(f"Error checking/unlocking alarm: {e}")
-        return False
-
-def get_status_response() -> str:
+def check_and_unlock_alarm():
     """
-    Send a status query ('?') and return the response if available.
-    Accepts both MPos (machine position) and WPos (work position) formats
-    depending on GRBL's $10 setting.
+    Compatibility shim. The firmware manages its own alarm/unlock; if the board
+    reports an Alarm state we ask it to home (which clears it). Returns True when
+    the board is reachable.
     """
-    if state.conn is None or not state.conn.is_connected():
-        logger.warning("Cannot get status response: no active connection")
+    st = poll_status_once()
+    if st is None:
         return False
+    if st.get("state") == "Alarm":
+        logger.info("Board in Alarm state; a home will be required to clear it")
+    return True
 
-    while True:
-        try:
-            state.conn.send('?')
-            response = state.conn.readline()
-            # Accept either MPos or WPos format (depends on GRBL $10 setting)
-            if "MPos" in response or "WPos" in response:
-                logger.debug(f"Status response: {response}")
-                return response
-        except Exception as e:
-            logger.error(f"Error getting status response: {e}")
-            return False
-        time.sleep(1)
-        
-def parse_machine_position(response: str):
-    """
-    Parse the position from a status response.
-    Supports both MPos (machine position) and WPos (work position) formats
-    depending on GRBL's $10 setting.
-    Expected formats:
-        "<...|MPos:-994.869,-321.861,0.000|...>"
-        "<...|WPos:0.000,19.000,0.000|...>"
-    Returns a tuple (x, y) if found, else None.
-    """
-    if "MPos:" not in response and "WPos:" not in response:
-        return None
-    try:
-        # Try MPos first, then WPos
-        pos_section = next((part for part in response.split("|") if part.startswith("MPos:")), None)
-        if pos_section is None:
-            pos_section = next((part for part in response.split("|") if part.startswith("WPos:")), None)
-
-        if pos_section:
-            pos_str = pos_section.split(":", 1)[1]
-            pos_values = pos_str.split(",")
-            pos_x = float(pos_values[0])
-            pos_y = float(pos_values[1])
-            return pos_x, pos_y
-    except Exception as e:
-        logger.error(f"Error parsing position: {e}")
-    return None
-
-
-async def send_grbl_coordinates(x, y, speed=600, timeout=30, home=False):
-    """
-    Send a G-code command to FluidNC and wait for an 'ok' response.
-    If no response after set timeout, returns False.
-
-    Args:
-        x: X coordinate
-        y: Y coordinate
-        speed: Feed rate in mm/min
-        timeout: Maximum time in seconds to wait for 'ok' response
-        home: If True, sends jog command ($J=) instead of G1
-
-    Returns:
-        True on success, False on timeout or error
-    """
-    logger.debug(f"Sending G-code: X{x} Y{y} at F{speed}")
-
-    overall_start_time = time.time()
-    max_retries = 3
-    retry_count = 0
-
-    while retry_count < max_retries:
-        # Check overall timeout
-        if time.time() - overall_start_time > timeout:
-            logger.error(f"Timeout waiting for 'ok' response after {timeout}s")
-            return False
-
-        try:
-            gcode = f"$J=G91 G21 Y{y:.2f} F{speed}" if home else f"G1 X{x:.2f} Y{y:.2f} F{speed}"
-            await asyncio.to_thread(state.conn.send, gcode + "\n")
-            logger.debug(f"Sent command: {gcode}")
-
-            # Wait for 'ok' response with timeout
-            response_start = time.time()
-            response_timeout = min(10, timeout - (time.time() - overall_start_time))
-
-            while time.time() - response_start < response_timeout:
-                # Check overall timeout
-                if time.time() - overall_start_time > timeout:
-                    logger.error("Overall timeout waiting for 'ok' response")
-                    return False
-
-                response = await asyncio.to_thread(state.conn.readline)
-                if response:
-                    logger.debug(f"Response: {response}")
-                    if response.lower().strip() == "ok":
-                        logger.debug("Command execution confirmed.")
-                        return True
-                    elif 'error' in response.lower():
-                        logger.warning(f"Got error response: {response}")
-                        # Don't immediately fail - some errors are recoverable
-                else:
-                    await asyncio.sleep(0.05)
-
-            # Response timeout for this attempt
-            logger.warning(f"No 'ok' received for {gcode}, retrying... ({retry_count + 1}/{max_retries})")
-            retry_count += 1
-            await asyncio.sleep(0.2)
-
-        except Exception as e:
-            error_str = str(e)
-            logger.warning(f"Error sending command: {error_str}")
-
-            # Immediately return for device not configured errors
-            if "Device not configured" in error_str or "Errno 6" in error_str:
-                logger.error(f"Device configuration error detected: {error_str}")
-                state.stop_requested = True
-                state.conn = None
-                state.is_connected = False
-                logger.info("Connection marked as disconnected due to device error")
-                return False
-
-            retry_count += 1
-            await asyncio.sleep(0.2)
-
-    logger.error(f"Failed to receive 'ok' response after {max_retries} retries")
-    return False
 
+###############################################################################
+# Homing
+###############################################################################
 
-def _detect_firmware():
+def home(timeout=120):
     """
-    Detect firmware type (FluidNC or GRBL) by sending $I command.
-    Returns tuple: (firmware_type: str, version: str or None)
-    firmware_type is 'fluidnc', 'grbl', or 'unknown'
+    Home the table by delegating to the board's /sand_home (which honors its own
+    $Sand/HomingMode — sensor or crash). Polls /sand_status until Idle.
     """
     if not state.conn or not state.conn.is_connected():
-        return ('unknown', None)
-
-    # Clear buffer first
-    try:
-        while state.conn.in_waiting() > 0:
-            state.conn.readline()
-    except Exception:
-        pass
-
-    try:
-        state.conn.send("$I\n")
-        time.sleep(0.3)
-
-        firmware_type = 'unknown'
-        version = None
-        start_time = time.time()
-
-        while time.time() - start_time < 2.0:
-            if state.conn.in_waiting() > 0:
-                response = state.conn.readline()
-                if response:
-                    logger.debug(f"Firmware detection response: {response}")
-                    response_lower = response.lower()
-
-                    if 'fluidnc' in response_lower:
-                        firmware_type = 'fluidnc'
-                        # Extract version like "v3.7.2" from response
-                        ver_match = re.search(r'v(\d+\.\d+\.\d+)', response_lower)
-                        if ver_match:
-                            version = f"v{ver_match.group(1)}"
-                        break
-                    elif 'grbl' in response_lower and 'fluidnc' not in response_lower:
-                        firmware_type = 'grbl'
-                        # Try to extract version like "Grbl 1.1h"
-                        parts = response.split()
-                        for i, part in enumerate(parts):
-                            if 'grbl' in part.lower() and i + 1 < len(parts):
-                                version = parts[i + 1]
-                                break
-                        break
-                    elif response.lower().strip() == 'ok':
-                        break
-            else:
-                time.sleep(0.05)
-
-        # Clear any remaining responses
-        while state.conn.in_waiting() > 0:
-            state.conn.readline()
-
-        return (firmware_type, version)
-
-    except Exception as e:
-        logger.warning(f"Firmware detection failed: {e}")
-        return ('unknown', None)
-
-
-def _get_steps_fluidnc():
-    """
-    Get steps/mm from FluidNC using individual setting queries.
-    Returns tuple: (x_steps_per_mm, y_steps_per_mm) or (None, None) on failure.
-
-    Note: Works even when device is in ALARM state (e.g., limit switch active).
-    """
-    x_steps = None
-    y_steps = None
-
-    # Clear buffer
-    try:
-        while state.conn.in_waiting() > 0:
-            state.conn.readline()
-    except Exception:
-        pass
-
-    # Query X steps/mm
-    try:
-        state.conn.send("$/axes/x/steps_per_mm\n")
-        time.sleep(0.2)
-
-        start_time = time.time()
-        while time.time() - start_time < 2.0:
-            if state.conn.in_waiting() > 0:
-                response = state.conn.readline()
-                if response:
-                    logger.debug(f"FluidNC X steps response: {response}")
-                    # Response format: "/axes/x/steps_per_mm=200.000" or similar
-                    if 'steps_per_mm=' in response:
-                        try:
-                            x_steps = float(response.split('=')[1].strip())
-                            state.x_steps_per_mm = x_steps
-                            logger.info(f"X steps per mm (FluidNC): {x_steps}")
-                        except (ValueError, IndexError) as e:
-                            logger.warning(f"Failed to parse X steps: {e}")
-                        break
-                    elif response.lower().strip() == 'ok':
-                        break
-                    elif 'error' in response.lower() or 'alarm' in response.lower():
-                        # Device may be in alarm state (e.g., limit switch active)
-                        # Log and continue - settings queries often work anyway
-                        logger.debug(f"Got error/alarm response, continuing: {response}")
-            else:
-                time.sleep(0.05)
-    except Exception as e:
-        logger.error(f"Error querying FluidNC X steps: {e}")
-
-    # Clear buffer before next query
-    try:
-        while state.conn.in_waiting() > 0:
-            state.conn.readline()
-    except Exception:
-        pass
-
-    # Query Y steps/mm
-    try:
-        state.conn.send("$/axes/y/steps_per_mm\n")
-        time.sleep(0.2)
-
-        start_time = time.time()
-        while time.time() - start_time < 2.0:
-            if state.conn.in_waiting() > 0:
-                response = state.conn.readline()
-                if response:
-                    logger.debug(f"FluidNC Y steps response: {response}")
-                    if 'steps_per_mm=' in response:
-                        try:
-                            y_steps = float(response.split('=')[1].strip())
-                            state.y_steps_per_mm = y_steps
-                            logger.info(f"Y steps per mm (FluidNC): {y_steps}")
-                        except (ValueError, IndexError) as e:
-                            logger.warning(f"Failed to parse Y steps: {e}")
-                        break
-                    elif response.lower().strip() == 'ok':
-                        break
-                    elif 'error' in response.lower() or 'alarm' in response.lower():
-                        logger.debug(f"Got error/alarm response, continuing: {response}")
-            else:
-                time.sleep(0.05)
-    except Exception as e:
-        logger.error(f"Error querying FluidNC Y steps: {e}")
-
-    # Clear buffer before homing query
-    try:
-        while state.conn.in_waiting() > 0:
-            state.conn.readline()
-    except Exception:
-        pass
-
-    # Query homing cycle setting (informational - user preference takes precedence)
-    try:
-        state.conn.send("$/axes/y/homing/cycle\n")
-        time.sleep(0.2)
-
-        start_time = time.time()
-        while time.time() - start_time < 1.5:
-            if state.conn.in_waiting() > 0:
-                response = state.conn.readline()
-                if response:
-                    logger.debug(f"FluidNC homing response: {response}")
-                    if 'homing/cycle=' in response:
-                        try:
-                            homing_cycle = int(float(response.split('=')[1].strip()))
-                            # cycle >= 1 means homing is enabled in firmware
-                            logger.info(f"Firmware homing setting (cycle): {homing_cycle}, using user preference: {state.homing}")
-                        except (ValueError, IndexError):
-                            pass
-                        break
-                    elif response.lower().strip() == 'ok':
-                        break
-            else:
-                time.sleep(0.05)
-    except Exception as e:
-        logger.debug(f"Could not query FluidNC homing setting: {e}")
+        logger.error("Cannot home: no board connection")
+        return False
 
-    # Clear buffer
+    state.is_homing = True
+    state.sensor_homing_failed = False
     try:
-        while state.conn.in_waiting() > 0:
-            state.conn.readline()
-    except Exception:
-        pass
-
-    return (x_steps, y_steps)
-
-
-def _get_steps_grbl():
-    """
-    Get steps/mm from GRBL using $$ command.
-    Returns tuple: (x_steps_per_mm, y_steps_per_mm) or (None, None) on failure.
-
-    Note: Works even when device is in ALARM state (e.g., limit switch active).
-    $$ command typically responds with settings even during alarm.
-    """
-    x_steps_per_mm = None
-    y_steps_per_mm = None
-
-    max_retries = 3
-    attempt_timeout = 4
-
-    for attempt in range(max_retries):
-        logger.info(f"Requesting GRBL settings with $$ command (attempt {attempt + 1}/{max_retries})")
-
-        try:
-            state.conn.send("$$\n")
-        except Exception as e:
-            logger.error(f"Error sending $$ command: {e}")
-            continue
-
-        attempt_start = time.time()
-        got_ok = False
-
-        while time.time() - attempt_start < attempt_timeout:
-            try:
-                response = state.conn.readline()
-
-                if not response:
-                    continue
-
-                logger.debug(f"Raw response: {response}")
-
-                for line in response.splitlines():
-                    line = line.strip()
-                    if not line:
-                        continue
-
-                    logger.debug(f"Config response: {line}")
-
-                    if line.startswith("$100="):
-                        x_steps_per_mm = float(line.split("=")[1])
-                        state.x_steps_per_mm = x_steps_per_mm
-                        logger.info(f"X steps per mm: {x_steps_per_mm}")
-                    elif line.startswith("$101="):
-                        y_steps_per_mm = float(line.split("=")[1])
-                        state.y_steps_per_mm = y_steps_per_mm
-                        logger.info(f"Y steps per mm: {y_steps_per_mm}")
-                    elif line.startswith("$22="):
-                        firmware_homing = int(line.split('=')[1])
-                        logger.info(f"Firmware homing setting ($22): {firmware_homing}, using user preference: {state.homing}")
-                    elif line.lower() == 'ok':
-                        got_ok = True
-                        logger.debug("Received 'ok' confirmation from GRBL")
-                    elif line.lower().startswith('error') or 'alarm' in line.lower():
-                        # Device may be in alarm state (e.g., limit switch active)
-                        # Log and continue - $$ typically works anyway
-                        logger.debug(f"Got error/alarm during settings query (proceeding): {line}")
-
-                if got_ok:
-                    if x_steps_per_mm is not None and y_steps_per_mm is not None:
-                        logger.info("Successfully received all GRBL settings")
-                        break
-                    else:
-                        logger.warning("Received 'ok' but missing some settings")
-                        break
-
-            except Exception as e:
-                logger.error(f"Error reading GRBL response: {e}")
-                break
-
-        if x_steps_per_mm is not None and y_steps_per_mm is not None:
-            break
-
-        if attempt < max_retries - 1:
-            logger.warning(f"Attempt {attempt + 1} did not get all settings, retrying...")
-            time.sleep(0.5)
+        _push_homing_settings()
+        logger.info("Sending home command to board...")
+        state.conn.home()
+        # Give the board a moment to enter the Home state before polling.
+        time.sleep(1.0)
+
+        start = time.time()
+        while time.time() - start < timeout:
             try:
-                while state.conn.in_waiting() > 0:
-                    state.conn.readline()
+                st = state.conn.get_status()
             except Exception:
-                pass
-
-    return (x_steps_per_mm, y_steps_per_mm)
-
-
-def get_machine_steps(timeout=10):
-    """
-    Get machine steps/mm from the controller (FluidNC or GRBL).
-    Returns True if successful, False otherwise.
+                time.sleep(1.0)
+                continue
+
+            machine_state = st.get("state", "")
+            if machine_state == "Idle":
+                apply_status(st)
+                logger.info(
+                    f"Homing complete (theta={state.current_theta}, rho={state.current_rho})"
+                )
+                state.save()
+                return True
+            if machine_state == "Alarm":
+                logger.error("Homing failed: board reports Alarm")
+                # In sensor mode an alarm means the switch was not found.
+                state.sensor_homing_failed = state.homing == 1
+                return False
+            time.sleep(1.0)
 
-    Detects firmware type first:
-    - FluidNC: Uses targeted $/axes/x/steps_per_mm queries (more reliable)
-    - GRBL: Falls back to $$ command with retries
-    """
-    if not state.conn or not state.conn.is_connected():
-        logger.error("Cannot get machine steps: No connection available")
+        logger.warning(f"Homing timed out after {timeout}s")
         return False
-
-    # Clear any pending data in the buffer
-    try:
-        while state.conn.in_waiting() > 0:
-            state.conn.readline()
-    except Exception as e:
-        logger.warning(f"Error clearing buffer: {e}")
-
-    # Verify controller is responsive before querying
-    try:
-        state.conn.send("?\n")
-        time.sleep(0.2)
-        ready_check_attempts = 5
-        controller_ready = False
-        for _ in range(ready_check_attempts):
-            if state.conn.in_waiting() > 0:
-                response = state.conn.readline()
-                if response and ('<' in response or 'Idle' in response or 'Alarm' in response):
-                    controller_ready = True
-                    if 'Alarm' in response:
-                        logger.info(f"Controller in ALARM state (likely limit switch active), proceeding with settings query: {response.strip()}")
-                    else:
-                        logger.debug(f"Controller ready, status: {response}")
-                    break
-            time.sleep(0.1)
-
-        if not controller_ready:
-            logger.warning("Controller not responding to status query, proceeding anyway...")
-
-        # Clear buffer after readiness check
-        while state.conn.in_waiting() > 0:
-            state.conn.readline()
-        time.sleep(0.1)
     except Exception as e:
-        logger.warning(f"Readiness check failed: {e}, proceeding anyway...")
-
-    # Detect firmware type
-    firmware_type, firmware_version = _detect_firmware()
-    state.firmware_type = firmware_type
-    state.firmware_version = firmware_version
-
-    if firmware_type == 'fluidnc':
-        if firmware_version:
-            logger.info(f"Detected FluidNC firmware, version: {firmware_version}")
-        else:
-            logger.info("Detected FluidNC firmware (version unknown)")
-        x_steps_per_mm, y_steps_per_mm = _get_steps_fluidnc()
-
-        # Fallback to GRBL method if FluidNC queries failed
-        if x_steps_per_mm is None or y_steps_per_mm is None:
-            logger.warning("FluidNC setting queries failed, falling back to $$ command...")
-            x_steps_per_mm, y_steps_per_mm = _get_steps_grbl()
-    else:
-        if firmware_type == 'grbl':
-            if firmware_version:
-                logger.info(f"Detected GRBL firmware, version: {firmware_version}")
-            else:
-                logger.info("Detected GRBL firmware (version unknown)")
-        else:
-            logger.info("Could not detect firmware type, using GRBL commands")
-        x_steps_per_mm, y_steps_per_mm = _get_steps_grbl()
-    
-    # Process results and determine table type
-    # Uses tolerance-based matching (±5) to handle firmware float variations
-    # (e.g., 287 vs 287.0) and checks both axes for reliable identification
-    def _steps_match(actual, expected, tolerance=5):
-        return abs(actual - expected) <= tolerance
-
-    settings_complete = (x_steps_per_mm is not None and y_steps_per_mm is not None)
-    if settings_complete:
-        if _steps_match(y_steps_per_mm, 180) and _steps_match(x_steps_per_mm, 256):
-            state.table_type = 'dune_weaver_mini'
-        elif _steps_match(y_steps_per_mm, 210) and _steps_match(x_steps_per_mm, 256):
-            state.table_type = 'dune_weaver_mini_pro_byj'
-        elif _steps_match(y_steps_per_mm, 164) and _steps_match(x_steps_per_mm, 200):
-            state.table_type = 'dune_weaver_mini_pro'
-        elif (_steps_match(y_steps_per_mm, 270) or _steps_match(y_steps_per_mm, 250)) and _steps_match(x_steps_per_mm, 200):
-            state.table_type = 'dune_weaver_gold'
-        elif _steps_match(y_steps_per_mm, 620) and _steps_match(x_steps_per_mm, 320):
-            state.table_type = 'dune_weaver_pro_pulley'
-        elif _steps_match(y_steps_per_mm, 533) and _steps_match(x_steps_per_mm, 320):
-            state.table_type = 'dune_weaver_pro'
-        elif _steps_match(y_steps_per_mm, 287) and _steps_match(x_steps_per_mm, 320):
-            state.table_type = 'dune_weaver'
-        else:
-            state.table_type = None
-            logger.warning(f"Unknown table type with X steps/mm: {x_steps_per_mm}, Y steps/mm: {y_steps_per_mm}")
-
-        # Use override if set, otherwise use detected table type
-        effective_table_type = state.table_type_override or state.table_type
-
-        # Set gear ratio based on effective table type (hardcoded)
-        if effective_table_type in ['dune_weaver_mini', 'dune_weaver_mini_pro', 'dune_weaver_mini_pro_byj', 'dune_weaver_gold']:
-            state.gear_ratio = 6.25
-        else:
-            state.gear_ratio = 10
-
-        # Check for environment variable override
-        gear_ratio_override = os.getenv('GEAR_RATIO')
-        if gear_ratio_override is not None:
-            try:
-                state.gear_ratio = float(gear_ratio_override)
-                logger.info(f"Machine type detected: {state.table_type}, effective: {effective_table_type}, gear ratio: {state.gear_ratio} (from GEAR_RATIO env var)")
-            except ValueError:
-                logger.error(f"Invalid GEAR_RATIO env var value: {gear_ratio_override}, using default: {state.gear_ratio}")
-                logger.info(f"Machine type detected: {state.table_type}, effective: {effective_table_type}, gear ratio: {state.gear_ratio} (hardcoded)")
-        elif state.table_type_override:
-            logger.info(f"Machine type detected: {state.table_type}, overridden to: {effective_table_type}, gear ratio: {state.gear_ratio}")
-        else:
-            logger.info(f"Machine type detected: {state.table_type}, gear ratio: {state.gear_ratio} (hardcoded)")
-
-        # User UI override takes highest priority
-        if state.gear_ratio_override is not None:
-            state.gear_ratio = state.gear_ratio_override
-            logger.info(f"Gear ratio overridden to: {state.gear_ratio} (user override)")
-
-        return True
-    else:
-        missing = []
-        if x_steps_per_mm is None:
-            missing.append("X steps/mm")
-        if y_steps_per_mm is None:
-            missing.append("Y steps/mm")
-        logger.error(f"Failed to get all machine parameters after {timeout}s. Missing: {', '.join(missing)}")
-        return False
-
-def home(timeout=120):
-    """
-    Perform homing sequence based on configured mode:
-
-    Mode 0 (Crash):
-        - Y axis moves -22mm (or -30mm for mini) until physical stop
-        - Set theta=0, rho=0 (no x0 y0 command)
-
-    Mode 1 (Sensor):
-        - Send $H command to home both X and Y axes
-        - Wait for [MSG:Homed:X] and [MSG:Homed:Y] messages
-        - Send x0 y0 to zero positions
-        - Set theta to compass offset, rho=0
-
-    Args:
-        timeout: Maximum time in seconds to wait for homing to complete (default: 120)
-                 Increased from 90s to allow buffer after soft reset recovery
-    """
-    import threading
-    import math
-
-    # Check for alarm state before homing and unlock if needed
-    if not check_and_unlock_alarm():
-        logger.error("Failed to unlock device from alarm state, cannot proceed with homing")
+        logger.error(f"Error during homing: {e}")
         return False
+    finally:
+        state.is_homing = False
 
-    # Flag to track if homing completed
-    homing_complete = threading.Event()
-    homing_success = False
-
-    def home_internal():
-        nonlocal homing_success
-        effective_table_type = state.table_type_override or state.table_type
-        homing_speed = 400
-        if effective_table_type == 'dune_weaver_mini':
-            homing_speed = 100
-        try:
-            if state.homing == 1:
-                # Mode 1: Sensor-based homing using $H
-                logger.info("Using sensor-based homing mode ($H)")
-
-                # Clear any pending responses
-                state.homed_x = False
-                state.homed_y = False
-
-                # Clear any stale data from previous operations
-                try:
-                    while state.conn.in_waiting() > 0:
-                        stale = state.conn.readline()
-                        logger.debug(f"Cleared stale data before homing: {stale}")
-                except Exception:
-                    pass
-
-                # Send $H command
-                state.conn.send("$H\n")
-                logger.info("Sent $H command, waiting for homing messages...")
 
-                # Wait for [MSG:Homed:X] and [MSG:Homed:Y] messages
-                max_wait_time = 60  # 60 seconds - boot recovery needs more time
-                start_time = time.time()
-
-                while (time.time() - start_time) < max_wait_time:
-                    try:
-                        response = state.conn.readline()
-                        if response:
-                            logger.debug(f"Homing response: {response}")
-
-                            # Check for homing messages
-                            if "[MSG:Homed:X]" in response:
-                                state.homed_x = True
-                                logger.info("Received [MSG:Homed:X]")
-                            if "[MSG:Homed:Y]" in response:
-                                state.homed_y = True
-                                logger.info("Received [MSG:Homed:Y]")
-
-                            # Break if we've received both messages
-                            if state.homed_x and state.homed_y:
-                                logger.info("Received both homing confirmation messages")
-                                break
-                    except Exception as e:
-                        logger.error(f"Error reading homing response: {e}")
-
-                    time.sleep(0.1)
-
-                if not (state.homed_x and state.homed_y):
-                    logger.warning(f"Did not receive all homing messages (X:{state.homed_x}, Y:{state.homed_y}), unlocking and continuing...")
-                    # Unlock machine to clear any alarm state
-                    state.conn.send("$X\n")
-                    time.sleep(0.5)
-
-                # Wait for idle state after $H
-                logger.info("Waiting for device to reach idle state after $H...")
-                idle_reached = check_idle()
-
-                if not idle_reached:
-                    logger.error("Device did not reach idle state after $H command")
-                    homing_complete.set()
-                    return
-
-                # If X homed but Y failed, fallback to crash homing for Y
-                if state.homed_x and not state.homed_y:
-                    logger.warning("Sensor homing incomplete (Y failed) - falling back to crash homing")
-
-                    # Perform crash homing as fallback
-                    logger.info(f"Executing crash homing fallback at {homing_speed} mm/min")
-
-                    loop = asyncio.new_event_loop()
-                    asyncio.set_event_loop(loop)
-                    try:
-                        if effective_table_type == 'dune_weaver_mini':
-                            result = loop.run_until_complete(send_grbl_coordinates(0, -30, homing_speed, home=True))
-                            if not result:
-                                logger.error("Crash homing fallback failed")
-                                homing_complete.set()
-                                return
-                        else:
-                            result = loop.run_until_complete(send_grbl_coordinates(0, -22, homing_speed, home=True))
-                            if not result:
-                                logger.error("Crash homing fallback failed")
-                                homing_complete.set()
-                                return
-                    finally:
-                        loop.close()
-
-                    # Wait for idle after crash homing
-                    logger.info("Waiting for device to reach idle state after crash homing fallback...")
-                    idle_reached = check_idle()
-                    if not idle_reached:
-                        logger.error("Device did not reach idle state after crash homing fallback")
-                        homing_complete.set()
-                        return
-
-                    # Set position like crash homing does
-                    state.current_theta = 0
-                    state.current_rho = 0
-                    logger.info("Crash homing fallback completed - theta=0, rho=0")
-
-                elif not state.homed_x and not state.homed_y:
-                    # Neither axis homed - this is a failure, don't proceed
-                    # Set sensor_homing_failed flag to notify UI for user action
-                    logger.error("Sensor homing failed - neither axis homed. User action required.")
-                    state.sensor_homing_failed = True
-                    homing_complete.set()
-                    return
-                else:
-                    # Send x0 y0 to zero both positions using send_grbl_coordinates
-                    logger.info(f"Zeroing positions with x0 y0 f{homing_speed}")
-
-                    # Run async function in new event loop
-                    loop = asyncio.new_event_loop()
-                    asyncio.set_event_loop(loop)
-                    try:
-                        # Send G1 X0 Y0 F{homing_speed}
-                        result = loop.run_until_complete(send_grbl_coordinates(0, 0, homing_speed))
-                        if not result:
-                            logger.error("Position zeroing failed - send_grbl_coordinates returned False")
-                            homing_complete.set()
-                            return
-                        logger.info("Position zeroing completed successfully")
-                    finally:
-                        loop.close()
-
-                    # Wait for device to reach idle state after zeroing movement
-                    logger.info("Waiting for device to reach idle state after zeroing...")
-                    idle_reached = check_idle()
-
-                    if not idle_reached:
-                        logger.error("Device did not reach idle state after zeroing")
-                        homing_complete.set()
-                        return
-
-                # Set current position based on compass reference point (sensor mode only)
-                offset_radians = math.radians(state.angular_homing_offset_degrees)
-                state.current_theta = offset_radians
-                state.current_rho = 0
-
-                logger.info(f"Sensor homing completed - theta set to {state.angular_homing_offset_degrees}° ({offset_radians:.3f} rad), rho=0")
-
-            else:
-                logger.info(f"Using crash homing mode at {homing_speed} mm/min")
-
-                # Run async function in new event loop
-                loop = asyncio.new_event_loop()
-                asyncio.set_event_loop(loop)
-                try:
-                    if effective_table_type == 'dune_weaver_mini':
-                        result = loop.run_until_complete(send_grbl_coordinates(0, -30, homing_speed, home=True))
-                        if not result:
-                            logger.error("Crash homing failed - send_grbl_coordinates returned False")
-                            homing_complete.set()
-                            return
-                        state.machine_y -= 30
-                    else:
-                        result = loop.run_until_complete(send_grbl_coordinates(0, -22, homing_speed, home=True))
-                        if not result:
-                            logger.error("Crash homing failed - send_grbl_coordinates returned False")
-                            homing_complete.set()
-                            return
-                        state.machine_y -= 22
-                finally:
-                    loop.close()
-
-                # Wait for device to reach idle state after crash homing
-                logger.info("Waiting for device to reach idle state after crash homing...")
-                idle_reached = check_idle()
-
-                if not idle_reached:
-                    logger.error("Device did not reach idle state after crash homing")
-                    homing_complete.set()
-                    return
-
-                # Crash homing just sets theta and rho to 0 (no x0 y0 command)
-                state.current_theta = 0
-                state.current_rho = 0
-
-                logger.info("Crash homing completed - theta=0, rho=0")
-
-            # Update machine position from hardware after homing
-            logger.info("Updating machine position after homing...")
-            try:
-                pos = get_machine_position()
-                if pos and pos[0] is not None and pos[1] is not None:
-                    state.machine_x, state.machine_y = pos
-                    state.save()
-                    logger.info(f"Machine position updated after homing: X={state.machine_x}, Y={state.machine_y}")
-                else:
-                    logger.warning("Could not get machine position after homing")
-            except Exception as e:
-                logger.error(f"Error updating machine position after homing: {e}")
-
-            homing_success = True
-            # Clear sensor_homing_failed flag on successful homing
-            state.sensor_homing_failed = False
-            homing_complete.set()
-
-        except Exception as e:
-            logger.error(f"Error during homing: {e}")
-            homing_complete.set()
-
-    # Start homing in a separate thread
-    homing_thread = threading.Thread(target=home_internal)
-    homing_thread.daemon = True
-    homing_thread.start()
-
-    # Wait for homing to complete or timeout
-    if not homing_complete.wait(timeout):
-        logger.error(f"Homing timeout after {timeout} seconds")
-        # Try to stop any ongoing movement
-        try:
-            if state.conn and state.conn.is_connected():
-                state.conn.send("!\n")  # Send feed hold
-                time.sleep(0.1)
-                state.conn.send("\x18\n")  # Send reset
-        except Exception as e:
-            logger.error(f"Error stopping movement after timeout: {e}")
-        return False
-
-    if not homing_success:
-        logger.error("Homing failed")
-        return False
-
-    logger.info("Homing completed successfully")
-    return True
-
-def check_idle():
-    """
-    Continuously check if the device is idle (synchronous version).
-    """
-    logger.info("Checking idle")
-    while True:
-        response = get_status_response()
-        if response and "Idle" in response:
-            logger.info("Device is idle")
-            # Schedule async update_machine_position in the existing event loop
-            try:
-                # Try to schedule in existing event loop if available
-                try:
-                    asyncio.get_running_loop()
-                    # Create a task but don't await it (fire and forget)
-                    asyncio.create_task(update_machine_position())
-                    logger.debug("Scheduled machine position update task")
-                except RuntimeError:
-                    # No event loop running, skip machine position update
-                    logger.debug("No event loop running, skipping machine position update")
-            except Exception as e:
-                logger.error(f"Error scheduling machine position update: {e}")
-            return True
-        time.sleep(1)
+###############################################################################
+# Idle / position / reset
+###############################################################################
 
 async def check_idle_async(timeout: float = 30.0):
-    """
-    Continuously check if the device is idle (async version).
-
-    Args:
-        timeout: Maximum seconds to wait for idle state (default 30s)
-
-    Returns:
-        True if device became idle, False if timeout or stop requested
-    """
-    logger.info("Checking idle (async)")
+    """Wait until the board reports Idle (or stop requested / timeout)."""
     start_time = asyncio.get_event_loop().time()
-
     while True:
-        # Check if stop was requested - exit early
         if state.stop_requested:
-            logger.info("Stop requested during idle check, exiting early")
+            logger.debug("Stop requested during idle check, exiting early")
             return False
-
-        # Check timeout
-        elapsed = asyncio.get_event_loop().time() - start_time
-        if elapsed > timeout:
-            logger.warning(f"Timeout ({timeout}s) waiting for device idle state")
+        if asyncio.get_event_loop().time() - start_time > timeout:
+            logger.warning(f"Timeout ({timeout}s) waiting for board idle state")
             return False
-
-        response = await asyncio.to_thread(get_status_response)
-        if response and "Idle" in response:
-            logger.info("Device is idle")
-            try:
-                await update_machine_position()
-            except Exception as e:
-                logger.error(f"Error updating machine position: {e}")
+        st = await asyncio.to_thread(poll_status_once)
+        if st and st.get("state") == "Idle":
             return True
-        await asyncio.sleep(1)
+        await asyncio.sleep(0.5)
 
-def is_machine_idle() -> bool:
-    """
-    Single check to see if the machine is currently idle.
-    Does not loop - returns immediately with current status.
 
-    Returns:
-        True if machine is idle, False otherwise
-    """
-    if not state.conn or not state.conn.is_connected():
-        logger.debug("No connection - machine not idle")
-        return False
-
-    try:
-        state.conn.send('?')
-        response = state.conn.readline()
-
-        if response and "Idle" in response:
-            logger.debug("Machine status: Idle")
-            return True
-        else:
-            logger.debug(f"Machine status: {response}")
-            return False
-    except Exception as e:
-        logger.error(f"Error checking machine idle status: {e}")
-        return False
+def is_machine_idle() -> bool:
+    """Single, immediate check of whether the board is idle."""
+    st = poll_status_once()
+    return bool(st and st.get("state") == "Idle")
 
 
 def get_machine_position(timeout=5):
     """
-    Query the device for its position.
-    Supports both MPos and WPos formats (depends on GRBL $10 setting).
+    Position is owned by the board now (reported as theta/rho in /sand_status, not
+    cartesian MPos). Return the last-known machine coordinates for persistence.
     """
-    start_time = time.time()
-    while time.time() - start_time < timeout:
-        try:
-            state.conn.send('?')
-            response = state.conn.readline()
-            logger.debug(f"Raw status response: {response}")
-            # Accept either MPos or WPos format
-            if "MPos" in response or "WPos" in response:
-                pos = parse_machine_position(response)
-                if pos:
-                    machine_x, machine_y = pos
-                    logger.debug(f"Machine position: X={machine_x}, Y={machine_y}")
-                    return machine_x, machine_y
-        except Exception as e:
-            logger.error(f"Error getting machine position: {e}")
-            return
-        time.sleep(0.1)
-    logger.warning("Timeout reached waiting for machine position")
-    return None, None
+    return state.machine_x, state.machine_y
+
 
 async def update_machine_position():
-    if (state.conn.is_connected() if state.conn else False):
+    """Refresh theta/rho from the board and persist state."""
+    if state.conn and state.conn.is_connected():
         try:
-            logger.info('Saving machine position')
-            state.machine_x, state.machine_y = await asyncio.to_thread(get_machine_position)
+            await asyncio.to_thread(poll_status_once)
             await asyncio.to_thread(state.save)
-            logger.info(f'Machine position saved: {state.machine_x}, {state.machine_y}')
         except Exception as e:
             logger.error(f"Error updating machine position: {e}")
 
 
-def perform_soft_reset_sync(max_retries: int = 5):
-    """
-    Synchronous version of soft reset for use during device initialization.
-
-    Supports both FluidNC ($Bye) and GRBL (Ctrl+X / 0x18) firmware.
-    Triggers a software reset which clears position counters to 0.
-    This is more reliable than G92 which only sets a work coordinate offset
-    without changing the actual machine position (MPos).
-
-    IMPORTANT: Position is only reset to (0,0) if confirmation is received.
-    This prevents position drift from accumulating over long operation periods.
-
-    Uses exponential backoff for retries:
-    - Attempt 1: 5s timeout
-    - Attempt 2: 7.5s timeout, 1s delay before retry
-    - Attempt 3: 11s timeout, 2s delay before retry
-    - Attempt 4: 17s timeout, 4s delay before retry
-    - Attempt 5: 25s timeout, 8s delay before retry
-
-    Args:
-        max_retries: Maximum number of reset attempts (default 5)
-
-    Returns:
-        True if reset confirmed, False if all attempts failed
-    """
+def perform_soft_reset_sync():
+    """Reboot the controller via $Bye (loses position; caller re-homes)."""
     if not state.conn or not state.conn.is_connected():
-        logger.warning("Cannot perform soft reset: no active connection")
+        logger.warning("Cannot perform soft reset: no board connection")
         return False
-
     try:
-        # Detect firmware type to use appropriate reset command
-        firmware_type, version = _detect_firmware()
-        logger.info(f"Detected firmware: {firmware_type} {version or ''}")
-        logger.info(f"Performing soft reset (was: X={state.machine_x:.2f}, Y={state.machine_y:.2f})")
-
-        for attempt in range(max_retries):
-            # Exponential backoff: 5s * 1.5^attempt → 5s, 7.5s, 11s, 17s, 25s
-            timeout = 5.0 * (1.5 ** attempt)
-            logger.info(f"Reset attempt {attempt + 1}/{max_retries} (timeout: {timeout:.1f}s)")
-
-            # Clear any pending data first
-            if isinstance(state.conn, SerialConnection):
-                state.conn.reset_input_buffer()
-
-            # Send appropriate reset command based on firmware
-            if firmware_type == 'fluidnc':
-                # FluidNC uses $Bye for soft reset
-                state.conn.send('$Bye\n')
-                logger.info(f"$Bye sent to {state.port}")
-            else:
-                # GRBL uses Ctrl+X (0x18) for soft reset
-                state.conn.send('\x18')
-                logger.info(f"Ctrl+X (0x18) sent to {state.port}")
-
-            # Wait for controller to fully restart
-            # FluidNC sequence: [MSG:INFO: Restarting] -> ... -> "Grbl 3.9 [FluidNC...]"
-            # GRBL sequence: "Grbl 1.1h ['$' for help]"
-            start_time = time.time()
-            reset_confirmed = False
-            while time.time() - start_time < timeout:
-                try:
-                    response = state.conn.readline()
-                    if response:
-                        logger.debug(f"Reset response: {response}")
-                        # Wait for the "Grbl" startup banner - this means fully ready
-                        if response.startswith("Grbl") or "fluidnc" in response.lower():
-                            reset_confirmed = True
-                            logger.info(f"Controller restart complete: {response}")
-                            break
-                except Exception:
-                    pass
-                time.sleep(0.05)
-
-            if reset_confirmed:
-                # Small delay to let controller fully stabilize
-                time.sleep(0.2)
-
-                # Unlock controller in case it's in alarm state after reset
-                logger.info("Sending $X to unlock controller after reset")
-                state.conn.send("$X\n")
-                # Wait for ok response
-                unlock_start = time.time()
-                while time.time() - unlock_start < 1.0:
-                    try:
-                        response = state.conn.readline()
-                        if response:
-                            logger.debug(f"$X response: {response}")
-                            if response.lower() == "ok":
-                                logger.info("Controller unlocked")
-                                break
-                    except Exception:
-                        pass
-                    time.sleep(0.05)
-
-                # Only reset state positions when confirmation received
-                state.machine_x = 0.0
-                state.machine_y = 0.0
-                reset_cmd = '$Bye' if firmware_type == 'fluidnc' else 'Ctrl+X'
-                logger.info(f"Machine position reset to 0 via {reset_cmd} soft reset")
-
-                # Save the reset position
-                state.save()
-                logger.info(f"Machine position saved: {state.machine_x}, {state.machine_y}")
-                return True
-
-            # Retry after failed attempt with exponential backoff delay
-            if attempt < max_retries - 1:
-                backoff_delay = 1.0 * (2 ** attempt)  # 1s, 2s, 4s, 8s
-                logger.warning(f"Reset attempt {attempt + 1}/{max_retries} failed, retrying in {backoff_delay:.0f}s...")
-                time.sleep(backoff_delay)
-
-        # All attempts failed - DO NOT reset position to prevent drift
-        logger.error(
-            f"All {max_retries} reset attempts failed - no confirmation received. "
-            f"Position NOT reset (still: X={state.machine_x:.2f}, Y={state.machine_y:.2f}). "
-            "This may indicate communication issues or controller not responding."
-        )
-        return False
-
+        state.conn.soft_reset()
+        time.sleep(2.0)  # board reboots; give it a moment
+        return True
     except Exception as e:
         logger.error(f"Error performing soft reset: {e}")
         return False
 
 
 async def perform_soft_reset():
-    """
-    Async version of soft reset for use in async contexts (API endpoints, pattern manager).
-    Wraps the sync version in a thread to avoid blocking the event loop.
-    """
     return await asyncio.to_thread(perform_soft_reset_sync)
 
 
-def reset_work_coordinates():
-    """
-    Clear all work coordinate offsets for a clean start.
-
-    This ensures the work coordinate system starts fresh on each connection,
-    preventing accumulated offsets from previous sessions from affecting
-    pattern execution.
-
-    G92.1: Clears any G92 offset (resets work coordinates to machine coordinates)
-    G10 L2 P1 X0 Y0: Sets G54 work offset to 0 (for completeness)
-    """
-    if not state.conn or not state.conn.is_connected():
-        logger.warning("Cannot reset work coordinates: no active connection")
-        return False
-
-    try:
-        logger.info("Resetting work coordinate offsets")
-
-        # Clear any stale input data first
+def restart_connection(homing=False):
+    """Close any existing connection and reconnect to the board."""
+    if state.conn:
         try:
-            while state.conn.in_waiting() > 0:
-                state.conn.readline()
+            state.conn.close()
         except Exception:
             pass
-
-        # Clear G92 offset
-        state.conn.send("G92.1\n")
-        time.sleep(0.2)
-
-        # Wait for 'ok' response
-        start_time = time.time()
-        got_ok = False
-        while time.time() - start_time < 2.0:
-            if state.conn.in_waiting() > 0:
-                response = state.conn.readline()
-                if response:
-                    logger.debug(f"G92.1 response: {response}")
-                    if response.lower() == "ok":
-                        got_ok = True
-                        break
-                    elif "error" in response.lower():
-                        logger.warning(f"G92.1 error: {response}")
-                        break
-            time.sleep(0.05)
-
-        if not got_ok:
-            logger.warning("Did not receive 'ok' for G92.1, continuing anyway")
-
-        # Set G54 offset to 0 (optional, for completeness)
-        state.conn.send("G10 L2 P1 X0 Y0\n")
-        time.sleep(0.2)
-
-        # Wait for 'ok' response
-        start_time = time.time()
-        got_ok = False
-        while time.time() - start_time < 2.0:
-            if state.conn.in_waiting() > 0:
-                response = state.conn.readline()
-                if response:
-                    logger.debug(f"G10 response: {response}")
-                    if response.lower() == "ok":
-                        got_ok = True
-                        break
-                    elif "error" in response.lower():
-                        logger.warning(f"G10 error: {response}")
-                        break
-            time.sleep(0.05)
-
-        if not got_ok:
-            logger.warning("Did not receive 'ok' for G10 L2 P1 X0 Y0, continuing anyway")
-
-        # Reset machine_x to 0 since work coordinates now start at 0
-        state.machine_x = 0.0
-        logger.info("Work coordinates reset complete")
-        return True
-
-    except Exception as e:
-        logger.error(f"Error resetting work coordinates: {e}")
-        return False
-
-
-def restart_connection(homing=False):
-    """
-    Restart the connection. If a connection exists, close it and attempt to establish a new one.
-    It will try to connect via serial first (if available), otherwise it will fall back to websocket.
-    The new connection is saved to state.conn.
-    
-    Returns:
-        True if the connection was restarted successfully, False otherwise.
-    """
-    try:
-        if (state.conn.is_connected() if state.conn else False):
-            logger.info("Closing current connection...")
-            state.conn.close()
-    except Exception as e:
-        logger.error(f"Error while closing connection: {e}")
-
-    # Clear the connection reference.
-    state.conn = None
-
-    logger.info("Attempting to restart connection...")
-    try:
-        connect_device(homing)  # This will set state.conn appropriately.
-        if (state.conn.is_connected() if state.conn else False):
-            logger.info("Connection restarted successfully.")
-            return True
-        else:
-            logger.error("Failed to restart connection.")
-            return False
-    except Exception as e:
-        logger.error(f"Error restarting connection: {e}")
-        return False
+        state.conn = None
+    connect_device(homing)
+    return state.conn is not None and state.conn.is_connected()

+ 186 - 0
modules/connection/fluidnc_client.py

@@ -0,0 +1,186 @@
+"""
+FluidNC HTTP client — the backend's transport to the headless board firmware.
+
+The board runs a FluidNC fork that owns kinematics, `.thr` playback, progress
+reporting and homing, and exposes an HTTP API (contract: the firmware repo's
+API.md). This client is the single seam the rest of the backend uses to talk to
+hardware; it replaces the old serial / websocket GRBL transport.
+
+Design notes:
+  - Calls are synchronous (``requests``). Callers that need async wrap them in
+    ``asyncio.to_thread`` (the codebase already does this for blocking I/O).
+  - "Actions" are fire-and-forget: the board applies them and the caller
+    confirms the effect by polling ``get_status()``. This matches the firmware's
+    own model (API.md: use ``/command`` + ``/sand_*`` to act, poll to confirm).
+  - It is multi-client-safe: status/listing reads and action routes are stateless
+    HTTP and keep working during playback.
+"""
+
+import logging
+import requests
+
+logger = logging.getLogger(__name__)
+
+
+class FluidNCClient:
+    """Stateless HTTP handle to one FluidNC sand-table board."""
+
+    def __init__(self, base_url: str, timeout: float = 5.0):
+        # base_url like "http://192.168.68.160"
+        self.base_url = base_url.rstrip("/")
+        self.timeout = timeout
+        # Mirrors the BaseConnection contract main.py relies on (is_connected/close).
+        self._connected = False
+
+    # -- low-level -----------------------------------------------------------
+
+    def _get(self, path: str, params: dict | None = None, timeout: float | None = None):
+        r = requests.get(self.base_url + path, params=params, timeout=timeout or self.timeout)
+        r.raise_for_status()
+        return r
+
+    # -- connection lifecycle (BaseConnection-compatible) --------------------
+
+    def is_connected(self) -> bool:
+        return self._connected
+
+    def close(self) -> None:
+        self._connected = False
+
+    def reachable(self) -> bool:
+        """True if the board answers a status read. Also updates is_connected()."""
+        try:
+            self._get("/sand_status", timeout=3.0)
+            self._connected = True
+        except Exception as e:
+            logger.debug(f"Board unreachable at {self.base_url}: {e}")
+            self._connected = False
+        return self._connected
+
+    # -- reads ---------------------------------------------------------------
+
+    def get_status(self) -> dict:
+        """The board's /sand_status object (schema in API.md)."""
+        return self._get("/sand_status", timeout=3.0).json()
+
+    def get_settings(self) -> dict:
+        """Flat string map of board settings (keys like 'Sand/HomingMode')."""
+        return self._get("/sand_settings").json()
+
+    def list_patterns(self) -> list:
+        return self._get("/sand_patterns").json()
+
+    def list_playlists(self) -> list:
+        return self._get("/sand_playlists").json()
+
+    def fetch_file(self, sd_path: str) -> bytes:
+        """Fetch raw SD file bytes, e.g. fetch_file('/patterns/star.thr')."""
+        return self._get("/sd" + sd_path, timeout=15.0).content
+
+    def file_exists(self, sd_path: str) -> bool:
+        try:
+            r = requests.get(self.base_url + "/sd" + sd_path, stream=True, timeout=5.0)
+            ok = r.status_code == 200
+            r.close()
+            return ok
+        except Exception:
+            return False
+
+    # -- commands / actions --------------------------------------------------
+
+    def run_command(self, plain: str) -> str:
+        """Fire a FluidNC command via the /command gateway (fire-and-forget)."""
+        return self._get("/command", params={"plain": plain}).text
+
+    def run_pattern(self, sd_path: str, clear: str | None = None) -> None:
+        """
+        Start a pattern. With a clear mode, uses $Sand/Run (which sequences
+        clear->pattern and aborts any running job first); otherwise $SD/Run.
+        Asynchronous — poll get_status() for progress/completion.
+        """
+        if clear and clear != "none":
+            self.run_command(f"$Sand/Run={sd_path} clear={clear}")
+        else:
+            self.run_command(f"$SD/Run={sd_path}")
+
+    def stop(self) -> str:
+        return self._get("/sand_stop").text
+
+    def pause(self) -> str:
+        return self._get("/sand_pause").text
+
+    def resume(self) -> str:
+        return self._get("/sand_resume").text
+
+    def skip(self) -> str:
+        return self.run_command("$Playlist/Skip")
+
+    def home(self) -> str:
+        """Home honoring the board's $Sand/HomingMode; safe over HTTP."""
+        return self._get("/sand_home").text
+
+    def set_feed(self, mm: int | None = None, pct: int | None = None, d: str | None = None) -> str:
+        """Set base feed (mm/min), an override percentage, or coarse up/down/reset."""
+        params: dict = {}
+        if mm is not None:
+            params["mm"] = int(mm)
+        if pct is not None:
+            params["pct"] = int(pct)
+        if d is not None:
+            params["d"] = d
+        return self._get("/sand_feed", params=params).text
+
+    def goto(self, theta: float | None = None, rho: float | None = None) -> str:
+        """Jog to an absolute theta (radians) and/or rho (0..1). Requires Idle."""
+        params: dict = {}
+        if theta is not None:
+            params["theta"] = theta
+        if rho is not None:
+            params["rho"] = rho
+        return self._get("/sand_goto", params=params).text
+
+    def set_led(self, **keys) -> str:
+        """Live LED control via /sand_led (keys: effect/palette/color/brightness/...)."""
+        return self._get("/sand_led", params=keys).text
+
+    def set_homing_mode(self, mode: str) -> str:
+        return self.run_command(f"$Sand/HomingMode={mode}")  # sensor | crash
+
+    def set_theta_offset(self, degrees: float) -> str:
+        return self.run_command(f"$Sand/ThetaOffset={degrees}")
+
+    def soft_reset(self) -> str:
+        """Reboot the controller (loses position) — host re-homes afterward."""
+        return self.run_command("$Bye")
+
+    # -- SD file management (ESP3D /upload protocol) -------------------------
+
+    def upload_file(self, sd_path: str, data: bytes, directory: str) -> dict:
+        """
+        Upload bytes to an SD path. Per the firmware's ESP3D handler the multipart
+        file part is named by the full SD path, plus a '<path>S' size field; the
+        ?path= query selects the directory used for the post-upload listing.
+        """
+        form = {f"{sd_path}S": str(len(data))}
+        files = {sd_path: (sd_path, data, "application/octet-stream")}
+        r = requests.post(
+            self.base_url + "/upload",
+            params={"path": directory},
+            data=form,
+            files=files,
+            timeout=30.0,
+        )
+        r.raise_for_status()
+        return r.json() if r.text else {}
+
+    def delete_file(self, directory: str, filename: str) -> dict:
+        return self._get(
+            "/upload",
+            params={"path": directory, "action": "delete", "filename": filename, "dontlist": "yes"},
+        ).json()
+
+    def rename_file(self, directory: str, old: str, new: str) -> dict:
+        return self._get(
+            "/upload",
+            params={"path": directory, "action": "rename", "filename": old, "newname": new, "dontlist": "yes"},
+        ).json()

+ 142 - 230
modules/core/pattern_manager.py

@@ -1166,6 +1166,31 @@ def is_clear_pattern(file_path):
     # Check if the file path matches any clear pattern path
     return normalized_path in normalized_clear_patterns
 
+def _to_sd_path(file_path):
+    """Map a host pattern path (e.g. './patterns/star.thr') to its board SD path
+    ('/patterns/star.thr'). The board SD mirrors the host's patterns/ tree."""
+    p = file_path.replace("\\", "/").lstrip(".").lstrip("/")
+    idx = p.find("patterns/")
+    rel = p[idx:] if idx >= 0 else ("patterns/" + os.path.basename(p))
+    return "/" + rel
+
+
+def _ensure_on_board(file_path, sd_path):
+    """Upload the pattern to the board's SD card if it isn't already there."""
+    try:
+        if not state.conn:
+            return
+        if state.conn.file_exists(sd_path):
+            return
+        with open(file_path, "rb") as f:
+            data = f.read()
+        directory = sd_path.rsplit("/", 1)[0] or "/patterns"
+        state.conn.upload_file(sd_path, data, directory)
+        logger.info(f"Mirrored {file_path} to board at {sd_path}")
+    except Exception as e:
+        logger.warning(f"Could not mirror {file_path} to board: {e}")
+
+
 async def _execute_pattern_internal(file_path):
     """Internal function to execute a pattern file. Must be called with lock already held.
 
@@ -1237,190 +1262,117 @@ async def _execute_pattern_internal(file_path):
 
     logger.info(f"Starting pattern execution: {file_path}")
     logger.info(f"t: {state.current_theta}, r: {state.current_rho}")
-    await reset_theta()
+    # --- Delegate playback to the board and poll /sand_status for progress ---
+    # The firmware owns kinematics and .thr playback; the host hands it the file,
+    # sets the feed, starts the job, then tracks progress via /sand_status.
+    sd_path = _to_sd_path(file_path)
+    await asyncio.to_thread(_ensure_on_board, file_path, sd_path)
+
+    current_speed = state.clear_pattern_speed if (is_clear_file and state.clear_pattern_speed is not None) else state.speed
 
     start_time = time.time()
-    total_pause_time = 0  # Track total time spent paused (manual + scheduled)
-    completed_weight = 0.0  # Track rho-weighted progress
-    smoothed_rate = None  # For exponential smoothing of time-per-unit-weight rate
-    # For WLED: always trigger (uses hardcoded preset 2)
-    # For DW_LED: only trigger if effect is configured
+    total_pause_time = 0.0
+    pause_clock = 0.0
+
+    # Trigger the "playing" LED effect (WLED always; DW only if an effect is set).
     if state.led_controller and state.led_automation_enabled and (state.led_provider == "wled" or state.dw_led_playing_effect):
         logger.info(f"Setting LED to playing effect: {state.dw_led_playing_effect}")
         await state.led_controller.effect_playing_async(state.dw_led_playing_effect)
-        # Cancel idle timeout when playing starts
         idle_timeout_manager.cancel_timeout()
 
-    with tqdm(
-        total=total_coordinates,
-        unit="coords",
-        desc=f"Executing Pattern {file_path}",
-        dynamic_ncols=True,
-        disable=False,
-        mininterval=1.0
-    ) as pbar:
-        for i, coordinate in enumerate(coordinates):
-            theta, rho = coordinate
-            if state.stop_requested:
-                logger.info("Execution stopped by user")
-                await start_idle_led_timeout()
-                break
-
-            if state.skip_requested:
-                logger.info("Skipping pattern...")
-                await connection_manager.check_idle_async()
-                await start_idle_led_timeout()
-                break
-
-            # Wait for resume if paused (manual or scheduled)
-            manual_pause = state.pause_requested
-            # Only check scheduled pause during pattern if "finish pattern first" is NOT enabled
-            scheduled_pause = is_in_scheduled_pause_period() if not state.scheduled_pause_finish_pattern else False
-
-            if manual_pause or scheduled_pause:
-                pause_start = time.time()  # Track when pause started
-                if manual_pause and scheduled_pause:
-                    logger.info("Execution paused (manual + scheduled pause active)...")
-                elif manual_pause:
-                    logger.info("Execution paused (manual)...")
-                else:
-                    logger.info("Execution paused (scheduled pause period)...")
-                    # Turn off LED controller if scheduled pause and control_wled is enabled
-                    if state.scheduled_pause_control_wled and state.led_controller:
-                        logger.info("Turning off LED lights during Still Sands period")
-                        await state.led_controller.set_power_async(0)
-
-                # Show idle effect for manual pause or scheduled pause without LED control
-                # (skip Still Sands check since we handle it above with local scheduled_pause variable)
-                if not (scheduled_pause and state.scheduled_pause_control_wled):
-                    await start_idle_led_timeout(check_still_sands=False)
-
-                # Remember if we turned off LED controller for scheduled pause
-                wled_was_off_for_scheduled = scheduled_pause and state.scheduled_pause_control_wled and not manual_pause
-
-                # Wait until both manual pause is released AND we're outside scheduled pause period
-                # Also check for stop/skip requests to allow immediate interruption
-                interrupted = False
-                while state.pause_requested or is_in_scheduled_pause_period():
-                    # Check for stop/skip first
-                    if state.stop_requested:
-                        logger.info("Stop requested during pause, exiting")
-                        interrupted = True
-                        break
-                    if state.skip_requested:
-                        logger.info("Skip requested during pause, skipping pattern")
-                        interrupted = True
-                        break
-
-                    if state.pause_requested:
-                        # For manual pause, wait on multiple events for immediate response
-                        # Wake on: resume, stop, skip, or timeout (for flag polling fallback)
-                        pause_event = get_pause_event()
-                        stop_event = state.get_stop_event()
-                        skip_event = state.get_skip_event()
-
-                        wait_tasks = [asyncio.create_task(pause_event.wait(), name='pause')]
-                        if stop_event:
-                            wait_tasks.append(asyncio.create_task(stop_event.wait(), name='stop'))
-                        if skip_event:
-                            wait_tasks.append(asyncio.create_task(skip_event.wait(), name='skip'))
-                        # Add timeout to ensure we periodically check flags even if events aren't set
-                        # This handles the case where stop is called from sync context (no event loop)
-                        timeout_task = asyncio.create_task(asyncio.sleep(1.0), name='timeout')
-                        wait_tasks.append(timeout_task)
-
-                        try:
-                            done, pending = await asyncio.wait(
-                                wait_tasks, return_when=asyncio.FIRST_COMPLETED
-                            )
-                        finally:
-                            for task in pending:
-                                task.cancel()
-                            for task in pending:
-                                try:
-                                    await task
-                                except asyncio.CancelledError:
-                                    pass
-                    else:
-                        # For scheduled pause, use wait_for_interrupt for instant response
-                        result = await state.wait_for_interrupt(timeout=1.0)
-                        if result in ('stopped', 'skipped'):
-                            interrupted = True
-                            break
+    # Start the job on the board.
+    try:
+        await asyncio.to_thread(state.conn.set_feed, int(current_speed))
+        await asyncio.to_thread(state.conn.run_pattern, sd_path)
+    except Exception as e:
+        logger.error(f"Failed to start pattern on board: {e}")
+        return False
 
-                total_pause_time += time.time() - pause_start  # Add pause duration
+    started = False
+    board_paused = False
+    startup_deadline = time.time() + 15
 
-                if interrupted:
-                    # Exit the coordinate loop if we were interrupted
-                    break
+    while True:
+        if state.stop_requested:
+            logger.info("Execution stopped by user")
+            await asyncio.to_thread(state.conn.stop)
+            await start_idle_led_timeout()
+            break
 
-                logger.info("Execution resumed...")
-                if state.led_controller:
-                    # Always power LEDs back on if they were turned off for scheduled pause,
-                    # regardless of whether a playing effect is configured
-                    if wled_was_off_for_scheduled and state.led_automation_enabled:
-                        logger.info("Turning LED lights back on as Still Sands period ended")
-                        await state.led_controller.set_power_async(1)
-                        # CRITICAL: Give LED controller time to fully power on before sending more commands
-                        # Without this delay, rapid-fire requests can crash controllers on resource-constrained Pis
-                        await asyncio.sleep(0.5)
-                    # Apply playing effect if configured
-                    # For WLED: always trigger (uses hardcoded preset 2)
-                    # For DW_LED: only trigger if effect is configured
-                    should_trigger_led = state.led_automation_enabled and (state.led_provider == "wled" or state.dw_led_playing_effect)
-                    if should_trigger_led:
-                        await state.led_controller.effect_playing_async(state.dw_led_playing_effect)
-                    # Cancel idle timeout when resuming from pause
-                    idle_timeout_manager.cancel_timeout()
-
-            # Dynamically determine the speed for each movement
-            # Use clear_pattern_speed if it's set and this is a clear file, otherwise use state.speed
-            if is_clear_file and state.clear_pattern_speed is not None:
-                current_speed = state.clear_pattern_speed
+        if state.skip_requested:
+            logger.info("Skipping pattern...")
+            await asyncio.to_thread(state.conn.stop)
+            await connection_manager.check_idle_async()
+            await start_idle_led_timeout()
+            break
+
+        # Pause handling: manual pause, or a scheduled Still Sands period.
+        manual_pause = state.pause_requested
+        scheduled_pause = is_in_scheduled_pause_period() if not state.scheduled_pause_finish_pattern else False
+        want_pause = manual_pause or scheduled_pause
+
+        if want_pause and not board_paused:
+            logger.info("Pausing pattern on board")
+            await asyncio.to_thread(state.conn.pause)
+            board_paused = True
+            pause_clock = time.time()
+            if scheduled_pause and state.scheduled_pause_control_wled and state.led_controller and not manual_pause:
+                await state.led_controller.set_power_async(0)
             else:
-                current_speed = state.speed
-
-            await move_polar(theta, rho, current_speed)
-
-            # Update progress for all coordinates including the first one
-            pbar.update(1)
-            elapsed_time = time.time() - start_time
-            coords_done = i + 1
-
-            # Track rho-weighted progress for accurate time estimation
-            completed_weight += coord_weights[i]
-            remaining_weight = total_weight - completed_weight
-
-            # Calculate actual execution time (excluding pauses)
-            active_time = elapsed_time - total_pause_time
-
-            # Need minimum samples for stable estimate (at least 100 coords and 10 seconds)
-            if coords_done >= 100 and active_time > 10:
-                # Rate is time per unit weight (accounts for slower moves near center)
-                current_rate = active_time / completed_weight
-
-                # Smooth the RATE for stability
-                if smoothed_rate is not None:
-                    alpha = 0.02  # Very smooth - 2% new, 98% old
-                    smoothed_rate = alpha * current_rate + (1 - alpha) * smoothed_rate
-                else:
-                    smoothed_rate = current_rate
+                await start_idle_led_timeout(check_still_sands=False)
+        elif not want_pause and board_paused:
+            logger.info("Resuming pattern on board")
+            await asyncio.to_thread(state.conn.resume)
+            board_paused = False
+            total_pause_time += time.time() - pause_clock
+            if state.led_controller:
+                if state.scheduled_pause_control_wled and state.led_automation_enabled:
+                    await state.led_controller.set_power_async(1)
+                    await asyncio.sleep(0.5)
+                should_trigger = state.led_automation_enabled and (state.led_provider == "wled" or state.dw_led_playing_effect)
+                if should_trigger:
+                    await state.led_controller.effect_playing_async(state.dw_led_playing_effect)
+                idle_timeout_manager.cancel_timeout()
+
+        if board_paused:
+            await asyncio.sleep(0.4)
+            continue
 
-                # Remaining time based on weighted remaining work
-                estimated_remaining_time = smoothed_rate * remaining_weight
+        # Poll the board and translate its progress (0..1) into our scale.
+        st = await asyncio.to_thread(connection_manager.poll_status_once)
+        elapsed_time = time.time() - start_time
+        if st:
+            running = bool(st.get("running"))
+            machine_state = st.get("state", "")
+            prog = st.get("progress", -1)
+            if running:
+                started = True
+            if prog is not None and prog >= 0:
+                coords_done = int(round(min(max(prog, 0.0), 1.0) * total_coordinates))
+                active = elapsed_time - total_pause_time
+                est_remaining = (active / prog - active) if prog > 0.02 else None
             else:
-                estimated_remaining_time = None
+                coords_done = state.execution_progress[0] if state.execution_progress else 0
+                est_remaining = None
+            state.execution_progress = (coords_done, total_coordinates, est_remaining, elapsed_time)
+            # Done once the job has started and the board is no longer running it.
+            if started and not running:
+                break
+            if started and machine_state == "Idle":
+                break
 
-            state.execution_progress = (coords_done, total_coordinates, estimated_remaining_time, elapsed_time)
+        if not started and time.time() > startup_deadline:
+            logger.warning("Pattern did not start on the board within 15s")
+            break
 
-            # Add a small delay to allow other async operations
-            await asyncio.sleep(0.001)
+        await asyncio.sleep(0.4)
 
-    # Update progress one last time to show 100%
+    # Final progress + timing.
     elapsed_time = time.time() - start_time
     actual_execution_time = elapsed_time - total_pause_time
-    state.execution_progress = (total_coordinates, total_coordinates, 0, elapsed_time)
-    # Give WebSocket a chance to send the final update
+    if started and not state.stop_requested and not state.skip_requested:
+        state.execution_progress = (total_coordinates, total_coordinates, 0, elapsed_time)
+    # Give the WebSocket a chance to send the final update.
     await asyncio.sleep(0.1)
 
     # Log execution time (only for completed patterns, not stopped/skipped)
@@ -1813,9 +1765,12 @@ async def stop_actions(clear_playlist = True, wait_for_lock = True):
         # Also set the pause event to wake up any paused patterns
         get_pause_event().set()
 
-        # Send stop command to motion thread to clear its queue
-        if motion_controller.running:
-            motion_controller.command_queue.put(MotionCommand('stop'))
+        # Tell the board to stop any motion in progress.
+        if state.conn and state.conn.is_connected():
+            try:
+                await asyncio.to_thread(state.conn.stop)
+            except Exception as e:
+                logger.error(f"Error sending stop to board: {e}")
 
         # Wait for the pattern lock to be released before continuing
         # This ensures that when stop_actions completes, the pattern has fully stopped
@@ -1874,39 +1829,23 @@ async def stop_actions(clear_playlist = True, wait_for_lock = True):
 
 async def move_polar(theta, rho, speed=None):
     """
-    Queue a motion command to be executed in the dedicated motion control thread.
-    This makes motion control non-blocking for API endpoints.
+    Jog to an absolute (theta, rho) by delegating to the board's /sand_goto.
+    Used by the manual move endpoints (center / perimeter / send_coordinate).
 
     Args:
-        theta (float): Target theta coordinate
-        rho (float): Target rho coordinate
-        speed (int, optional): Speed override. If None, uses state.speed
+        theta (float): Target theta in radians
+        rho (float): Target rho (0..1)
+        speed (int, optional): Feed override. If None, uses the board's current feed.
     """
-    # Note: stop_requested is cleared once at pattern start (execute_theta_rho_file line 890)
-    # Don't clear it here on every coordinate - causes performance issues with event system
-
-    # Ensure motion control thread is running
-    if not motion_controller.running:
-        motion_controller.start()
-
-    # Create future for async/await pattern
-    loop = asyncio.get_event_loop()
-    future = loop.create_future()
-
-    # Create and queue motion command
-    command = MotionCommand(
-        command_type='move',
-        theta=theta,
-        rho=rho,
-        speed=speed,
-        future=future
-    )
-
-    motion_controller.command_queue.put(command)
-    logger.debug(f"Queued motion command: theta={theta}, rho={rho}, speed={speed}")
-
-    # Wait for command completion
-    await future
+    if not state.conn or not state.conn.is_connected():
+        logger.warning("Cannot move: no board connection")
+        return
+    if speed is not None:
+        try:
+            await asyncio.to_thread(state.conn.set_feed, int(speed))
+        except Exception as e:
+            logger.warning(f"Could not set feed before jog: {e}")
+    await asyncio.to_thread(state.conn.goto, theta, rho)
     
 def pause_execution():
     """Pause pattern execution using asyncio Event."""
@@ -1924,42 +1863,15 @@ def resume_execution():
     
 async def reset_theta():
     """
-    Reset theta to [0, 2π) range and optionally hard reset machine position using $Bye.
-
-    When state.hard_reset_theta is True:
-    - $Bye sends a soft reset to FluidNC which resets the controller and clears
-      all position counters to 0. This is more reliable than G92 which only sets
-      a work coordinate offset without changing the actual machine position (MPos).
-    - We wait for machine to be idle before sending $Bye to avoid error:25
+    Normalize the host's tracked theta to [0, 2π).
 
-    When state.hard_reset_theta is False (default):
-    - Only normalizes theta to [0, 2π) range without affecting machine position
-    - Faster and doesn't interrupt machine state
+    The board now owns machine position and theta accumulation, so this only keeps
+    the host-side ``current_theta`` tidy for display. (The old $Bye hard-reset path
+    was removed — rebooting the controller on a manual move is never what we want.)
     """
-    logger.info('Resetting Theta')
-
-    # Always normalize theta to [0, 2π) range
     state.current_theta = state.current_theta % (2 * pi)
     logger.info(f'Theta normalized to {state.current_theta:.4f} radians')
 
-    # Only perform hard reset if enabled
-    if state.hard_reset_theta:
-        logger.info('Hard reset enabled - performing machine soft reset')
-
-        # Wait for machine to be idle before reset to prevent error:25
-        if state.conn and state.conn.is_connected():
-            logger.info("Waiting for machine to be idle before reset...")
-            idle = await connection_manager.check_idle_async(timeout=30)
-            if not idle:
-                logger.warning("Machine not idle after 30s, proceeding with reset anyway")
-
-        # Hard reset machine position using $Bye via connection_manager
-        success = await connection_manager.perform_soft_reset()
-        if not success:
-            logger.error("Soft reset failed - theta reset may be unreliable")
-    else:
-        logger.info('Hard reset disabled - skipping machine soft reset')
-
 def set_speed(new_speed):
     state.speed = new_speed
     logger.info(f'Set new state.speed {new_speed}')

+ 5 - 0
modules/core/state.py

@@ -98,6 +98,9 @@ class AppState:
         self.conn = None
         self.port = None
         self.preferred_port = None  # User's preferred port for auto-connect
+        # Address of the FluidNC board (e.g. "http://192.168.68.160" or a bare IP).
+        # None => fall back to the DUNE_BOARD_URL env var or the built-in default.
+        self.board_url = None
         self.wled_ip = None
         self.led_provider = "none"  # "wled", "dw_leds", or "none"
         self.led_controller = None
@@ -507,6 +510,7 @@ class AppState:
             "custom_clear_from_in": self.custom_clear_from_in,
             "custom_clear_from_out": self.custom_clear_from_out,
             "preferred_port": self.preferred_port,
+            "board_url": self.board_url,
             "wled_ip": self.wled_ip,
             "led_provider": self.led_provider,
             "dw_led_num_leds": self.dw_led_num_leds,
@@ -613,6 +617,7 @@ class AppState:
         self.custom_clear_from_in = data.get("custom_clear_from_in", None)
         self.custom_clear_from_out = data.get("custom_clear_from_out", None)
         self.preferred_port = data.get("preferred_port", None)
+        self.board_url = data.get("board_url", None)
         self.wled_ip = data.get('wled_ip', None)
         self.led_provider = data.get('led_provider', "none")
         self.dw_led_num_leds = data.get('dw_led_num_leds', 60)

+ 144 - 282
tests/unit/test_connection_manager.py

@@ -1,282 +1,144 @@
-"""
-Unit tests for connection_manager parsing functions.
-
-Tests the pure functions that parse GRBL responses:
-- Machine position parsing (MPos and WPos formats)
-- Serial port listing/filtering
-"""
-import pytest
-from unittest.mock import patch, MagicMock
-
-
-class TestParseMachinePosition:
-    """Tests for parse_machine_position function."""
-
-    def test_parse_machine_position_mpos_format(self):
-        """Test parsing MPos format from GRBL status response."""
-        from modules.connection.connection_manager import parse_machine_position
-
-        response = "<Idle|MPos:100.500,-50.250,0.000|Bf:15,128>"
-        result = parse_machine_position(response)
-
-        assert result is not None
-        assert result == (100.5, -50.25)
-
-    def test_parse_machine_position_wpos_format(self):
-        """Test parsing WPos format from GRBL status response."""
-        from modules.connection.connection_manager import parse_machine_position
-
-        response = "<Idle|WPos:0.000,19.000,0.000|Bf:15,128>"
-        result = parse_machine_position(response)
-
-        assert result is not None
-        assert result == (0.0, 19.0)
-
-    def test_parse_machine_position_prefers_mpos(self):
-        """Test that MPos is preferred when both are present (rare but possible)."""
-        from modules.connection.connection_manager import parse_machine_position
-
-        # This response has both MPos and WPos - MPos should be used first
-        response = "<Idle|MPos:10.0,20.0,0.0|WPos:5.0,10.0,0.0|Bf:15,128>"
-        result = parse_machine_position(response)
-
-        assert result is not None
-        assert result == (10.0, 20.0)
-
-    def test_parse_machine_position_invalid(self):
-        """Test parsing returns None for invalid response."""
-        from modules.connection.connection_manager import parse_machine_position
-
-        # No position info
-        result = parse_machine_position("ok")
-        assert result is None
-
-        # Empty string
-        result = parse_machine_position("")
-        assert result is None
-
-        # Malformed response
-        result = parse_machine_position("<Idle|Bf:15,128>")
-        assert result is None
-
-    def test_parse_machine_position_run_state(self):
-        """Test parsing position during run state."""
-        from modules.connection.connection_manager import parse_machine_position
-
-        response = "<Run|MPos:-994.869,-321.861,0.000|Bf:15,127>"
-        result = parse_machine_position(response)
-
-        assert result is not None
-        assert result[0] == pytest.approx(-994.869)
-        assert result[1] == pytest.approx(-321.861)
-
-    def test_parse_machine_position_alarm_state(self):
-        """Test parsing position during alarm state."""
-        from modules.connection.connection_manager import parse_machine_position
-
-        response = "<Alarm|MPos:0.000,0.000,0.000|Bf:15,128|Pn:XY>"
-        result = parse_machine_position(response)
-
-        assert result is not None
-        assert result == (0.0, 0.0)
-
-    def test_parse_machine_position_with_extra_info(self):
-        """Test parsing position with extra fields in response."""
-        from modules.connection.connection_manager import parse_machine_position
-
-        # Response with WCO (Work Coordinate Offset)
-        response = "<Idle|MPos:5.0,10.0,0.0|FS:0,0|WCO:0,0,0>"
-        result = parse_machine_position(response)
-
-        assert result is not None
-        assert result == (5.0, 10.0)
-
-    def test_parse_machine_position_negative_coords(self):
-        """Test parsing negative coordinates."""
-        from modules.connection.connection_manager import parse_machine_position
-
-        response = "<Idle|MPos:-100.123,-200.456,0.000|Bf:15,128>"
-        result = parse_machine_position(response)
-
-        assert result is not None
-        assert result[0] == pytest.approx(-100.123)
-        assert result[1] == pytest.approx(-200.456)
-
-    def test_parse_machine_position_high_precision(self):
-        """Test parsing high precision coordinates."""
-        from modules.connection.connection_manager import parse_machine_position
-
-        response = "<Idle|MPos:123.456789,987.654321,0.000000|Bf:15,128>"
-        result = parse_machine_position(response)
-
-        assert result is not None
-        assert result[0] == pytest.approx(123.456789)
-        assert result[1] == pytest.approx(987.654321)
-
-
-class TestListSerialPorts:
-    """Tests for list_serial_ports function."""
-
-    def test_list_serial_ports_filters_ignored(self):
-        """Test that ignored ports are filtered out."""
-        # Create mock port objects
-        mock_port1 = MagicMock()
-        mock_port1.device = "/dev/ttyUSB0"
-
-        mock_port2 = MagicMock()
-        mock_port2.device = "/dev/cu.debug-console"  # Should be filtered
-
-        mock_port3 = MagicMock()
-        mock_port3.device = "/dev/cu.Bluetooth-Incoming-Port"  # Should be filtered
-
-        mock_port4 = MagicMock()
-        mock_port4.device = "/dev/ttyACM0"
-
-        with patch("serial.tools.list_ports.comports", return_value=[mock_port1, mock_port2, mock_port3, mock_port4]):
-            from modules.connection.connection_manager import list_serial_ports
-
-            ports = list_serial_ports()
-
-        assert "/dev/ttyUSB0" in ports
-        assert "/dev/ttyACM0" in ports
-        assert "/dev/cu.debug-console" not in ports
-        assert "/dev/cu.Bluetooth-Incoming-Port" not in ports
-        assert len(ports) == 2
-
-    def test_list_serial_ports_empty(self):
-        """Test list_serial_ports returns empty when no ports available."""
-        with patch("serial.tools.list_ports.comports", return_value=[]):
-            from modules.connection.connection_manager import list_serial_ports
-
-            ports = list_serial_ports()
-
-        assert ports == []
-
-    def test_list_serial_ports_all_ignored(self):
-        """Test list_serial_ports when all ports are ignored."""
-        mock_port1 = MagicMock()
-        mock_port1.device = "/dev/cu.debug-console"
-
-        mock_port2 = MagicMock()
-        mock_port2.device = "/dev/cu.Bluetooth-Incoming-Port"
-
-        with patch("serial.tools.list_ports.comports", return_value=[mock_port1, mock_port2]):
-            from modules.connection.connection_manager import list_serial_ports
-
-            ports = list_serial_ports()
-
-        assert ports == []
-
-
-class TestConnectionClasses:
-    """Tests for connection class structure (no hardware required)."""
-
-    def test_base_connection_interface(self):
-        """Test that BaseConnection defines required interface."""
-        from modules.connection.connection_manager import BaseConnection
-
-        # BaseConnection should have these abstract methods
-        base = BaseConnection()
-
-        with pytest.raises(NotImplementedError):
-            base.send("test")
-
-        with pytest.raises(NotImplementedError):
-            base.flush()
-
-        with pytest.raises(NotImplementedError):
-            base.readline()
-
-        with pytest.raises(NotImplementedError):
-            base.in_waiting()
-
-        with pytest.raises(NotImplementedError):
-            base.is_connected()
-
-        with pytest.raises(NotImplementedError):
-            base.close()
-
-    def test_serial_connection_inherits_base(self):
-        """Test SerialConnection inherits from BaseConnection."""
-        from modules.connection.connection_manager import SerialConnection, BaseConnection
-
-        assert issubclass(SerialConnection, BaseConnection)
-
-    def test_websocket_connection_inherits_base(self):
-        """Test WebSocketConnection inherits from BaseConnection."""
-        from modules.connection.connection_manager import WebSocketConnection, BaseConnection
-
-        assert issubclass(WebSocketConnection, BaseConnection)
-
-
-class TestIgnorePorts:
-    """Tests for IGNORE_PORTS and DEPRIORITIZED_PORTS constants."""
-
-    def test_ignore_ports_defined(self):
-        """Test that IGNORE_PORTS constant is defined."""
-        from modules.connection.connection_manager import IGNORE_PORTS
-
-        assert isinstance(IGNORE_PORTS, list)
-        assert "/dev/cu.debug-console" in IGNORE_PORTS
-        assert "/dev/cu.Bluetooth-Incoming-Port" in IGNORE_PORTS
-
-    def test_deprioritized_ports_defined(self):
-        """Test that DEPRIORITIZED_PORTS constant is defined."""
-        from modules.connection.connection_manager import DEPRIORITIZED_PORTS
-
-        assert isinstance(DEPRIORITIZED_PORTS, list)
-        # ttyS0 is typically the Pi hardware UART - should be deprioritized
-        assert "/dev/ttyS0" in DEPRIORITIZED_PORTS
-
-
-class TestIsMachineIdle:
-    """Tests for is_machine_idle function."""
-
-    def test_is_machine_idle_no_connection(self, mock_state):
-        """Test is_machine_idle returns False when no connection."""
-        mock_state.conn = None
-
-        with patch("modules.connection.connection_manager.state", mock_state):
-            from modules.connection.connection_manager import is_machine_idle
-
-            result = is_machine_idle()
-
-        assert result is False
-
-    def test_is_machine_idle_disconnected(self, mock_state):
-        """Test is_machine_idle returns False when disconnected."""
-        mock_state.conn.is_connected.return_value = False
-
-        with patch("modules.connection.connection_manager.state", mock_state):
-            from modules.connection.connection_manager import is_machine_idle
-
-            result = is_machine_idle()
-
-        assert result is False
-
-    def test_is_machine_idle_when_idle(self, mock_state):
-        """Test is_machine_idle returns True when machine is idle."""
-        mock_state.conn.is_connected.return_value = True
-        mock_state.conn.send = MagicMock()
-        mock_state.conn.readline.return_value = "<Idle|MPos:0,0,0|Bf:15,128>"
-
-        with patch("modules.connection.connection_manager.state", mock_state):
-            from modules.connection.connection_manager import is_machine_idle
-
-            result = is_machine_idle()
-
-        assert result is True
-        mock_state.conn.send.assert_called_with('?')
-
-    def test_is_machine_idle_when_running(self, mock_state):
-        """Test is_machine_idle returns False when machine is running."""
-        mock_state.conn.is_connected.return_value = True
-        mock_state.conn.send = MagicMock()
-        mock_state.conn.readline.return_value = "<Run|MPos:0,0,0|Bf:15,128>"
-
-        with patch("modules.connection.connection_manager.state", mock_state):
-            from modules.connection.connection_manager import is_machine_idle
-
-            result = is_machine_idle()
-
-        assert result is False
+"""
+Unit tests for the FluidNC HTTP connection layer.
+
+The backend drives the board over HTTP now (no serial/GRBL transport), so these
+tests cover the FluidNCClient command/route formatting and the connection_manager
+helpers that sit on top of it. HTTP is mocked — no board required.
+"""
+import pytest
+from unittest.mock import patch, MagicMock
+
+from modules.connection.fluidnc_client import FluidNCClient
+
+
+def _resp(text="ok", json_data=None, status=200):
+    r = MagicMock()
+    r.text = text
+    r.status_code = status
+    r.raise_for_status = MagicMock()
+    if json_data is not None:
+        r.json = MagicMock(return_value=json_data)
+    return r
+
+
+class TestFluidNCClient:
+    """Command / route formatting for the board client."""
+
+    def test_run_pattern_uses_sd_run(self):
+        client = FluidNCClient("http://board")
+        with patch("modules.connection.fluidnc_client.requests") as req:
+            req.get.return_value = _resp()
+            client.run_pattern("/patterns/star.thr")
+        url, kwargs = req.get.call_args[0][0], req.get.call_args[1]
+        assert url == "http://board/command"
+        assert kwargs["params"] == {"plain": "$SD/Run=/patterns/star.thr"}
+
+    def test_run_pattern_with_clear_uses_sand_run(self):
+        client = FluidNCClient("http://board")
+        with patch("modules.connection.fluidnc_client.requests") as req:
+            req.get.return_value = _resp()
+            client.run_pattern("/patterns/star.thr", clear="adaptive")
+        assert req.get.call_args[1]["params"] == {
+            "plain": "$Sand/Run=/patterns/star.thr clear=adaptive"
+        }
+
+    def test_set_feed_mm(self):
+        client = FluidNCClient("http://board")
+        with patch("modules.connection.fluidnc_client.requests") as req:
+            req.get.return_value = _resp()
+            client.set_feed(mm=850)
+        assert req.get.call_args[0][0] == "http://board/sand_feed"
+        assert req.get.call_args[1]["params"] == {"mm": 850}
+
+    def test_goto_includes_only_given_axes(self):
+        client = FluidNCClient("http://board")
+        with patch("modules.connection.fluidnc_client.requests") as req:
+            req.get.return_value = _resp()
+            client.goto(rho=0)
+        assert req.get.call_args[1]["params"] == {"rho": 0}
+
+    def test_stop_hits_sand_stop(self):
+        client = FluidNCClient("http://board")
+        with patch("modules.connection.fluidnc_client.requests") as req:
+            req.get.return_value = _resp()
+            client.stop()
+        assert req.get.call_args[0][0] == "http://board/sand_stop"
+
+    def test_get_status_returns_json(self):
+        client = FluidNCClient("http://board")
+        with patch("modules.connection.fluidnc_client.requests") as req:
+            req.get.return_value = _resp(json_data={"state": "Idle", "theta": 1.0})
+            st = client.get_status()
+        assert st["state"] == "Idle"
+
+    def test_reachable_true_and_false(self):
+        client = FluidNCClient("http://board")
+        with patch("modules.connection.fluidnc_client.requests") as req:
+            req.get.return_value = _resp(json_data={"state": "Idle"})
+            assert client.reachable() is True
+            assert client.is_connected() is True
+            req.get.side_effect = Exception("timeout")
+            assert client.reachable() is False
+            assert client.is_connected() is False
+
+    def test_upload_file_field_naming(self):
+        """The multipart file part is named by the full SD path, plus a '<path>S' size field."""
+        client = FluidNCClient("http://board")
+        with patch("modules.connection.fluidnc_client.requests") as req:
+            req.post.return_value = _resp(text="{}", json_data={})
+            client.upload_file("/playlists/a.txt", b"hello", "/playlists")
+        kwargs = req.post.call_args[1]
+        assert kwargs["params"] == {"path": "/playlists"}
+        assert kwargs["data"] == {"/playlists/a.txtS": "5"}
+        assert "/playlists/a.txt" in kwargs["files"]
+
+    def test_delete_file_action(self):
+        client = FluidNCClient("http://board")
+        with patch("modules.connection.fluidnc_client.requests") as req:
+            req.get.return_value = _resp(text="{}", json_data={"status": "ok"})
+            client.delete_file("patterns", "old.thr")
+        assert req.get.call_args[1]["params"]["action"] == "delete"
+        assert req.get.call_args[1]["params"]["filename"] == "old.thr"
+
+
+class TestConnectionManagerHelpers:
+    """Helpers in connection_manager that sit on top of the client."""
+
+    def test_normalize_board_url(self):
+        from modules.connection import connection_manager as cm
+        assert cm._normalize_board_url("192.168.1.5") == "http://192.168.1.5"
+        assert cm._normalize_board_url("http://x/") == "http://x"
+        assert cm._normalize_board_url("") == ""
+
+    def test_list_serial_ports_returns_board_url(self, mock_state):
+        from modules.connection import connection_manager as cm
+        mock_state.board_url = "http://192.168.1.9"
+        with patch("modules.connection.connection_manager.state", mock_state):
+            ports = cm.list_serial_ports()
+        assert ports == ["http://192.168.1.9"]
+
+    def test_apply_status_maps_fields(self, mock_state):
+        from modules.connection import connection_manager as cm
+        with patch("modules.connection.connection_manager.state", mock_state):
+            cm.apply_status({"theta": 1.23, "rho": 0.5, "feed": 900})
+        assert mock_state.current_theta == 1.23
+        assert mock_state.current_rho == 0.5
+        assert mock_state.speed == 900
+
+    def test_is_machine_idle_true(self, mock_state):
+        from modules.connection import connection_manager as cm
+        mock_state.conn.get_status.return_value = {"state": "Idle"}
+        with patch("modules.connection.connection_manager.state", mock_state):
+            assert cm.is_machine_idle() is True
+
+    def test_is_machine_idle_running(self, mock_state):
+        from modules.connection import connection_manager as cm
+        mock_state.conn.get_status.return_value = {"state": "Run"}
+        with patch("modules.connection.connection_manager.state", mock_state):
+            assert cm.is_machine_idle() is False
+
+    def test_is_machine_idle_no_connection(self, mock_state):
+        from modules.connection import connection_manager as cm
+        mock_state.conn = None
+        with patch("modules.connection.connection_manager.state", mock_state):
+            assert cm.is_machine_idle() is False