Sfoglia il codice sorgente

feat(touch): wire touch app directly to FluidNC firmware HTTP API

Replace the dune-weaver host FastAPI dependency with direct
communication to the headless FluidNC sand-table firmware. The QML
interface (properties/slots/signals) is preserved unchanged.

- firmware_client.py: shared async HTTP client singleton (status,
  $-commands, /sand_* routes, /upload file-ops, LED + clear-mode maps)
- discovery.py: mDNS auto-discovery of _http._tcp tables (model=dune-weaver)
- thr_preview.py: render .thr polar paths to cached PNG previews
- backend.py: WebSocket status -> /sand_status polling; all actions
  remapped to firmware; local screen/LCD control kept
- models: PatternModel/PlaylistModel now firmware-backed
- requirements (+zeroconf), .env.example, run.sh, README, .gitignore
- FIRMWARE_MIGRATION.md: progress notes, mapping decisions, follow-ups

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tuan Nguyen 1 settimana fa
parent
commit
9fe64d2cce

+ 8 - 6
dune-weaver-touch/.env.example

@@ -1,10 +1,12 @@
 # Dune Weaver Touch Configuration
 # Copy this file to .env and modify as needed
 
-# URL of the Dune Weaver server
-# Default: http://localhost:8080
+# Address of the Dune Weaver sand table (the FluidNC firmware, HTTP port 80).
+# Leave this UNSET to let the app auto-discover the table over mDNS. Set it to
+# pin a specific table (skips discovery). A scheme is optional (http:// is
+# assumed) and the default HTTP port is 80.
 # Examples:
-#   DUNE_WEAVER_URL=http://localhost:8080
-#   DUNE_WEAVER_URL=http://dwmp.local:8080
-#   DUNE_WEAVER_URL=http://192.168.1.100:8080
-DUNE_WEAVER_URL=http://localhost:8080
+#   DUNE_WEAVER_URL=dunetable.local
+#   DUNE_WEAVER_URL=http://dunetable.local
+#   DUNE_WEAVER_URL=http://192.168.1.100
+# DUNE_WEAVER_URL=dunetable.local

+ 9 - 0
dune-weaver-touch/.gitignore

@@ -0,0 +1,9 @@
+# Local runtime state (not source)
+preview_cache/
+touch_settings.json
+.env
+venv/
+
+# Python
+__pycache__/
+*.pyc

+ 75 - 0
dune-weaver-touch/FIRMWARE_MIGRATION.md

@@ -0,0 +1,75 @@
+# Touch app → FluidNC firmware migration
+
+Status of wiring `dune-weaver-touch` directly to the new headless FluidNC
+firmware (`dune-weaver-firmware`), replacing the old dune-weaver host FastAPI
+server. The entire QML interface (properties / slots / signals) is preserved, so
+no `.qml` files were changed.
+
+## Architecture change
+
+| Concern | Before (host FastAPI) | After (firmware direct) |
+|---------|-----------------------|-------------------------|
+| Status | WebSocket `/ws/status` | poll `GET /sand_status` (~1 Hz) |
+| Actions | REST `/run_theta_rho`, `/stop_execution`, … | `$...` cmds via `/command` + `/sand_*` routes |
+| Patterns | local FS `../patterns` | `GET /sand_patterns`, files from `/sd/patterns/...` |
+| Previews | prebuilt PNG cache | rendered locally from `.thr`, cached in `preview_cache/` |
+| Playlists | `../playlists.json` | `/sand_playlists` + `.txt` on SD (upload/delete file-ops) |
+| LEDs | `/api/dw_leds/*` numeric ids | `$LED/*` / `/sand_led` named effects/palettes |
+| Table address | `DUNE_WEAVER_URL` (localhost) | mDNS auto-discovery + `DUNE_WEAVER_URL` pin |
+| Screen / LCD | local sysfs (unchanged) | local sysfs (unchanged) |
+
+## Files
+
+**New**
+- `firmware_client.py` — shared async HTTP client singleton (`FirmwareClient.instance()`);
+  status/patterns/playlists/settings reads, `$...` commands, `/sand_*` actions,
+  `/upload` file-ops, LED + clear-mode maps, firmware error surfacing.
+- `discovery.py` — mDNS discovery of `_http._tcp` tables (`model=dune-weaver`);
+  optional `zeroconf`, degrades to empty list if missing.
+- `thr_preview.py` — parse `.thr`, render polar path → cached PNG, sync cache lookup.
+
+**Rewritten**
+- `backend.py` — QML-facing controller. Status polling, all actions remapped to
+  firmware, LED catalogue, playlist file editing, table connect/disconnect via the
+  old "serial" UI, local screen/LCD kept verbatim.
+- `models/pattern_model.py`, `models/playlist_model.py` — firmware-backed, refresh
+  on `baseUrlChanged`.
+
+**Updated**
+- `requirements.txt` (+`zeroconf`), `.env.example`, `run.sh` (dropped localhost:8080
+  gate), `README.md`, added `.gitignore`.
+
+## Mapping decisions (verify on hardware)
+
+- **Clear modes:** `clear_center → in`, `clear_perimeter → out`, `adaptive → adaptive`,
+  `none → none` (`CLEAR_MODE_MAP` in `firmware_client.py`). `$Sand/Run … clear=` needs a
+  `playlist:` config section on the board.
+- **Speed** → `/sand_feed?mm=` (motor mm/min); the 50–500 options pass through as feed.
+- **"Restart backend"** → reboots the table (`$Bye`). **"Shutdown Pi"** → local `sudo shutdown`.
+- **Auto-play-on-boot:** disabling clears `$Playlist/Autostart` reliably; enabling needs a
+  chosen playlist name (best-effort / logged only).
+- **Table picker** reuses the serial UI: discovered URLs populate the port list;
+  `connectSerial(url)` selects one; saved to `touch_settings.json`.
+
+## Verification done
+
+- All modules byte-compile.
+- `.thr` renderer produces valid PNGs; `discovery.py` imports with/without zeroconf.
+- Full contract check: every QML-consumed slot/property/signal exists in the rewrite.
+- Not yet run against real hardware or a live GUI (no PySide6/display in the dev env).
+
+## Known gaps / follow-ups
+
+- **No in-UI manual IP entry** — if mDNS fails, fall back to `DUNE_WEAVER_URL`. A kiosk
+  text field in `TableControlPage.qml` would be a good addition.
+- Auto-play-on-boot could be wired to a playlist picker for full parity.
+- Live smoke test against a table still needed.
+
+## Before running
+
+```bash
+pip install -r requirements.txt   # adds zeroconf
+# optionally pin the table:
+echo 'DUNE_WEAVER_URL=dunetable.local' >> .env
+./run.sh
+```

+ 47 - 14
dune-weaver-touch/README.md

@@ -1,6 +1,34 @@
 # Dune Weaver Touch Interface
 
-A PySide6/QML touch interface for the Dune Weaver sand table system that works alongside the existing FastAPI web server.
+A PySide6/QML touch interface for the Dune Weaver sand table. It talks **directly to the
+table's FluidNC firmware** over its stateless HTTP/JSON API — there is no separate host
+(Raspberry Pi FastAPI) service in between.
+
+## Connecting to the table
+
+The firmware is headless and advertises itself over mDNS. On launch the app:
+
+1. **Auto-discovers** tables via mDNS (`_http._tcp` advertisements with `model=dune-weaver`).
+   If exactly one is found it connects automatically; otherwise pick one from the Control
+   page's table list (tap **Refresh** to re-scan).
+2. **Pin a table** by setting `DUNE_WEAVER_URL` in `.env` (e.g. `DUNE_WEAVER_URL=dunetable.local`
+   or an IP). This skips discovery.
+
+The chosen table is remembered in `touch_settings.json`.
+
+### How it maps to the firmware API
+
+- **Status** is polled from `GET /sand_status` (~1 Hz) instead of a WebSocket.
+- **Actions** go out as `$...` commands via `/command` and the `/sand_*` routes
+  (run/stop/pause/resume/home/goto/feed/LED).
+- **Patterns** come from `GET /sand_patterns`; previews are rendered locally from the
+  raw `.thr` files (`/sd/patterns/...`) and cached under `preview_cache/`.
+- **Playlists** are `.txt` files on the SD card — listed via `GET /sand_playlists`,
+  read from `/sd/playlists/...`, and created/edited by uploading/deleting files.
+- **LEDs** use the firmware's named effect/palette catalogue (`$LED/*` / `/sand_led`).
+- **Screen / LCD backlight** control stays local to the touch host (sysfs + sudo scripts).
+
+See the firmware's `API.md` for the full contract.
 
 ## Features
 
@@ -14,11 +42,19 @@ A PySide6/QML touch interface for the Dune Weaver sand table system that works a
 
 ## Architecture
 
-- **Pattern Browsing**: Direct file system access for instant loading
-- **Execution Control**: REST API calls to existing FastAPI endpoints  
-- **Status Monitoring**: WebSocket connection for real-time updates
+- **Pattern Browsing**: `GET /sand_patterns` on the table; `.thr` previews rendered locally
+- **Execution Control**: firmware `$...` commands via `/command` + `/sand_*` routes
+- **Status Monitoring**: polling `GET /sand_status` (~1 Hz)
 - **Navigation**: StackView-based page navigation
 
+### Key modules
+
+- `firmware_client.py` — shared async HTTP client + LED/clear-mode maps (singleton)
+- `discovery.py` — mDNS table discovery (zeroconf; degrades gracefully if absent)
+- `thr_preview.py` — renders `.thr` → cached PNG previews
+- `backend.py` — QML-facing controller (status poll, actions, LEDs, playlists, local screen)
+- `models/` — `PatternModel` / `PlaylistModel`, both firmware-backed
+
 ## Quick Installation (Auto-Start Setup)
 
 **One command to set up everything for kiosk/production use:**
@@ -55,11 +91,8 @@ sudo systemctl disable dune-weaver-touch
    pip install -r requirements.txt
    ```
 
-2. Ensure the main Dune Weaver FastAPI server is running:
-   ```bash
-   cd ../  # Go to main dune-weaver directory
-   python main.py
-   ```
+2. Power on the sand table and make sure it's on the same network (or set
+   `DUNE_WEAVER_URL` in `.env` to pin its address).
 
 3. Run the touch interface:
    ```bash
@@ -121,8 +154,8 @@ dune-weaver-touch/
 
 ## Notes
 
-- The touch interface runs independently from the web UI
-- Both interfaces can be used simultaneously
-- Pattern browsing works even if the FastAPI server is offline
-- Execution requires the FastAPI server to be running
-- Paths are relative to the main dune-weaver directory
+- The touch interface talks directly to the table firmware; no host service is required
+- The firmware's HTTP API is multi-client, so the app can run alongside other clients
+  (phone/web app, Home Assistant) at the same time
+- Pattern previews render locally the first time a pattern is seen, then load from cache
+- Requires the table to be reachable on the network (mDNS or `DUNE_WEAVER_URL`)

+ 1172 - 1877
dune-weaver-touch/backend.py

@@ -1,1877 +1,1172 @@
-from PySide6.QtCore import QObject, Signal, Property, Slot, QTimer
-from PySide6.QtQml import QmlElement
-from PySide6.QtWebSockets import QWebSocket
-from PySide6.QtNetwork import QAbstractSocket
-import aiohttp
-import asyncio
-import json
-import logging
-import subprocess
-import threading
-import time
-from pathlib import Path
-import os
-
-# Configure logging
-logging.basicConfig(
-    level=logging.INFO,
-    format='%(asctime)s [%(levelname)s] %(message)s',
-    datefmt='%H:%M:%S'
-)
-logger = logging.getLogger("DuneWeaver")
-
-QML_IMPORT_NAME = "DuneWeaver"
-QML_IMPORT_MAJOR_VERSION = 1
-
-@QmlElement
-class Backend(QObject):
-    """Backend controller for API and WebSocket communication"""
-    
-    # Constants
-    SETTINGS_FILE = "touch_settings.json"
-    DEFAULT_SCREEN_TIMEOUT = 300  # 5 minutes in seconds
-    
-    # Predefined timeout options (in seconds)
-    TIMEOUT_OPTIONS = {
-        "30 seconds": 30,
-        "1 minute": 60, 
-        "5 minutes": 300,
-        "10 minutes": 600,
-        "Never": 0  # 0 means never timeout
-    }
-    
-    # Predefined speed options
-    SPEED_OPTIONS = {
-        "50": 50,
-        "100": 100,
-        "150": 150,
-        "200": 200,
-        "300": 300,
-        "500": 500
-    }
-    
-    # Predefined pause between patterns options (in seconds)
-    PAUSE_OPTIONS = {
-        "0s": 0,        # No pause
-        "1 min": 60,    # 1 minute
-        "5 min": 300,   # 5 minutes
-        "15 min": 900,  # 15 minutes
-        "30 min": 1800, # 30 minutes
-        "1 hour": 3600, # 1 hour
-        "2 hour": 7200, # 2 hours
-        "3 hour": 10800, # 3 hours
-        "4 hour": 14400, # 4 hours
-        "5 hour": 18000, # 5 hours
-        "6 hour": 21600, # 6 hours
-        "12 hour": 43200 # 12 hours
-    }
-    
-    # Signals
-    statusChanged = Signal()
-    progressChanged = Signal()
-    connectionChanged = Signal()
-    executionStarted = Signal(str, str)  # patternName, patternPreview
-    executionStopped = Signal()
-    errorOccurred = Signal(str)
-    serialPortsUpdated = Signal(list)
-    serialConnectionChanged = Signal(bool)
-    currentPortChanged = Signal(str)
-    speedChanged = Signal(int)
-    settingsLoaded = Signal()
-    screenStateChanged = Signal(bool)  # True = on, False = off
-    screenTimeoutChanged = Signal(int)  # New signal for timeout changes
-    pauseBetweenPatternsChanged = Signal(int)  # New signal for pause changes
-    pausedChanged = Signal(bool)  # Signal when pause state changes
-    playlistSettingsChanged = Signal()  # Signal when any playlist setting changes
-    patternsRefreshCompleted = Signal(bool, str)  # (success, message) for pattern refresh
-
-    # Playlist management signals
-    playlistCreated = Signal(bool, str)      # (success, message)
-    playlistDeleted = Signal(bool, str)      # (success, message)
-    patternAddedToPlaylist = Signal(bool, str)  # (success, message)
-    playlistModified = Signal(bool, str)     # (success, message)
-
-    # Backend connection status signals
-    backendConnectionChanged = Signal(bool)  # True = backend reachable, False = unreachable
-    reconnectStatusChanged = Signal(str)  # Current reconnection status message
-
-    # LED control signals
-    ledStatusChanged = Signal()
-    ledEffectsLoaded = Signal(list)  # List of available effects
-    ledPalettesLoaded = Signal(list)  # List of available palettes
-
-    # LCD brightness signals
-    lcdBrightnessChanged = Signal()
-    
-    def __init__(self):
-        super().__init__()
-        # Load base URL from environment variable, default to localhost
-        self.base_url = os.environ.get("DUNE_WEAVER_URL", "http://localhost:8080")
-        
-        # Initialize all status properties first
-        self._current_file = ""
-        self._progress = 0
-        self._is_running = False
-        self._is_paused = False  # Track pause state separately
-        self._is_connected = False
-        self._serial_ports = []
-        self._serial_connected = False
-        self._current_port = ""
-        self._current_speed = 130
-        self._auto_play_on_boot = False
-        self._pause_between_patterns = 10800  # Default: 3 hours (10800 seconds)
-
-        # Playlist settings (persisted locally)
-        self._playlist_shuffle = True  # Default: shuffle on
-        self._playlist_run_mode = "loop"  # Default: loop mode
-        self._playlist_clear_pattern = "adaptive"  # Default: adaptive
-        
-        # Backend connection status
-        self._backend_connected = False
-        self._reconnect_status = "Connecting to backend..."
-
-        # LCD brightness state
-        self._lcd_brightness = 255
-        self._lcd_brightness_path = ""  # Empty = auto-detect
-        self._lcd_max_brightness = 0    # 0 = read from sysfs
-
-        # LED control state
-        self._led_provider = "none"  # "none", "wled", or "dw_leds"
-        self._led_connected = False
-        self._led_power_on = False
-        self._led_brightness = 100
-        self._led_effects = []
-        self._led_palettes = []
-        self._led_current_effect = 0
-        self._led_current_palette = 0
-        self._led_color = "#ffffff"
-        
-        # WebSocket for status with reconnection
-        self.ws = QWebSocket()
-        self.ws.connected.connect(self._on_ws_connected)
-        self.ws.disconnected.connect(self._on_ws_disconnected)
-        self.ws.errorOccurred.connect(self._on_ws_error)
-        self.ws.textMessageReceived.connect(self._on_ws_message)
-        
-        # WebSocket reconnection management
-        self._reconnect_timer = QTimer()
-        self._reconnect_timer.timeout.connect(self._attempt_ws_reconnect)
-        self._reconnect_timer.setSingleShot(True)
-        self._reconnect_attempts = 0
-        self._reconnect_delay = 1000  # Fixed 1 second delay between retries
-        
-        # Screen management
-        self._screen_on = True
-        self._screen_timeout = self.DEFAULT_SCREEN_TIMEOUT  # Will be loaded from settings
-        self._last_activity = time.time()
-        self._screen_transition_lock = threading.Lock()  # Prevent rapid state changes
-        self._last_screen_change = 0  # Track last state change time
-        self._screen_timer = QTimer()
-        self._screen_timer.timeout.connect(self._check_screen_timeout)
-        self._screen_timer.start(1000)  # Check every second
-        # Load local settings first
-        self._load_local_settings()
-        logger.debug(f"Screen management initialized: timeout={self._screen_timeout}s, timer started")
-
-        # Detect and initialize LCD backlight
-        self._detect_backlight()
-        
-        # HTTP session - initialize lazily
-        self.session = None
-        self._session_initialized = False
-        
-        # Use QTimer to defer session initialization until event loop is running
-        QTimer.singleShot(100, self._delayed_init)
-        
-        # Start initial WebSocket connection (after all attributes are initialized)
-        # Use QTimer to ensure it happens after constructor completes
-        QTimer.singleShot(200, self._attempt_ws_reconnect)
-    
-    @Slot()
-    def _delayed_init(self):
-        """Initialize session after Qt event loop is running"""
-        if not self._session_initialized:
-            try:
-                loop = asyncio.get_event_loop()
-                if loop.is_running():
-                    asyncio.create_task(self._init_session())
-                else:
-                    # If no loop is running, try again later
-                    QTimer.singleShot(500, self._delayed_init)
-            except RuntimeError:
-                # No event loop yet, try again
-                QTimer.singleShot(500, self._delayed_init)
-    
-    async def _init_session(self):
-        """Initialize aiohttp session"""
-        if not self._session_initialized:
-            # Create connector with SSL disabled for localhost
-            connector = aiohttp.TCPConnector(ssl=False)
-            self.session = aiohttp.ClientSession(connector=connector)
-            self._session_initialized = True
-    
-    # Properties
-    @Property(str, notify=statusChanged)
-    def currentFile(self):
-        return self._current_file
-    
-    @Property(float, notify=progressChanged)
-    def progress(self):
-        return self._progress
-    
-    @Property(bool, notify=statusChanged)
-    def isRunning(self):
-        return self._is_running
-
-    @Property(bool, notify=pausedChanged)
-    def isPaused(self):
-        return self._is_paused
-
-    @Property(bool, notify=connectionChanged)
-    def isConnected(self):
-        return self._is_connected
-    
-    @Property(list, notify=serialPortsUpdated)
-    def serialPorts(self):
-        return self._serial_ports
-    
-    @Property(bool, notify=serialConnectionChanged)
-    def serialConnected(self):
-        return self._serial_connected
-    
-    @Property(str, notify=currentPortChanged)
-    def currentPort(self):
-        return self._current_port
-    
-    @Property(int, notify=speedChanged)
-    def currentSpeed(self):
-        return self._current_speed
-    
-    @Property(bool, notify=settingsLoaded)
-    def autoPlayOnBoot(self):
-        return self._auto_play_on_boot
-    
-    @Property(bool, notify=backendConnectionChanged)
-    def backendConnected(self):
-        return self._backend_connected
-    
-    @Property(str, notify=reconnectStatusChanged)
-    def reconnectStatus(self):
-        return self._reconnect_status
-    
-    # WebSocket handlers
-    @Slot()
-    def _on_ws_connected(self):
-        logger.info("WebSocket connected successfully")
-        self._is_connected = True
-        self._backend_connected = True
-        self._reconnect_attempts = 0  # Reset reconnection counter
-        self._reconnect_status = "Connected to backend"
-        self.connectionChanged.emit()
-        self.backendConnectionChanged.emit(True)
-        self.reconnectStatusChanged.emit("Connected to backend")
-
-        # Load initial settings when we connect
-        self.loadControlSettings()
-        # Also load LED config automatically
-        self.loadLedConfig()
-    
-    @Slot()
-    def _on_ws_disconnected(self):
-        logger.error("WebSocket disconnected")
-        self._is_connected = False
-        self._backend_connected = False
-        self._reconnect_status = "Backend connection lost..."
-        self.connectionChanged.emit()
-        self.backendConnectionChanged.emit(False)
-        self.reconnectStatusChanged.emit("Backend connection lost...")
-        # Start reconnection attempts
-        self._schedule_reconnect()
-    
-    @Slot()
-    def _on_ws_error(self, error):
-        logger.error(f"WebSocket error: {error}")
-        self._is_connected = False
-        self._backend_connected = False
-        self._reconnect_status = f"Backend error: {error}"
-        self.connectionChanged.emit()
-        self.backendConnectionChanged.emit(False)
-        self.reconnectStatusChanged.emit(f"Backend error: {error}")
-        # Start reconnection attempts
-        self._schedule_reconnect()
-    
-    def _schedule_reconnect(self):
-        """Schedule a reconnection attempt with fixed 1-second delay."""
-        # Always retry - no maximum attempts for touch interface
-        status_msg = f"Reconnecting in 1s... (attempt {self._reconnect_attempts + 1})"
-        logger.debug(f"{status_msg}")
-        self._reconnect_status = status_msg
-        self.reconnectStatusChanged.emit(status_msg)
-        self._reconnect_timer.start(self._reconnect_delay)  # Always 1 second
-    
-    @Slot()
-    def _attempt_ws_reconnect(self):
-        """Attempt to reconnect WebSocket."""
-        if self.ws.state() == QAbstractSocket.SocketState.ConnectedState:
-            logger.info("WebSocket already connected")
-            return
-            
-        self._reconnect_attempts += 1
-        status_msg = f"Connecting to backend... (attempt {self._reconnect_attempts})"
-        logger.debug(f"{status_msg}")
-        self._reconnect_status = status_msg
-        self.reconnectStatusChanged.emit(status_msg)
-        
-        # Close existing connection if any
-        if self.ws.state() != QAbstractSocket.SocketState.UnconnectedState:
-            self.ws.close()
-        
-        # Attempt new connection - derive WebSocket URL from base URL
-        ws_url = self.base_url.replace("http://", "ws://").replace("https://", "wss://") + "/ws/status"
-        self.ws.open(ws_url)
-    
-    @Slot()
-    def retryConnection(self):
-        """Manually retry connection (reset attempts and try again)."""
-        logger.debug("Manual connection retry requested")
-        self._reconnect_attempts = 0
-        self._reconnect_timer.stop()  # Stop any scheduled reconnect
-        self._attempt_ws_reconnect()
-    
-    @Slot(str)
-    def _on_ws_message(self, message):
-        try:
-            data = json.loads(message)
-            if data.get("type") == "status_update":
-                status = data.get("data", {})
-                new_file = status.get("current_file", "")
-
-                # Detect pattern change and emit executionStarted signal
-                if new_file and new_file != self._current_file:
-                    logger.info(f"Pattern changed from '{self._current_file}' to '{new_file}'")
-                    # Find preview for the new pattern
-                    preview_path = self._find_pattern_preview(new_file)
-                    logger.debug(f"Preview path for new pattern: {preview_path}")
-                    # Emit signal so UI can update
-                    self.executionStarted.emit(new_file, preview_path)
-
-                self._current_file = new_file
-                self._is_running = status.get("is_running", False)
-
-                # Handle pause state from WebSocket
-                new_paused = status.get("is_paused", False)
-                if new_paused != self._is_paused:
-                    logger.info(f"Pause state changed: {self._is_paused} -> {new_paused}")
-                    self._is_paused = new_paused
-                    self.pausedChanged.emit(new_paused)
-
-                # Handle serial connection status from WebSocket
-                ws_connection_status = status.get("connection_status", False)
-                if ws_connection_status != self._serial_connected:
-                    logger.info(f"WebSocket serial connection status changed: {ws_connection_status}")
-                    self._serial_connected = ws_connection_status
-                    self.serialConnectionChanged.emit(ws_connection_status)
-
-                    # If we're connected, we need to get the current port
-                    if ws_connection_status:
-                        # We'll need to fetch the current port via HTTP since WS doesn't include port info
-                        asyncio.create_task(self._get_current_port())
-                    else:
-                        self._current_port = ""
-                        self.currentPortChanged.emit("")
-
-                # Handle speed updates from WebSocket
-                ws_speed = status.get("speed", None)
-                if ws_speed and ws_speed != self._current_speed:
-                    logger.debug(f"WebSocket speed changed: {ws_speed}")
-                    self._current_speed = ws_speed
-                    self.speedChanged.emit(ws_speed)
-
-                if status.get("progress"):
-                    self._progress = status["progress"].get("percentage", 0)
-
-                self.statusChanged.emit()
-                self.progressChanged.emit()
-        except json.JSONDecodeError:
-            pass
-    
-    async def _get_current_port(self):
-        """Fetch the current port when we detect a connection via WebSocket"""
-        if not self.session:
-            return
-        
-        try:
-            async with self.session.get(f"{self.base_url}/serial_status") as resp:
-                if resp.status == 200:
-                    data = await resp.json()
-                    current_port = data.get("port", "")
-                    if current_port:
-                        self._current_port = current_port
-                        self.currentPortChanged.emit(current_port)
-                        logger.info(f"Updated current port from WebSocket trigger: {current_port}")
-        except Exception as e:
-            logger.error(f"Exception getting current port: {e}")
-    
-    # API Methods
-    @Slot(str, str)
-    def executePattern(self, fileName, preExecution="adaptive"):
-        logger.info(f"ExecutePattern called: fileName='{fileName}', preExecution='{preExecution}'")
-        asyncio.create_task(self._execute_pattern(fileName, preExecution))
-    
-    async def _execute_pattern(self, fileName, preExecution):
-        if not self.session:
-            logger.error("Backend session not ready")
-            self.errorOccurred.emit("Backend not ready, please try again")
-            return
-        
-        try:
-            request_data = {"file_name": fileName, "pre_execution": preExecution}
-            logger.debug(f"Making HTTP POST to: {self.base_url}/run_theta_rho")
-            logger.debug(f"Request payload: {request_data}")
-            
-            async with self.session.post(
-                f"{self.base_url}/run_theta_rho",
-                json=request_data
-            ) as resp:
-                logger.debug(f"Response status: {resp.status}")
-                logger.debug(f"Response headers: {dict(resp.headers)}")
-                
-                response_text = await resp.text()
-                logger.debug(f"Response body: {response_text}")
-                
-                if resp.status == 200:
-                    logger.info("Pattern execution request successful")
-                    # Find preview image for the pattern
-                    preview_path = self._find_pattern_preview(fileName)
-                    logger.debug(f"Pattern preview path: {preview_path}")
-                    logger.debug(f"About to emit executionStarted signal with: fileName='{fileName}', preview='{preview_path}'")
-                    try:
-                        self.executionStarted.emit(fileName, preview_path)
-                        logger.info("ExecutionStarted signal emitted successfully")
-                    except Exception as e:
-                        logger.error(f"Error emitting executionStarted signal: {e}")
-                else:
-                    logger.error(f"Pattern execution failed with status {resp.status}")
-                    self.errorOccurred.emit(f"Failed to execute: {resp.status} - {response_text}")
-        except Exception as e:
-            logger.error(f"Exception in _execute_pattern: {e}")
-            self.errorOccurred.emit(str(e))
-    
-    def _find_pattern_preview(self, fileName):
-        """Find the preview image for a pattern"""
-        try:
-            # Extract just the filename from the path (remove any directory prefixes)
-            clean_filename = fileName.split('/')[-1]  # Get last part of path
-            logger.debug(f"Original fileName: {fileName}, clean filename: {clean_filename}")
-
-            # Check multiple possible locations for patterns directory
-            # Use relative paths that work across different environments
-            possible_dirs = [
-                Path("../patterns"),  # One level up (for when running from touch subdirectory)
-                Path("patterns"),     # Same level (for when running from main directory)
-                Path(__file__).parent.parent / "patterns"  # Dynamic path relative to backend.py
-            ]
-
-            for patterns_dir in possible_dirs:
-                cache_dir = patterns_dir / "cached_images"
-                if cache_dir.exists():
-                    logger.debug(f"Searching for preview in cache directory: {cache_dir}")
-
-                    # Extensions to try - PNG first for better kiosk compatibility
-                    extensions = [".png", ".webp", ".jpg", ".jpeg"]
-
-                    # Filenames to try - with and without .thr suffix
-                    base_name = clean_filename.replace(".thr", "")
-                    filenames_to_try = [clean_filename, base_name]
-
-                    # Try direct path in cache_dir first (fastest)
-                    for filename in filenames_to_try:
-                        for ext in extensions:
-                            preview_file = cache_dir / (filename + ext)
-                            if preview_file.exists():
-                                logger.info(f"Found preview (direct): {preview_file}")
-                                return str(preview_file.absolute())
-
-                    # If not found directly, search recursively through subdirectories
-                    logger.debug(f"Searching recursively in {cache_dir}...")
-                    for filename in filenames_to_try:
-                        for ext in extensions:
-                            target_name = filename + ext
-                            # Use rglob to search recursively
-                            matches = list(cache_dir.rglob(target_name))
-                            if matches:
-                                # Return the first match found
-                                preview_file = matches[0]
-                                logger.info(f"Found preview (recursive): {preview_file}")
-                                return str(preview_file.absolute())
-
-            logger.error("No preview image found")
-            return ""
-        except Exception as e:
-            logger.error(f"Exception finding preview: {e}")
-            return ""
-    
-    @Slot()
-    def stopExecution(self):
-        asyncio.create_task(self._stop_execution())
-    
-    async def _stop_execution(self):
-        if not self.session:
-            self.errorOccurred.emit("Backend not ready")
-            return
-        
-        try:
-            logger.info("Calling stop_execution endpoint...")
-            # Backend stop_actions() waits up to 10s for pattern lock + 30s for idle check
-            # Use 45s timeout to accommodate worst-case scenario
-            timeout = aiohttp.ClientTimeout(total=45)
-            async with self.session.post(f"{self.base_url}/stop_execution", timeout=timeout) as resp:
-                logger.info(f"Stop execution response status: {resp.status}")
-                if resp.status == 200:
-                    response_data = await resp.json()
-                    logger.info(f"Stop execution response: {response_data}")
-                    self.executionStopped.emit()
-                else:
-                    logger.error(f"Stop execution failed with status: {resp.status}")
-                    response_text = await resp.text()
-                    self.errorOccurred.emit(f"Stop failed: {resp.status} - {response_text}")
-        except asyncio.TimeoutError:
-            logger.warning("Stop execution request timed out")
-            self.errorOccurred.emit("Stop execution request timed out")
-        except Exception as e:
-            logger.error(f"Exception in _stop_execution: {e}")
-            self.errorOccurred.emit(str(e))
-    
-    @Slot()
-    def pauseExecution(self):
-        logger.info("Pausing execution...")
-        asyncio.create_task(self._api_call("/pause_execution"))
-    
-    @Slot()
-    def resumeExecution(self):
-        logger.info("Resuming execution...")
-        asyncio.create_task(self._api_call("/resume_execution"))
-    
-    @Slot()
-    def skipPattern(self):
-        logger.info("Skipping pattern...")
-        asyncio.create_task(self._api_call("/skip_pattern"))
-    
-    @Slot(str, float, str, str, bool)
-    def executePlaylist(self, playlistName, pauseTime=0.0, clearPattern="adaptive", runMode="single", shuffle=False):
-        logger.info(f"ExecutePlaylist called: playlist='{playlistName}', pauseTime={pauseTime}, clearPattern='{clearPattern}', runMode='{runMode}', shuffle={shuffle}")
-        asyncio.create_task(self._execute_playlist(playlistName, pauseTime, clearPattern, runMode, shuffle))
-    
-    async def _execute_playlist(self, playlistName, pauseTime, clearPattern, runMode, shuffle):
-        if not self.session:
-            logger.error("Backend session not ready")
-            self.errorOccurred.emit("Backend not ready, please try again")
-            return
-        
-        try:
-            request_data = {
-                "playlist_name": playlistName,
-                "pause_time": pauseTime,
-                "clear_pattern": clearPattern,
-                "run_mode": runMode,
-                "shuffle": shuffle
-            }
-            logger.debug(f"Making HTTP POST to: {self.base_url}/run_playlist")
-            logger.debug(f"Request payload: {request_data}")
-            
-            async with self.session.post(
-                f"{self.base_url}/run_playlist",
-                json=request_data
-            ) as resp:
-                logger.debug(f"Response status: {resp.status}")
-                
-                response_text = await resp.text()
-                logger.debug(f"Response body: {response_text}")
-                
-                if resp.status == 200:
-                    logger.info(f"Playlist execution request successful: {playlistName}")
-                    # The playlist will start executing patterns automatically
-                    # Status updates will come through WebSocket
-                else:
-                    logger.error(f"Playlist execution failed with status {resp.status}")
-                    self.errorOccurred.emit(f"Failed to execute playlist: {resp.status} - {response_text}")
-        except Exception as e:
-            logger.error(f"Exception in _execute_playlist: {e}")
-            self.errorOccurred.emit(str(e))
-    
-    async def _api_call(self, endpoint):
-        if not self.session:
-            self.errorOccurred.emit("Backend not ready")
-            return
-        
-        try:
-            logger.debug(f"Calling API endpoint: {endpoint}")
-            # Add timeout to prevent hanging
-            timeout = aiohttp.ClientTimeout(total=10)  # 10 second timeout
-            async with self.session.post(f"{self.base_url}{endpoint}", timeout=timeout) as resp:
-                logger.debug(f"API response status for {endpoint}: {resp.status}")
-                if resp.status == 200:
-                    response_data = await resp.json()
-                    logger.debug(f"API response for {endpoint}: {response_data}")
-                else:
-                    logger.error(f"API call {endpoint} failed with status: {resp.status}")
-                    response_text = await resp.text()
-                    self.errorOccurred.emit(f"API call failed: {endpoint} - {resp.status} - {response_text}")
-        except asyncio.TimeoutError:
-            logger.warning(f"API call {endpoint} timed out")
-            self.errorOccurred.emit(f"API call {endpoint} timed out")
-        except Exception as e:
-            logger.error(f"Exception in API call {endpoint}: {e}")
-            self.errorOccurred.emit(str(e))
-    
-    # Serial Port Management
-    @Slot()
-    def refreshSerialPorts(self):
-        logger.info("Refreshing serial ports...")
-        asyncio.create_task(self._refresh_serial_ports())
-    
-    async def _refresh_serial_ports(self):
-        if not self.session:
-            self.errorOccurred.emit("Backend not ready")
-            return
-        
-        try:
-            async with self.session.get(f"{self.base_url}/list_serial_ports") as resp:
-                if resp.status == 200:
-                    # The endpoint returns a list directly, not a dictionary
-                    ports = await resp.json()
-                    self._serial_ports = ports if isinstance(ports, list) else []
-                    logger.debug(f"Found serial ports: {self._serial_ports}")
-                    self.serialPortsUpdated.emit(self._serial_ports)
-                else:
-                    logger.error(f"Failed to get serial ports: {resp.status}")
-        except Exception as e:
-            logger.error(f"Exception refreshing serial ports: {e}")
-            self.errorOccurred.emit(str(e))
-    
-    @Slot(str)
-    def connectSerial(self, port):
-        logger.info(f"Connecting to serial port: {port}")
-        asyncio.create_task(self._connect_serial(port))
-    
-    async def _connect_serial(self, port):
-        if not self.session:
-            self.errorOccurred.emit("Backend not ready")
-            return
-        
-        try:
-            async with self.session.post(f"{self.base_url}/connect", json={"port": port}) as resp:
-                if resp.status == 200:
-                    logger.info(f"Connected to {port}")
-                    self._serial_connected = True
-                    self._current_port = port
-                    self.serialConnectionChanged.emit(True)
-                    self.currentPortChanged.emit(port)
-                else:
-                    response_text = await resp.text()
-                    logger.error(f"Failed to connect to {port}: {resp.status} - {response_text}")
-                    self.errorOccurred.emit(f"Failed to connect: {response_text}")
-        except Exception as e:
-            logger.error(f"Exception connecting to serial: {e}")
-            self.errorOccurred.emit(str(e))
-    
-    @Slot()
-    def disconnectSerial(self):
-        logger.info("Disconnecting serial...")
-        asyncio.create_task(self._disconnect_serial())
-    
-    async def _disconnect_serial(self):
-        if not self.session:
-            self.errorOccurred.emit("Backend not ready")
-            return
-        
-        try:
-            async with self.session.post(f"{self.base_url}/disconnect") as resp:
-                if resp.status == 200:
-                    logger.info("Disconnected from serial")
-                    self._serial_connected = False
-                    self._current_port = ""
-                    self.serialConnectionChanged.emit(False)
-                    self.currentPortChanged.emit("")
-                else:
-                    response_text = await resp.text()
-                    logger.error(f"Failed to disconnect: {resp.status} - {response_text}")
-        except Exception as e:
-            logger.error(f"Exception disconnecting serial: {e}")
-            self.errorOccurred.emit(str(e))
-    
-    # Hardware Movement Controls
-    @Slot()
-    def sendHome(self):
-        logger.debug("Sending home command...")
-        asyncio.create_task(self._send_home())
-
-    async def _send_home(self):
-        """Send home command without timeout - homing can take up to 90 seconds."""
-        if not self.session:
-            self.errorOccurred.emit("Backend not ready")
-            return
-
-        try:
-            logger.debug("Calling /send_home (no timeout - homing can take up to 90s)...")
-            async with self.session.post(f"{self.base_url}/send_home") as resp:
-                logger.debug(f"Home command response status: {resp.status}")
-                if resp.status == 200:
-                    response_data = await resp.json()
-                    logger.info(f"Home command successful: {response_data}")
-                else:
-                    logger.error(f"Home command failed with status: {resp.status}")
-                    response_text = await resp.text()
-                    self.errorOccurred.emit(f"Home failed: {resp.status} - {response_text}")
-        except Exception as e:
-            logger.error(f"Exception in home command: {e}")
-            self.errorOccurred.emit(str(e))
-    
-    @Slot()
-    def moveToCenter(self):
-        logger.info("Moving to center...")
-        asyncio.create_task(self._move_to("/move_to_center"))
-
-    @Slot()
-    def moveToPerimeter(self):
-        logger.info("Moving to perimeter...")
-        asyncio.create_task(self._move_to("/move_to_perimeter"))
-
-    async def _move_to(self, endpoint):
-        """Move to center/perimeter with extended timeout.
-
-        Backend calls reset_theta() (up to 30s idle check if hard_reset_theta
-        enabled) then move_polar() + check_idle_async(timeout=60). Use 95s
-        timeout to accommodate the worst-case scenario.
-        """
-        if not self.session:
-            self.errorOccurred.emit("Backend not ready")
-            return
-
-        try:
-            logger.debug(f"Calling {endpoint} (95s timeout for idle checks)...")
-            timeout = aiohttp.ClientTimeout(total=95)
-            async with self.session.post(f"{self.base_url}{endpoint}", timeout=timeout) as resp:
-                logger.debug(f"Move response status for {endpoint}: {resp.status}")
-                if resp.status == 200:
-                    response_data = await resp.json()
-                    logger.info(f"Move command {endpoint} successful: {response_data}")
-                else:
-                    logger.error(f"Move command {endpoint} failed with status: {resp.status}")
-                    response_text = await resp.text()
-                    self.errorOccurred.emit(f"Move failed: {endpoint} - {resp.status} - {response_text}")
-        except asyncio.TimeoutError:
-            logger.warning(f"Move command {endpoint} timed out after 95s")
-            self.errorOccurred.emit(f"Move command {endpoint} timed out")
-        except Exception as e:
-            logger.error(f"Exception in move command {endpoint}: {e}")
-            self.errorOccurred.emit(str(e))
-    
-    # Speed Control
-    @Slot(int)
-    def setSpeed(self, speed):
-        logger.debug(f"Setting speed to: {speed}")
-        asyncio.create_task(self._set_speed(speed))
-    
-    async def _set_speed(self, speed):
-        if not self.session:
-            self.errorOccurred.emit("Backend not ready")
-            return
-        
-        try:
-            async with self.session.post(f"{self.base_url}/set_speed", json={"speed": speed}) as resp:
-                if resp.status == 200:
-                    logger.info(f"Speed set to {speed}")
-                    self._current_speed = speed
-                    self.speedChanged.emit(speed)
-                else:
-                    response_text = await resp.text()
-                    logger.error(f"Failed to set speed: {resp.status} - {response_text}")
-        except Exception as e:
-            logger.error(f"Exception setting speed: {e}")
-            self.errorOccurred.emit(str(e))
-    
-    # Auto Play on Boot Setting
-    @Slot(bool)
-    def setAutoPlayOnBoot(self, enabled):
-        logger.info(f"Setting auto play on boot: {enabled}")
-        asyncio.create_task(self._set_auto_play_on_boot(enabled))
-    
-    async def _set_auto_play_on_boot(self, enabled):
-        if not self.session:
-            self.errorOccurred.emit("Backend not ready")
-            return
-        
-        try:
-            # Use the kiosk mode API endpoint for auto-play on boot
-            async with self.session.post(f"{self.base_url}/api/kiosk-mode", json={"enabled": enabled}) as resp:
-                if resp.status == 200:
-                    logger.info(f"Auto play on boot set to {enabled}")
-                    self._auto_play_on_boot = enabled
-                else:
-                    response_text = await resp.text()
-                    logger.error(f"Failed to set auto play: {resp.status} - {response_text}")
-        except Exception as e:
-            logger.error(f"Exception setting auto play: {e}")
-            self.errorOccurred.emit(str(e))
-    
-    # Note: Screen timeout is now managed locally in touch_settings.json
-    # The main application doesn't have a kiosk-mode endpoint, so we manage this locally
-    
-    # Load Settings
-    def _load_local_settings(self):
-        """Load settings from local JSON file"""
-        try:
-            if os.path.exists(self.SETTINGS_FILE):
-                with open(self.SETTINGS_FILE, 'r') as f:
-                    settings = json.load(f)
-
-                screen_timeout = settings.get('screen_timeout', self.DEFAULT_SCREEN_TIMEOUT)
-                if isinstance(screen_timeout, (int, float)) and screen_timeout >= 0:
-                    self._screen_timeout = int(screen_timeout)
-                    if screen_timeout == 0:
-                        logger.debug("Loaded screen timeout from local settings: Never (0s)")
-                    else:
-                        logger.debug(f"Loaded screen timeout from local settings: {self._screen_timeout}s")
-                else:
-                    logger.warning(f"Invalid screen timeout in settings, using default: {self.DEFAULT_SCREEN_TIMEOUT}s")
-
-                # Load LCD brightness settings
-                self._lcd_brightness = settings.get('lcd_brightness', 255)
-                self._lcd_brightness_path = settings.get('lcd_brightness_path', "")
-                self._lcd_max_brightness = settings.get('lcd_max_brightness', 0)
-                logger.debug(f"Loaded LCD settings: brightness={self._lcd_brightness}, path='{self._lcd_brightness_path}', max={self._lcd_max_brightness}")
-
-                # Load playlist settings
-                self._pause_between_patterns = settings.get('pause_between_patterns', 10800)  # Default 3h
-                self._playlist_shuffle = settings.get('playlist_shuffle', True)
-                self._playlist_run_mode = settings.get('playlist_run_mode', "loop")
-                self._playlist_clear_pattern = settings.get('playlist_clear_pattern', "adaptive")
-                logger.info(f"Loaded playlist settings: pause={self._pause_between_patterns}s, shuffle={self._playlist_shuffle}, mode={self._playlist_run_mode}, clear={self._playlist_clear_pattern}")
-            else:
-                logger.debug("No local settings file found, creating with defaults")
-                self._save_local_settings()
-        except Exception as e:
-            logger.error(f"Error loading local settings: {e}, using defaults")
-            self._screen_timeout = self.DEFAULT_SCREEN_TIMEOUT
-
-    def _save_local_settings(self):
-        """Save settings to local JSON file"""
-        try:
-            settings = {
-                'screen_timeout': self._screen_timeout,
-                'lcd_brightness': self._lcd_brightness,
-                'lcd_brightness_path': self._lcd_brightness_path,
-                'lcd_max_brightness': self._lcd_max_brightness,
-                'pause_between_patterns': self._pause_between_patterns,
-                'playlist_shuffle': self._playlist_shuffle,
-                'playlist_run_mode': self._playlist_run_mode,
-                'playlist_clear_pattern': self._playlist_clear_pattern,
-                'version': '1.2'
-            }
-            with open(self.SETTINGS_FILE, 'w') as f:
-                json.dump(settings, f, indent=2)
-            logger.debug(f"Saved local settings: screen_timeout={self._screen_timeout}s, playlist settings saved")
-        except Exception as e:
-            logger.error(f"Error saving local settings: {e}")
-
-    @Slot()
-    def loadControlSettings(self):
-        logger.debug("Loading control settings...")
-        asyncio.create_task(self._load_settings())
-    
-    async def _load_settings(self):
-        if not self.session:
-            logger.warning("Session not ready for loading settings")
-            return
-        
-        try:
-            # Load auto play setting from the working endpoint
-            timeout = aiohttp.ClientTimeout(total=5)  # 5 second timeout
-            async with self.session.get(f"{self.base_url}/api/auto_play-mode", timeout=timeout) as resp:
-                if resp.status == 200:
-                    data = await resp.json()
-                    self._auto_play_on_boot = data.get("enabled", False)
-                    logger.info(f"Loaded auto play setting: {self._auto_play_on_boot}")
-                # Note: Screen timeout is managed locally, not from server
-            
-            # Serial status will be handled by WebSocket updates automatically
-            # But we still load the initial port info if connected
-            async with self.session.get(f"{self.base_url}/serial_status", timeout=timeout) as resp:
-                if resp.status == 200:
-                    data = await resp.json()
-                    initial_connected = data.get("connected", False)
-                    current_port = data.get("port", "")
-                    logger.info(f"Initial serial status: connected={initial_connected}, port={current_port}")
-                    
-                    # Only update if WebSocket hasn't already set this
-                    if initial_connected and current_port and not self._current_port:
-                        self._current_port = current_port
-                        self.currentPortChanged.emit(current_port)
-                    
-                    # Set initial connection status (WebSocket will take over from here)
-                    if self._serial_connected != initial_connected:
-                        self._serial_connected = initial_connected
-                        self.serialConnectionChanged.emit(initial_connected)
-            
-            logger.info("Settings loaded - WebSocket will handle real-time updates")
-            self.settingsLoaded.emit()
-            
-        except aiohttp.ClientConnectorError as e:
-            logger.warning(f"Cannot connect to backend at {self.base_url}: {e}")
-            # Don't emit error - this is expected when backend is down
-            # WebSocket will handle reconnection
-        except asyncio.TimeoutError:
-            logger.warning(f"Timeout loading settings from {self.base_url}")
-            # Don't emit error - expected when backend is slow/down
-        except Exception as e:
-            logger.error(f"Unexpected error loading settings: {e}")
-            # Only emit error for unexpected issues
-            if "ssl" not in str(e).lower():
-                self.errorOccurred.emit(str(e))
-    
-    # Screen Management Properties
-    @Property(bool, notify=screenStateChanged)
-    def screenOn(self):
-        return self._screen_on
-    
-    @Property(int, notify=screenTimeoutChanged)
-    def screenTimeout(self):
-        return self._screen_timeout
-    
-    @screenTimeout.setter
-    def setScreenTimeout(self, timeout):
-        if self._screen_timeout != timeout:
-            old_timeout = self._screen_timeout
-            self._screen_timeout = timeout
-            logger.debug(f"Screen timeout changed from {old_timeout}s to {timeout}s")
-            
-            # Save to local settings
-            self._save_local_settings()
-            
-            # Emit change signal for QML
-            self.screenTimeoutChanged.emit(timeout)
-    
-    @Slot(result='QStringList')
-    def getScreenTimeoutOptions(self):
-        """Get list of screen timeout options for QML"""
-        return list(self.TIMEOUT_OPTIONS.keys())
-    
-    @Slot(result=str)
-    def getCurrentScreenTimeoutOption(self):
-        """Get current screen timeout as option string"""
-        current_timeout = self._screen_timeout
-        for option, value in self.TIMEOUT_OPTIONS.items():
-            if value == current_timeout:
-                return option
-        # If custom value, return closest match or custom description
-        if current_timeout == 0:
-            return "Never"
-        elif current_timeout < 60:
-            return f"{current_timeout} seconds"
-        elif current_timeout < 3600:
-            minutes = current_timeout // 60
-            return f"{minutes} minute{'s' if minutes != 1 else ''}"
-        else:
-            hours = current_timeout // 3600
-            return f"{hours} hour{'s' if hours != 1 else ''}"
-    
-    @Slot(str)
-    def setScreenTimeoutByOption(self, option):
-        """Set screen timeout by option string"""
-        if option in self.TIMEOUT_OPTIONS:
-            timeout_value = self.TIMEOUT_OPTIONS[option]
-            # Don't call the setter method, just assign to trigger the property setter
-            if self._screen_timeout != timeout_value:
-                old_timeout = self._screen_timeout
-                self._screen_timeout = timeout_value
-                logger.debug(f"Screen timeout changed from {old_timeout}s to {timeout_value}s ({option})")
-                
-                # Save to local settings
-                self._save_local_settings()
-                
-                # Emit change signal for QML
-                self.screenTimeoutChanged.emit(timeout_value)
-        else:
-            logger.warning(f"Unknown timeout option: {option}")
-    
-    @Slot(result='QStringList')
-    def getSpeedOptions(self):
-        """Get list of speed options for QML"""
-        return list(self.SPEED_OPTIONS.keys())
-    
-    @Slot(result=str)
-    def getCurrentSpeedOption(self):
-        """Get current speed as option string"""
-        current_speed = self._current_speed
-        for option, value in self.SPEED_OPTIONS.items():
-            if value == current_speed:
-                return option
-        # If custom value, return as string
-        return str(current_speed)
-    
-    @Slot(str)
-    def setSpeedByOption(self, option):
-        """Set speed by option string"""
-        if option in self.SPEED_OPTIONS:
-            speed_value = self.SPEED_OPTIONS[option]
-            # Don't call setter method, just assign directly  
-            if self._current_speed != speed_value:
-                old_speed = self._current_speed
-                self._current_speed = speed_value
-                logger.debug(f"Speed changed from {old_speed} to {speed_value} ({option})")
-                
-                # Send to main application
-                asyncio.create_task(self._set_speed_async(speed_value))
-                
-                # Emit change signal for QML
-                self.speedChanged.emit(speed_value)
-        else:
-            logger.warning(f"Unknown speed option: {option}")
-    
-    async def _set_speed_async(self, speed):
-        """Send speed to main application asynchronously"""
-        if not self.session:
-            return
-        try:
-            async with self.session.post(f"{self.base_url}/set_speed", json={"speed": speed}) as resp:
-                if resp.status == 200:
-                    logger.info(f"Speed set successfully: {speed}")
-                else:
-                    logger.error(f"Failed to set speed: {resp.status}")
-        except Exception as e:
-            logger.error(f"Exception setting speed: {e}")
-    
-    # Pause Between Patterns Methods
-    @Slot(result='QStringList')
-    def getPauseOptions(self):
-        """Get list of pause between patterns options for QML"""
-        return list(self.PAUSE_OPTIONS.keys())
-    
-    @Slot(result=str)
-    def getCurrentPauseOption(self):
-        """Get current pause between patterns as option string"""
-        current_pause = self._pause_between_patterns
-        for option, value in self.PAUSE_OPTIONS.items():
-            if value == current_pause:
-                return option
-        # If custom value, return descriptive string
-        if current_pause == 0:
-            return "0s"
-        elif current_pause < 60:
-            return f"{current_pause}s"
-        elif current_pause < 3600:
-            minutes = current_pause // 60
-            return f"{minutes} min"
-        else:
-            hours = current_pause // 3600
-            return f"{hours} hour"
-    
-    @Slot(str)
-    def setPauseByOption(self, option):
-        """Set pause between patterns by option string"""
-        if option in self.PAUSE_OPTIONS:
-            pause_value = self.PAUSE_OPTIONS[option]
-            if self._pause_between_patterns != pause_value:
-                old_pause = self._pause_between_patterns
-                self._pause_between_patterns = pause_value
-                logger.info(f"Pause between patterns changed from {old_pause}s to {pause_value}s ({option})")
-
-                # Save to local settings
-                self._save_local_settings()
-
-                # Emit change signal for QML
-                self.pauseBetweenPatternsChanged.emit(pause_value)
-        else:
-            logger.warning(f"Unknown pause option: {option}")
-
-    # Property for pause between patterns
-    @Property(int, notify=pauseBetweenPatternsChanged)
-    def pauseBetweenPatterns(self):
-        """Get current pause between patterns in seconds"""
-        return self._pause_between_patterns
-
-    # Playlist Settings Properties and Slots
-    @Property(bool, notify=playlistSettingsChanged)
-    def playlistShuffle(self):
-        """Get playlist shuffle setting"""
-        return self._playlist_shuffle
-
-    @Slot(bool)
-    def setPlaylistShuffle(self, enabled):
-        """Set playlist shuffle setting"""
-        if self._playlist_shuffle != enabled:
-            self._playlist_shuffle = enabled
-            logger.info(f"Playlist shuffle changed to: {enabled}")
-            self._save_local_settings()
-            self.playlistSettingsChanged.emit()
-
-    @Property(str, notify=playlistSettingsChanged)
-    def playlistRunMode(self):
-        """Get playlist run mode (single/loop)"""
-        return self._playlist_run_mode
-
-    @Slot(str)
-    def setPlaylistRunMode(self, mode):
-        """Set playlist run mode"""
-        if mode in ["single", "loop"] and self._playlist_run_mode != mode:
-            self._playlist_run_mode = mode
-            logger.info(f"Playlist run mode changed to: {mode}")
-            self._save_local_settings()
-            self.playlistSettingsChanged.emit()
-
-    @Property(str, notify=playlistSettingsChanged)
-    def playlistClearPattern(self):
-        """Get playlist clear pattern setting"""
-        return self._playlist_clear_pattern
-
-    @Slot(str)
-    def setPlaylistClearPattern(self, pattern):
-        """Set playlist clear pattern"""
-        valid_patterns = ["adaptive", "clear_center", "clear_perimeter", "none"]
-        if pattern in valid_patterns and self._playlist_clear_pattern != pattern:
-            self._playlist_clear_pattern = pattern
-            logger.info(f"Playlist clear pattern changed to: {pattern}")
-            self._save_local_settings()
-            self.playlistSettingsChanged.emit()
-    
-    # Screen Control Methods
-    @Slot()
-    def turnScreenOn(self):
-        """Turn the screen on and reset activity timer"""
-        if not self._screen_on:
-            self._turn_screen_on()
-        self._reset_activity_timer()
-    
-    @Slot()
-    def turnScreenOff(self):
-        """Turn the screen off (QML MouseArea handles wake-on-touch)"""
-        self._turn_screen_off()
-    
-    @Slot()
-    def resetActivityTimer(self):
-        """Reset the activity timer (call on user interaction)"""
-        self._reset_activity_timer()
-        if not self._screen_on:
-            self._turn_screen_on()
-    
-    def _turn_screen_on(self):
-        """Internal method to turn screen on"""
-        with self._screen_transition_lock:
-            # Debounce: Don't turn on if we just changed state
-            time_since_change = time.time() - self._last_screen_change
-            if time_since_change < 2.0:  # 2 second debounce
-                logger.debug(f"Screen state change blocked (debounce: {time_since_change:.1f}s < 2s)")
-                return
-            
-            if self._screen_on:
-                logger.debug("Screen already ON, skipping")
-                return
-            
-            try:
-                # Determine the brightness to restore (saved value, not max)
-                restore_brightness = self._lcd_brightness if self._lcd_brightness > 0 else self._lcd_max_brightness or 255
-
-                # Use the working screen-on script if available
-                screen_on_script = Path('/usr/local/bin/screen-on')
-                if screen_on_script.exists():
-                    result = subprocess.run(['sudo', '/usr/local/bin/screen-on'],
-                                          capture_output=True, text=True, timeout=5)
-                    if result.returncode == 0:
-                        logger.debug("Screen turned ON (screen-on script)")
-                    else:
-                        logger.warning(f"screen-on script failed: {result.stderr}")
-                    # Restore saved brightness (script sets max, we want saved level)
-                    if self._lcd_brightness_path and restore_brightness < (self._lcd_max_brightness or 255):
-                        subprocess.run(
-                            ['sudo', 'sh', '-c', f'echo {restore_brightness} > {self._lcd_brightness_path}'],
-                            check=False, timeout=5
-                        )
-                        logger.debug(f"Restored saved brightness: {restore_brightness}")
-                else:
-                    # Fallback: Manual control — unblank framebuffer and restore backlight
-                    if self._lcd_brightness_path:
-                        subprocess.run(['sudo', 'sh', '-c',
-                                      f'echo 0 > /sys/class/graphics/fb0/blank && echo {restore_brightness} > {self._lcd_brightness_path}'],
-                                     check=False, timeout=5)
-                    else:
-                        subprocess.run(['sudo', 'sh', '-c',
-                                      f'echo 0 > /sys/class/graphics/fb0/blank && echo {restore_brightness} > /sys/class/backlight/*/brightness'],
-                                     check=False, timeout=5)
-                    logger.debug(f"Screen turned ON (manual, brightness: {restore_brightness})")
-                
-                self._screen_on = True
-                self._last_screen_change = time.time()
-                self.screenStateChanged.emit(True)
-
-            except Exception as e:
-                logger.error(f"Failed to turn screen on: {e}")
-    
-    def _turn_screen_off(self):
-        """Internal method to turn screen off"""
-        logger.debug("_turn_screen_off() called")
-        with self._screen_transition_lock:
-            # Debounce: Don't turn off if we just changed state
-            time_since_change = time.time() - self._last_screen_change
-            if time_since_change < 2.0:  # 2 second debounce
-                logger.debug(f"Screen state change blocked (debounce: {time_since_change:.1f}s < 2s)")
-                return
-            
-            if not self._screen_on:
-                logger.debug("Screen already OFF, skipping")
-                return
-        
-        try:
-            # Use the working screen-off script if available
-            screen_off_script = Path('/usr/local/bin/screen-off')
-            logger.debug(f"Checking for screen-off script at: {screen_off_script}")
-            logger.debug(f"Script exists: {screen_off_script.exists()}")
-            
-            if screen_off_script.exists():
-                logger.debug("Executing screen-off script...")
-                result = subprocess.run(['sudo', '/usr/local/bin/screen-off'], 
-                                      capture_output=True, text=True, timeout=10)
-                logger.debug(f"Script return code: {result.returncode}")
-                if result.stdout:
-                    logger.debug(f"Script stdout: {result.stdout}")
-                if result.stderr:
-                    logger.debug(f"Script stderr: {result.stderr}")
-                    
-                if result.returncode == 0:
-                    logger.info("Screen turned OFF (screen-off script)")
-                else:
-                    logger.warning(f"screen-off script failed: return code {result.returncode}")
-            else:
-                logger.debug("Using manual screen control...")
-                # Fallback: Manual control matching the script
-                # Blank framebuffer and turn off backlight
-                subprocess.run(['sudo', 'sh', '-c', 
-                              'echo 0 > /sys/class/backlight/*/brightness && echo 1 > /sys/class/graphics/fb0/blank'], 
-                             check=False, timeout=5)
-                logger.debug("Screen turned OFF (manual)")
-            
-            self._screen_on = False
-            self._last_screen_change = time.time()
-            self.screenStateChanged.emit(False)
-            logger.debug("Screen state set to OFF, signal emitted")
-            
-        except Exception as e:
-            logger.error(f"Failed to turn screen off: {e}")
-            import traceback
-            traceback.print_exc()
-    
-    def _reset_activity_timer(self):
-        """Reset the last activity timestamp"""
-        old_time = self._last_activity
-        self._last_activity = time.time()
-        time_since_last = self._last_activity - old_time
-        if time_since_last > 1:  # Only log if it's been more than 1 second
-            logger.debug(f"Activity detected - timer reset (was idle for {time_since_last:.1f}s)")
-    
-    def _check_screen_timeout(self):
-        """Check if screen should be turned off due to inactivity.
-
-        Wake-on-touch is handled by QML's global MouseArea which calls
-        resetActivityTimer() on any touch event, even when screen is off.
-        """
-        if self._screen_on and self._screen_timeout > 0:  # Only check if timeout is enabled
-            idle_time = time.time() - self._last_activity
-            # Log every 10 seconds when getting close to timeout
-            if idle_time > self._screen_timeout - 10 and idle_time % 10 < 1:
-                logger.debug(f"Screen idle for {idle_time:.0f}s (timeout at {self._screen_timeout}s)")
-
-            if idle_time > self._screen_timeout:
-                logger.debug(f"Screen timeout reached! Idle for {idle_time:.0f}s (timeout: {self._screen_timeout}s)")
-                self._turn_screen_off()
-        # If timeout is 0 (Never), screen stays on indefinitely
-
-    # ==================== LED Control Methods ====================
-
-    # LED Properties
-    @Property(str, notify=ledStatusChanged)
-    def ledProvider(self):
-        return self._led_provider
-
-    @Property(bool, notify=ledStatusChanged)
-    def ledConnected(self):
-        return self._led_connected
-
-    @Property(bool, notify=ledStatusChanged)
-    def ledPowerOn(self):
-        return self._led_power_on
-
-    @Property(int, notify=ledStatusChanged)
-    def ledBrightness(self):
-        return self._led_brightness
-
-    @Property(list, notify=ledEffectsLoaded)
-    def ledEffects(self):
-        return self._led_effects
-
-    @Property(list, notify=ledPalettesLoaded)
-    def ledPalettes(self):
-        return self._led_palettes
-
-    @Property(int, notify=ledStatusChanged)
-    def ledCurrentEffect(self):
-        return self._led_current_effect
-
-    @Property(int, notify=ledStatusChanged)
-    def ledCurrentPalette(self):
-        return self._led_current_palette
-
-    @Property(str, notify=ledStatusChanged)
-    def ledColor(self):
-        return self._led_color
-
-    # ==================== LCD Brightness Methods ====================
-
-    def _detect_backlight(self):
-        """Auto-detect the sysfs backlight path and max brightness on startup."""
-        # Auto-detect path if not configured
-        if not self._lcd_brightness_path:
-            try:
-                result = subprocess.run(
-                    ['ls', '/sys/class/backlight'],
-                    capture_output=True, text=True, timeout=2
-                )
-                if result.returncode == 0 and result.stdout.strip():
-                    device = result.stdout.strip().split('\n')[0]
-                    self._lcd_brightness_path = f"/sys/class/backlight/{device}/brightness"
-                    logger.info(f"Auto-detected backlight path: {self._lcd_brightness_path}")
-                else:
-                    logger.warning("No backlight device found in /sys/class/backlight/")
-                    return
-            except Exception as e:
-                logger.warning(f"Failed to detect backlight path: {e}")
-                return
-
-        # Derive the directory from the brightness path
-        backlight_dir = str(Path(self._lcd_brightness_path).parent)
-
-        # Read max_brightness if not configured (0 = auto-detect)
-        if self._lcd_max_brightness == 0:
-            try:
-                max_path = f"{backlight_dir}/max_brightness"
-                result = subprocess.run(
-                    ['cat', max_path],
-                    capture_output=True, text=True, timeout=2
-                )
-                if result.returncode == 0 and result.stdout.strip():
-                    self._lcd_max_brightness = int(result.stdout.strip())
-                    logger.info(f"Auto-detected max brightness: {self._lcd_max_brightness}")
-                else:
-                    self._lcd_max_brightness = 255  # Sensible fallback
-                    logger.warning("Could not read max_brightness, defaulting to 255")
-            except Exception as e:
-                self._lcd_max_brightness = 255
-                logger.warning(f"Failed to read max_brightness: {e}, defaulting to 255")
-
-        # Read current brightness from sysfs
-        try:
-            result = subprocess.run(
-                ['cat', self._lcd_brightness_path],
-                capture_output=True, text=True, timeout=2
-            )
-            if result.returncode == 0 and result.stdout.strip():
-                current = int(result.stdout.strip())
-                self._lcd_brightness = current
-                logger.info(f"Current LCD brightness: {current}/{self._lcd_max_brightness}")
-        except Exception as e:
-            logger.warning(f"Failed to read current brightness: {e}")
-
-    @Property(int, notify=lcdBrightnessChanged)
-    def lcdBrightness(self):
-        return self._lcd_brightness
-
-    @Property(int, notify=lcdBrightnessChanged)
-    def lcdMaxBrightness(self):
-        return self._lcd_max_brightness
-
-    @Slot(int)
-    def setLcdBrightness(self, value):
-        """Set LCD screen brightness by writing to sysfs backlight."""
-        value = max(0, min(value, self._lcd_max_brightness))
-        if not self._lcd_brightness_path:
-            logger.warning("No backlight path configured, cannot set brightness")
-            return
-
-        try:
-            subprocess.run(
-                ['sudo', 'sh', '-c', f'echo {value} > {self._lcd_brightness_path}'],
-                check=False, timeout=5
-            )
-            self._lcd_brightness = value
-            logger.debug(f"LCD brightness set to {value}/{self._lcd_max_brightness}")
-            self._save_local_settings()
-            self.lcdBrightnessChanged.emit()
-        except Exception as e:
-            logger.error(f"Failed to set LCD brightness: {e}")
-
-    @Slot()
-    def loadLedConfig(self):
-        """Load LED configuration from the server"""
-        logger.debug("Loading LED configuration...")
-        asyncio.create_task(self._load_led_config())
-
-    async def _load_led_config(self):
-        if not self.session:
-            logger.warning("Session not ready for LED config")
-            return
-
-        try:
-            timeout = aiohttp.ClientTimeout(total=5)
-            async with self.session.get(f"{self.base_url}/get_led_config", timeout=timeout) as resp:
-                if resp.status == 200:
-                    data = await resp.json()
-                    self._led_provider = data.get("provider", "none")
-                    logger.debug(f"LED provider: {self._led_provider}")
-
-                    if self._led_provider == "dw_leds":
-                        # Load DW LEDs status
-                        await self._load_led_status()
-                        await self._load_led_effects()
-                        await self._load_led_palettes()
-
-                    self.ledStatusChanged.emit()
-                else:
-                    logger.error(f"Failed to get LED config: {resp.status}")
-        except Exception as e:
-            logger.error(f"Exception loading LED config: {e}")
-
-    async def _load_led_status(self):
-        """Load current LED status"""
-        if not self.session:
-            return
-
-        try:
-            timeout = aiohttp.ClientTimeout(total=5)
-            async with self.session.get(f"{self.base_url}/api/dw_leds/status", timeout=timeout) as resp:
-                if resp.status == 200:
-                    data = await resp.json()
-                    self._led_connected = data.get("connected", False)
-                    self._led_power_on = data.get("power_on", False)
-                    self._led_brightness = data.get("brightness", 100)
-                    self._led_current_effect = data.get("current_effect", 0)
-                    self._led_current_palette = data.get("current_palette", 0)
-                    logger.debug(f"LED status: connected={self._led_connected}, power={self._led_power_on}, brightness={self._led_brightness}")
-                    self.ledStatusChanged.emit()
-        except Exception as e:
-            logger.error(f"Exception loading LED status: {e}")
-
-    async def _load_led_effects(self):
-        """Load available LED effects"""
-        if not self.session:
-            return
-
-        try:
-            timeout = aiohttp.ClientTimeout(total=5)
-            async with self.session.get(f"{self.base_url}/api/dw_leds/effects", timeout=timeout) as resp:
-                if resp.status == 200:
-                    data = await resp.json()
-                    # API returns effects as [[id, name], ...] arrays
-                    raw_effects = data.get("effects", [])
-                    # Convert to list of dicts for easier use in QML
-                    self._led_effects = [{"id": e[0], "name": e[1]} for e in raw_effects if len(e) >= 2]
-                    logger.debug(f"Loaded {len(self._led_effects)} LED effects")
-                    self.ledEffectsLoaded.emit(self._led_effects)
-        except Exception as e:
-            logger.error(f"Exception loading LED effects: {e}")
-
-    async def _load_led_palettes(self):
-        """Load available LED palettes"""
-        if not self.session:
-            return
-
-        try:
-            timeout = aiohttp.ClientTimeout(total=5)
-            async with self.session.get(f"{self.base_url}/api/dw_leds/palettes", timeout=timeout) as resp:
-                if resp.status == 200:
-                    data = await resp.json()
-                    # API returns palettes as [[id, name], ...] arrays
-                    raw_palettes = data.get("palettes", [])
-                    # Convert to list of dicts for easier use in QML
-                    self._led_palettes = [{"id": p[0], "name": p[1]} for p in raw_palettes if len(p) >= 2]
-                    logger.debug(f"Loaded {len(self._led_palettes)} LED palettes")
-                    self.ledPalettesLoaded.emit(self._led_palettes)
-        except Exception as e:
-            logger.error(f"Exception loading LED palettes: {e}")
-
-    @Slot()
-    def refreshLedStatus(self):
-        """Refresh LED status from server"""
-        logger.debug("Refreshing LED status...")
-        asyncio.create_task(self._load_led_status())
-
-    @Slot()
-    def toggleLedPower(self):
-        """Toggle LED power on/off"""
-        logger.debug("Toggling LED power...")
-        asyncio.create_task(self._toggle_led_power())
-
-    async def _toggle_led_power(self):
-        if not self.session:
-            self.errorOccurred.emit("Backend not ready")
-            return
-
-        try:
-            async with self.session.post(
-                f"{self.base_url}/api/dw_leds/power",
-                json={"state": 2}  # Toggle
-            ) as resp:
-                if resp.status == 200:
-                    data = await resp.json()
-                    self._led_power_on = data.get("power_on", False)
-                    self._led_connected = data.get("connected", False)
-                    logger.debug(f"LED power toggled: {self._led_power_on}")
-                    self.ledStatusChanged.emit()
-                else:
-                    self.errorOccurred.emit(f"Failed to toggle LED power: {resp.status}")
-        except Exception as e:
-            logger.error(f"Exception toggling LED power: {e}")
-            self.errorOccurred.emit(str(e))
-
-    @Slot(bool)
-    def setLedPower(self, on):
-        """Set LED power state (True=on, False=off)"""
-        logger.debug(f"Setting LED power: {on}")
-        asyncio.create_task(self._set_led_power(on))
-
-    async def _set_led_power(self, on):
-        if not self.session:
-            self.errorOccurred.emit("Backend not ready")
-            return
-
-        try:
-            async with self.session.post(
-                f"{self.base_url}/api/dw_leds/power",
-                json={"state": 1 if on else 0}
-            ) as resp:
-                if resp.status == 200:
-                    data = await resp.json()
-                    self._led_power_on = data.get("power_on", False)
-                    self._led_connected = data.get("connected", False)
-                    logger.debug(f"LED power set: {self._led_power_on}")
-                    self.ledStatusChanged.emit()
-                else:
-                    self.errorOccurred.emit(f"Failed to set LED power: {resp.status}")
-        except Exception as e:
-            logger.error(f"Exception setting LED power: {e}")
-            self.errorOccurred.emit(str(e))
-
-    @Slot(int)
-    def setLedBrightness(self, value):
-        """Set LED brightness (0-100)"""
-        logger.debug(f"Setting LED brightness: {value}")
-        asyncio.create_task(self._set_led_brightness(value))
-
-    async def _set_led_brightness(self, value):
-        if not self.session:
-            self.errorOccurred.emit("Backend not ready")
-            return
-
-        try:
-            async with self.session.post(
-                f"{self.base_url}/api/dw_leds/brightness",
-                json={"value": value}
-            ) as resp:
-                if resp.status == 200:
-                    self._led_brightness = value
-                    logger.debug(f"LED brightness set: {value}")
-                    self.ledStatusChanged.emit()
-                else:
-                    self.errorOccurred.emit(f"Failed to set brightness: {resp.status}")
-        except Exception as e:
-            logger.error(f"Exception setting LED brightness: {e}")
-            self.errorOccurred.emit(str(e))
-
-    @Slot(int, int, int)
-    def setLedColor(self, r, g, b):
-        """Set LED color using RGB values"""
-        logger.debug(f"Setting LED color: RGB({r}, {g}, {b})")
-        asyncio.create_task(self._set_led_color(r, g, b))
-
-    async def _set_led_color(self, r, g, b):
-        if not self.session:
-            self.errorOccurred.emit("Backend not ready")
-            return
-
-        try:
-            async with self.session.post(
-                f"{self.base_url}/api/dw_leds/color",
-                json={"color": [r, g, b]}
-            ) as resp:
-                if resp.status == 200:
-                    self._led_color = f"#{r:02x}{g:02x}{b:02x}"
-                    logger.debug(f"LED color set: {self._led_color}")
-                    self.ledStatusChanged.emit()
-                else:
-                    self.errorOccurred.emit(f"Failed to set color: {resp.status}")
-        except Exception as e:
-            logger.error(f"Exception setting LED color: {e}")
-            self.errorOccurred.emit(str(e))
-
-    @Slot(str)
-    def setLedColorHex(self, hexColor):
-        """Set LED color using hex string (e.g., '#ff0000')"""
-        # Parse hex color
-        hexColor = hexColor.lstrip('#')
-        if len(hexColor) == 6:
-            r = int(hexColor[0:2], 16)
-            g = int(hexColor[2:4], 16)
-            b = int(hexColor[4:6], 16)
-            self.setLedColor(r, g, b)
-        else:
-            logger.warning(f"Invalid hex color: {hexColor}")
-
-    @Slot(int)
-    def setLedEffect(self, effectId):
-        """Set LED effect by ID"""
-        logger.debug(f"Setting LED effect: {effectId}")
-        asyncio.create_task(self._set_led_effect(effectId))
-
-    async def _set_led_effect(self, effectId):
-        if not self.session:
-            self.errorOccurred.emit("Backend not ready")
-            return
-
-        try:
-            async with self.session.post(
-                f"{self.base_url}/api/dw_leds/effect",
-                json={"effect_id": effectId}
-            ) as resp:
-                if resp.status == 200:
-                    self._led_current_effect = effectId
-                    logger.debug(f"LED effect set: {effectId}")
-                    self.ledStatusChanged.emit()
-                else:
-                    self.errorOccurred.emit(f"Failed to set effect: {resp.status}")
-        except Exception as e:
-            logger.error(f"Exception setting LED effect: {e}")
-            self.errorOccurred.emit(str(e))
-
-    @Slot(int)
-    def setLedPalette(self, paletteId):
-        """Set LED palette by ID"""
-        logger.debug(f"Setting LED palette: {paletteId}")
-        asyncio.create_task(self._set_led_palette(paletteId))
-
-    async def _set_led_palette(self, paletteId):
-        if not self.session:
-            self.errorOccurred.emit("Backend not ready")
-            return
-
-        try:
-            async with self.session.post(
-                f"{self.base_url}/api/dw_leds/palette",
-                json={"palette_id": paletteId}
-            ) as resp:
-                if resp.status == 200:
-                    self._led_current_palette = paletteId
-                    logger.debug(f"LED palette set: {paletteId}")
-                    self.ledStatusChanged.emit()
-                else:
-                    self.errorOccurred.emit(f"Failed to set palette: {resp.status}")
-        except Exception as e:
-            logger.error(f"Exception setting LED palette: {e}")
-            self.errorOccurred.emit(str(e))
-
-    # ==================== Pattern Refresh Methods ====================
-
-    @Slot()
-    def refreshPatterns(self):
-        """Refresh pattern cache - converts new WebPs to PNG and rescans patterns"""
-        logger.debug("Refreshing patterns...")
-        asyncio.create_task(self._refresh_patterns())
-
-    async def _refresh_patterns(self):
-        """Async implementation of pattern refresh"""
-        try:
-            from png_cache_manager import PngCacheManager
-            cache_manager = PngCacheManager()
-            success = await cache_manager.ensure_png_cache_available()
-
-            message = "Patterns refreshed" if success else "Refreshed with warnings"
-            logger.info(f"Pattern refresh completed: {message}")
-            self.patternsRefreshCompleted.emit(True, message)
-        except Exception as e:
-            logger.error(f"Pattern refresh failed: {e}")
-            self.patternsRefreshCompleted.emit(False, str(e))
-
-    # ==================== System Control Methods ====================
-
-    @Slot()
-    def restartBackend(self):
-        """Restart the dune-weaver backend via API"""
-        logger.debug("Requesting backend restart via API...")
-        asyncio.create_task(self._restart_backend())
-
-    async def _restart_backend(self):
-        """Async implementation of backend restart"""
-        if not self.session:
-            self.errorOccurred.emit("Backend not ready")
-            return
-
-        try:
-            async with self.session.post(f"{self.base_url}/api/system/restart") as resp:
-                if resp.status == 200:
-                    logger.info("Backend restart initiated via API")
-                else:
-                    response_text = await resp.text()
-                    logger.error(f"Failed to restart backend: {resp.status} - {response_text}")
-                    self.errorOccurred.emit(f"Failed to restart: {response_text}")
-        except Exception as e:
-            logger.error(f"Exception restarting backend: {e}")
-            self.errorOccurred.emit(str(e))
-
-    @Slot()
-    def shutdownPi(self):
-        """Shutdown the Raspberry Pi via API"""
-        logger.info("Requesting Pi shutdown via API...")
-        asyncio.create_task(self._shutdown_pi())
-
-    async def _shutdown_pi(self):
-        """Async implementation of Pi shutdown"""
-        if not self.session:
-            self.errorOccurred.emit("Backend not ready")
-            return
-
-        try:
-            async with self.session.post(f"{self.base_url}/api/system/shutdown") as resp:
-                if resp.status == 200:
-                    logger.info("Shutdown initiated via API")
-                else:
-                    response_text = await resp.text()
-                    logger.error(f"Failed to shutdown: {resp.status} - {response_text}")
-                    self.errorOccurred.emit(f"Failed to shutdown: {response_text}")
-        except Exception as e:
-            logger.error(f"Exception during shutdown: {e}")
-            self.errorOccurred.emit(str(e))
-
-    # ==================== Playlist Management Methods ====================
-
-    @Slot(str)
-    def createPlaylist(self, playlistName):
-        """Create a new empty playlist"""
-        logger.debug(f"Creating playlist: {playlistName}")
-        asyncio.create_task(self._create_playlist(playlistName))
-
-    async def _create_playlist(self, playlistName):
-        """Async implementation of playlist creation"""
-        if not self.session:
-            self.playlistCreated.emit(False, "Backend not ready")
-            return
-
-        try:
-            async with self.session.post(
-                f"{self.base_url}/create_playlist",
-                json={"playlist_name": playlistName, "files": []}
-            ) as resp:
-                if resp.status == 200:
-                    logger.info(f"Playlist created: {playlistName}")
-                    self.playlistCreated.emit(True, f"Created: {playlistName}")
-                else:
-                    response_text = await resp.text()
-                    logger.error(f"Failed to create playlist: {resp.status} - {response_text}")
-                    self.playlistCreated.emit(False, f"Failed: {response_text}")
-        except Exception as e:
-            logger.error(f"Exception creating playlist: {e}")
-            self.playlistCreated.emit(False, str(e))
-
-    @Slot(str)
-    def deletePlaylist(self, playlistName):
-        """Delete a playlist"""
-        logger.info(f"Deleting playlist: {playlistName}")
-        asyncio.create_task(self._delete_playlist(playlistName))
-
-    async def _delete_playlist(self, playlistName):
-        """Async implementation of playlist deletion"""
-        if not self.session:
-            self.playlistDeleted.emit(False, "Backend not ready")
-            return
-
-        try:
-            async with self.session.request(
-                "DELETE",
-                f"{self.base_url}/delete_playlist",
-                json={"playlist_name": playlistName}
-            ) as resp:
-                if resp.status == 200:
-                    logger.info(f"Playlist deleted: {playlistName}")
-                    self.playlistDeleted.emit(True, f"Deleted: {playlistName}")
-                else:
-                    response_text = await resp.text()
-                    logger.error(f"Failed to delete playlist: {resp.status} - {response_text}")
-                    self.playlistDeleted.emit(False, f"Failed: {response_text}")
-        except Exception as e:
-            logger.error(f"Exception deleting playlist: {e}")
-            self.playlistDeleted.emit(False, str(e))
-
-    @Slot(str, str)
-    def addPatternToPlaylist(self, playlistName, patternPath):
-        """Add a pattern to an existing playlist"""
-        logger.info(f"Adding pattern to playlist: {patternPath} -> {playlistName}")
-        asyncio.create_task(self._add_pattern_to_playlist(playlistName, patternPath))
-
-    async def _add_pattern_to_playlist(self, playlistName, patternPath):
-        """Async implementation of adding pattern to playlist"""
-        if not self.session:
-            self.patternAddedToPlaylist.emit(False, "Backend not ready")
-            return
-
-        try:
-            async with self.session.post(
-                f"{self.base_url}/add_to_playlist",
-                json={"playlist_name": playlistName, "pattern": patternPath}
-            ) as resp:
-                if resp.status == 200:
-                    logger.info(f"Pattern added to {playlistName}")
-                    self.patternAddedToPlaylist.emit(True, f"Added to {playlistName}")
-                else:
-                    response_text = await resp.text()
-                    logger.error(f"Failed to add pattern: {resp.status} - {response_text}")
-                    self.patternAddedToPlaylist.emit(False, f"Failed: {response_text}")
-        except Exception as e:
-            logger.error(f"Exception adding pattern: {e}")
-            self.patternAddedToPlaylist.emit(False, str(e))
-
-    @Slot(str, list)
-    def updatePlaylistPatterns(self, playlistName, patterns):
-        """Update a playlist with a new list of patterns (used for removing patterns)"""
-        logger.debug(f"Updating playlist patterns: {playlistName} -> {len(patterns)} patterns")
-        asyncio.create_task(self._update_playlist_patterns(playlistName, patterns))
-
-    async def _update_playlist_patterns(self, playlistName, patterns):
-        """Async implementation of playlist pattern update"""
-        if not self.session:
-            self.playlistModified.emit(False, "Backend not ready")
-            return
-
-        try:
-            async with self.session.post(
-                f"{self.base_url}/modify_playlist",
-                json={"playlist_name": playlistName, "files": patterns}
-            ) as resp:
-                if resp.status == 200:
-                    logger.info(f"Playlist updated: {playlistName}")
-                    self.playlistModified.emit(True, f"Updated: {playlistName}")
-                else:
-                    response_text = await resp.text()
-                    logger.error(f"Failed to update playlist: {resp.status} - {response_text}")
-                    self.playlistModified.emit(False, f"Failed: {response_text}")
-        except Exception as e:
-            logger.error(f"Exception updating playlist: {e}")
-            self.playlistModified.emit(False, str(e))
-
-    @Slot(result=list)
-    def getPlaylistNames(self):
-        """Get list of all playlist names (synchronous, reads from local file)"""
-        try:
-            playlists_file = Path("../playlists.json")
-            if playlists_file.exists():
-                with open(playlists_file, 'r') as f:
-                    data = json.load(f)
-                    return sorted(list(data.keys()))
-        except Exception as e:
-            logger.error(f"Error reading playlists: {e}")
-        return []
+from PySide6.QtCore import QObject, Signal, Property, Slot, QTimer
+from PySide6.QtQml import QmlElement
+import asyncio
+import json
+import logging
+import subprocess
+import threading
+import time
+from pathlib import Path
+import os
+
+from firmware_client import FirmwareClient, LED_EFFECTS, LED_PALETTES
+import discovery
+
+# Configure logging
+logging.basicConfig(
+    level=logging.INFO,
+    format='%(asctime)s [%(levelname)s] %(message)s',
+    datefmt='%H:%M:%S'
+)
+logger = logging.getLogger("DuneWeaver")
+
+QML_IMPORT_NAME = "DuneWeaver"
+QML_IMPORT_MAJOR_VERSION = 1
+
+# Firmware clear-mode -> touch UI pre-execution option (reverse of CLEAR_MODE_MAP)
+_FW_TO_UI_CLEAR = {
+    "in": "clear_center", "out": "clear_perimeter",
+    "adaptive": "adaptive", "none": "none",
+    "sideway": "adaptive", "random": "adaptive",
+}
+
+
+def _run(coro):
+    """Schedule a coroutine on the running (qasync) event loop, if any."""
+    try:
+        asyncio.get_event_loop().create_task(coro)
+    except RuntimeError:
+        logger.warning("No running event loop to schedule task")
+
+
+@QmlElement
+class Backend(QObject):
+    """Backend controller: drives a headless FluidNC sand table over HTTP."""
+
+    # Constants
+    SETTINGS_FILE = "touch_settings.json"
+    DEFAULT_SCREEN_TIMEOUT = 300  # 5 minutes in seconds
+    STATUS_POLL_MS = 1000         # /sand_status poll interval
+
+    # Predefined timeout options (in seconds)
+    TIMEOUT_OPTIONS = {
+        "30 seconds": 30,
+        "1 minute": 60,
+        "5 minutes": 300,
+        "10 minutes": 600,
+        "Never": 0  # 0 means never timeout
+    }
+
+    # Predefined speed options (motor feed, mm/min)
+    SPEED_OPTIONS = {
+        "50": 50,
+        "100": 100,
+        "150": 150,
+        "200": 200,
+        "300": 300,
+        "500": 500
+    }
+
+    # Predefined pause between patterns options (in seconds)
+    PAUSE_OPTIONS = {
+        "0s": 0, "1 min": 60, "5 min": 300, "15 min": 900, "30 min": 1800,
+        "1 hour": 3600, "2 hour": 7200, "3 hour": 10800, "4 hour": 14400,
+        "5 hour": 18000, "6 hour": 21600, "12 hour": 43200
+    }
+
+    # Signals
+    statusChanged = Signal()
+    progressChanged = Signal()
+    connectionChanged = Signal()
+    executionStarted = Signal(str, str)  # patternName, patternPreview
+    executionStopped = Signal()
+    errorOccurred = Signal(str)
+    serialPortsUpdated = Signal(list)          # now: list of discovered table URLs
+    serialConnectionChanged = Signal(bool)     # now: table reachable
+    currentPortChanged = Signal(str)           # now: current table URL
+    speedChanged = Signal(int)
+    settingsLoaded = Signal()
+    screenStateChanged = Signal(bool)
+    screenTimeoutChanged = Signal(int)
+    pauseBetweenPatternsChanged = Signal(int)
+    pausedChanged = Signal(bool)
+    playlistSettingsChanged = Signal()
+    patternsRefreshCompleted = Signal(bool, str)
+
+    # Playlist management signals
+    playlistCreated = Signal(bool, str)
+    playlistDeleted = Signal(bool, str)
+    patternAddedToPlaylist = Signal(bool, str)
+    playlistModified = Signal(bool, str)
+
+    # Backend/table connection status signals
+    backendConnectionChanged = Signal(bool)
+    reconnectStatusChanged = Signal(str)
+
+    # LED control signals
+    ledStatusChanged = Signal()
+    ledEffectsLoaded = Signal(list)
+    ledPalettesLoaded = Signal(list)
+
+    # LCD brightness signals
+    lcdBrightnessChanged = Signal()
+
+    def __init__(self):
+        super().__init__()
+        self.client = FirmwareClient.instance()
+
+        # Status properties
+        self._current_file = ""
+        self._progress = 0
+        self._is_running = False
+        self._is_paused = False
+        self._is_connected = False
+        self._serial_ports = []
+        self._serial_connected = False
+        self._current_port = ""
+        self._current_speed = 130
+        self._auto_play_on_boot = False
+        self._pause_between_patterns = 10800
+
+        # Playlist settings (mirror the table's NVS; also persisted locally)
+        self._playlist_shuffle = True
+        self._playlist_run_mode = "loop"
+        self._playlist_clear_pattern = "adaptive"
+
+        # Connection status
+        self._backend_connected = False
+        self._reconnect_status = "Looking for table..."
+        self._saved_table_url = ""
+
+        # LCD brightness state
+        self._lcd_brightness = 255
+        self._lcd_brightness_path = ""
+        self._lcd_max_brightness = 0
+
+        # LED control state
+        self._led_provider = "none"       # "none" or "dw_leds"
+        self._led_connected = False
+        self._led_power_on = False
+        self._led_brightness = 100        # 0..100 for QML (firmware is 0..255)
+        self._led_effects = []
+        self._led_palettes = []
+        self._led_current_effect = 0
+        self._led_current_palette = 0
+        self._led_color = "#ffffff"
+        self._led_last_effect = 2         # remembered on power-off (default rainbow)
+
+        # Screen management
+        self._screen_on = True
+        self._screen_timeout = self.DEFAULT_SCREEN_TIMEOUT
+        self._last_activity = time.time()
+        self._screen_transition_lock = threading.Lock()
+        self._last_screen_change = 0
+        self._screen_timer = QTimer()
+        self._screen_timer.timeout.connect(self._check_screen_timeout)
+        self._screen_timer.start(1000)
+
+        # Load local settings first
+        self._load_local_settings()
+        self._detect_backlight()
+
+        # Point the shared client at the saved / env-configured table.
+        env_url = os.environ.get("DUNE_WEAVER_URL", "")
+        initial_url = env_url or self._saved_table_url
+        if initial_url:
+            self.client.set_base_url(initial_url)
+            self._current_port = self.client.base_url
+
+        # Status polling replaces the old WebSocket status stream.
+        self._status_timer = QTimer()
+        self._status_timer.timeout.connect(self._tick_status)
+        self._time_synced = False
+
+        # Kick everything off once the event loop is running.
+        QTimer.singleShot(200, self._start)
+
+    @Slot()
+    def _start(self):
+        self._status_timer.start(self.STATUS_POLL_MS)
+        if not self.client.base_url:
+            # No configured table -> try to discover one automatically.
+            _run(self._auto_discover())
+
+    async def _auto_discover(self):
+        self._set_reconnect_status("Searching for tables (mDNS)...")
+        try:
+            tables = await discovery.discover_tables(timeout=3.0)
+        except Exception as exc:
+            logger.warning(f"Discovery failed: {exc}")
+            tables = []
+        self._serial_ports = [t.base_url for t in tables]
+        self.serialPortsUpdated.emit(self._serial_ports)
+        if len(tables) == 1:
+            logger.info(f"Auto-connecting to the only discovered table: {tables[0].base_url}")
+            self._connect_to(tables[0].base_url)
+        elif not tables:
+            self._set_reconnect_status("No table found. Enter the table address.")
+        else:
+            self._set_reconnect_status(f"{len(tables)} tables found. Pick one.")
+
+    # ==================== Status polling ====================
+    @Slot()
+    def _tick_status(self):
+        _run(self._poll_status())
+
+    async def _poll_status(self):
+        if not self.client.base_url:
+            return
+        try:
+            data = await self.client.status()
+        except Exception as exc:
+            self._on_unreachable(str(exc))
+            return
+        self._on_status(data)
+
+    def _on_status(self, status):
+        # Reachability
+        if not self._backend_connected:
+            self._backend_connected = True
+            self._is_connected = True
+            self._serial_connected = True
+            self._current_port = self.client.base_url
+            self._reconnect_status = "Connected"
+            self.connectionChanged.emit()
+            self.backendConnectionChanged.emit(True)
+            self.serialConnectionChanged.emit(True)
+            self.currentPortChanged.emit(self._current_port)
+            self.reconnectStatusChanged.emit("Connected")
+            # Load settings + LED config on (re)connect.
+            self.loadControlSettings()
+            self.loadLedConfig()
+            _run(self._sync_time_once())
+
+        state = status.get("state", "")
+
+        # Current pattern / execution start detection
+        raw_file = status.get("file", "") or ""
+        new_file = raw_file
+        for prefix in ("/sd/patterns/", "/patterns/", "/sd/", "/"):
+            if new_file.startswith(prefix):
+                new_file = new_file[len(prefix):]
+                break
+        if new_file and new_file != self._current_file:
+            logger.info(f"Pattern changed to '{new_file}'")
+            self.executionStarted.emit(new_file, self._preview_path(new_file))
+        self._current_file = new_file
+
+        self._is_running = bool(status.get("running", False))
+
+        new_paused = (state == "Hold")
+        if new_paused != self._is_paused:
+            self._is_paused = new_paused
+            self.pausedChanged.emit(new_paused)
+
+        feed = status.get("feed")
+        if feed and int(feed) != self._current_speed:
+            self._current_speed = int(feed)
+            self.speedChanged.emit(self._current_speed)
+
+        prog = status.get("progress")
+        if prog is not None:
+            self._progress = 0 if prog < 0 else float(prog) * 100.0
+
+        # Live LED snapshot (effect/brightness) from status
+        led = status.get("led")
+        if isinstance(led, dict):
+            self._ingest_led_status(led)
+
+        self.statusChanged.emit()
+        self.progressChanged.emit()
+
+    def _on_unreachable(self, reason):
+        if self._backend_connected or self._is_connected:
+            logger.warning(f"Table unreachable: {reason}")
+        self._backend_connected = False
+        self._is_connected = False
+        if self._serial_connected:
+            self._serial_connected = False
+            self.serialConnectionChanged.emit(False)
+        self._reconnect_status = "Table connection lost, retrying..."
+        self.connectionChanged.emit()
+        self.backendConnectionChanged.emit(False)
+        self.reconnectStatusChanged.emit(self._reconnect_status)
+
+    def _set_reconnect_status(self, msg):
+        self._reconnect_status = msg
+        self.reconnectStatusChanged.emit(msg)
+
+    async def _sync_time_once(self):
+        if self._time_synced:
+            return
+        try:
+            await self.client.sync_time(int(time.time()))
+            self._time_synced = True
+        except Exception as exc:
+            logger.debug(f"time sync failed: {exc}")
+
+    def _preview_path(self, rel_name):
+        """Best-effort cached preview path for a pattern (may be empty)."""
+        try:
+            import thr_preview
+            return thr_preview.cached_preview(self.client.base_url, rel_name)
+        except Exception:
+            return ""
+
+    # ==================== Properties ====================
+    @Property(str, notify=statusChanged)
+    def currentFile(self):
+        return self._current_file
+
+    @Property(float, notify=progressChanged)
+    def progress(self):
+        return self._progress
+
+    @Property(bool, notify=statusChanged)
+    def isRunning(self):
+        return self._is_running
+
+    @Property(bool, notify=pausedChanged)
+    def isPaused(self):
+        return self._is_paused
+
+    @Property(bool, notify=connectionChanged)
+    def isConnected(self):
+        return self._is_connected
+
+    @Property(list, notify=serialPortsUpdated)
+    def serialPorts(self):
+        return self._serial_ports
+
+    @Property(bool, notify=serialConnectionChanged)
+    def serialConnected(self):
+        return self._serial_connected
+
+    @Property(str, notify=currentPortChanged)
+    def currentPort(self):
+        return self._current_port
+
+    @Property(int, notify=speedChanged)
+    def currentSpeed(self):
+        return self._current_speed
+
+    @Property(bool, notify=settingsLoaded)
+    def autoPlayOnBoot(self):
+        return self._auto_play_on_boot
+
+    @Property(bool, notify=backendConnectionChanged)
+    def backendConnected(self):
+        return self._backend_connected
+
+    @Property(str, notify=reconnectStatusChanged)
+    def reconnectStatus(self):
+        return self._reconnect_status
+
+    # ==================== Connection management ====================
+    @Slot()
+    def retryConnection(self):
+        logger.debug("Manual connection retry requested")
+        if self.client.base_url:
+            _run(self._poll_status())
+        else:
+            _run(self._auto_discover())
+
+    @Slot()
+    def refreshSerialPorts(self):
+        """Re-run mDNS discovery to populate the table picker."""
+        logger.info("Discovering tables...")
+        _run(self._auto_discover())
+
+    @Slot(str)
+    def connectSerial(self, port):
+        """Point the app at a table (URL or host)."""
+        logger.info(f"Connecting to table: {port}")
+        self._connect_to(port)
+
+    def _connect_to(self, url):
+        self.client.set_base_url(url)
+        self._current_port = self.client.base_url
+        self._saved_table_url = self.client.base_url
+        self._time_synced = False
+        self._save_local_settings()
+        self.currentPortChanged.emit(self._current_port)
+        self._set_reconnect_status(f"Connecting to {self._current_port}...")
+        _run(self._poll_status())
+
+    @Slot()
+    def disconnectSerial(self):
+        logger.info("Disconnecting from table...")
+        self.client.set_base_url("")
+        self._current_port = ""
+        self._serial_connected = False
+        self._backend_connected = False
+        self._is_connected = False
+        self.serialConnectionChanged.emit(False)
+        self.currentPortChanged.emit("")
+        self.backendConnectionChanged.emit(False)
+        self.connectionChanged.emit()
+
+    # ==================== Pattern execution ====================
+    @Slot(str, str)
+    def executePattern(self, fileName, preExecution="adaptive"):
+        logger.info(f"ExecutePattern: '{fileName}' (clear={preExecution})")
+        _run(self._execute_pattern(fileName, preExecution))
+
+    async def _execute_pattern(self, fileName, preExecution):
+        try:
+            await self.client.run_pattern(fileName, preExecution)
+            self.executionStarted.emit(fileName, self._preview_path(fileName))
+        except Exception as exc:
+            logger.error(f"executePattern failed: {exc}")
+            self.errorOccurred.emit(str(exc))
+
+    @Slot()
+    def stopExecution(self):
+        _run(self._simple_action(self.client.stop, on_ok=self.executionStopped.emit,
+                                 label="stop"))
+
+    @Slot()
+    def pauseExecution(self):
+        logger.info("Pausing execution...")
+        _run(self._simple_action(self.client.pause, label="pause"))
+
+    @Slot()
+    def resumeExecution(self):
+        logger.info("Resuming execution...")
+        _run(self._simple_action(self.client.resume, label="resume"))
+
+    @Slot()
+    def skipPattern(self):
+        logger.info("Skipping pattern...")
+        _run(self._simple_action(self.client.playlist_skip, label="skip"))
+
+    async def _simple_action(self, coro_fn, *, on_ok=None, label="action"):
+        try:
+            await coro_fn()
+            if on_ok:
+                on_ok()
+        except Exception as exc:
+            logger.error(f"{label} failed: {exc}")
+            self.errorOccurred.emit(f"{label} failed: {exc}")
+
+    @Slot(str, float, str, str, bool)
+    def executePlaylist(self, playlistName, pauseTime=0.0, clearPattern="adaptive",
+                        runMode="single", shuffle=False):
+        logger.info(f"ExecutePlaylist: '{playlistName}' pause={pauseTime} "
+                    f"clear={clearPattern} mode={runMode} shuffle={shuffle}")
+        _run(self._execute_playlist(playlistName, pauseTime, clearPattern, runMode, shuffle))
+
+    async def _execute_playlist(self, name, pauseTime, clearPattern, runMode, shuffle):
+        try:
+            await self.client.run_playlist(
+                name, pause_time=pauseTime, clear_pattern=clearPattern,
+                run_mode=runMode, shuffle=shuffle)
+        except Exception as exc:
+            logger.error(f"executePlaylist failed: {exc}")
+            self.errorOccurred.emit(str(exc))
+
+    # ==================== Hardware movement ====================
+    @Slot()
+    def sendHome(self):
+        logger.debug("Sending home command...")
+        _run(self._simple_action(self.client.home, label="home"))
+
+    @Slot()
+    def moveToCenter(self):
+        logger.info("Moving to center...")
+        _run(self._simple_action(lambda: self.client.goto(rho=0), label="move to center"))
+
+    @Slot()
+    def moveToPerimeter(self):
+        logger.info("Moving to perimeter...")
+        _run(self._simple_action(lambda: self.client.goto(rho=1), label="move to perimeter"))
+
+    # ==================== Speed ====================
+    @Slot(int)
+    def setSpeed(self, speed):
+        logger.debug(f"Setting speed (feed) to: {speed}")
+        _run(self._set_speed(speed))
+
+    async def _set_speed(self, speed):
+        try:
+            await self.client.set_feed_mm(speed)
+            self._current_speed = speed
+            self.speedChanged.emit(speed)
+        except Exception as exc:
+            logger.error(f"set speed failed: {exc}")
+            self.errorOccurred.emit(str(exc))
+
+    @Slot(result='QStringList')
+    def getSpeedOptions(self):
+        return list(self.SPEED_OPTIONS.keys())
+
+    @Slot(result=str)
+    def getCurrentSpeedOption(self):
+        for option, value in self.SPEED_OPTIONS.items():
+            if value == self._current_speed:
+                return option
+        return str(self._current_speed)
+
+    @Slot(str)
+    def setSpeedByOption(self, option):
+        if option in self.SPEED_OPTIONS:
+            self.setSpeed(self.SPEED_OPTIONS[option])
+        else:
+            logger.warning(f"Unknown speed option: {option}")
+
+    # ==================== Auto play on boot ====================
+    @Slot(bool)
+    def setAutoPlayOnBoot(self, enabled):
+        logger.info(f"Setting auto play on boot: {enabled}")
+        # The firmware auto-plays a *named* playlist ($Playlist/Autostart). We
+        # can reliably disable it here; enabling requires choosing a playlist
+        # (done from the playlist page), so True is best-effort.
+        if not enabled:
+            _run(self._simple_action(lambda: self.client.set_autostart(""),
+                                     label="clear autostart"))
+        else:
+            logger.info("Enable auto-play: select a playlist to autostart on the playlist page")
+        self._auto_play_on_boot = enabled
+        self.settingsLoaded.emit()
+
+    # ==================== Load settings from the table ====================
+    @Slot()
+    def loadControlSettings(self):
+        logger.debug("Loading control settings from table...")
+        _run(self._load_settings())
+
+    async def _load_settings(self):
+        try:
+            settings = await self.client.settings()
+        except Exception as exc:
+            logger.debug(f"Could not load settings: {exc}")
+            return
+
+        def as_int(key, default):
+            try:
+                return int(float(settings.get(key, default)))
+            except (TypeError, ValueError):
+                return default
+
+        # Speed / feed
+        feed = as_int("THR/Feed", self._current_speed)
+        if feed and feed != self._current_speed:
+            self._current_speed = feed
+            self.speedChanged.emit(feed)
+
+        # Playlist settings (mirror the table)
+        mode = settings.get("Playlist/Mode", self._playlist_run_mode)
+        if mode in ("single", "loop"):
+            self._playlist_run_mode = mode
+        self._playlist_shuffle = str(settings.get("Playlist/Shuffle", "ON")).upper() == "ON"
+        self._pause_between_patterns = as_int("Playlist/PauseTime", self._pause_between_patterns)
+        fw_clear = settings.get("Playlist/ClearPattern", "adaptive")
+        self._playlist_clear_pattern = _FW_TO_UI_CLEAR.get(fw_clear, "adaptive")
+        self._auto_play_on_boot = bool(settings.get("Playlist/Autostart", "").strip())
+
+        self.playlistSettingsChanged.emit()
+        self.pauseBetweenPatternsChanged.emit(self._pause_between_patterns)
+        self.settingsLoaded.emit()
+        logger.info("Settings loaded from table")
+
+    # ==================== Screen management ====================
+    @Property(bool, notify=screenStateChanged)
+    def screenOn(self):
+        return self._screen_on
+
+    @Property(int, notify=screenTimeoutChanged)
+    def screenTimeout(self):
+        return self._screen_timeout
+
+    @screenTimeout.setter
+    def setScreenTimeout(self, timeout):
+        if self._screen_timeout != timeout:
+            self._screen_timeout = timeout
+            self._save_local_settings()
+            self.screenTimeoutChanged.emit(timeout)
+
+    @Slot(result='QStringList')
+    def getScreenTimeoutOptions(self):
+        return list(self.TIMEOUT_OPTIONS.keys())
+
+    @Slot(result=str)
+    def getCurrentScreenTimeoutOption(self):
+        current_timeout = self._screen_timeout
+        for option, value in self.TIMEOUT_OPTIONS.items():
+            if value == current_timeout:
+                return option
+        if current_timeout == 0:
+            return "Never"
+        elif current_timeout < 60:
+            return f"{current_timeout} seconds"
+        elif current_timeout < 3600:
+            minutes = current_timeout // 60
+            return f"{minutes} minute{'s' if minutes != 1 else ''}"
+        else:
+            hours = current_timeout // 3600
+            return f"{hours} hour{'s' if hours != 1 else ''}"
+
+    @Slot(str)
+    def setScreenTimeoutByOption(self, option):
+        if option in self.TIMEOUT_OPTIONS:
+            timeout_value = self.TIMEOUT_OPTIONS[option]
+            if self._screen_timeout != timeout_value:
+                self._screen_timeout = timeout_value
+                self._save_local_settings()
+                self.screenTimeoutChanged.emit(timeout_value)
+        else:
+            logger.warning(f"Unknown timeout option: {option}")
+
+    @Slot()
+    def turnScreenOn(self):
+        if not self._screen_on:
+            self._turn_screen_on()
+        self._reset_activity_timer()
+
+    @Slot()
+    def turnScreenOff(self):
+        self._turn_screen_off()
+
+    @Slot()
+    def resetActivityTimer(self):
+        self._reset_activity_timer()
+        if not self._screen_on:
+            self._turn_screen_on()
+
+    def _turn_screen_on(self):
+        with self._screen_transition_lock:
+            time_since_change = time.time() - self._last_screen_change
+            if time_since_change < 2.0:
+                return
+            if self._screen_on:
+                return
+            try:
+                restore_brightness = self._lcd_brightness if self._lcd_brightness > 0 else self._lcd_max_brightness or 255
+                screen_on_script = Path('/usr/local/bin/screen-on')
+                if screen_on_script.exists():
+                    result = subprocess.run(['sudo', '/usr/local/bin/screen-on'],
+                                            capture_output=True, text=True, timeout=5)
+                    if result.returncode != 0:
+                        logger.warning(f"screen-on script failed: {result.stderr}")
+                    if self._lcd_brightness_path and restore_brightness < (self._lcd_max_brightness or 255):
+                        subprocess.run(
+                            ['sudo', 'sh', '-c', f'echo {restore_brightness} > {self._lcd_brightness_path}'],
+                            check=False, timeout=5)
+                else:
+                    if self._lcd_brightness_path:
+                        subprocess.run(['sudo', 'sh', '-c',
+                                        f'echo 0 > /sys/class/graphics/fb0/blank && echo {restore_brightness} > {self._lcd_brightness_path}'],
+                                       check=False, timeout=5)
+                    else:
+                        subprocess.run(['sudo', 'sh', '-c',
+                                        f'echo 0 > /sys/class/graphics/fb0/blank && echo {restore_brightness} > /sys/class/backlight/*/brightness'],
+                                       check=False, timeout=5)
+                self._screen_on = True
+                self._last_screen_change = time.time()
+                self.screenStateChanged.emit(True)
+            except Exception as e:
+                logger.error(f"Failed to turn screen on: {e}")
+
+    def _turn_screen_off(self):
+        with self._screen_transition_lock:
+            time_since_change = time.time() - self._last_screen_change
+            if time_since_change < 2.0:
+                return
+            if not self._screen_on:
+                return
+        try:
+            screen_off_script = Path('/usr/local/bin/screen-off')
+            if screen_off_script.exists():
+                result = subprocess.run(['sudo', '/usr/local/bin/screen-off'],
+                                        capture_output=True, text=True, timeout=10)
+                if result.returncode == 0:
+                    logger.info("Screen turned OFF (screen-off script)")
+                else:
+                    logger.warning(f"screen-off script failed: return code {result.returncode}")
+            else:
+                subprocess.run(['sudo', 'sh', '-c',
+                                'echo 0 > /sys/class/backlight/*/brightness && echo 1 > /sys/class/graphics/fb0/blank'],
+                               check=False, timeout=5)
+            self._screen_on = False
+            self._last_screen_change = time.time()
+            self.screenStateChanged.emit(False)
+        except Exception as e:
+            logger.error(f"Failed to turn screen off: {e}")
+
+    def _reset_activity_timer(self):
+        self._last_activity = time.time()
+
+    def _check_screen_timeout(self):
+        if self._screen_on and self._screen_timeout > 0:
+            idle_time = time.time() - self._last_activity
+            if idle_time > self._screen_timeout:
+                self._turn_screen_off()
+
+    # ==================== Local settings persistence ====================
+    def _load_local_settings(self):
+        try:
+            if os.path.exists(self.SETTINGS_FILE):
+                with open(self.SETTINGS_FILE, 'r') as f:
+                    settings = json.load(f)
+                screen_timeout = settings.get('screen_timeout', self.DEFAULT_SCREEN_TIMEOUT)
+                if isinstance(screen_timeout, (int, float)) and screen_timeout >= 0:
+                    self._screen_timeout = int(screen_timeout)
+                self._lcd_brightness = settings.get('lcd_brightness', 255)
+                self._lcd_brightness_path = settings.get('lcd_brightness_path', "")
+                self._lcd_max_brightness = settings.get('lcd_max_brightness', 0)
+                self._pause_between_patterns = settings.get('pause_between_patterns', 10800)
+                self._playlist_shuffle = settings.get('playlist_shuffle', True)
+                self._playlist_run_mode = settings.get('playlist_run_mode', "loop")
+                self._playlist_clear_pattern = settings.get('playlist_clear_pattern', "adaptive")
+                self._saved_table_url = settings.get('table_url', "")
+            else:
+                self._save_local_settings()
+        except Exception as e:
+            logger.error(f"Error loading local settings: {e}, using defaults")
+            self._screen_timeout = self.DEFAULT_SCREEN_TIMEOUT
+
+    def _save_local_settings(self):
+        try:
+            settings = {
+                'screen_timeout': self._screen_timeout,
+                'lcd_brightness': self._lcd_brightness,
+                'lcd_brightness_path': self._lcd_brightness_path,
+                'lcd_max_brightness': self._lcd_max_brightness,
+                'pause_between_patterns': self._pause_between_patterns,
+                'playlist_shuffle': self._playlist_shuffle,
+                'playlist_run_mode': self._playlist_run_mode,
+                'playlist_clear_pattern': self._playlist_clear_pattern,
+                'table_url': self._saved_table_url,
+                'version': '2.0'
+            }
+            with open(self.SETTINGS_FILE, 'w') as f:
+                json.dump(settings, f, indent=2)
+        except Exception as e:
+            logger.error(f"Error saving local settings: {e}")
+
+    # ==================== Pause between patterns ====================
+    @Slot(result='QStringList')
+    def getPauseOptions(self):
+        return list(self.PAUSE_OPTIONS.keys())
+
+    @Slot(result=str)
+    def getCurrentPauseOption(self):
+        current_pause = self._pause_between_patterns
+        for option, value in self.PAUSE_OPTIONS.items():
+            if value == current_pause:
+                return option
+        if current_pause == 0:
+            return "0s"
+        elif current_pause < 60:
+            return f"{current_pause}s"
+        elif current_pause < 3600:
+            return f"{current_pause // 60} min"
+        else:
+            return f"{current_pause // 3600} hour"
+
+    @Slot(str)
+    def setPauseByOption(self, option):
+        if option in self.PAUSE_OPTIONS:
+            pause_value = self.PAUSE_OPTIONS[option]
+            if self._pause_between_patterns != pause_value:
+                self._pause_between_patterns = pause_value
+                self._save_local_settings()
+                _run(self._simple_action(
+                    lambda: self.client.command(f"$Playlist/PauseTime={pause_value}"),
+                    label="set pause"))
+                self.pauseBetweenPatternsChanged.emit(pause_value)
+        else:
+            logger.warning(f"Unknown pause option: {option}")
+
+    @Property(int, notify=pauseBetweenPatternsChanged)
+    def pauseBetweenPatterns(self):
+        return self._pause_between_patterns
+
+    # ==================== Playlist settings ====================
+    @Property(bool, notify=playlistSettingsChanged)
+    def playlistShuffle(self):
+        return self._playlist_shuffle
+
+    @Slot(bool)
+    def setPlaylistShuffle(self, enabled):
+        if self._playlist_shuffle != enabled:
+            self._playlist_shuffle = enabled
+            self._save_local_settings()
+            _run(self._simple_action(
+                lambda: self.client.command(f"$Playlist/Shuffle={'ON' if enabled else 'OFF'}"),
+                label="set shuffle"))
+            self.playlistSettingsChanged.emit()
+
+    @Property(str, notify=playlistSettingsChanged)
+    def playlistRunMode(self):
+        return self._playlist_run_mode
+
+    @Slot(str)
+    def setPlaylistRunMode(self, mode):
+        if mode in ("single", "loop") and self._playlist_run_mode != mode:
+            self._playlist_run_mode = mode
+            self._save_local_settings()
+            _run(self._simple_action(
+                lambda: self.client.command(f"$Playlist/Mode={mode}"),
+                label="set run mode"))
+            self.playlistSettingsChanged.emit()
+
+    @Property(str, notify=playlistSettingsChanged)
+    def playlistClearPattern(self):
+        return self._playlist_clear_pattern
+
+    @Slot(str)
+    def setPlaylistClearPattern(self, pattern):
+        valid = ["adaptive", "clear_center", "clear_perimeter", "none"]
+        if pattern in valid and self._playlist_clear_pattern != pattern:
+            self._playlist_clear_pattern = pattern
+            self._save_local_settings()
+            from firmware_client import CLEAR_MODE_MAP
+            fw = CLEAR_MODE_MAP.get(pattern, "adaptive")
+            _run(self._simple_action(
+                lambda: self.client.command(f"$Playlist/ClearPattern={fw}"),
+                label="set clear pattern"))
+            self.playlistSettingsChanged.emit()
+
+    # ==================== LED control ====================
+    @Property(str, notify=ledStatusChanged)
+    def ledProvider(self):
+        return self._led_provider
+
+    @Property(bool, notify=ledStatusChanged)
+    def ledConnected(self):
+        return self._led_connected
+
+    @Property(bool, notify=ledStatusChanged)
+    def ledPowerOn(self):
+        return self._led_power_on
+
+    @Property(int, notify=ledStatusChanged)
+    def ledBrightness(self):
+        return self._led_brightness
+
+    @Property(list, notify=ledEffectsLoaded)
+    def ledEffects(self):
+        return self._led_effects
+
+    @Property(list, notify=ledPalettesLoaded)
+    def ledPalettes(self):
+        return self._led_palettes
+
+    @Property(int, notify=ledStatusChanged)
+    def ledCurrentEffect(self):
+        return self._led_current_effect
+
+    @Property(int, notify=ledStatusChanged)
+    def ledCurrentPalette(self):
+        return self._led_current_palette
+
+    @Property(str, notify=ledStatusChanged)
+    def ledColor(self):
+        return self._led_color
+
+    @Slot()
+    def loadLedConfig(self):
+        logger.debug("Loading LED configuration...")
+        _run(self._load_led_config())
+
+    async def _load_led_config(self):
+        try:
+            settings = await self.client.settings()
+        except Exception as exc:
+            logger.debug(f"LED config load failed: {exc}")
+            return
+
+        has_leds = any(k.startswith("LED/") for k in settings)
+        self._led_provider = "dw_leds" if has_leds else "none"
+        self._led_connected = has_leds
+
+        if has_leds:
+            # Expose the fixed catalogues as {id, name} lists for the QML page.
+            self._led_effects = [{"id": i, "name": n} for i, n in enumerate(LED_EFFECTS)]
+            self._led_palettes = [{"id": i, "name": n} for i, n in enumerate(LED_PALETTES)]
+            self.ledEffectsLoaded.emit(self._led_effects)
+            self.ledPalettesLoaded.emit(self._led_palettes)
+
+            effect = settings.get("LED/Effect", "off")
+            if effect in LED_EFFECTS:
+                self._led_current_effect = LED_EFFECTS.index(effect)
+            palette = settings.get("LED/Palette", "rainbow")
+            if palette in LED_PALETTES:
+                self._led_current_palette = LED_PALETTES.index(palette)
+            self._led_power_on = (effect != "off")
+            if self._led_power_on:
+                self._led_last_effect = self._led_current_effect
+            try:
+                self._led_brightness = round(int(settings.get("LED/Brightness", 255)) * 100 / 255)
+            except (TypeError, ValueError):
+                self._led_brightness = 100
+            color = settings.get("LED/Color", "ffffff")
+            self._led_color = f"#{color.lstrip('#')}"
+
+        self.ledStatusChanged.emit()
+
+    def _ingest_led_status(self, led):
+        """Update live LED effect/brightness from a /sand_status snapshot."""
+        changed = False
+        effect = led.get("effect")
+        if effect in LED_EFFECTS:
+            idx = LED_EFFECTS.index(effect)
+            if idx != self._led_current_effect:
+                self._led_current_effect = idx
+                changed = True
+            power = (effect != "off")
+            if power != self._led_power_on:
+                self._led_power_on = power
+                changed = True
+            if power:
+                self._led_last_effect = idx
+        b = led.get("brightness")
+        if b is not None:
+            scaled = round(int(b) * 100 / 255)
+            if scaled != self._led_brightness:
+                self._led_brightness = scaled
+                changed = True
+        if changed:
+            self.ledStatusChanged.emit()
+
+    @Slot()
+    def refreshLedStatus(self):
+        logger.debug("Refreshing LED status...")
+        _run(self._load_led_config())
+
+    @Slot()
+    def toggleLedPower(self):
+        self.setLedPower(not self._led_power_on)
+
+    @Slot(bool)
+    def setLedPower(self, on):
+        logger.debug(f"Setting LED power: {on}")
+        if on:
+            effect = LED_EFFECTS[self._led_last_effect] if self._led_last_effect else "rainbow"
+            if effect == "off":
+                effect = "rainbow"
+            _run(self._apply_led(effect=effect))
+            self._led_power_on = True
+            self._led_current_effect = LED_EFFECTS.index(effect)
+        else:
+            if self._led_current_effect:
+                self._led_last_effect = self._led_current_effect
+            _run(self._apply_led(effect="off"))
+            self._led_power_on = False
+        self.ledStatusChanged.emit()
+
+    async def _apply_led(self, **kwargs):
+        try:
+            await self.client.set_led(**kwargs)
+        except Exception as exc:
+            logger.error(f"LED update failed: {exc}")
+            self.errorOccurred.emit(str(exc))
+
+    @Slot(int)
+    def setLedBrightness(self, value):
+        logger.debug(f"Setting LED brightness: {value}")
+        value = max(0, min(100, value))
+        self._led_brightness = value
+        _run(self._apply_led(brightness=round(value * 255 / 100)))
+        self.ledStatusChanged.emit()
+
+    @Slot(int, int, int)
+    def setLedColor(self, r, g, b):
+        self.setLedColorHex(f"#{r:02x}{g:02x}{b:02x}")
+
+    @Slot(str)
+    def setLedColorHex(self, hexColor):
+        hexColor = hexColor.lstrip('#')
+        if len(hexColor) != 6:
+            logger.warning(f"Invalid hex color: {hexColor}")
+            return
+        self._led_color = f"#{hexColor}"
+        _run(self._apply_led(color=hexColor))
+        self.ledStatusChanged.emit()
+
+    @Slot(int)
+    def setLedEffect(self, effectId):
+        logger.debug(f"Setting LED effect: {effectId}")
+        if 0 <= effectId < len(LED_EFFECTS):
+            name = LED_EFFECTS[effectId]
+            self._led_current_effect = effectId
+            self._led_power_on = (name != "off")
+            if self._led_power_on:
+                self._led_last_effect = effectId
+            _run(self._apply_led(effect=name))
+            self.ledStatusChanged.emit()
+
+    @Slot(int)
+    def setLedPalette(self, paletteId):
+        logger.debug(f"Setting LED palette: {paletteId}")
+        if 0 <= paletteId < len(LED_PALETTES):
+            self._led_current_palette = paletteId
+            _run(self._apply_led(palette=LED_PALETTES[paletteId]))
+            self.ledStatusChanged.emit()
+
+    # ==================== LCD brightness ====================
+    def _detect_backlight(self):
+        if not self._lcd_brightness_path:
+            try:
+                result = subprocess.run(['ls', '/sys/class/backlight'],
+                                        capture_output=True, text=True, timeout=2)
+                if result.returncode == 0 and result.stdout.strip():
+                    device = result.stdout.strip().split('\n')[0]
+                    self._lcd_brightness_path = f"/sys/class/backlight/{device}/brightness"
+                    logger.info(f"Auto-detected backlight path: {self._lcd_brightness_path}")
+                else:
+                    logger.warning("No backlight device found")
+                    return
+            except Exception as e:
+                logger.warning(f"Failed to detect backlight path: {e}")
+                return
+
+        backlight_dir = str(Path(self._lcd_brightness_path).parent)
+        if self._lcd_max_brightness == 0:
+            try:
+                result = subprocess.run(['cat', f"{backlight_dir}/max_brightness"],
+                                        capture_output=True, text=True, timeout=2)
+                if result.returncode == 0 and result.stdout.strip():
+                    self._lcd_max_brightness = int(result.stdout.strip())
+                else:
+                    self._lcd_max_brightness = 255
+            except Exception:
+                self._lcd_max_brightness = 255
+
+        try:
+            result = subprocess.run(['cat', self._lcd_brightness_path],
+                                    capture_output=True, text=True, timeout=2)
+            if result.returncode == 0 and result.stdout.strip():
+                self._lcd_brightness = int(result.stdout.strip())
+        except Exception as e:
+            logger.warning(f"Failed to read current brightness: {e}")
+
+    @Property(int, notify=lcdBrightnessChanged)
+    def lcdBrightness(self):
+        return self._lcd_brightness
+
+    @Property(int, notify=lcdBrightnessChanged)
+    def lcdMaxBrightness(self):
+        return self._lcd_max_brightness
+
+    @Slot(int)
+    def setLcdBrightness(self, value):
+        value = max(0, min(value, self._lcd_max_brightness))
+        if not self._lcd_brightness_path:
+            logger.warning("No backlight path configured")
+            return
+        try:
+            subprocess.run(['sudo', 'sh', '-c', f'echo {value} > {self._lcd_brightness_path}'],
+                           check=False, timeout=5)
+            self._lcd_brightness = value
+            self._save_local_settings()
+            self.lcdBrightnessChanged.emit()
+        except Exception as e:
+            logger.error(f"Failed to set LCD brightness: {e}")
+
+    # ==================== Pattern refresh ====================
+    @Slot()
+    def refreshPatterns(self):
+        """Re-fetch the pattern catalogue from the table (models reload)."""
+        logger.debug("Refreshing patterns...")
+        # onPatternsRefreshCompleted in QML triggers patternModel.refresh().
+        self.patternsRefreshCompleted.emit(True, "Patterns refreshed")
+
+    # ==================== System control ====================
+    @Slot()
+    def restartBackend(self):
+        """Reboot the table controller (closest analog to the old backend restart)."""
+        logger.info("Rebooting the table...")
+        _run(self._simple_action(self.client.reboot, label="reboot table"))
+
+    @Slot()
+    def shutdownPi(self):
+        """Shut down the local touch host (Raspberry Pi)."""
+        logger.info("Shutting down the Pi...")
+        try:
+            subprocess.run(['sudo', 'shutdown', '-h', 'now'], check=False, timeout=5)
+        except Exception as e:
+            logger.error(f"Shutdown failed: {e}")
+            self.errorOccurred.emit(str(e))
+
+    # ==================== Playlist management ====================
+    @Slot(str)
+    def createPlaylist(self, playlistName):
+        logger.debug(f"Creating playlist: {playlistName}")
+        _run(self._create_playlist(playlistName))
+
+    async def _create_playlist(self, name):
+        try:
+            content = f"# {name}\n".encode("utf-8")
+            await self.client.upload_file(f"{name}.txt", content, path="/playlists")
+            self.playlistCreated.emit(True, f"Created: {name}")
+        except Exception as exc:
+            logger.error(f"create playlist failed: {exc}")
+            self.playlistCreated.emit(False, f"Failed: {exc}")
+
+    @Slot(str)
+    def deletePlaylist(self, playlistName):
+        logger.info(f"Deleting playlist: {playlistName}")
+        _run(self._delete_playlist(playlistName))
+
+    async def _delete_playlist(self, name):
+        try:
+            await self.client.delete_file(f"{name}.txt", path="/playlists")
+            self.playlistDeleted.emit(True, f"Deleted: {name}")
+        except Exception as exc:
+            logger.error(f"delete playlist failed: {exc}")
+            self.playlistDeleted.emit(False, f"Failed: {exc}")
+
+    @Slot(str, str)
+    def addPatternToPlaylist(self, playlistName, patternPath):
+        logger.info(f"Adding pattern to playlist: {patternPath} -> {playlistName}")
+        _run(self._add_pattern_to_playlist(playlistName, patternPath))
+
+    async def _add_pattern_to_playlist(self, name, patternPath):
+        try:
+            # Fetch current contents, append the SD path, re-upload.
+            try:
+                raw = await self.client.fetch_sd_file(f"/playlists/{name}.txt")
+                lines = raw.decode("utf-8", errors="ignore").splitlines()
+            except Exception:
+                lines = [f"# {name}"]
+            sd_path = self._to_sd_pattern_path(patternPath)
+            lines.append(sd_path)
+            content = ("\n".join(lines) + "\n").encode("utf-8")
+            await self.client.upload_file(f"{name}.txt", content, path="/playlists")
+            self.patternAddedToPlaylist.emit(True, f"Added to {name}")
+        except Exception as exc:
+            logger.error(f"add pattern failed: {exc}")
+            self.patternAddedToPlaylist.emit(False, f"Failed: {exc}")
+
+    @Slot(str, list)
+    def updatePlaylistPatterns(self, playlistName, patterns):
+        logger.debug(f"Updating playlist {playlistName} -> {len(patterns)} patterns")
+        _run(self._update_playlist_patterns(playlistName, patterns))
+
+    async def _update_playlist_patterns(self, name, patterns):
+        try:
+            lines = [f"# {name}"]
+            lines += [self._to_sd_pattern_path(p) for p in patterns]
+            content = ("\n".join(lines) + "\n").encode("utf-8")
+            await self.client.upload_file(f"{name}.txt", content, path="/playlists")
+            self.playlistModified.emit(True, f"Updated: {name}")
+        except Exception as exc:
+            logger.error(f"update playlist failed: {exc}")
+            self.playlistModified.emit(False, f"Failed: {exc}")
+
+    @staticmethod
+    def _to_sd_pattern_path(pattern):
+        """Normalize a pattern reference to an SD-absolute /patterns path."""
+        p = str(pattern).strip()
+        if p.startswith("/patterns/") or p.startswith("/sd/"):
+            return p.replace("/sd/", "/", 1) if p.startswith("/sd/") else p
+        return "/patterns/" + p.lstrip("/")
+
+    @Slot(result=list)
+    def getPlaylistNames(self):
+        """Synchronous accessor kept for QML compatibility (best-effort)."""
+        # Playlists now live on the table; the PlaylistModel is the source of
+        # truth. This returns an empty list rather than reading a local file.
+        return []

+ 104 - 0
dune-weaver-touch/discovery.py

@@ -0,0 +1,104 @@
+"""mDNS discovery for Dune Weaver sand tables.
+
+The firmware advertises ``_http._tcp.local`` with TXT records identifying a
+sand table (``model=dune-weaver``, ``api=sandtable/1``, ``ws=<port>``). We
+browse for those services and hand back a list the UI can pick from. Manual IP
+entry remains the fallback (and works even when zeroconf is unavailable).
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from dataclasses import dataclass
+
+logger = logging.getLogger("DuneWeaver.Discovery")
+
+try:
+    from zeroconf import ServiceStateChange
+    from zeroconf.asyncio import AsyncServiceBrowser, AsyncZeroconf
+    ZEROCONF_AVAILABLE = True
+except Exception:  # pragma: no cover - optional dependency
+    ZEROCONF_AVAILABLE = False
+
+
+@dataclass
+class DiscoveredTable:
+    name: str        # friendly service name
+    host: str        # e.g. "dunetable.local"
+    address: str     # numeric IP
+    port: int        # HTTP port
+    ws_port: int     # webui-v3 websocket port (from TXT), 0 if absent
+
+    @property
+    def base_url(self) -> str:
+        # Prefer the numeric address (reliable on kiosks without working mDNS
+        # resolution); fall back to the .local hostname.
+        host = self.address or self.host
+        if self.port and self.port != 80:
+            return f"http://{host}:{self.port}"
+        return f"http://{host}"
+
+
+def _txt_get(info, key: str) -> str:
+    try:
+        raw = info.properties.get(key.encode())
+        return raw.decode() if raw else ""
+    except Exception:
+        return ""
+
+
+async def discover_tables(timeout: float = 3.0) -> list[DiscoveredTable]:
+    """Browse ``_http._tcp`` for ~``timeout`` seconds; return sand tables only."""
+    if not ZEROCONF_AVAILABLE:
+        logger.warning("zeroconf not installed - mDNS discovery unavailable")
+        return []
+
+    results: dict[str, DiscoveredTable] = {}
+    aiozc = AsyncZeroconf()
+
+    async def resolve(zeroconf, service_type, name):
+        from zeroconf.asyncio import AsyncServiceInfo
+        info = AsyncServiceInfo(service_type, name)
+        try:
+            ok = await info.async_request(zeroconf, 2500)
+        except Exception as exc:
+            logger.debug(f"resolve failed for {name}: {exc}")
+            return
+        if not ok:
+            return
+        # Keep only advertisements that declare themselves a sand table.
+        if _txt_get(info, "model") != "dune-weaver":
+            return
+        addresses = info.parsed_scoped_addresses() if hasattr(info, "parsed_scoped_addresses") else []
+        address = addresses[0] if addresses else ""
+        host = (info.server or "").rstrip(".")
+        ws_txt = _txt_get(info, "ws")
+        table = DiscoveredTable(
+            name=name.split(".")[0],
+            host=host,
+            address=address,
+            port=info.port or 80,
+            ws_port=int(ws_txt) if ws_txt.isdigit() else 0,
+        )
+        results[table.base_url] = table
+        logger.info(f"Discovered table: {table.name} @ {table.base_url}")
+
+    pending = []
+
+    def on_change(zeroconf, service_type, name, state_change):
+        if state_change is ServiceStateChange.Added:
+            pending.append(asyncio.ensure_future(resolve(zeroconf, service_type, name)))
+
+    browser = AsyncServiceBrowser(
+        aiozc.zeroconf, "_http._tcp.local.", handlers=[on_change]
+    )
+    try:
+        await asyncio.sleep(timeout)
+    finally:
+        if pending:
+            await asyncio.gather(*pending, return_exceptions=True)
+        await browser.async_cancel()
+        await aiozc.async_close()
+
+    return list(results.values())

+ 310 - 0
dune-weaver-touch/firmware_client.py

@@ -0,0 +1,310 @@
+"""Async HTTP client for the Dune Weaver FluidNC firmware.
+
+The table is a headless ESP32 (FluidNC ``sandtable`` build). It exposes a
+stateless HTTP/JSON API (see the firmware's ``API.md``): status is *polled*
+from ``/sand_status``, actions go out as ``$...`` commands via ``/command`` and
+the dedicated ``/sand_*`` routes, and pattern/playlist files are fetched from
+``/sd/...``.
+
+This module is the single place that knows how to talk to the board. It is a
+process-wide ``QObject`` singleton so the ``Backend`` controller and the
+``PatternModel`` / ``PlaylistModel`` list models all share one aiohttp session
+and one notion of "which table are we pointed at".
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from typing import Optional
+from urllib.parse import quote
+
+import aiohttp
+from PySide6.QtCore import QObject, Signal
+
+logger = logging.getLogger("DuneWeaver.Firmware")
+
+# Firmware LED capability is a fixed catalogue of named effects/palettes
+# (API.md -> "LEDs"). The QML LED page works in terms of integer ids, so we
+# expose these lists and map id <-> name by index.
+LED_EFFECTS = [
+    "off", "static", "rainbow", "breathe", "colorloop", "theater", "scan",
+    "running", "sine", "gradient", "sinelon", "twinkle", "sparkle", "fire",
+    "candle", "meteor", "bouncing", "wipe", "dualscan", "juggle", "multicomet",
+    "glitter", "dissolve", "ripple", "drip", "lightning", "fireworks", "plasma",
+    "heartbeat", "strobe", "police", "chase", "railway", "pacifica", "aurora",
+    "pride", "colorwaves", "bpm", "ball",
+]
+LED_PALETTES = [
+    "rainbow", "ocean", "lava", "forest", "party", "cloud", "heat", "sunset",
+]
+
+# Pattern "pre-execution" clear modes as used by the touch UI mapped to the
+# firmware's clear= modes ($Sand/Run ... clear=<mode>). The touch UI speaks in
+# center/perimeter terms; the firmware ships clear-from-in / clear-from-out
+# templates.
+CLEAR_MODE_MAP = {
+    "adaptive": "adaptive",
+    "clear_center": "in",      # start at the center, clear outward
+    "clear_perimeter": "out",  # start at the perimeter, clear inward
+    "none": "none",
+    # pass-through for firmware-native names so callers may use them directly
+    "in": "in", "out": "out", "sideway": "sideway", "random": "random",
+}
+
+DEFAULT_HTTP_TIMEOUT = 6      # seconds, for normal requests
+STATUS_TIMEOUT = 3           # seconds, for the ~1 Hz status poll
+
+
+def _raise_file_error(status: int, body) -> None:
+    """Raise a helpful error from a file-op JSON body on HTTP failure."""
+    if status < 400:
+        return
+    detail = ""
+    if isinstance(body, dict):
+        err = body.get("error")
+        if isinstance(err, dict):
+            detail = err.get("message", "")
+        detail = detail or body.get("status", "")
+    raise RuntimeError(detail or f"HTTP {status}")
+
+
+def normalize_base_url(value: str) -> str:
+    """Turn a user/mDNS supplied host into a ``http://host[:port]`` base URL."""
+    value = (value or "").strip().rstrip("/")
+    if not value:
+        return ""
+    if not value.startswith(("http://", "https://")):
+        value = "http://" + value
+    return value
+
+
+class FirmwareClient(QObject):
+    """Process-wide async client for one FluidNC sand table."""
+
+    # Emitted whenever the target table changes (empty string = no table).
+    baseUrlChanged = Signal(str)
+    # Emitted when reachability changes based on request success/failure.
+    reachabilityChanged = Signal(bool)
+
+    _instance: Optional["FirmwareClient"] = None
+
+    @classmethod
+    def instance(cls) -> "FirmwareClient":
+        if cls._instance is None:
+            cls._instance = FirmwareClient()
+        return cls._instance
+
+    def __init__(self):
+        super().__init__()
+        self._base_url = ""
+        self._session: Optional[aiohttp.ClientSession] = None
+        self._reachable = False
+
+    # ------------------------------------------------------------------ target
+    @property
+    def base_url(self) -> str:
+        return self._base_url
+
+    @property
+    def reachable(self) -> bool:
+        return self._reachable
+
+    def set_base_url(self, value: str) -> None:
+        normalized = normalize_base_url(value)
+        if normalized == self._base_url:
+            return
+        logger.info(f"Target table set to: {normalized or '(none)'}")
+        self._base_url = normalized
+        self._set_reachable(False)
+        self.baseUrlChanged.emit(self._base_url)
+
+    def _set_reachable(self, value: bool) -> None:
+        if value != self._reachable:
+            self._reachable = value
+            self.reachabilityChanged.emit(value)
+
+    # ----------------------------------------------------------------- session
+    async def _ensure_session(self) -> aiohttp.ClientSession:
+        if self._session is None or self._session.closed:
+            connector = aiohttp.TCPConnector(ssl=False, limit=8)
+            self._session = aiohttp.ClientSession(connector=connector)
+        return self._session
+
+    async def close(self) -> None:
+        if self._session and not self._session.closed:
+            await self._session.close()
+
+    # ------------------------------------------------------------- HTTP helpers
+    async def _get(self, path: str, *, timeout: float = DEFAULT_HTTP_TIMEOUT):
+        """GET ``path`` (leading-slash relative). Returns the aiohttp response
+        inside a context manager caller. Raises on transport error."""
+        if not self._base_url:
+            raise RuntimeError("No table selected")
+        session = await self._ensure_session()
+        client_timeout = aiohttp.ClientTimeout(total=timeout)
+        return session.get(f"{self._base_url}{path}", timeout=client_timeout)
+
+    async def get_json(self, path: str, *, timeout: float = DEFAULT_HTTP_TIMEOUT):
+        async with await self._get(path, timeout=timeout) as resp:
+            resp.raise_for_status()
+            data = await resp.json(content_type=None)
+            self._set_reachable(True)
+            return data
+
+    async def get_text(self, path: str, *, timeout: float = DEFAULT_HTTP_TIMEOUT) -> str:
+        async with await self._get(path, timeout=timeout) as resp:
+            resp.raise_for_status()
+            text = await resp.text()
+            self._set_reachable(True)
+            return text
+
+    async def get_bytes(self, path: str, *, timeout: float = DEFAULT_HTTP_TIMEOUT) -> bytes:
+        async with await self._get(path, timeout=timeout) as resp:
+            resp.raise_for_status()
+            data = await resp.read()
+            self._set_reachable(True)
+            return data
+
+    # -------------------------------------------------------------- status/read
+    async def status(self) -> dict:
+        """Poll ``/sand_status`` (fast, works during motion)."""
+        return await self.get_json("/sand_status", timeout=STATUS_TIMEOUT)
+
+    async def patterns(self) -> list:
+        """``/sand_patterns`` -> list of ``.thr`` paths relative to /patterns."""
+        data = await self.get_json("/sand_patterns")
+        return data if isinstance(data, list) else []
+
+    async def playlists(self) -> list:
+        """``/sand_playlists`` -> list of ``.txt`` file names."""
+        data = await self.get_json("/sand_playlists")
+        return data if isinstance(data, list) else []
+
+    async def settings(self) -> dict:
+        """``/sand_settings`` -> flat dict of setting name -> string value."""
+        data = await self.get_json("/sand_settings")
+        return data if isinstance(data, dict) else {}
+
+    async def fetch_sd_file(self, sd_path: str) -> bytes:
+        """Fetch a file from the SD card, e.g. ``/patterns/star.thr``."""
+        sd_path = "/" + sd_path.lstrip("/")
+        return await self.get_bytes(f"/sd{sd_path}")
+
+    # ------------------------------------------------------------------ actions
+    async def command(self, cmd: str, *, timeout: float = DEFAULT_HTTP_TIMEOUT) -> str:
+        """Fire a ``$...`` command via ``/command?plain=`` (fire-and-forget).
+
+        Output routing over ``/command`` is racy for anything but ``$/`` reads,
+        so callers that need a value should poll a ``/sand_*`` route instead.
+        """
+        return await self.get_text(f"/command?plain={quote(cmd)}", timeout=timeout)
+
+    async def run_pattern(self, rel_path: str, clear: str = "none") -> None:
+        """Run ``/patterns/<rel_path>`` with an optional pre-execution clear."""
+        path = "/patterns/" + rel_path.lstrip("/")
+        mode = CLEAR_MODE_MAP.get(clear, "none")
+        if mode == "none":
+            await self.command(f"$SD/Run={path}")
+        else:
+            await self.command(f"$Sand/Run={path} clear={mode}")
+
+    async def run_playlist(self, name: str, *, pause_time=None, clear_pattern=None,
+                           run_mode=None, shuffle=None) -> None:
+        """Apply the run parameters (NVS) then start the playlist."""
+        if run_mode in ("single", "loop"):
+            await self.command(f"$Playlist/Mode={run_mode}")
+        if shuffle is not None:
+            await self.command(f"$Playlist/Shuffle={'ON' if shuffle else 'OFF'}")
+        if pause_time is not None:
+            await self.command(f"$Playlist/PauseTime={int(pause_time)}")
+        if clear_pattern is not None:
+            mode = CLEAR_MODE_MAP.get(clear_pattern, clear_pattern)
+            await self.command(f"$Playlist/ClearPattern={mode}")
+        await self.command(f"$Playlist/Run={name}")
+
+    async def playlist_stop(self) -> None:
+        await self.command("$Playlist/Stop")
+
+    async def playlist_skip(self) -> None:
+        await self.command("$Playlist/Skip")
+
+    async def stop(self) -> None:
+        """Stop the whole sequence (clear + pattern + playlist)."""
+        await self.get_text("/sand_stop")
+
+    async def pause(self) -> None:
+        await self.get_text("/sand_pause")
+
+    async def resume(self) -> None:
+        await self.get_text("/sand_resume")
+
+    async def home(self) -> None:
+        # Homing can take a while; give it room. Runs in the main loop (safe).
+        await self.get_text("/sand_home", timeout=95)
+
+    async def goto(self, *, theta=None, rho=None) -> None:
+        params = []
+        if theta is not None:
+            params.append(f"theta={theta}")
+        if rho is not None:
+            params.append(f"rho={rho}")
+        await self.get_text("/sand_goto?" + "&".join(params), timeout=95)
+
+    async def set_feed_mm(self, mm: int) -> None:
+        """Set the base feed rate (motor mm/min); works mid-pattern."""
+        await self.get_text(f"/sand_feed?mm={int(mm)}")
+
+    async def set_led(self, **kwargs) -> None:
+        """Live LED control via ``/sand_led?...`` (applies immediately)."""
+        params = "&".join(f"{k}={quote(str(v))}" for k, v in kwargs.items() if v is not None)
+        await self.get_text(f"/sand_led?{params}")
+
+    async def set_autostart(self, name: str) -> None:
+        """Set (or clear, with empty name) the boot auto-play playlist."""
+        await self.command(f"$Playlist/Autostart={name}")
+
+    async def reboot(self) -> None:
+        await self.command("$Bye")
+
+    async def sync_time(self, epoch: int) -> None:
+        await self.get_text(f"/sand_time?epoch={int(epoch)}")
+
+    # --------------------------------------------------------------- file ops
+    async def upload_file(self, name: str, data: bytes, path: str = "/patterns") -> dict:
+        """Upload ``data`` as ``<path>/<name>`` (multipart, firmware contract)."""
+        if not self._base_url:
+            raise RuntimeError("No table selected")
+        session = await self._ensure_session()
+        form = aiohttp.FormData()
+        form.add_field(f"{name}S", str(len(data)))
+        form.add_field(name, data, filename=name,
+                       content_type="application/octet-stream")
+        url = f"{self._base_url}/upload?path={quote(path)}"
+        timeout = aiohttp.ClientTimeout(total=60)
+        async with session.post(url, data=form, timeout=timeout) as resp:
+            body = await resp.json(content_type=None)
+            self._set_reachable(True)
+            _raise_file_error(resp.status, body)
+            return body
+
+    async def _file_action(self, action: str, filename: str, path: str, **extra) -> dict:
+        if not self._base_url:
+            raise RuntimeError("No table selected")
+        session = await self._ensure_session()
+        params = {"action": action, "filename": filename, "path": path, "dontlist": "yes"}
+        params.update(extra)
+        query = "&".join(f"{k}={quote(str(v))}" for k, v in params.items())
+        url = f"{self._base_url}/upload?{query}"
+        timeout = aiohttp.ClientTimeout(total=DEFAULT_HTTP_TIMEOUT)
+        async with session.get(url, timeout=timeout) as resp:
+            body = await resp.json(content_type=None)
+            self._set_reachable(True)
+            _raise_file_error(resp.status, body)
+            return body
+
+    async def delete_file(self, filename: str, path: str = "/playlists") -> dict:
+        return await self._file_action("delete", filename, path)
+
+    async def create_dir(self, filename: str, path: str = "/") -> dict:
+        return await self._file_action("createdir", filename, path)

+ 162 - 101
dune-weaver-touch/models/pattern_model.py

@@ -1,101 +1,162 @@
-from PySide6.QtCore import QAbstractListModel, Qt, Slot
-from PySide6.QtQml import QmlElement
-from pathlib import Path
-
-QML_IMPORT_NAME = "DuneWeaver"
-QML_IMPORT_MAJOR_VERSION = 1
-
-@QmlElement
-class PatternModel(QAbstractListModel):
-    """Model for pattern list with direct file system access"""
-    
-    NameRole = Qt.UserRole + 1
-    PathRole = Qt.UserRole + 2
-    PreviewRole = Qt.UserRole + 3
-    
-    def __init__(self):
-        super().__init__()
-        self._patterns = []
-        self._filtered_patterns = []
-        # Look for patterns in the parent directory (main dune-weaver folder)
-        self.patterns_dir = Path("../patterns")
-        self.cache_dir = Path("../patterns/cached_images")
-        self.refresh()
-    
-    def roleNames(self):
-        return {
-            self.NameRole: b"name",
-            self.PathRole: b"path", 
-            self.PreviewRole: b"preview"
-        }
-    
-    def rowCount(self, parent=None):
-        return len(self._filtered_patterns)
-    
-    def data(self, index, role):
-        if not index.isValid() or index.row() >= len(self._filtered_patterns):
-            return None
-        
-        pattern = self._filtered_patterns[index.row()]
-        
-        if role == self.NameRole:
-            return pattern["name"]
-        elif role == self.PathRole:
-            return pattern["path"]
-        elif role == self.PreviewRole:
-            # For patterns in subdirectories, check both flattened and hierarchical cache structures
-            pattern_name = pattern["name"]
-            
-            # Try PNG format for kiosk compatibility
-            # First try hierarchical structure (preserving subdirectories)
-            preview_path_hierarchical = self.cache_dir / f"{pattern_name}.png"
-            if preview_path_hierarchical.exists():
-                return str(preview_path_hierarchical.absolute())
-            
-            # Then try flattened structure (replace / with _)
-            preview_name_flat = pattern_name.replace("/", "_").replace("\\", "_")
-            preview_path_flat = self.cache_dir / f"{preview_name_flat}.png"
-            if preview_path_flat.exists():
-                return str(preview_path_flat.absolute())
-            
-            # Fallback to WebP if PNG not found (for existing caches)
-            preview_path_hierarchical_webp = self.cache_dir / f"{pattern_name}.webp"
-            if preview_path_hierarchical_webp.exists():
-                return str(preview_path_hierarchical_webp.absolute())
-            
-            preview_path_flat_webp = self.cache_dir / f"{preview_name_flat}.webp"
-            if preview_path_flat_webp.exists():
-                return str(preview_path_flat_webp.absolute())
-            
-            return ""
-        
-        return None
-    
-    @Slot()
-    def refresh(self):
-        print(f"Loading patterns from: {self.patterns_dir.absolute()}")
-        self.beginResetModel()
-        patterns = []
-        for file_path in self.patterns_dir.rglob("*.thr"):
-            relative = file_path.relative_to(self.patterns_dir)
-            patterns.append({
-                "name": str(relative),
-                "path": str(file_path)
-            })
-        self._patterns = sorted(patterns, key=lambda x: x["name"])
-        self._filtered_patterns = self._patterns.copy()
-        print(f"Loaded {len(self._patterns)} patterns")
-        self.endResetModel()
-    
-    @Slot(str)
-    def filter(self, search_text):
-        self.beginResetModel()
-        if not search_text:
-            self._filtered_patterns = self._patterns.copy()
-        else:
-            search_lower = search_text.lower()
-            self._filtered_patterns = [
-                p for p in self._patterns 
-                if search_lower in p["name"].lower()
-            ]
-        self.endResetModel()
+"""Pattern list model backed by the firmware's ``/sand_patterns`` route.
+
+Patterns now live on the table's SD card, not the local filesystem. This model
+fetches the catalogue over HTTP and renders each ``.thr`` preview locally
+(cached to disk), updating rows as previews become available.
+"""
+
+import asyncio
+import logging
+
+from PySide6.QtCore import QAbstractListModel, Qt, Slot, QModelIndex
+from PySide6.QtQml import QmlElement
+
+from firmware_client import FirmwareClient
+import thr_preview
+
+QML_IMPORT_NAME = "DuneWeaver"
+QML_IMPORT_MAJOR_VERSION = 1
+
+logger = logging.getLogger("DuneWeaver.PatternModel")
+
+
+@QmlElement
+class PatternModel(QAbstractListModel):
+    """Model for the pattern grid, sourced from the sand table over HTTP."""
+
+    NameRole = Qt.UserRole + 1
+    PathRole = Qt.UserRole + 2
+    PreviewRole = Qt.UserRole + 3
+
+    def __init__(self):
+        super().__init__()
+        self._patterns = []           # all patterns [{name, path}]
+        self._filtered_patterns = []  # current view
+        self._search_text = ""
+        self._previews = {}           # rel_path -> cached png path ("" = pending)
+        self._rendering = set()       # rel_paths with an in-flight render
+
+        self._client = FirmwareClient.instance()
+        self._client.baseUrlChanged.connect(self._on_table_changed)
+        self.refresh()
+
+    def roleNames(self):
+        return {
+            self.NameRole: b"name",
+            self.PathRole: b"path",
+            self.PreviewRole: b"preview",
+        }
+
+    def rowCount(self, parent=QModelIndex()):
+        return len(self._filtered_patterns)
+
+    def data(self, index, role):
+        if not index.isValid() or index.row() >= len(self._filtered_patterns):
+            return None
+
+        pattern = self._filtered_patterns[index.row()]
+
+        if role == self.NameRole:
+            return pattern["name"]
+        elif role == self.PathRole:
+            return pattern["path"]
+        elif role == self.PreviewRole:
+            return self._preview_for(pattern["name"])
+
+        return None
+
+    # ------------------------------------------------------------- previews
+    def _preview_for(self, rel_path):
+        """Return a cached preview path, kicking off a render if needed."""
+        cached = self._previews.get(rel_path)
+        if cached is not None:
+            return cached
+        # Fast synchronous cache lookup on disk.
+        on_disk = thr_preview.cached_preview(self._client.base_url, rel_path)
+        if on_disk:
+            self._previews[rel_path] = on_disk
+            return on_disk
+        # Not cached yet - render asynchronously and update the row later.
+        self._schedule_render(rel_path)
+        return ""
+
+    def _schedule_render(self, rel_path):
+        if rel_path in self._rendering or not self._client.base_url:
+            return
+        self._rendering.add(rel_path)
+        try:
+            asyncio.get_event_loop().create_task(self._render(rel_path))
+        except RuntimeError:
+            self._rendering.discard(rel_path)
+
+    async def _render(self, rel_path):
+        base_url = self._client.base_url
+        try:
+            path = await thr_preview.render_preview(self._client, base_url, rel_path)
+        finally:
+            self._rendering.discard(rel_path)
+        if base_url != self._client.base_url:
+            return  # table changed under us; drop stale result
+        self._previews[rel_path] = path
+        self._emit_preview_changed(rel_path)
+
+    def _emit_preview_changed(self, rel_path):
+        for row, pattern in enumerate(self._filtered_patterns):
+            if pattern["name"] == rel_path:
+                idx = self.index(row, 0)
+                self.dataChanged.emit(idx, idx, [self.PreviewRole])
+                break
+
+    # -------------------------------------------------------------- fetching
+    def _on_table_changed(self, _base_url):
+        self._previews.clear()
+        self._rendering.clear()
+        self.refresh()
+
+    @Slot()
+    def refresh(self):
+        try:
+            asyncio.get_event_loop().create_task(self._fetch_patterns())
+        except RuntimeError:
+            logger.debug("No running loop yet; patterns will load once started")
+
+    async def _fetch_patterns(self):
+        if not self._client.base_url:
+            self._apply_patterns([])
+            return
+        try:
+            paths = await self._client.patterns()
+        except Exception as exc:
+            logger.warning(f"Failed to fetch patterns: {exc}")
+            return
+        patterns = []
+        for p in paths:
+            rel = str(p).lstrip("/")
+            # /sand_patterns may return paths with or without a /patterns prefix
+            if rel.startswith("patterns/"):
+                rel = rel[len("patterns/"):]
+            patterns.append({"name": rel, "path": rel})
+        patterns.sort(key=lambda x: x["name"].lower())
+        self._apply_patterns(patterns)
+
+    def _apply_patterns(self, patterns):
+        self.beginResetModel()
+        self._patterns = patterns
+        self._filtered_patterns = self._apply_filter(patterns, self._search_text)
+        self.endResetModel()
+        logger.info(f"Loaded {len(self._patterns)} patterns")
+
+    # ---------------------------------------------------------------- filter
+    @staticmethod
+    def _apply_filter(patterns, search_text):
+        if not search_text:
+            return list(patterns)
+        needle = search_text.lower()
+        return [p for p in patterns if needle in p["name"].lower()]
+
+    @Slot(str)
+    def filter(self, search_text):
+        self._search_text = search_text or ""
+        self.beginResetModel()
+        self._filtered_patterns = self._apply_filter(self._patterns, self._search_text)
+        self.endResetModel()

+ 132 - 101
dune-weaver-touch/models/playlist_model.py

@@ -1,101 +1,132 @@
-from PySide6.QtCore import QAbstractListModel, Qt, Slot
-from PySide6.QtQml import QmlElement
-from pathlib import Path
-import json
-
-QML_IMPORT_NAME = "DuneWeaver"
-QML_IMPORT_MAJOR_VERSION = 1
-
-@QmlElement
-class PlaylistModel(QAbstractListModel):
-    """Model for playlist list with direct JSON file access"""
-    
-    NameRole = Qt.UserRole + 1
-    ItemCountRole = Qt.UserRole + 2
-    
-    def __init__(self):
-        super().__init__()
-        self._playlists = []
-        # Look for playlists in the parent directory (main dune-weaver folder)
-        self.playlists_file = Path("../playlists.json")
-        self.refresh()
-    
-    def roleNames(self):
-        return {
-            self.NameRole: b"name",
-            self.ItemCountRole: b"itemCount"
-        }
-    
-    def rowCount(self, parent=None):
-        return len(self._playlists)
-    
-    def data(self, index, role):
-        if not index.isValid() or index.row() >= len(self._playlists):
-            return None
-        
-        playlist = self._playlists[index.row()]
-        
-        if role == self.NameRole:
-            return playlist["name"]
-        elif role == self.ItemCountRole:
-            return playlist["itemCount"]
-        
-        return None
-    
-    @Slot()
-    def refresh(self):
-        self.beginResetModel()
-        playlists = []
-        
-        if self.playlists_file.exists():
-            try:
-                with open(self.playlists_file, 'r') as f:
-                    self._playlist_data = json.load(f)
-                    for name, playlist_patterns in self._playlist_data.items():
-                        # playlist_patterns is a list of pattern filenames
-                        playlists.append({
-                            "name": name,
-                            "itemCount": len(playlist_patterns) if isinstance(playlist_patterns, list) else 0
-                        })
-            except (json.JSONDecodeError, KeyError, AttributeError):
-                self._playlist_data = {}
-        else:
-            self._playlist_data = {}
-        
-        self._playlists = sorted(playlists, key=lambda x: x["name"])
-        self.endResetModel()
-    
-    @Slot(str, result=list)
-    def getPatternsForPlaylist(self, playlistName):
-        """Get the list of patterns for a given playlist (cleaned for display)"""
-        if hasattr(self, '_playlist_data') and playlistName in self._playlist_data:
-            patterns = self._playlist_data[playlistName]
-            if isinstance(patterns, list):
-                # Clean up pattern names for display
-                cleaned_patterns = []
-                for pattern in patterns:
-                    # Remove path and .thr extension for display
-                    clean_name = pattern
-                    if '/' in clean_name:
-                        clean_name = clean_name.split('/')[-1]
-                    if clean_name.endswith('.thr'):
-                        clean_name = clean_name[:-4]
-                    cleaned_patterns.append(clean_name)
-                return cleaned_patterns
-        return []
-
-    @Slot(str, result=list)
-    def getRawPatternsForPlaylist(self, playlistName):
-        """Get the raw list of patterns for a playlist (with full paths, for API calls)"""
-        if hasattr(self, '_playlist_data') and playlistName in self._playlist_data:
-            patterns = self._playlist_data[playlistName]
-            if isinstance(patterns, list):
-                return patterns
-        return []
-
-    @Slot(result=list)
-    def getAllPlaylistNames(self):
-        """Get a list of all playlist names"""
-        if hasattr(self, '_playlist_data'):
-            return sorted(list(self._playlist_data.keys()))
-        return []
+"""Playlist list model backed by the firmware's playlist routes.
+
+Playlists are plain ``.txt`` files on the table's SD card (one SD-relative
+pattern path per line). We list them via ``/sand_playlists`` and read their
+contents from ``/sd/playlists/<name>.txt``.
+"""
+
+import asyncio
+import logging
+
+from PySide6.QtCore import QAbstractListModel, Qt, Slot, QModelIndex
+from PySide6.QtQml import QmlElement
+
+from firmware_client import FirmwareClient
+
+QML_IMPORT_NAME = "DuneWeaver"
+QML_IMPORT_MAJOR_VERSION = 1
+
+logger = logging.getLogger("DuneWeaver.PlaylistModel")
+
+
+def _strip_txt(name):
+    return name[:-4] if name.endswith(".txt") else name
+
+
+def _parse_playlist(text):
+    """Parse a playlist file into a list of SD-relative pattern paths."""
+    items = []
+    for line in text.splitlines():
+        line = line.strip()
+        if not line or line.startswith("#"):
+            continue
+        items.append(line)
+    return items
+
+
+@QmlElement
+class PlaylistModel(QAbstractListModel):
+    """Model for playlists, sourced from the sand table over HTTP."""
+
+    NameRole = Qt.UserRole + 1
+    ItemCountRole = Qt.UserRole + 2
+
+    def __init__(self):
+        super().__init__()
+        self._playlists = []        # [{name, itemCount}]
+        self._contents = {}         # name -> [raw pattern path, ...]
+
+        self._client = FirmwareClient.instance()
+        self._client.baseUrlChanged.connect(lambda _u: self.refresh())
+        self.refresh()
+
+    def roleNames(self):
+        return {
+            self.NameRole: b"name",
+            self.ItemCountRole: b"itemCount",
+        }
+
+    def rowCount(self, parent=QModelIndex()):
+        return len(self._playlists)
+
+    def data(self, index, role):
+        if not index.isValid() or index.row() >= len(self._playlists):
+            return None
+        playlist = self._playlists[index.row()]
+        if role == self.NameRole:
+            return playlist["name"]
+        elif role == self.ItemCountRole:
+            return playlist["itemCount"]
+        return None
+
+    # -------------------------------------------------------------- fetching
+    @Slot()
+    def refresh(self):
+        try:
+            asyncio.get_event_loop().create_task(self._fetch())
+        except RuntimeError:
+            logger.debug("No running loop yet; playlists will load once started")
+
+    async def _fetch(self):
+        if not self._client.base_url:
+            self._apply([], {})
+            return
+        try:
+            names = await self._client.playlists()
+        except Exception as exc:
+            logger.warning(f"Failed to fetch playlists: {exc}")
+            return
+
+        contents = {}
+        playlists = []
+        for raw_name in names:
+            name = _strip_txt(str(raw_name).lstrip("/"))
+            items = []
+            try:
+                text_bytes = await self._client.fetch_sd_file(f"/playlists/{name}.txt")
+                items = _parse_playlist(text_bytes.decode("utf-8", errors="ignore"))
+            except Exception as exc:
+                logger.debug(f"Failed to read playlist {name}: {exc}")
+            contents[name] = items
+            playlists.append({"name": name, "itemCount": len(items)})
+
+        playlists.sort(key=lambda x: x["name"].lower())
+        self._apply(playlists, contents)
+
+    def _apply(self, playlists, contents):
+        self.beginResetModel()
+        self._playlists = playlists
+        self._contents = contents
+        self.endResetModel()
+        logger.info(f"Loaded {len(self._playlists)} playlists")
+
+    # --------------------------------------------------------------- queries
+    @Slot(str, result=list)
+    def getPatternsForPlaylist(self, playlistName):
+        """Cleaned pattern names for display (no path, no .thr)."""
+        cleaned = []
+        for pattern in self._contents.get(playlistName, []):
+            clean = pattern.split("/")[-1]
+            if clean.endswith(".thr"):
+                clean = clean[:-4]
+            cleaned.append(clean)
+        return cleaned
+
+    @Slot(str, result=list)
+    def getRawPatternsForPlaylist(self, playlistName):
+        """Raw SD-relative pattern paths (for API calls / editing)."""
+        return list(self._contents.get(playlistName, []))
+
+    @Slot(result=list)
+    def getAllPlaylistNames(self):
+        return sorted(self._contents.keys())

+ 2 - 1
dune-weaver-touch/requirements.txt

@@ -2,4 +2,5 @@ PySide6>=6.5.0
 qasync>=0.27.0
 aiohttp>=3.9.0
 Pillow>=10.0.0
-python-dotenv>=1.0.0
+python-dotenv>=1.0.0
+zeroconf>=0.131.0

+ 7 - 32
dune-weaver-touch/run.sh

@@ -11,41 +11,16 @@ if [ ! -d "$SCRIPT_DIR/venv" ]; then
     exit 1
 fi
 
-# Check if backend is running at localhost:8080
-echo "🔍 Checking backend availability at localhost:8080..."
-BACKEND_URL="http://localhost:8080"
-MAX_ATTEMPTS=30
-ATTEMPT=0
-
-while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
-    if curl -s --connect-timeout 2 "$BACKEND_URL/serial_status" > /dev/null 2>&1; then
-        echo "✅ Backend is available at localhost:8080"
-        break
-    else
-        ATTEMPT=$((ATTEMPT + 1))
-        if [ $ATTEMPT -eq 1 ]; then
-            echo "⏳ Waiting for backend to become available..."
-            echo "   Make sure the main Dune Weaver application is running"
-            echo "   Attempting connection ($ATTEMPT/$MAX_ATTEMPTS)..."
-        elif [ $((ATTEMPT % 5)) -eq 0 ]; then
-            echo "   Still waiting... ($ATTEMPT/$MAX_ATTEMPTS)"
-        fi
-        sleep 1
-    fi
-done
-
-if [ $ATTEMPT -eq $MAX_ATTEMPTS ]; then
-    echo "❌ Backend not available after $MAX_ATTEMPTS attempts"
-    echo "   Please ensure the main Dune Weaver application is running at localhost:8080"
-    echo "   You can start it with: cd .. && python main.py"
-    exit 1
-fi
-
-# Run the application using the virtual environment
+# The table (FluidNC firmware) is reached over the network and auto-discovered
+# via mDNS, or pinned with DUNE_WEAVER_URL. No local backend is required.
 echo ""
 echo "🚀 Starting Dune Weaver Touch (development mode)"
 echo "   Using virtual environment: $SCRIPT_DIR/venv"
-echo "   Connected to backend: $BACKEND_URL"
+if [ -n "$DUNE_WEAVER_URL" ]; then
+    echo "   Table (pinned): $DUNE_WEAVER_URL"
+else
+    echo "   Table: auto-discovering over mDNS..."
+fi
 echo "   Press Ctrl+C to stop"
 echo ""
 

+ 144 - 0
dune-weaver-touch/thr_preview.py

@@ -0,0 +1,144 @@
+"""Render polar ``.thr`` pattern files to cached PNG previews.
+
+The firmware serves no thumbnails - only the raw ``.thr`` files (lists of
+``<theta_radians> <rho_0..1>`` points). Clients render previews locally. We
+fetch each ``.thr`` from ``/sd/patterns/...``, draw the polar path, and cache
+the PNG on disk so it renders instantly next time.
+
+Previews are cached under ``preview_cache/<table-slug>/`` keyed by the pattern
+path plus a hash of the file contents, so a changed pattern re-renders.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import logging
+import math
+import re
+from pathlib import Path
+from urllib.parse import urlparse
+
+logger = logging.getLogger("DuneWeaver.Preview")
+
+try:
+    from PIL import Image, ImageDraw
+except ImportError:  # pragma: no cover
+    Image = None
+    ImageDraw = None
+
+CACHE_ROOT = Path(__file__).parent / "preview_cache"
+IMAGE_SIZE = 320          # rendered PNG is IMAGE_SIZE x IMAGE_SIZE
+_MARGIN = 12
+# Limit concurrent renders so a full grid doesn't stampede the ESP32's small
+# HTTP socket pool.
+_semaphore = asyncio.Semaphore(4)
+
+
+def _slug(base_url: str) -> str:
+    host = urlparse(base_url).netloc or base_url or "table"
+    return re.sub(r"[^A-Za-z0-9_.-]", "_", host)
+
+
+def _cache_path(base_url: str, rel_path: str, content_hash: str) -> Path:
+    safe = re.sub(r"[^A-Za-z0-9_.-]", "_", rel_path)
+    return CACHE_ROOT / _slug(base_url) / f"{safe}.{content_hash}.png"
+
+
+def _parse_thr(text: str) -> list[tuple[float, float]]:
+    points = []
+    for line in text.splitlines():
+        line = line.strip()
+        if not line or line.startswith("#"):
+            continue
+        parts = line.replace(",", " ").split()
+        if len(parts) < 2:
+            continue
+        try:
+            theta = float(parts[0])
+            rho = float(parts[1])
+        except ValueError:
+            continue
+        points.append((theta, rho))
+    return points
+
+
+def _render_png(points: list[tuple[float, float]], out_path: Path) -> bool:
+    if Image is None:
+        return False
+    size = IMAGE_SIZE
+    img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
+    draw = ImageDraw.Draw(img)
+    center = size / 2
+    radius = center - _MARGIN
+
+    # Sand-table dish backdrop.
+    draw.ellipse(
+        [_MARGIN, _MARGIN, size - _MARGIN, size - _MARGIN],
+        fill=(28, 28, 30, 255), outline=(70, 70, 74, 255), width=2,
+    )
+
+    xy = []
+    for theta, rho in points:
+        rho = max(0.0, min(1.0, rho))
+        r = rho * radius
+        xy.append((center + r * math.cos(theta), center - r * math.sin(theta)))
+
+    if len(xy) >= 2:
+        draw.line(xy, fill=(0xF5, 0xC9, 0x6b, 255), width=2, joint="curve")
+    elif len(xy) == 1:
+        x, y = xy[0]
+        draw.ellipse([x - 2, y - 2, x + 2, y + 2], fill=(0xF5, 0xC9, 0x6b, 255))
+
+    out_path.parent.mkdir(parents=True, exist_ok=True)
+    tmp = out_path.with_suffix(".tmp.png")
+    img.save(tmp, "PNG")
+    tmp.replace(out_path)
+    return True
+
+
+def cached_preview(base_url: str, rel_path: str) -> str:
+    """Return an existing cached preview for this pattern, or "" if none.
+
+    Cheap, synchronous lookup for the list model's ``data()`` fast path. Because
+    the cache filename embeds a content hash we don't know here, we glob for any
+    cached render of this pattern (patterns rarely change on disk).
+    """
+    folder = CACHE_ROOT / _slug(base_url)
+    safe = re.sub(r"[^A-Za-z0-9_.-]", "_", rel_path)
+    if not folder.exists():
+        return ""
+    matches = sorted(folder.glob(f"{safe}.*.png"))
+    return str(matches[0].absolute()) if matches else ""
+
+
+async def render_preview(client, base_url: str, rel_path: str) -> str:
+    """Fetch ``rel_path`` from the table and render its preview PNG.
+
+    ``client`` is a :class:`firmware_client.FirmwareClient`. Returns the absolute
+    path to the cached PNG, or "" on failure.
+    """
+    if Image is None:
+        logger.warning("Pillow not available - cannot render previews")
+        return ""
+    async with _semaphore:
+        try:
+            data = await client.fetch_sd_file(f"/patterns/{rel_path}")
+        except Exception as exc:
+            logger.debug(f"preview fetch failed for {rel_path}: {exc}")
+            return ""
+        content_hash = hashlib.sha1(data).hexdigest()[:10]
+        out_path = _cache_path(base_url, rel_path, content_hash)
+        if out_path.exists():
+            return str(out_path.absolute())
+        try:
+            text = data.decode("utf-8", errors="ignore")
+            points = _parse_thr(text)
+            if not points:
+                return ""
+            # Rendering is CPU-bound; keep it off the event loop.
+            ok = await asyncio.to_thread(_render_png, points, out_path)
+            return str(out_path.absolute()) if ok else ""
+        except Exception as exc:
+            logger.error(f"preview render failed for {rel_path}: {exc}")
+            return ""