Bladeren bron

Native deployment, zero-SSH experience, WiFi hotspot, and security modes (#121)

* Add app security, settings/state split, and browse page improvements

- Split state.json into state.json (runtime) + settings.json (user config) with auto-migration
- Add security feature: Full Lockdown and Play Only modes with SHA-256 password
- Play Only mode hides Settings, Table Control, upload, and playlist editing
- Add lock/unlock button in header bar when security is active
- Base64 encode MQTT password in settings.json
- Add play count and last run time stats to pattern cards in browse page
- Default Most Played and Last Played sort to descending order
- Equalize Select dropdown left/right padding
- Fix TableControlPage tests: reset Zustand store in beforeEach to prevent
  real WebSocket status data from leaking into test environment
- Add settings.json to .gitignore
- Bump version to 4.1.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add skip button to MQTT/Home Assistant integration

Expose the existing skip functionality (skip to next pattern in a
playlist) as a Home Assistant button entity via MQTT. The button is
only available when a playlist is actively running.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* update table detection

* Add LCD screen brightness control to Touch LED page

Auto-detects sysfs backlight path and max_brightness on startup,
exposes slider on LED Control page, persists setting across reboots,
and restores saved brightness (not max) when waking from screen timeout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix dw start URL to use frontend port instead of backend port

The access URL was hardcoded to :8080 (backend), but users access
the app through the frontend nginx container on FRONTEND_PORT (default 80).
Also make apt non-interactive in setup-pi.sh to avoid config prompts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* remove .env

* Add home-on-connect toggle and normalize pattern theta offsets

- Add "Home on Connect" setting to skip automatic homing on startup while
  keeping auto-connect. Users can trigger homing manually later.
- Normalize large theta offsets in patterns to avoid unnecessary revolutions
  at pattern start (handles community patterns starting at e.g. 498 rad).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* update config

* Add autohotspot system for plug-and-play WiFi setup

When no known WiFi network is found on boot, the Pi creates a "Dune Weaver"
hotspot. Users connect, configure WiFi via the app (captive portal), and the
Pi reboots into client mode. Targets Pi OS Trixie (NetworkManager-based).

New files:
- wifi/autohotspot: Boot-time script that scans for known WiFi (30s) then
  falls back to hotspot mode with dnsmasq DNS redirect
- wifi/autohotspot.service: systemd unit (after NM, before Docker)
- wifi/dnsmasq-hotspot.conf: DNS hijack config for captive portal detection
- wifi/setup-wifi.sh: One-time installer for dnsmasq, NM hotspot profile
- modules/wifi/: Backend module with WiFi management API endpoints
  (status, networks, connect, forget, saved) via nmcli/nsenter
- frontend WiFiSetupPage: Network scanning, connect form, saved networks

Modified:
- main.py: Mount WiFi API router
- dw: Add `dw wifi` subcommand (status, scan, connect, hotspot, setup)
- setup-pi.sh: Add --no-hotspot flag, integrate autohotspot setup step
- App.tsx: Add /wifi-setup route
- SettingsPage.tsx: Add WiFi accordion section linking to setup page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix captive portal: use NM built-in dnsmasq + add portal detection handlers

Two fixes for hotspot mode:

1. DNS redirect: NetworkManager's shared mode runs its own dnsmasq for
   DHCP+DNS, which blocked our separate dnsmasq from binding port 53.
   Now we install our config into /etc/NetworkManager/dnsmasq-shared.d/
   so NM's own dnsmasq handles the DNS redirect. Removed standalone
   dnsmasq dependency entirely.

2. Captive portal blank page: Added backend endpoints for well-known
   captive portal detection paths (/hotspot-detect.html, /generate_204,
   etc.) that serve a minimal HTML redirect to /wifi-setup. Added
   nginx proxy rules and vite proxy for these paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix nmcli key-mgmt error: use explicit connection profiles

'nmcli dev wifi connect' fails on Pi Trixie with 'key-mgmt: property
is missing' for WPA networks. Switch to explicit profile creation
(nmcli con add + nmcli con up) with wifi-sec.key-mgmt wpa-psk set
explicitly. Applied to both dw CLI and backend Python module.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix Docker: use nsenter for hostname, improve command error logging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix nsenter PATH: nmcli not found in minimal PID 1 environment

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix nsenter: use host shell for binary resolution

nsenter resolves binaries from container filesystem even when entering
host namespaces. Use /bin/sh -c to execute via the host's shell,
which resolves nmcli from the host's PATH.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix host commands: use chroot /proc/1/root instead of nsenter

nsenter enters namespaces but binary resolution still uses the container
filesystem. chroot into /proc/1/root (host root, accessible in privileged
mode) resolves commands from the host where nmcli is installed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix Docker WiFi: install nmcli in image, use D-Bus instead of chroot

The chroot/nsenter approach to run host nmcli from Docker failed because
PID 1 in the container is uvicorn, not the host init. Instead, install
network-manager in the Docker image so nmcli communicates with the host's
NetworkManager over the already-mounted D-Bus socket — same pattern as
systemctl poweroff.

Also detect WiFi mode from NM state instead of reading /tmp file, mount
/etc/hostname for host identity, and use systemctl reboot for reboots.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix WiFi mode detection: use active connections instead of dev show

nmcli dev show wlan0 may not report GENERAL.CONNECTION reliably in AP
mode across NM versions. Switch to nmcli con show --active which lists
active connections by name/type/device — works consistently for both
hotspot and client mode. Add logging to aid debugging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Show WiFi password form inline below selected network

Move the connection form (password + connect button) from the bottom of
the network list to directly below the selected network item. Also
suppress noisy warning when deleting non-existent connection profiles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add WiFi Setup shortcut to hamburger menu

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Use modal dialog for WiFi network connection

Tapping a network now opens a dialog with password field and connect
button, instead of inline expansion that pushed content off-screen.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Rename Pre-Execution Action to Clear, add hover descriptions

Rename the label from "Pre-Execution Action" to "Clear" for clarity.
Add tooltip descriptions on hover explaining each clear mode. Also add
descriptions to the playlist page's clear dropdown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Replace clear mode tooltips with permanent description text

Show a persistent text below the clear options that explains the
currently selected mode, instead of hover-only tooltips.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Improve WiFi page mobile layout and fix desktop menu close

- Compact card layout: replace CardHeader/CardTitle with inline headings
- Status items use flex-wrap with Host right-aligned on desktop
- Tighter spacing throughout for mobile viewports
- Icon-only refresh button for network scan
- Move Raspberry Pi note inside status card
- Desktop hamburger menu now closes on WiFi Setup click

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Trigger autohotspot fallback when active network is forgotten

When the user forgets the currently connected WiFi network, re-run
the autohotspot script to fall back to hotspot mode. In Docker where
the script isn't available, directly activate the hotspot profile
via nmcli over D-Bus.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add periodic autohotspot check via systemd timer

The autohotspot script now supports two modes:
- Boot mode (default): 30s scan with retries at startup
- Check mode (--check): quick single-pass check every 60s

A systemd timer (autohotspot-check.timer) runs --check every 60s to:
- Fall back to hotspot if WiFi drops unexpectedly
- Reconnect to known WiFi when it becomes available in hotspot mode
- Recover from any disconnected state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Self-host fonts and icons for offline/hotspot mode

Replace Google Fonts CDN imports with bundled npm packages:
- @fontsource/plus-jakarta-sans (400-700)
- @fontsource/noto-sans (400-700)
- material-icons (filled + outlined)

Font files are now included in the Vite build output, so the
app renders correctly in hotspot mode with no internet.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Bypass connection overlay in captive portal mode

Apple's captive portal webview (captive.apple.com) blocks WebSocket
connections, causing the app to get stuck on "Connecting to Backend".

Detect captive portal context by checking window.location.hostname
against known OS probe domains. When detected:
- Skip the WebSocket connection blocking overlay
- Auto-redirect to /wifi-setup page

HTTP API calls still work fine through DNS redirect, so the WiFi
setup page functions normally in the captive portal webview.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Use light theme for captive portal splash and WiFi setup pages

- Backend captive portal HTML: switch from dark (#0a0a0a) to light
  (#f8fafc) styling to match the OS captive portal webview chrome
- Frontend: force light mode when in captive portal context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Install/update WiFi host scripts during dw update and dw checkout

- dw update: if autohotspot not installed, run full setup-wifi.sh;
  if already installed, update script and service files to latest
- dw checkout: install WiFi scripts if branch has them but host doesn't
- setup-pi.sh already handles fresh installs via setup_autohotspot()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add manual WiFi entry, hotspot password, and forget confirmation

- Add "+" button to manually enter hidden network SSID and password
- Add hotspot password management (GET/POST /api/wifi/hotspot/password)
- Add confirmation dialog when forgetting saved networks with
  context-aware warnings about hotspot fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add save-for-later WiFi option, reorder page, fix playlist preview

WiFi setup:
- Add POST /api/wifi/save to save network without connecting
- Manual entry dialog shows Save and Connect buttons side by side
- Move Networks scan list to bottom of page
- Saved Networks and Hotspot Password cards shown first

Playlist:
- Fix wrong preview when playlist starts with clear pattern
  (removed premature current_playing_file assignment in playlist_manager)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Remove reboot after WiFi connect (nmcli switches live)

- Remove _schedule_reboot and reboot logic from connect_to_network
- Remove rebooting screen from frontend
- After connecting, refresh status/saved/networks in-place
- Update help text to remove reboot mention

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix Still Sands LED control: pause loops, clear pattern flicker, idle monitor

- Monitor Still Sands transitions during inter-pattern pauses so LEDs turn
  off when a quiet period starts mid-pause (e.g. 24h pause time)
- Skip idle LED trigger after clear pattern to prevent ~0.3s flicker
- Add background monitor to turn off LEDs when table is idle and Still
  Sands period begins (no playlist running)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix LEDs not turning back on when Still Sands period ends

Power LEDs back on unconditionally when Still Sands ends, regardless of
whether a playing effect is configured. Previously the set_power(1) call
was gated behind should_trigger_led, so DW LED users without a playing
effect configured would have LEDs stay off permanently after a quiet
period ended.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add LED control mode: Manual/HA vs DW Automated

Users controlling LEDs via Home Assistant or manually can now prevent
Dune Weaver from overriding their settings. Manual mode disables
auto-switching effects on play/idle, idle timeout, and Still Sands
turn-on (turn-off still works). Defaults to "automated" for backward
compatibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add Setup page, FluidNC config editor, gear ratio override, firmware version warning

- Add SetupPage with calibration wizard and FluidNC config editor
- Add FluidNC config read/write/command API endpoints
- Add gear ratio override setting (persisted, highest priority)
- Add firmware version detection in status WebSocket payload
- Add persistent header warning when FluidNC version doesn't match expected
  (v3.8.3 for mini, v3.9.5 for other tables)
- Clean version parsing with regex to strip framing characters
- Move Home on Connect to Connection section, add hardware setup link

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Migrate from Docker to native systemd + nginx deployment

Remove Docker containers (backend + nginx) in favor of running directly
on the host with systemd service management and system nginx. This
eliminates 9 volume mounts, privileged mode, and workarounds for
timezone, shutdown, WiFi, and self-update that Docker isolation required.

- Add dune-weaver.service (systemd unit with CPU pinning)
- Add nginx/dune-weaver.conf (site config, replaces nginx.conf)
- Rewrite setup-pi.sh: native-only flow with venv, Node.js, nginx, sudoers
- Rewrite dw CLI: remove all Docker branches, systemd-only commands
- Update backend: systemctl restart instead of docker restart
- Update frontend: "Restart Docker" → "Restart"
- Delete Dockerfile, frontend/Dockerfile, docker-compose.yml, nginx.conf,
  .github/workflows/docker-publish.yml

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix libgpiod package name for Debian Trixie

libgpiod2 was renamed to libgpiod3 in Trixie (soname bump).
Use libgpiod-dev + gpiod instead, which pulls the correct shared
library as a dependency and works across Bookworm and Trixie.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add swig to build deps for lgpio on Python 3.13

Trixie ships Python 3.13 which has no pre-built lgpio wheel,
so pip builds from source and needs swig to generate the C bindings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add missing build deps and fix sudo user handling

- Add scons (rpi-ws281x build) and curl (nodesource download)
- Resolve REAL_USER/REAL_HOME via SUDO_USER so running
  `sudo bash setup-pi.sh` still installs to the right user's
  home dir and creates the systemd service for that user
- Run venv, pip, npm as the real user (not root) via run_as_user()
- Clone repo as real user to avoid root-owned files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Build lgpio C library from source for Trixie

Trixie dropped the liblgpio system package. The Python lgpio wheel
needs the C library (liblgpio.so) to link against, so we build it
from joan2937/lg source before pip install. Includes ldconfig cache
update and idempotent check via ldconfig -p.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Symlink liblgpio into system lib path for pip builds

joan2937/lg installs to /usr/local/lib/ but pip's lgpio build
hardcodes -L/usr/lib/aarch64-linux-gnu/. Symlink bridges the gap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix lgpio symlink: always check, don't skip with early return

The symlink code was inside the build-from-source block, so when
install_lgpio detected the library was already built (from a previous
run), it returned early and never created the symlinks that pip needs
to find liblgpio.so during wheel compilation.

Now the build and symlink steps are independent — symlinks are always
checked regardless of whether the C library was just built or already
existed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix lgpio: create unversioned .so symlink from Pi OS's .so.1

Raspberry Pi OS Trixie ships liblgpio.so.1 but no liblgpio.so
(normally a -dev package creates this). The build-time linker
needs the unversioned name (-llgpio -> liblgpio.so). Only falls
back to building from source if no versioned library exists.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Grant nginx traversal permission to static files directory

nginx runs as www-data which can't traverse the user's home directory
(default 700/750 perms). Add chmod o+x on each parent directory up to
root so nginx can reach INSTALL_DIR/static/dist/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add UART configuration step to setup-pi.sh

Asks user whether they connect to the controller via USB or UART GPIO
pins. For UART: adds dtoverlay and enable_uart to config.txt, disables
serial console via raspi-config nonint. For USB: offers to clean up
any existing UART config from a previous setup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Move UART prompt to start of setup before long-running installs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix reboot prompt message to be generic (not WiFi-specific)

NEEDS_REBOOT can be set by UART config or WiFi fix, so use a
generic message that covers both cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Update UART prompt: remove 'most common', add Pi 3B+ USB warning

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Change UART prompt color from blue to green for readability

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add raspi-config guidance before UART setup, wait for user

Show the user what to select (No for console, Yes for hardware),
wait for Enter, then run raspi-config nonint and config.txt changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix UART prompt: use echo -e for colors, add visible banner

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Suppress npm ci deprecation warnings and audit noise

Use --loglevel=error to hide warn-level deprecation notices
and vulnerability reports during install/update.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Skip tsc type-checking during Pi builds to avoid OOM

Use `npx vite build` instead of `npm run build` (which runs
`tsc -b && vite build`). TypeScript's type checker loads the
entire type graph into memory, exceeding the Pi's heap limit.
Vite's esbuild handles transpilation with minimal memory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add temporary swap for Pi Zero 2W builds (512MB RAM)

Detect low-memory boards (<1GB) and create a 1GB swap file before
npm ci / vite build, then remove it after. Without this, npm ci
hangs or gets OOM-killed on the Pi Zero 2W.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Commit pre-built frontend, remove Node.js from Pi deployment

The Pi Zero 2W (512MB RAM) can't survive npm ci + vite build.
Since static/dist/ is only ~3.6MB / 98 files, commit it to git
and eliminate Node.js entirely from the Pi setup and update flow.

- Un-ignore static/dist/ and track pre-built frontend assets
- Remove install_nodejs(), enable_swap()/disable_swap() from setup-pi.sh
- Remove frontend build steps from setup-pi.sh, dw update, dw checkout
- Add cleanup_unused() to purge Node.js and Docker from prior installs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add in-app software update with fun waiting dialog

Replace SSH instructions with a one-click "Update Now" button that fires
`dw update` as a detached subprocess and shows an animated dialog with
rotating sand-themed messages while polling for the service to come back.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix NeoPixel SEGV crash loop by checking /dev/mem permissions

The rpi_ws281x C library segfaults when it can't access /dev/mem,
which Python try/except can't catch. Guard with an os.access() check
so the service degrades gracefully (no LEDs) instead of crash-looping.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Revert /dev/mem guard (broke LEDs), simplify update dialog

The /dev/mem permission check was too strict — rpi_ws281x can also use
/dev/gpiomem which the gpio group provides. Revert the guard to restore
LED functionality. Also remove auto-polling from update dialog and ask
the user to reload manually instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Ask user to reload page after update completes

Add reload reminder to `dw update` CLI output and update the in-app
update dialog message to match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Test NeoPixel .show() during init to prevent SEGV crash loop

The NeoPixel constructor succeeds without root, but .show() needs DMA
via /dev/mem. The first failed .show() raises a Python exception, but
leaves the C library in a corrupted state — subsequent calls SEGV.
Test .show() during init and deinit on failure so the effect loop
thread never touches a broken NeoPixel object.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Grant CAP_SYS_RAWIO for NeoPixel DMA access without root

The rpi_ws281x library needs /dev/mem access for DMA-based LED control.
Add AmbientCapabilities=CAP_SYS_RAWIO and kmem group to the service
file so it works without running the whole service as root.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Revert LED controller and service file to working state (d2037ee)

Fully revert dw_led_controller.py and dune-weaver.service to match
commit d2037ee where LEDs were working. The .show() test and service
capability changes were causing issues.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Run service as root for NeoPixel DMA access

The rpi_ws281x library needs /dev/mem for DMA-based LED control, which
requires root. Any service restart was causing a SEGV crash loop
because the process ran as a regular user.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Log dw update output to update.log for debugging

Write subprocess output to update.log instead of /dev/null so we can
see why the update process isn't doing anything.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix git 'dubious ownership' when dw update runs as root

The service runs as root for NeoPixel DMA, so dw update inherits root.
Git refuses to operate on a user-owned repo as root. Add safe.directory
before git pull to allow it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Use WebSocket state for update dialog instead of manual polling

Track the existing /ws/status connection: when it drops during an
update the dialog shows the waiting animation, and when it reconnects
it shows 'Update complete!' then auto-reloads after 2 seconds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix SK6812 RGBW LED support and add frontend build to pre-commit hook

Write 4-tuples (r,g,b,w) for RGBW strips in Segment.set_pixel_color()
and use correct off-color fill tuples in DWLEDController. Previously,
selecting GRBW/RGBW pixel order in settings would crash the effect loop
because NeoPixel enforces tuple length matching bpp.

Also widen pre-commit hook to trigger frontend build on any UI file
change (CSS, config, HTML) and auto-stage static/dist/ output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Expose touch screen power and brightness to Home Assistant via MQTT

Add ScreenController module that auto-detects the Linux sysfs backlight
device and provides power/brightness control. Register as MQTT discovery
entities (switch + number slider) following the existing LED entity pattern.
Gracefully no-ops on machines without /sys/class/backlight.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Restrict /setup and /wifi-setup in Play Only security mode

These pages were accessible even in Play Only mode, allowing someone to
change motor configuration or WiFi settings without the password.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Tuan Nguyen 4 maanden geleden
bovenliggende
commit
065e8d2b07
100 gewijzigde bestanden met toevoegingen van 5308 en 971 verwijderingen
  1. 0 43
      .env.example
  2. 0 111
      .github/workflows/docker-publish.yml
  3. 1 1
      .gitignore
  4. 0 40
      Dockerfile
  5. 1 1
      VERSION
  6. 0 45
      docker-compose.yml
  7. 126 17
      dune-weaver-touch/backend.py
  8. 1 1
      dune-weaver-touch/dune-weaver-touch.service
  9. 53 0
      dune-weaver-touch/qml/pages/LedControlPage.qml
  10. 17 0
      dune-weaver.service
  11. 183 116
      dw
  12. 1 1
      firmware/dune_weaver/config.yaml
  13. 1 1
      firmware/dune_weaver_gold/config (DAGGKAPRIFOL).yaml
  14. 1 1
      firmware/dune_weaver_gold/config.yaml
  15. 1 1
      firmware/dune_weaver_mini/config.yaml
  16. 1 1
      firmware/dune_weaver_mini_pro/config (28BYJ-48 variant).yaml
  17. 1 1
      firmware/dune_weaver_mini_pro/config.yaml
  18. 1 1
      firmware/dune_weaver_pro/config.yaml
  19. 0 29
      frontend/Dockerfile
  20. 27 0
      frontend/package-lock.json
  21. 3 0
      frontend/package.json
  22. 4 0
      frontend/src/App.tsx
  23. 3 0
      frontend/src/__tests__/pages/TableControlPage.test.tsx
  24. 193 0
      frontend/src/components/UpdateDialog.tsx
  25. 258 16
      frontend/src/components/layout/Layout.tsx
  26. 1 1
      frontend/src/components/ui/select.tsx
  27. 1 5
      frontend/src/index.css
  28. 7 7
      frontend/src/lib/types.ts
  29. 13 0
      frontend/src/main.tsx
  30. 165 109
      frontend/src/pages/BrowsePage.tsx
  31. 92 13
      frontend/src/pages/LEDPage.tsx
  32. 135 60
      frontend/src/pages/PlaylistsPage.tsx
  33. 294 7
      frontend/src/pages/SettingsPage.tsx
  34. 914 0
      frontend/src/pages/SetupPage.tsx
  35. 630 0
      frontend/src/pages/WiFiSetupPage.tsx
  36. 2 0
      frontend/src/stores/useStatusStore.ts
  37. 4 0
      frontend/vite.config.ts
  38. 396 48
      main.py
  39. 28 16
      modules/connection/connection_manager.py
  40. 337 0
      modules/connection/fluidnc_config.py
  41. 91 38
      modules/core/pattern_manager.py
  42. 0 3
      modules/core/playlist_manager.py
  43. 137 33
      modules/core/state.py
  44. 5 3
      modules/led/dw_led_controller.py
  45. 8 2
      modules/led/dw_leds/segment.py
  46. 99 2
      modules/mqtt/handler.py
  47. 4 0
      modules/mqtt/utils.py
  48. 0 0
      modules/screen/__init__.py
  49. 125 0
      modules/screen/screen_controller.py
  50. 15 36
      modules/update/update_manager.py
  51. 1 0
      modules/wifi/__init__.py
  52. 447 0
      modules/wifi/manager.py
  53. 153 0
      modules/wifi/router.py
  54. 61 52
      nginx/dune-weaver.conf
  55. 17 5
      scripts/pre-commit
  56. 249 104
      setup-pi.sh
  57. 0 0
      static/dist/assets/index-bDuzJAeR.js
  58. 0 0
      static/dist/assets/index-iGL2FzQP.css
  59. BIN
      static/dist/assets/material-icons-Dr0goTwe.woff
  60. BIN
      static/dist/assets/material-icons-kAwBdRge.woff2
  61. BIN
      static/dist/assets/material-icons-outlined-BpWbwl2n.woff
  62. BIN
      static/dist/assets/material-icons-outlined-DZhiGvEA.woff2
  63. BIN
      static/dist/assets/noto-sans-cyrillic-400-normal-BDYvNhAR.woff
  64. BIN
      static/dist/assets/noto-sans-cyrillic-400-normal-CHP_ranX.woff2
  65. BIN
      static/dist/assets/noto-sans-cyrillic-500-normal-9zZ_jNuA.woff2
  66. BIN
      static/dist/assets/noto-sans-cyrillic-500-normal-BxM0HQjg.woff
  67. BIN
      static/dist/assets/noto-sans-cyrillic-600-normal-BRIw9PIU.woff
  68. BIN
      static/dist/assets/noto-sans-cyrillic-600-normal-KpAl9xZA.woff2
  69. BIN
      static/dist/assets/noto-sans-cyrillic-700-normal-D8UNalU-.woff
  70. BIN
      static/dist/assets/noto-sans-cyrillic-700-normal-DYZmzPmX.woff2
  71. BIN
      static/dist/assets/noto-sans-cyrillic-ext-400-normal-BjDhGU6t.woff2
  72. BIN
      static/dist/assets/noto-sans-cyrillic-ext-400-normal-d9FrwbiD.woff
  73. BIN
      static/dist/assets/noto-sans-cyrillic-ext-500-normal-Bw4G4pNe.woff
  74. BIN
      static/dist/assets/noto-sans-cyrillic-ext-500-normal-CuwgPeWW.woff2
  75. BIN
      static/dist/assets/noto-sans-cyrillic-ext-600-normal-Cwz1867h.woff
  76. BIN
      static/dist/assets/noto-sans-cyrillic-ext-600-normal-DlWr7wnj.woff2
  77. BIN
      static/dist/assets/noto-sans-cyrillic-ext-700-normal-D83T7awq.woff
  78. BIN
      static/dist/assets/noto-sans-cyrillic-ext-700-normal-OK-fZO_i.woff2
  79. BIN
      static/dist/assets/noto-sans-devanagari-400-normal-C3FccbrF.woff2
  80. BIN
      static/dist/assets/noto-sans-devanagari-400-normal-g9fsM2jL.woff
  81. BIN
      static/dist/assets/noto-sans-devanagari-500-normal-B62tDw8r.woff
  82. BIN
      static/dist/assets/noto-sans-devanagari-500-normal-VG35fhMU.woff2
  83. BIN
      static/dist/assets/noto-sans-devanagari-600-normal-Bly84zfI.woff
  84. BIN
      static/dist/assets/noto-sans-devanagari-600-normal-Ewgvvq1j.woff2
  85. BIN
      static/dist/assets/noto-sans-devanagari-700-normal-CT12sGlc.woff
  86. BIN
      static/dist/assets/noto-sans-devanagari-700-normal-DVs0dmkg.woff2
  87. BIN
      static/dist/assets/noto-sans-greek-400-normal-Be2BcUUc.woff
  88. BIN
      static/dist/assets/noto-sans-greek-400-normal-DCESwnT1.woff2
  89. BIN
      static/dist/assets/noto-sans-greek-500-normal-BAAA_uK7.woff
  90. BIN
      static/dist/assets/noto-sans-greek-500-normal-D_0l3T9g.woff2
  91. BIN
      static/dist/assets/noto-sans-greek-600-normal-C0bz_iEd.woff
  92. BIN
      static/dist/assets/noto-sans-greek-600-normal-CT9U7UAD.woff2
  93. BIN
      static/dist/assets/noto-sans-greek-700-normal-DDNJsN3F.woff
  94. BIN
      static/dist/assets/noto-sans-greek-700-normal-x3kNWF-0.woff2
  95. BIN
      static/dist/assets/noto-sans-greek-ext-400-normal-L11LEhi4.woff
  96. BIN
      static/dist/assets/noto-sans-greek-ext-400-normal-i2oSBwXz.woff2
  97. BIN
      static/dist/assets/noto-sans-greek-ext-500-normal-CbZNESfr.woff
  98. BIN
      static/dist/assets/noto-sans-greek-ext-500-normal-D6bOGD5V.woff2
  99. BIN
      static/dist/assets/noto-sans-greek-ext-600-normal-B4z4a2vi.woff2
  100. BIN
      static/dist/assets/noto-sans-greek-ext-600-normal-BjvVOqxV.woff

+ 0 - 43
.env.example

@@ -1,43 +0,0 @@
-# Frontend port (default: 80)
-# Change this if port 80 is blocked or already in use on your network
-# FRONTEND_PORT=8000
-
-# MQTT Configuration
-# this configuration is needed to activat ethe integration with Home assistant
-# If you leave MQTT_BROKER empty, the integration will be skipped, and it won't affect the app in any way
-# to correctly configure the integration, you must copy this .env.example to a fine named just .env
-# and then fill all the fields
-
-# the ip address of your mqtt server
-# if you have the mosquitto addon, this is the same as the Home assistant IP
-MQTT_BROKER=
-# the port of the mqtt broker, 1883 is the default port
-MQTT_PORT=1883
-# the username and password can either be the ones of a Home Assistant user (if you're using the addon)
-# or a specific mqtt user (see https://github.com/home-assistant/addons/blob/master/mosquitto/DOCS.md)
-MQTT_USERNAME=
-MQTT_PASSWORD=
-
-# Status update interval in seconds
-# most of the updates are done on demand anyway, so you probably don't need to edit this
-MQTT_STATUS_INTERVAL=30  
-
-# Home Assistant MQTT Discovery
-# this is usually homeassistant
-# if you didn't edit it yourself in your install, leave as is
-MQTT_DISCOVERY_PREFIX=homeassistant
-
-
-
-# unique ids: you need to edit these only if you have multiple tables that you want to connect to Home assistant
-# unique id of the devvice in Home Assistant
-HA_DEVICE_ID=dune_weaver
-# unique id of the device in mqtt
-MQTT_CLIENT_ID=dune_weaver
-# display name of the table in Home assistant
-HA_DEVICE_NAME=Dune Weaver
-
-# MQTT Topics
-# you can probably leave this as is
-MQTT_STATUS_TOPIC=dune_weaver/status
-MQTT_COMMAND_TOPIC=dune_weaver/command

+ 0 - 111
.github/workflows/docker-publish.yml

@@ -1,111 +0,0 @@
-name: Docker
-
-on:
-  push:
-    branches: [ "main" ]
-    paths:
-      - 'Dockerfile'
-      - 'frontend/Dockerfile'
-      - 'frontend/**'
-      - 'requirements.txt'
-      - 'VERSION'
-      - '**.py'
-  pull_request:
-    branches: [ "main" ]
-    paths:
-      - 'Dockerfile'
-      - 'frontend/Dockerfile'
-      - 'frontend/**'
-      - 'requirements.txt'
-      - 'VERSION'
-      - '**.py'
-  # Allow manual trigger on any branch
-  workflow_dispatch:
-    inputs:
-      branch:
-        description: 'Branch to build from'
-        required: false
-        default: ''
-
-env:
-  REGISTRY: ghcr.io
-
-jobs:
-  build-backend:
-    if: github.event_name != 'pull_request' || github.actor == 'tuanchris'
-    runs-on: ubuntu-latest
-    permissions:
-      contents: read
-      packages: write
-      id-token: write
-
-    steps:
-      - name: Checkout repository
-        uses: actions/checkout@v4
-
-      - name: Set up Docker Buildx
-        uses: docker/setup-buildx-action@v3
-
-      - name: Log into registry ${{ env.REGISTRY }}
-        uses: docker/login-action@v3
-        with:
-          registry: ${{ env.REGISTRY }}
-          username: ${{ github.actor }}
-          password: ${{ secrets.GITHUB_TOKEN }}
-
-      - name: Extract Docker metadata
-        id: meta
-        uses: docker/metadata-action@v5
-        with:
-          images: ${{ env.REGISTRY }}/${{ github.repository }}
-
-      - name: Build and push backend image
-        uses: docker/build-push-action@v5
-        with:
-          context: .
-          push: true
-          tags: ${{ steps.meta.outputs.tags }}
-          labels: ${{ steps.meta.outputs.labels }}
-          platforms: linux/amd64,linux/arm64
-          cache-from: type=gha,scope=backend
-          cache-to: type=gha,mode=max,scope=backend
-
-  build-frontend:
-    if: github.event_name != 'pull_request' || github.actor == 'tuanchris'
-    runs-on: ubuntu-latest
-    permissions:
-      contents: read
-      packages: write
-      id-token: write
-
-    steps:
-      - name: Checkout repository
-        uses: actions/checkout@v4
-
-      - name: Set up Docker Buildx
-        uses: docker/setup-buildx-action@v3
-
-      - name: Log into registry ${{ env.REGISTRY }}
-        uses: docker/login-action@v3
-        with:
-          registry: ${{ env.REGISTRY }}
-          username: ${{ github.actor }}
-          password: ${{ secrets.GITHUB_TOKEN }}
-
-      - name: Extract Docker metadata
-        id: meta
-        uses: docker/metadata-action@v5
-        with:
-          images: ${{ env.REGISTRY }}/${{ github.repository }}-frontend
-
-      - name: Build and push frontend image
-        uses: docker/build-push-action@v5
-        with:
-          context: ./frontend
-          file: ./frontend/Dockerfile
-          push: true
-          tags: ${{ steps.meta.outputs.tags }}
-          labels: ${{ steps.meta.outputs.labels }}
-          platforms: linux/amd64,linux/arm64
-          cache-from: type=gha,scope=frontend
-          cache-to: type=gha,mode=max,scope=frontend

+ 1 - 1
.gitignore

@@ -6,6 +6,7 @@ __pycache__/
 .idea
 # Ignore state and data JSON files, but not package.json
 state.json
+settings.json
 playlists.json
 metadata_cache.json
 tsconfig.json
@@ -29,7 +30,6 @@ node_modules/
 static/custom/*
 !static/custom/.gitkeep
 .claude/
-static/dist/
 .planning/
 .coverage
 # Frontend test artifacts

+ 0 - 40
Dockerfile

@@ -1,40 +0,0 @@
-# Backend-only Dockerfile
-FROM python:3.11-slim-bookworm
-
-# Faster, repeatable builds
-ENV PYTHONDONTWRITEBYTECODE=1 \
-    PYTHONUNBUFFERED=1 \
-    PIP_NO_CACHE_DIR=1 \
-    PIP_DISABLE_PIP_VERSION_CHECK=1
-
-WORKDIR /app
-
-COPY requirements.txt ./
-RUN apt-get update && apt-get install -y --no-install-recommends \
-        gcc g++ make libjpeg-dev zlib1g-dev git \
-        # GPIO/NeoPixel support for DW LEDs
-        python3-dev python3-pip \
-        libgpiod2 libgpiod-dev \
-        scons \
-        systemd \
-        # Docker CLI for container self-restart/update
-        ca-certificates curl gnupg \
-    && pip install --upgrade pip \
-    && pip install --no-cache-dir -r requirements.txt \
-    # Install Docker CLI from official Docker repo
-    && install -m 0755 -d /etc/apt/keyrings \
-    && curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
-    && chmod a+r /etc/apt/keyrings/docker.gpg \
-    && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian bookworm stable" > /etc/apt/sources.list.d/docker.list \
-    && apt-get update \
-    && apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
-    && apt-get purge -y gcc g++ make scons \
-    && rm -rf /var/lib/apt/lists/*
-
-# Copy backend code
-COPY . .
-
-# Expose backend API port
-EXPOSE 8080
-
-CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]

+ 1 - 1
VERSION

@@ -1 +1 @@
-4.0.5
+4.1.0

+ 0 - 45
docker-compose.yml

@@ -1,45 +0,0 @@
-services:
-  frontend:
-    build:
-      context: ./frontend
-      dockerfile: Dockerfile
-    image: ghcr.io/tuanchris/dune-weaver-frontend:${IMAGE_TAG:-main}
-    restart: always
-    ports:
-      - "${FRONTEND_PORT:-80}:80"
-    volumes:
-      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
-    depends_on:
-      - backend
-    container_name: dune-weaver-frontend
-
-  backend:
-    build: .
-    image: ghcr.io/tuanchris/dune-weaver:${IMAGE_TAG:-main}
-    restart: always
-    # Pin motion-critical backend to cores 0-2 (Raspberry Pi 4/5 has cores 0-3)
-    # This prevents CPU contention from touch app blocking I/O calls
-    cpuset: "0,1,2"
-    ports:
-      - "8080:8080"
-    # Environment variables for testing (uncomment to enable):
-    # environment:
-    #   FORCE_UPDATE_AVAILABLE: "1"        # Always show update available
-    #   FAKE_LATEST_VERSION: "99.0.0"      # Fake a newer version
-    volumes:
-      # Mount entire app directory for persistence
-      - .:/app
-      # Mount Docker socket for container self-restart/update
-      - /var/run/docker.sock:/var/run/docker.sock
-      # Mount timezone file from host for scheduling features
-      - /etc/timezone:/etc/host-timezone:ro
-      # Mount systemd for host shutdown capability
-      - /run/systemd/system:/run/systemd/system:ro
-      - /var/run/dbus/system_bus_socket:/var/run/dbus/system_bus_socket:ro
-      - /sys/fs/cgroup:/sys/fs/cgroup:ro
-      # Mount GPIO for DW LEDs and Desert Compass (reed switch)
-      - /sys:/sys
-      # Mount /dev for serial port access (devices may not exist at start time)
-      - /dev:/dev
-    privileged: true
-    container_name: dune-weaver-backend

+ 126 - 17
dune-weaver-touch/backend.py

@@ -99,6 +99,9 @@ class Backend(QObject):
     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__()
@@ -127,6 +130,11 @@ class Backend(QObject):
         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
@@ -164,6 +172,9 @@ class Backend(QObject):
         # 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
@@ -825,6 +836,12 @@ class Backend(QObject):
                 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)
@@ -843,11 +860,14 @@ class Backend(QObject):
         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.1'
+                'version': '1.2'
             }
             with open(self.SETTINGS_FILE, 'w') as f:
                 json.dump(settings, f, indent=2)
@@ -1149,31 +1169,36 @@ class Backend(QObject):
                 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'], 
+                    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 matching the script
-                    # Unblank framebuffer and restore backlight
-                    max_brightness = 255
-                    try:
-                        result = subprocess.run(['cat', '/sys/class/backlight/*/max_brightness'], 
-                                              shell=True, capture_output=True, text=True, timeout=2)
-                        if result.returncode == 0 and result.stdout.strip():
-                            max_brightness = int(result.stdout.strip())
-                    except Exception:
-                        pass
-                    
-                    subprocess.run(['sudo', 'sh', '-c', 
-                                  f'echo 0 > /sys/class/graphics/fb0/blank && echo {max_brightness} > /sys/class/backlight/*/brightness'], 
-                                 check=False, timeout=5)
-                    logger.debug(f"Screen turned ON (manual, brightness: {max_brightness})")
+                    # 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()
@@ -1299,6 +1324,90 @@ class Backend(QObject):
     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(f"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"""

+ 1 - 1
dune-weaver-touch/dune-weaver-touch.service

@@ -17,7 +17,7 @@ Environment=QT_QPA_EGLFS_HIDECURSOR=1
 Environment=QT_QPA_EGLFS_INTEGRATION=eglfs_kms
 Environment=QT_QPA_EGLFS_KMS_ATOMIC=1
 # CPU isolation: Pin touch app to core 3, lower priority to prevent starving motion backend
-# Backend runs in Docker pinned to cores 0-2 for serial I/O timing reliability
+# Backend runs pinned to cores 0-2 for serial I/O timing reliability
 Nice=10
 CPUQuota=25%
 ExecStart=/usr/bin/taskset -c 3 /home/pi/dune-weaver-touch/venv/bin/python /home/pi/dune-weaver-touch/main.py

+ 53 - 0
dune-weaver-touch/qml/pages/LedControlPage.qml

@@ -120,6 +120,59 @@ Page {
                 anchors.margins: 5
                 spacing: 2
 
+                // Screen Brightness Section (always visible, controls Pi LCD backlight)
+                Rectangle {
+                    Layout.fillWidth: true
+                    Layout.preferredHeight: 60
+                    Layout.margins: 5
+                    radius: 8
+                    color: Components.ThemeManager.surfaceColor
+                    visible: backend && backend.lcdMaxBrightness > 0
+
+                    RowLayout {
+                        anchors.fill: parent
+                        anchors.leftMargin: 15
+                        anchors.rightMargin: 15
+                        anchors.topMargin: 10
+                        anchors.bottomMargin: 10
+                        spacing: 10
+
+                        Label {
+                            text: "\u2600"
+                            font.pixelSize: 20
+                            color: Components.ThemeManager.textSecondary
+                        }
+
+                        Slider {
+                            id: lcdBrightnessSlider
+                            Layout.fillWidth: true
+                            from: 0
+                            to: backend ? backend.lcdMaxBrightness : 255
+                            stepSize: 1
+                            value: backend ? backend.lcdBrightness : 255
+
+                            onMoved: {
+                                if (backend) {
+                                    backend.setLcdBrightness(Math.round(value))
+                                }
+                            }
+                        }
+
+                        Label {
+                            text: {
+                                var max = backend ? backend.lcdMaxBrightness : 255
+                                if (max <= 0) return "0%"
+                                return Math.round(lcdBrightnessSlider.value / max * 100) + "%"
+                            }
+                            font.pixelSize: 12
+                            font.bold: true
+                            color: Components.ThemeManager.textPrimary
+                            Layout.preferredWidth: 35
+                            horizontalAlignment: Text.AlignRight
+                        }
+                    }
+                }
+
                 // Provider Info & Power/Brightness Section
                 Rectangle {
                     Layout.fillWidth: true

+ 17 - 0
dune-weaver.service

@@ -0,0 +1,17 @@
+[Unit]
+Description=Dune Weaver Sand Table Controller
+After=network.target
+
+[Service]
+Type=simple
+User=root
+WorkingDirectory=INSTALL_DIR_PLACEHOLDER
+ExecStart=INSTALL_DIR_PLACEHOLDER/.venv/bin/python INSTALL_DIR_PLACEHOLDER/main.py
+Restart=always
+RestartSec=5
+Environment=PYTHONUNBUFFERED=1
+CPUAffinity=0 1 2
+SupplementaryGroups=dialout gpio
+
+[Install]
+WantedBy=multi-user.target

+ 183 - 116
dw

@@ -5,16 +5,17 @@
 # Usage: dw <command>
 #
 # Commands:
-#   install     Run initial setup (Docker + WiFi fix)
+#   install     Run initial setup
 #   start       Start Dune Weaver
 #   stop        Stop Dune Weaver
 #   restart     Restart Dune Weaver
 #   update      Pull latest changes and restart
 #   logs        View live logs (Ctrl+C to exit)
-#   status      Show container status
-#   shell       Open a shell in the container
-#   checkout    Switch to a branch and pull its Docker images
+#   status      Show service status
+#   shell       Open a shell with the venv activated
+#   checkout    Switch to a branch and rebuild
 #   touch       Manage touch screen app
+#   wifi        Manage WiFi and hotspot
 #   help        Show this help message
 #
 
@@ -41,19 +42,6 @@ find_install_dir() {
     fi
 }
 
-# Check if using Docker or systemd
-is_docker_mode() {
-    # Docker mode if docker-compose.yml exists and docker is available
-    [[ -f "$INSTALL_DIR/docker-compose.yml" ]] && command -v docker &> /dev/null
-}
-
-# Set IMAGE_TAG from current git branch (e.g. feature/foo -> feature-foo)
-set_image_tag() {
-    local branch
-    branch=$(git -C "$INSTALL_DIR" rev-parse --abbrev-ref HEAD)
-    export IMAGE_TAG="${branch//\//-}"
-}
-
 INSTALL_DIR=$(find_install_dir)
 
 # Check if installed
@@ -73,7 +61,7 @@ check_installed() {
 cmd_install() {
     if [[ -z "$INSTALL_DIR" ]]; then
         # Not installed, check if we're in the right directory
-        if [[ -f "./docker-compose.yml" ]] && [[ -f "./main.py" ]]; then
+        if [[ -f "./main.py" ]] && [[ -f "./requirements.txt" ]]; then
             INSTALL_DIR=$(pwd)
         else
             echo -e "${RED}Error: Run this from the dune-weaver directory${NC}"
@@ -92,47 +80,21 @@ cmd_install() {
 cmd_start() {
     check_installed
     echo -e "${BLUE}Starting Dune Weaver...${NC}"
-    cd "$INSTALL_DIR"
-
-    if is_docker_mode; then
-        set_image_tag
-        sudo -E docker compose up -d
-    else
-        sudo systemctl start dune-weaver
-    fi
-
-    local fe_port="${FRONTEND_PORT:-80}"
-    local port_suffix=""
-    [[ "$fe_port" != "80" ]] && port_suffix=":$fe_port"
-    echo -e "${GREEN}Started!${NC} Access at http://$(hostname -I | awk '{print $1}')${port_suffix}"
+    sudo systemctl start dune-weaver
+    echo -e "${GREEN}Started!${NC} Access at http://$(hostname -I | awk '{print $1}')"
 }
 
 cmd_stop() {
     check_installed
     echo -e "${BLUE}Stopping Dune Weaver...${NC}"
-    cd "$INSTALL_DIR"
-
-    if is_docker_mode; then
-        sudo docker compose down
-    else
-        sudo systemctl stop dune-weaver
-    fi
-
+    sudo systemctl stop dune-weaver
     echo -e "${GREEN}Stopped${NC}"
 }
 
 cmd_restart() {
     check_installed
     echo -e "${BLUE}Restarting Dune Weaver...${NC}"
-    cd "$INSTALL_DIR"
-
-    if is_docker_mode; then
-        set_image_tag
-        sudo -E docker compose restart
-    else
-        sudo systemctl restart dune-weaver
-    fi
-
+    sudo systemctl restart dune-weaver
     echo -e "${GREEN}Restarted${NC}"
 }
 
@@ -144,6 +106,7 @@ cmd_update() {
     # Check if we should skip the pull phase (called after re-exec)
     if [[ "$1" != "--continue" ]]; then
         echo "Pulling latest code..."
+        git config --global --add safe.directory "$INSTALL_DIR" 2>/dev/null || true
         git pull
 
         # Update dw CLI
@@ -156,29 +119,45 @@ cmd_update() {
         exec /usr/local/bin/dw update --continue
     fi
 
-    if is_docker_mode; then
-        set_image_tag
-        echo -e "Image tag: ${BLUE}${IMAGE_TAG}${NC}"
-
-        echo "Pulling latest Docker image..."
-        sudo -E docker compose pull
-
-        echo "Stopping current container..."
-        sudo docker compose down
-
-        echo "Starting with new version..."
-        sudo -E docker compose up -d --remove-orphans
-
-        echo "Cleaning up unused Docker resources..."
-        sudo docker image prune -f
-        sudo docker container prune -f
-    else
-        echo "Updating Python dependencies..."
-        source .venv/bin/activate
-        pip install -r requirements.txt
-
-        echo "Restarting service..."
-        sudo systemctl restart dune-weaver
+    echo "Updating Python dependencies..."
+    source .venv/bin/activate
+    pip install -r requirements.txt
+
+    # Update nginx config
+    echo "Updating nginx config..."
+    sudo cp "$INSTALL_DIR/nginx/dune-weaver.conf" /etc/nginx/sites-available/dune-weaver.conf
+    sudo sed -i "s|INSTALL_DIR_PLACEHOLDER|$INSTALL_DIR|g" /etc/nginx/sites-available/dune-weaver.conf
+    sudo nginx -t && sudo systemctl restart nginx
+
+    # Update systemd service
+    echo "Updating systemd service..."
+    sudo cp "$INSTALL_DIR/dune-weaver.service" /etc/systemd/system/dune-weaver.service
+    sudo sed -i "s|USER_PLACEHOLDER|$USER|g" /etc/systemd/system/dune-weaver.service
+    sudo sed -i "s|INSTALL_DIR_PLACEHOLDER|$INSTALL_DIR|g" /etc/systemd/system/dune-weaver.service
+    sudo systemctl daemon-reload
+
+    echo "Restarting service..."
+    sudo systemctl restart dune-weaver
+
+    # Install/update WiFi host scripts if not present
+    if [[ -f "$INSTALL_DIR/wifi/setup-wifi.sh" ]]; then
+        if [[ ! -f /usr/local/bin/autohotspot ]]; then
+            echo ""
+            echo -e "${BLUE}Installing autohotspot WiFi scripts...${NC}"
+            sudo bash "$INSTALL_DIR/wifi/setup-wifi.sh"
+        else
+            # Always update the script and service files to latest
+            echo "Updating WiFi host scripts..."
+            sudo cp "$INSTALL_DIR/wifi/autohotspot" /usr/local/bin/autohotspot
+            sudo chmod +x /usr/local/bin/autohotspot
+
+            # Update service/timer files
+            sudo cp "$INSTALL_DIR/wifi/autohotspot.service" /etc/systemd/system/autohotspot.service
+            sudo cp "$INSTALL_DIR/wifi/autohotspot-check.service" /etc/systemd/system/autohotspot-check.service 2>/dev/null || true
+            sudo cp "$INSTALL_DIR/wifi/autohotspot-check.timer" /etc/systemd/system/autohotspot-check.timer 2>/dev/null || true
+            sudo systemctl daemon-reload
+            sudo systemctl enable autohotspot-check.timer 2>/dev/null || true
+        fi
     fi
 
     # Update touch app if installed (check both directory and service exist)
@@ -202,52 +181,33 @@ cmd_update() {
     fi
 
     echo -e "${GREEN}Update complete!${NC}"
+    echo ""
+    echo -e "${YELLOW}Please reload the web page in your browser to see the changes.${NC}"
 }
 
 cmd_logs() {
     check_installed
-    cd "$INSTALL_DIR"
 
     # Parse optional line count (e.g., dw logs 100)
     local lines="${1:-}"
-    local follow="-f"
 
     if [[ -n "$lines" ]]; then
-        follow="--tail $lines"
-    fi
-
-    if is_docker_mode; then
-        sudo docker compose logs $follow
+        sudo journalctl -u dune-weaver -n "$lines"
     else
-        if [[ -n "$lines" ]]; then
-            sudo journalctl -u dune-weaver -n "$lines"
-        else
-            sudo journalctl -u dune-weaver -f
-        fi
+        sudo journalctl -u dune-weaver -f
     fi
 }
 
 cmd_status() {
     check_installed
-    cd "$INSTALL_DIR"
-
-    if is_docker_mode; then
-        sudo docker compose ps
-    else
-        sudo systemctl status dune-weaver
-    fi
+    sudo systemctl status dune-weaver
 }
 
 cmd_shell() {
     check_installed
     cd "$INSTALL_DIR"
-
-    if is_docker_mode; then
-        sudo docker compose exec dune-weaver /bin/bash
-    else
-        echo -e "${YELLOW}Shell not available in systemd mode${NC}"
-        echo "Use: cd $INSTALL_DIR && source .venv/bin/activate"
-    fi
+    echo -e "${BLUE}Activating venv...${NC}"
+    exec bash --init-file <(echo "source '$INSTALL_DIR/.venv/bin/activate'; cd '$INSTALL_DIR'; echo 'Dune Weaver venv activated. Type \"exit\" to leave.'")
 }
 
 # Touch app commands (systemd service)
@@ -346,45 +306,148 @@ cmd_checkout() {
 
     echo -e "Switched to branch: ${GREEN}${branch}${NC}"
 
-    # Pull and restart Docker images for the new branch
-    if is_docker_mode; then
-        set_image_tag
-        echo -e "Image tag: ${BLUE}${IMAGE_TAG}${NC}"
-
-        echo "Pulling Docker images..."
-        sudo -E docker compose pull
-
-        echo "Restarting with new images..."
-        sudo docker compose down
-        sudo -E docker compose up -d --remove-orphans
-
-        echo "Cleaning up old images..."
-        sudo docker image prune -f
-    fi
+    # Update Python dependencies
+    echo "Updating Python dependencies..."
+    source .venv/bin/activate
+    pip install -r requirements.txt
 
     # Update dw CLI from the new branch
     sudo cp "$INSTALL_DIR/dw" /usr/local/bin/dw
     sudo chmod +x /usr/local/bin/dw
 
+    # Restart service
+    sudo systemctl restart dune-weaver
+
+    # Install WiFi scripts if available on this branch but not yet installed
+    if [[ -f "$INSTALL_DIR/wifi/setup-wifi.sh" ]] && [[ ! -f /usr/local/bin/autohotspot ]]; then
+        echo ""
+        echo -e "${BLUE}Installing autohotspot WiFi scripts...${NC}"
+        sudo bash "$INSTALL_DIR/wifi/setup-wifi.sh"
+    fi
+
     echo -e "${GREEN}Checkout complete!${NC}"
 }
 
+# WiFi / Hotspot commands
+cmd_wifi() {
+    local subcmd="${1:-help}"
+
+    case "$subcmd" in
+        status)
+            check_installed
+            echo -e "${BLUE}WiFi Status${NC}"
+            echo ""
+
+            # Show current mode
+            local mode="unknown"
+            if [[ -f /tmp/dw-wifi-mode ]]; then
+                mode=$(cat /tmp/dw-wifi-mode)
+            fi
+            echo -e "Mode: ${GREEN}${mode}${NC}"
+
+            # Show connected network
+            local ssid
+            ssid=$(nmcli -t -f GENERAL.CONNECTION dev show wlan0 2>/dev/null | head -1 | cut -d: -f2)
+            if [[ -n "$ssid" && "$ssid" != "--" ]]; then
+                echo -e "Network: ${GREEN}${ssid}${NC}"
+            fi
+
+            # Show IP
+            local ip
+            ip=$(nmcli -t -f IP4.ADDRESS dev show wlan0 2>/dev/null | head -1 | cut -d: -f2 | cut -d/ -f1)
+            if [[ -n "$ip" ]]; then
+                echo -e "IP: ${GREEN}${ip}${NC}"
+            fi
+            ;;
+        scan)
+            echo -e "${BLUE}Scanning for WiFi networks...${NC}"
+            nmcli dev wifi rescan 2>/dev/null || true
+            sleep 2
+            nmcli dev wifi list
+            ;;
+        connect)
+            echo -e "${BLUE}Available WiFi networks:${NC}"
+            nmcli dev wifi rescan 2>/dev/null || true
+            sleep 2
+            nmcli dev wifi list
+            echo ""
+            read -p "Enter SSID to connect to: " ssid
+            if [[ -z "$ssid" ]]; then
+                echo -e "${RED}No SSID provided${NC}"
+                exit 1
+            fi
+            read -sp "Enter password (leave empty for open networks): " password
+            echo ""
+
+            # Delete any stale connection profile for this SSID
+            sudo nmcli con delete "$ssid" 2>/dev/null || true
+
+            if [[ -n "$password" ]]; then
+                # Create connection with explicit security settings
+                # (nmcli dev wifi connect can fail on Trixie without key-mgmt)
+                sudo nmcli con add type wifi ifname wlan0 con-name "$ssid" \
+                    ssid "$ssid" \
+                    wifi-sec.key-mgmt wpa-psk \
+                    wifi-sec.psk "$password"
+            else
+                sudo nmcli con add type wifi ifname wlan0 con-name "$ssid" \
+                    ssid "$ssid"
+            fi
+
+            echo -e "${BLUE}Connecting to '$ssid'...${NC}"
+            if sudo nmcli con up "$ssid"; then
+                echo -e "${GREEN}Connected!${NC}"
+            else
+                echo -e "${RED}Failed to connect. Check password and try again.${NC}"
+                sudo nmcli con delete "$ssid" 2>/dev/null || true
+                exit 1
+            fi
+            ;;
+        hotspot)
+            echo -e "${BLUE}Switching to hotspot mode...${NC}"
+            sudo nmcli dev disconnect wlan0 2>/dev/null || true
+            sudo nmcli con up DuneWeaver-Hotspot 2>/dev/null
+            echo "hotspot" | sudo tee /tmp/dw-wifi-mode > /dev/null
+            echo -e "${GREEN}Hotspot active!${NC} Connect to the 'Dune Weaver' WiFi network."
+            ;;
+        setup)
+            check_installed
+            cd "$INSTALL_DIR"
+            sudo bash wifi/setup-wifi.sh
+            ;;
+        help|*)
+            echo -e "${GREEN}WiFi Commands${NC}"
+            echo ""
+            echo "Usage: dw wifi <command>"
+            echo ""
+            echo "Commands:"
+            echo "  status      Show current WiFi mode and connection"
+            echo "  scan        Scan for available networks"
+            echo "  connect     Interactive: scan + select + connect"
+            echo "  hotspot     Force hotspot mode"
+            echo "  setup       Run initial autohotspot setup"
+            echo "  help        Show this help message"
+            ;;
+    esac
+}
+
 cmd_help() {
     echo -e "${GREEN}Dune Weaver CLI${NC}"
     echo ""
     echo "Usage: dw <command>"
     echo ""
     echo "Commands:"
-    echo "  install     Run initial setup (Docker + WiFi fix)"
+    echo "  install     Run initial setup"
     echo "  start       Start Dune Weaver"
     echo "  stop        Stop Dune Weaver"
     echo "  restart     Restart Dune Weaver"
     echo "  update      Pull latest changes and restart"
-    echo "  checkout    Switch to a branch and pull its Docker images"
+    echo "  checkout    Switch to a branch and rebuild"
     echo "  logs [N]    View logs (N=number of lines, or follow if omitted)"
-    echo "  status      Show container status"
-    echo "  shell       Open a shell in the container"
+    echo "  status      Show service status"
+    echo "  shell       Open a shell with the venv activated"
     echo "  touch       Manage touch screen app (run 'dw touch help')"
+    echo "  wifi        Manage WiFi and hotspot (run 'dw wifi help')"
     echo "  help        Show this help message"
     echo ""
     if [[ -n "$INSTALL_DIR" ]]; then
@@ -426,6 +489,10 @@ case "${1:-help}" in
         shift
         cmd_touch "$@"
         ;;
+    wifi)
+        shift
+        cmd_wifi "$@"
+        ;;
     help|--help|-h)
         cmd_help
         ;;

+ 1 - 1
firmware/dune_weaver/config.yaml

@@ -33,7 +33,7 @@ axes:
     steps_per_mm: 287
     max_rate_mm_per_min: 500
     acceleration_mm_per_sec2: 10
-    max_travel_mm: 10
+    max_travel_mm: 22
     soft_limits: false
     motor0:
       limit_neg_pin: gpio.35

+ 1 - 1
firmware/dune_weaver_gold/config (DAGGKAPRIFOL).yaml

@@ -33,7 +33,7 @@ axes:
     steps_per_mm: 250
     max_rate_mm_per_min: 500
     acceleration_mm_per_sec2: 10
-    max_travel_mm: 6.25
+    max_travel_mm: 22
     soft_limits: false
     motor0:
       limit_neg_pin: gpio.35

+ 1 - 1
firmware/dune_weaver_gold/config.yaml

@@ -33,7 +33,7 @@ axes:
     steps_per_mm: 270
     max_rate_mm_per_min: 500
     acceleration_mm_per_sec2: 10
-    max_travel_mm: 6.25
+    max_travel_mm: 22
     soft_limits: false
     motor0:
       limit_neg_pin: gpio.35

+ 1 - 1
firmware/dune_weaver_mini/config.yaml

@@ -30,7 +30,7 @@ axes:
     steps_per_mm: 180
     max_rate_mm_per_min: 200
     acceleration_mm_per_sec2: 10
-    max_travel_mm: 6.25
+    max_travel_mm: 32
     motor0:
       hard_limits: false
       unipolar:

+ 1 - 1
firmware/dune_weaver_mini_pro/config (28BYJ-48 variant).yaml

@@ -29,7 +29,7 @@ axes:
     steps_per_mm: 210
     max_rate_mm_per_min: 200
     acceleration_mm_per_sec2: 10
-    max_travel_mm: 6.25
+    max_travel_mm: 32
     motor0:
       hard_limits: false
       unipolar:

+ 1 - 1
firmware/dune_weaver_mini_pro/config.yaml

@@ -33,7 +33,7 @@ axes:
     steps_per_mm: 164
     max_rate_mm_per_min: 500
     acceleration_mm_per_sec2: 10
-    max_travel_mm: 6.25
+    max_travel_mm: 22
     soft_limits: false
     motor0:
       limit_neg_pin: gpio.35

+ 1 - 1
firmware/dune_weaver_pro/config.yaml

@@ -33,7 +33,7 @@ axes:
     steps_per_mm: 533
     max_rate_mm_per_min: 500
     acceleration_mm_per_sec2: 10
-    max_travel_mm: 10
+    max_travel_mm: 22
     soft_limits: false
     motor0:
       limit_neg_pin: gpio.35

+ 0 - 29
frontend/Dockerfile

@@ -1,29 +0,0 @@
-# Build stage
-FROM node:20-slim AS builder
-
-WORKDIR /app
-
-# Copy package files
-COPY package*.json ./
-
-# Install dependencies
-RUN npm ci
-
-# Copy source
-COPY . .
-
-# Override output to local directory for Docker build
-RUN npm run build -- --outDir ./dist
-
-# Production stage
-FROM nginx:alpine
-
-# Copy built files from builder
-COPY --from=builder /app/dist /usr/share/nginx/html
-
-# Copy nginx config (will be mounted or copied separately)
-# Note: nginx.conf should be copied in docker-compose or here
-
-EXPOSE 80
-
-CMD ["nginx", "-g", "daemon off;"]

+ 27 - 0
frontend/package-lock.json

@@ -11,6 +11,8 @@
         "@dnd-kit/core": "^6.3.1",
         "@dnd-kit/sortable": "^10.0.0",
         "@dnd-kit/utilities": "^3.2.2",
+        "@fontsource/noto-sans": "^5.2.10",
+        "@fontsource/plus-jakarta-sans": "^5.2.8",
         "@radix-ui/react-accordion": "^1.2.12",
         "@radix-ui/react-dialog": "^1.1.15",
         "@radix-ui/react-label": "^2.1.8",
@@ -26,6 +28,7 @@
         "@radix-ui/react-tooltip": "^1.2.8",
         "@tailwindcss/postcss": "^4.1.18",
         "@tanstack/react-query": "^5.90.16",
+        "material-icons": "^1.13.14",
         "motion": "^12.27.1",
         "next-themes": "^0.4.6",
         "react": "^19.2.0",
@@ -2826,6 +2829,24 @@
       "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
       "license": "MIT"
     },
+    "node_modules/@fontsource/noto-sans": {
+      "version": "5.2.10",
+      "resolved": "https://registry.npmjs.org/@fontsource/noto-sans/-/noto-sans-5.2.10.tgz",
+      "integrity": "sha512-J58RVfS/C0Z2VBF+PoU260Tx8cdRGYuS+e3yQe4hYaIYDl0sEVn5CzlLo5zVRvQD0HaIUTV8AZMfqR7rtdEpqQ==",
+      "license": "OFL-1.1",
+      "funding": {
+        "url": "https://github.com/sponsors/ayuhito"
+      }
+    },
+    "node_modules/@fontsource/plus-jakarta-sans": {
+      "version": "5.2.8",
+      "resolved": "https://registry.npmjs.org/@fontsource/plus-jakarta-sans/-/plus-jakarta-sans-5.2.8.tgz",
+      "integrity": "sha512-P5qE49fqdeD+7DXH1KBxmMPlB17LTz1zvBhFH0tFzfnYTKVJVyb0pR6plh0ZGXxcB+Oayb54FZZw3V42/DawTw==",
+      "license": "OFL-1.1",
+      "funding": {
+        "url": "https://github.com/sponsors/ayuhito"
+      }
+    },
     "node_modules/@hono/node-server": {
       "version": "1.19.9",
       "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
@@ -10483,6 +10504,12 @@
       "integrity": "sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==",
       "license": "ISC"
     },
+    "node_modules/material-icons": {
+      "version": "1.13.14",
+      "resolved": "https://registry.npmjs.org/material-icons/-/material-icons-1.13.14.tgz",
+      "integrity": "sha512-kZOfc7xCC0rAT8Q3DQixYAeT+tBqZnxkseQtp2bxBxz7q5pMAC+wmit7vJn1g/l7wRU+HEPq23gER4iPjGs5Cg==",
+      "license": "Apache-2.0"
+    },
     "node_modules/math-intrinsics": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",

+ 3 - 0
frontend/package.json

@@ -19,6 +19,8 @@
     "@dnd-kit/core": "^6.3.1",
     "@dnd-kit/sortable": "^10.0.0",
     "@dnd-kit/utilities": "^3.2.2",
+    "@fontsource/noto-sans": "^5.2.10",
+    "@fontsource/plus-jakarta-sans": "^5.2.8",
     "@radix-ui/react-accordion": "^1.2.12",
     "@radix-ui/react-dialog": "^1.1.15",
     "@radix-ui/react-label": "^2.1.8",
@@ -34,6 +36,7 @@
     "@radix-ui/react-tooltip": "^1.2.8",
     "@tailwindcss/postcss": "^4.1.18",
     "@tanstack/react-query": "^5.90.16",
+    "material-icons": "^1.13.14",
     "motion": "^12.27.1",
     "next-themes": "^0.4.6",
     "react": "^19.2.0",

+ 4 - 0
frontend/src/App.tsx

@@ -5,6 +5,8 @@ import { PlaylistsPage } from '@/pages/PlaylistsPage'
 import { TableControlPage } from '@/pages/TableControlPage'
 import { LEDPage } from '@/pages/LEDPage'
 import { SettingsPage } from '@/pages/SettingsPage'
+import { WiFiSetupPage } from '@/pages/WiFiSetupPage'
+import { SetupPage } from '@/pages/SetupPage'
 import { Toaster } from '@/components/ui/sonner'
 import { TableProvider } from '@/contexts/TableContext'
 
@@ -18,6 +20,8 @@ function App() {
           <Route path="table-control" element={<TableControlPage />} />
           <Route path="led" element={<LEDPage />} />
           <Route path="settings" element={<SettingsPage />} />
+          <Route path="wifi-setup" element={<WiFiSetupPage />} />
+          <Route path="setup" element={<SetupPage />} />
         </Route>
       </Routes>
       <Toaster position="top-center" richColors closeButton />

+ 3 - 0
frontend/src/__tests__/pages/TableControlPage.test.tsx

@@ -3,10 +3,13 @@ import { renderWithProviders, screen, waitFor, userEvent } from '../../test/util
 import { server } from '../../test/mocks/server'
 import { http, HttpResponse } from 'msw'
 import { TableControlPage } from '../../pages/TableControlPage'
+import { useStatusStore } from '../../stores/useStatusStore'
 
 describe('TableControlPage', () => {
   beforeEach(() => {
     vi.clearAllMocks()
+    // Reset Zustand store to prevent real WebSocket data from leaking into tests
+    useStatusStore.setState({ status: null, isBackendConnected: false, connectionAttempts: 0 })
   })
 
   describe('Rendering', () => {

+ 193 - 0
frontend/src/components/UpdateDialog.tsx

@@ -0,0 +1,193 @@
+import { useState, useEffect, useRef } from 'react'
+import { apiClient } from '@/lib/apiClient'
+import { useStatusStore } from '@/stores/useStatusStore'
+import { Button } from '@/components/ui/button'
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+
+interface UpdateDialogProps {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  currentVersion: string
+  latestVersion: string
+}
+
+type UpdateState = 'confirming' | 'updating' | 'complete' | 'error'
+
+const FUN_MESSAGES = [
+  'Shifting the sands...',
+  'Aligning the stars...',
+  'Polishing the steel ball...',
+  'Recalculating the spirals...',
+  'Tuning the motors...',
+  'Almost there...',
+]
+
+export function UpdateDialog({ open, onOpenChange, currentVersion, latestVersion }: UpdateDialogProps) {
+  const [state, setState] = useState<UpdateState>('confirming')
+  const [errorMessage, setErrorMessage] = useState('')
+  const [messageIndex, setMessageIndex] = useState(0)
+  const messageRef = useRef<ReturnType<typeof setInterval> | null>(null)
+  // Track that we've seen the WS go down (so reconnect means update finished)
+  const sawDisconnect = useRef(false)
+
+  const isBackendConnected = useStatusStore((s) => s.isBackendConnected)
+
+  // Reset state when dialog closes
+  useEffect(() => {
+    if (!open) {
+      if (messageRef.current) clearInterval(messageRef.current)
+      setState('confirming')
+      setErrorMessage('')
+      setMessageIndex(0)
+      sawDisconnect.current = false
+    }
+  }, [open])
+
+  // Track WS disconnect/reconnect while updating
+  useEffect(() => {
+    if (state !== 'updating') return
+
+    if (!isBackendConnected) {
+      sawDisconnect.current = true
+    } else if (sawDisconnect.current) {
+      // WS came back after being down — update is done
+      setState('complete')
+    }
+  }, [state, isBackendConnected])
+
+  // Auto-reload shortly after complete
+  useEffect(() => {
+    if (state !== 'complete') return
+    const timer = setTimeout(() => window.location.reload(), 2000)
+    return () => clearTimeout(timer)
+  }, [state])
+
+  // Rotate fun messages every 4 seconds while updating
+  useEffect(() => {
+    if (state !== 'updating') return
+    messageRef.current = setInterval(() => {
+      setMessageIndex(i => (i + 1) % FUN_MESSAGES.length)
+    }, 4000)
+    return () => {
+      if (messageRef.current) clearInterval(messageRef.current)
+    }
+  }, [state])
+
+  const handleUpdate = async () => {
+    setState('updating')
+    setMessageIndex(0)
+    sawDisconnect.current = false
+    try {
+      const res = await apiClient.request<{ success: boolean; message: string }>('/api/update', {
+        method: 'POST',
+      })
+      if (!res.success) {
+        setState('error')
+        setErrorMessage(res.message || 'Update failed')
+      }
+    } catch (err) {
+      setState('error')
+      setErrorMessage(err instanceof Error ? err.message : 'Failed to start update')
+    }
+  }
+
+  const isBlocked = state === 'updating' || state === 'complete'
+
+  return (
+    <Dialog open={open} onOpenChange={isBlocked ? undefined : onOpenChange}>
+      <DialogContent
+        onPointerDownOutside={isBlocked ? (e) => e.preventDefault() : undefined}
+        onEscapeKeyDown={isBlocked ? (e) => e.preventDefault() : undefined}
+        className={isBlocked ? '[&>button:last-child]:hidden' : ''}
+      >
+        {state === 'confirming' && (
+          <>
+            <DialogHeader>
+              <DialogTitle>Update Software</DialogTitle>
+              <DialogDescription>
+                Update from v{currentVersion} to v{latestVersion}
+              </DialogDescription>
+            </DialogHeader>
+            <p className="text-sm text-muted-foreground">
+              The system will download the latest version and restart automatically.
+              This usually takes 1-2 minutes.
+            </p>
+            <DialogFooter>
+              <Button variant="outline" onClick={() => onOpenChange(false)}>
+                Cancel
+              </Button>
+              <Button onClick={handleUpdate}>
+                Update Now
+              </Button>
+            </DialogFooter>
+          </>
+        )}
+
+        {state === 'updating' && (
+          <div className="flex flex-col items-center py-8 gap-6">
+            <div className="relative w-16 h-16">
+              <div className="absolute inset-0 rounded-full border-4 border-muted" />
+              <div className="absolute inset-0 rounded-full border-4 border-t-primary animate-spin" />
+              <div className="absolute inset-[6px] rounded-full border-4 border-muted" />
+              <div
+                className="absolute inset-[6px] rounded-full border-4 border-t-primary/60"
+                style={{ animation: 'spin 1.5s linear infinite reverse' }}
+              />
+            </div>
+            <div className="text-center space-y-2">
+              <p className="text-lg font-medium animate-pulse">
+                {FUN_MESSAGES[messageIndex]}
+              </p>
+              <p className="text-sm text-muted-foreground">
+                This usually takes 1-2 minutes.
+                <br />
+                The page will reload automatically.
+              </p>
+            </div>
+          </div>
+        )}
+
+        {state === 'complete' && (
+          <div className="flex flex-col items-center py-8 gap-4">
+            <div className="w-16 h-16 flex items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
+              <span className="material-icons text-green-600 dark:text-green-400 text-4xl">check_circle</span>
+            </div>
+            <div className="text-center space-y-1">
+              <p className="text-lg font-medium">Update complete!</p>
+              <p className="text-sm text-muted-foreground">Reloading...</p>
+            </div>
+          </div>
+        )}
+
+        {state === 'error' && (
+          <>
+            <DialogHeader>
+              <DialogTitle>Update Failed</DialogTitle>
+              <DialogDescription>
+                Something went wrong while starting the update.
+              </DialogDescription>
+            </DialogHeader>
+            <div className="rounded-lg bg-destructive/10 p-3">
+              <p className="text-sm text-destructive">{errorMessage}</p>
+            </div>
+            <DialogFooter>
+              <Button variant="outline" onClick={() => onOpenChange(false)}>
+                Close
+              </Button>
+              <Button onClick={handleUpdate}>
+                Retry
+              </Button>
+            </DialogFooter>
+          </>
+        )}
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 258 - 16
frontend/src/components/layout/Layout.tsx

@@ -1,8 +1,9 @@
-import { Outlet, Link, useLocation } from 'react-router-dom'
+import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom'
 import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
 import { toast } from 'sonner'
 import { NowPlayingBar } from '@/components/NowPlayingBar'
 import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
 import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
 import { Separator } from '@/components/ui/separator'
 import { cacheAllPreviews } from '@/lib/previewCache'
@@ -23,14 +24,37 @@ const navItems = [
 
 const DEFAULT_APP_NAME = 'Dune Weaver'
 
+// Detect captive portal context (DNS-redirected domains used by OS probe requests)
+const CAPTIVE_PORTAL_HOSTS = [
+  'captive.apple.com',
+  'connectivitycheck.gstatic.com',
+  'connectivitycheck.android.com',
+  'clients3.google.com',
+  'nmcheck.gnome.org',
+  'network-test.debian.org',
+  'msftconnecttest.com',
+  'www.msftconnecttest.com',
+]
+const isCaptivePortal = CAPTIVE_PORTAL_HOSTS.some(
+  (h) => window.location.hostname === h || window.location.hostname.endsWith('.' + h)
+)
+
 export function Layout() {
   const location = useLocation()
+  const navigate = useNavigate()
 
   // Scroll to top on route change
   useEffect(() => {
     window.scrollTo(0, 0)
   }, [location.pathname])
 
+  // Captive portal: redirect straight to WiFi setup
+  useEffect(() => {
+    if (isCaptivePortal && location.pathname !== '/wifi-setup') {
+      navigate('/wifi-setup', { replace: true })
+    }
+  }, [location.pathname, navigate])
+
   // Multi-table context - must be called before any hooks that depend on activeTable
   const { activeTable, tables } = useTable()
 
@@ -38,6 +62,8 @@ export function Layout() {
   const hasMultipleTables = tables.length > 1
 
   const [isDark, setIsDark] = useState(() => {
+    // Force light mode in captive portal to match the webview chrome
+    if (isCaptivePortal) return false
     if (typeof window !== 'undefined') {
       const saved = localStorage.getItem('theme')
       if (saved) return saved === 'dark'
@@ -62,6 +88,8 @@ export function Layout() {
   const isConnected = useStatusStore((s) => s.status?.connection_status ?? false)
   const isHoming = useStatusStore((s) => s.status?.is_homing ?? false)
   const sensorHomingFailed = useStatusStore((s) => s.status?.sensor_homing_failed ?? false)
+  const firmwareVersion = useStatusStore((s) => s.status?.firmware_version ?? null)
+  const tableType = useStatusStore((s) => s.status?.table_type ?? null)
   const statusCurrentFile = useStatusStore((s) => s.status?.current_file ?? null)
   const statusIsRunning = useStatusStore((s) => s.status?.is_running ?? false)
   const statusIsPaused = useStatusStore((s) => s.status?.is_paused ?? false)
@@ -80,9 +108,26 @@ export function Layout() {
   // Update availability
   const [updateAvailable, setUpdateAvailable] = useState(false)
 
+  // Security state
+  const [securityMode, setSecurityMode] = useState<'off' | 'lockdown' | 'play_only'>('off')
+  const [hasSecurityPassword, setHasSecurityPassword] = useState(false)
+  const [isUnlocked, setIsUnlocked] = useState(() => {
+    return sessionStorage.getItem('security-unlocked') === 'true'
+  })
+  const [showPasswordDialog, setShowPasswordDialog] = useState(false)
+  const [passwordInput, setPasswordInput] = useState('')
+  const [passwordError, setPasswordError] = useState(false)
+
+  // FluidNC version warning — each table type has an expected version
+  const expectedFirmwareVersion = tableType === 'dune_weaver_mini' ? 'v3.8.3' : 'v3.9.5'
+  const showFirmwareWarning = useMemo(() => {
+    if (!firmwareVersion) return false
+    return firmwareVersion !== expectedFirmwareVersion
+  }, [firmwareVersion, expectedFirmwareVersion])
+
   // Fetch app settings
   const fetchAppSettings = () => {
-    apiClient.get<{ app?: { name?: string; custom_logo?: string } }>('/api/settings')
+    apiClient.get<{ app?: { name?: string; custom_logo?: string }; security?: { mode?: string; has_password?: boolean } }>('/api/settings')
       .then((settings) => {
         if (settings.app?.name) {
           setAppName(settings.app.name)
@@ -90,6 +135,10 @@ export function Layout() {
           setAppName(DEFAULT_APP_NAME)
         }
         setCustomLogo(settings.app?.custom_logo || null)
+        // Security settings
+        const mode = settings.security?.mode as 'off' | 'lockdown' | 'play_only' | undefined
+        setSecurityMode(mode || 'off')
+        setHasSecurityPassword(settings.security?.has_password || false)
       })
       .catch(() => {})
   }
@@ -97,14 +146,19 @@ export function Layout() {
   useEffect(() => {
     fetchAppSettings()
 
-    // Listen for branding updates from Settings page
+    // Listen for branding/security updates from Settings page
     const handleBrandingUpdate = () => {
       fetchAppSettings()
     }
+    const handleSecurityUpdate = () => {
+      fetchAppSettings()
+    }
     window.addEventListener('branding-updated', handleBrandingUpdate)
+    window.addEventListener('security-updated', handleSecurityUpdate)
 
     return () => {
       window.removeEventListener('branding-updated', handleBrandingUpdate)
+      window.removeEventListener('security-updated', handleSecurityUpdate)
     }
     // Refetch when active table changes
   }, [activeTable?.id])
@@ -140,6 +194,7 @@ export function Layout() {
 
   // Mobile menu state
   const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
+  const [isDesktopMenuOpen, setIsDesktopMenuOpen] = useState(false)
 
   // Logs drawer state
   const [isLogsOpen, setIsLogsOpen] = useState(false)
@@ -540,13 +595,13 @@ export function Layout() {
   }
 
   const handleRestart = async () => {
-    if (!confirm('Are you sure you want to restart Docker containers?')) return
+    if (!confirm('Are you sure you want to restart Dune Weaver?')) return
 
     try {
       await apiClient.post('/api/system/restart')
-      toast.success('Docker containers are restarting...')
+      toast.success('Dune Weaver is restarting...')
     } catch {
-      toast.error('Failed to restart Docker containers')
+      toast.error('Failed to restart Dune Weaver')
     }
   }
 
@@ -589,6 +644,60 @@ export function Layout() {
     }
   }
 
+  // Security password verification
+  const handlePasswordSubmit = async (e: React.FormEvent) => {
+    e.preventDefault()
+    setPasswordError(false)
+    try {
+      const result = await apiClient.post<{ valid: boolean }>('/api/security/verify', {
+        password: passwordInput,
+      })
+      if (result.valid) {
+        sessionStorage.setItem('security-unlocked', 'true')
+        setIsUnlocked(true)
+        setPasswordInput('')
+        // If unlocking via play-only dialog, navigate to settings
+        if (showPasswordDialog) {
+          setShowPasswordDialog(false)
+          navigate('/settings')
+        }
+      } else {
+        setPasswordError(true)
+      }
+    } catch {
+      setPasswordError(true)
+    }
+  }
+
+  // Re-lock the app
+  const handleLock = () => {
+    sessionStorage.removeItem('security-unlocked')
+    setIsUnlocked(false)
+    navigate('/')
+  }
+
+  // Determine if security is active and blocking
+  const isLockdownActive = securityMode === 'lockdown' && hasSecurityPassword && !isUnlocked
+  const isPlayOnlyActive = securityMode === 'play_only' && hasSecurityPassword && !isUnlocked
+  const isSecurityUnlocked = securityMode !== 'off' && hasSecurityPassword && isUnlocked
+
+  // Redirect away from restricted pages if play_only is active and not unlocked
+  const restrictedPaths = ['/settings', '/table-control', '/setup', '/wifi-setup']
+  useEffect(() => {
+    if (isPlayOnlyActive && restrictedPaths.includes(location.pathname)) {
+      navigate('/')
+    }
+  }, [isPlayOnlyActive, location.pathname, navigate])
+
+  // Filter nav items based on security mode
+  const playOnlyHiddenPaths = ['/settings', '/table-control']
+  const visibleNavItems = useMemo(() => {
+    if (isPlayOnlyActive) {
+      return navItems.filter((item) => !playOnlyHiddenPaths.includes(item.path))
+    }
+    return navItems
+  }, [isPlayOnlyActive])
+
   // Update document title based on current page
   useEffect(() => {
     const currentNav = navItems.find((item) => item.path === location.pathname)
@@ -641,8 +750,9 @@ export function Layout() {
     const showOverlay = !isBackendConnected || isHoming || homingJustCompleted
 
     if (!showOverlay) {
-      setConnectionLogs([])
-      // Close WebSocket if open - only if OPEN (CONNECTING will close in onopen)
+      // Don't clear logs here — they'll be cleared when the next session starts.
+      // Clearing here races with the homingJustCompleted setState, wiping logs
+      // before the completion overlay renders.
       if (blockingLogsWsRef.current && blockingLogsWsRef.current.readyState === WebSocket.OPEN) {
         blockingLogsWsRef.current.close()
       }
@@ -676,6 +786,7 @@ export function Layout() {
 
     // If homing, connect to logs WebSocket to stream real logs
     if (isHoming && isBackendConnected) {
+      setConnectionLogs([])
       addLog('INFO', 'Homing started...')
 
       let shouldConnect = true
@@ -748,6 +859,7 @@ export function Layout() {
 
     // If backend disconnected, show connection retry logs
     if (!isBackendConnected) {
+      setConnectionLogs([])
       addLog('INFO', `Attempting to connect to backend at ${window.location.host}...`)
 
       const interval = setInterval(() => {
@@ -963,6 +1075,72 @@ export function Layout() {
 
   return (
     <div className="min-h-dvh bg-background flex flex-col">
+      {/* Security Lockdown Overlay */}
+      {isLockdownActive && (
+        <div className="fixed inset-0 z-[60] bg-background flex items-center justify-center p-4">
+          <div className="w-full max-w-sm space-y-6 text-center">
+            <div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-2">
+              <span className="material-icons-outlined text-4xl text-primary">lock</span>
+            </div>
+            <h2 className="text-2xl font-bold">{displayName}</h2>
+            <p className="text-muted-foreground">This table is locked. Enter the password to continue.</p>
+            <form onSubmit={handlePasswordSubmit} className="space-y-3">
+              <Input
+                type="password"
+                placeholder="Password"
+                value={passwordInput}
+                onChange={(e) => { setPasswordInput(e.target.value); setPasswordError(false) }}
+                autoFocus
+              />
+              {passwordError && (
+                <p className="text-sm text-destructive">Incorrect password</p>
+              )}
+              <Button type="submit" className="w-full">Unlock</Button>
+            </form>
+          </div>
+        </div>
+      )}
+
+      {/* Security Password Dialog (for play-only mode) */}
+      {showPasswordDialog && (
+        <div className="fixed inset-0 z-[60] bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
+          <div className="bg-background rounded-lg shadow-xl w-full max-w-sm">
+            <div className="p-6 space-y-4">
+              <div className="text-center space-y-2">
+                <div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-primary/10 mb-2">
+                  <span className="material-icons-outlined text-2xl text-primary">lock</span>
+                </div>
+                <h3 className="text-lg font-semibold">Settings Locked</h3>
+                <p className="text-sm text-muted-foreground">Enter the password to access settings.</p>
+              </div>
+              <form onSubmit={handlePasswordSubmit} className="space-y-3">
+                <Input
+                  type="password"
+                  placeholder="Password"
+                  value={passwordInput}
+                  onChange={(e) => { setPasswordInput(e.target.value); setPasswordError(false) }}
+                  autoFocus
+                />
+                {passwordError && (
+                  <p className="text-sm text-destructive">Incorrect password</p>
+                )}
+                <div className="flex gap-2">
+                  <Button
+                    type="button"
+                    variant="ghost"
+                    className="flex-1"
+                    onClick={() => { setShowPasswordDialog(false); setPasswordInput(''); setPasswordError(false) }}
+                  >
+                    Cancel
+                  </Button>
+                  <Button type="submit" className="flex-1">Unlock</Button>
+                </div>
+              </form>
+            </div>
+          </div>
+        </div>
+      )}
+
       {/* Sensor Homing Failure Popup */}
       {sensorHomingFailed && (
         <div className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
@@ -1151,8 +1329,9 @@ export function Layout() {
       )}
 
       {/* Backend Connection / Homing Blocking Overlay */}
+      {/* Skip in captive portal mode (WebSocket won't connect in sandboxed webview) */}
       {/* Don't show this overlay when sensor homing failed - that has its own dialog */}
-      {!sensorHomingFailed && (!isBackendConnected || (isHoming && !homingDismissed) || homingJustCompleted) && (
+      {!isCaptivePortal && !sensorHomingFailed && (!isBackendConnected || (isHoming && !homingDismissed) || homingJustCompleted) && (
         <div className="fixed inset-0 z-50 bg-background/95 backdrop-blur-sm flex flex-col items-center justify-center p-4">
           <div className="w-full max-w-2xl space-y-6">
             {/* Status Header */}
@@ -1337,7 +1516,8 @@ export function Layout() {
           <div className="absolute inset-0 bg-background/80 backdrop-blur-md supports-[backdrop-filter]:bg-background/50" style={{ height: 'calc(5rem + env(safe-area-inset-top, 0px))' }} />
         )}
         <div className="relative w-full max-w-5xl mx-auto px-3 sm:px-4 pt-3 pointer-events-none">
-          <div className="flex h-12 items-center justify-between px-4 rounded-full bg-card shadow-lg border border-border pointer-events-auto">
+          <div className="rounded-full bg-card shadow-lg border border-border pointer-events-auto">
+          <div className="flex h-12 items-center justify-between px-4">
           <div className="flex items-center gap-2">
             <Link to="/">
               <img
@@ -1379,8 +1559,28 @@ export function Layout() {
             </TableSelector>
           </div>
 
+          {showFirmwareWarning && (
+            <div className="flex items-center gap-1.5 min-w-0 mx-2 text-amber-500">
+              <span className="material-icons-outlined text-base shrink-0">warning</span>
+              <span className="text-xs truncate">
+                FluidNC {firmwareVersion} — change to {expectedFirmwareVersion}
+              </span>
+            </div>
+          )}
+
           {/* Desktop actions */}
           <div className="hidden md:flex items-center gap-0 ml-2">
+            {isSecurityUnlocked && (
+              <Button
+                variant="ghost"
+                size="icon"
+                className="rounded-full"
+                onClick={handleLock}
+                title="Lock"
+              >
+                <span className="material-icons-outlined">lock_open</span>
+              </Button>
+            )}
             {updateAvailable && (
               <Link to="/settings?section=version" title="Software update available">
                 <span className="relative flex items-center justify-center w-8 h-8 rounded-full hover:bg-accent transition-colors">
@@ -1389,7 +1589,7 @@ export function Layout() {
                 </span>
               </Link>
             )}
-            <Popover>
+            <Popover open={isDesktopMenuOpen} onOpenChange={setIsDesktopMenuOpen}>
               <PopoverTrigger asChild>
                 <Button
                   variant="ghost"
@@ -1418,13 +1618,23 @@ export function Layout() {
                     <span className="material-icons-outlined text-xl">article</span>
                     View Logs
                   </button>
+                  <button
+                    onClick={() => {
+                      navigate('/wifi-setup')
+                      setIsDesktopMenuOpen(false)
+                    }}
+                    className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors"
+                  >
+                    <span className="material-icons-outlined text-xl">wifi</span>
+                    WiFi Setup
+                  </button>
                   <Separator className="my-1" />
                   <button
                     onClick={handleRestart}
                     className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors text-amber-500"
                   >
                     <span className="material-icons-outlined text-xl">restart_alt</span>
-                    Restart Docker
+                    Restart
                   </button>
                   <button
                     onClick={handleShutdown}
@@ -1440,6 +1650,17 @@ export function Layout() {
 
           {/* Mobile actions */}
           <div className="flex md:hidden items-center gap-0 ml-2">
+            {isSecurityUnlocked && (
+              <Button
+                variant="ghost"
+                size="icon"
+                className="rounded-full"
+                onClick={handleLock}
+                title="Lock"
+              >
+                <span className="material-icons-outlined">lock_open</span>
+              </Button>
+            )}
             {updateAvailable && (
               <Link to="/settings?section=version" title="Software update available">
                 <span className="relative flex items-center justify-center w-8 h-8 rounded-full hover:bg-accent transition-colors">
@@ -1485,6 +1706,16 @@ export function Layout() {
                     <span className="material-icons-outlined text-xl">article</span>
                     View Logs
                   </button>
+                  <button
+                    onClick={() => {
+                      navigate('/wifi-setup')
+                      setIsMobileMenuOpen(false)
+                    }}
+                    className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors"
+                  >
+                    <span className="material-icons-outlined text-xl">wifi</span>
+                    WiFi Setup
+                  </button>
                   <Separator className="my-1" />
                   <button
                     onClick={() => {
@@ -1494,7 +1725,7 @@ export function Layout() {
                     className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors text-amber-500"
                   >
                     <span className="material-icons-outlined text-xl">restart_alt</span>
-                    Restart Docker
+                    Restart
                   </button>
                   <button
                     onClick={() => {
@@ -1511,6 +1742,7 @@ export function Layout() {
             </Popover>
             </div>
           </div>
+          </div>
         </div>
       </header>
 
@@ -1528,7 +1760,7 @@ export function Layout() {
               : 'calc(8rem + env(safe-area-inset-bottom, 0px))' // floating pill + nav + safe area
         }}
       >
-        <Outlet />
+        <Outlet context={{ isPlayOnlyActive }} />
       </main>
 
       {/* Now Playing Bar */}
@@ -1714,8 +1946,8 @@ export function Layout() {
 
       {/* Bottom Navigation */}
       <nav className="fixed bottom-0 left-0 right-0 z-40 border-t border-border bg-card pb-safe">
-        <div className="max-w-5xl mx-auto grid grid-cols-5 h-16">
-          {navItems.map((item) => {
+        <div className={`max-w-5xl mx-auto grid h-16`} style={{ gridTemplateColumns: `repeat(${visibleNavItems.length + (isPlayOnlyActive ? 1 : 0)}, minmax(0, 1fr))` }}>
+          {visibleNavItems.map((item) => {
             const isActive = location.pathname === item.path
             return (
               <Link
@@ -1738,6 +1970,16 @@ export function Layout() {
               </Link>
             )
           })}
+          {/* Lock icon replacing Settings when play-only mode is active */}
+          {isPlayOnlyActive && (
+            <button
+              onClick={() => { setShowPasswordDialog(true); setPasswordInput(''); setPasswordError(false) }}
+              className="relative flex flex-col items-center justify-center gap-1 transition-all duration-200 text-muted-foreground hover:text-foreground active:scale-95"
+            >
+              <span className="material-icons-outlined text-xl">lock</span>
+              <span className="text-xs font-medium">Settings</span>
+            </button>
+          )}
         </div>
       </nav>
     </div>

+ 1 - 1
frontend/src/components/ui/select.tsx

@@ -116,7 +116,7 @@ const SelectItem = React.forwardRef<
   <SelectPrimitive.Item
     ref={ref}
     className={cn(
-      "relative flex w-full cursor-default select-none items-center rounded-xl py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
+      "relative flex w-full cursor-default select-none items-center rounded-xl py-1.5 pl-8 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
       className
     )}
     {...props}

+ 1 - 5
frontend/src/index.css

@@ -1,10 +1,6 @@
 @import "tailwindcss";
 
-/* Import fonts */
-@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Noto+Sans:wght@400;500;600;700&display=swap');
-
-/* Material Icons */
-@import url('https://fonts.googleapis.com/icon?family=Material+Icons|Material+Icons+Outlined');
+/* Fonts imported in main.tsx (self-hosted, no CDN dependency) */
 
 /* Theme configuration using CSS variables */
 @theme {

+ 7 - 7
frontend/src/lib/types.ts

@@ -20,14 +20,14 @@ export interface Playlist {
   files: string[]
 }
 
-export type SortOption = 'name' | 'date' | 'size' | 'favorites'
+export type SortOption = 'name' | 'date' | 'size' | 'favorites' | 'plays' | 'last_played'
 export type PreExecution = 'none' | 'adaptive' | 'clear_from_in' | 'clear_from_out' | 'clear_sideway'
 export type RunMode = 'single' | 'indefinite'
 
-export const preExecutionOptions: { value: PreExecution; label: string }[] = [
-  { value: 'adaptive', label: 'Adaptive' },
-  { value: 'clear_from_in', label: 'Clear From Center' },
-  { value: 'clear_from_out', label: 'Clear From Perimeter' },
-  { value: 'clear_sideway', label: 'Clear Sideways' },
-  { value: 'none', label: 'None' },
+export const preExecutionOptions: { value: PreExecution; label: string; description: string }[] = [
+  { value: 'adaptive', label: 'Adaptive', description: 'Automatically picks the best clear direction based on where the ball is' },
+  { value: 'clear_from_in', label: 'From Center', description: 'Spirals outward from the center to erase the current pattern' },
+  { value: 'clear_from_out', label: 'From Perimeter', description: 'Spirals inward from the edge to erase the current pattern' },
+  { value: 'clear_sideway', label: 'Sideways', description: 'Sweeps side-to-side across the sand to erase the current pattern' },
+  { value: 'none', label: 'None', description: 'Start drawing immediately without clearing the sand first' },
 ]

+ 13 - 0
frontend/src/main.tsx

@@ -2,6 +2,19 @@ import { StrictMode } from 'react'
 import { createRoot } from 'react-dom/client'
 import { BrowserRouter } from 'react-router-dom'
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+
+// Self-hosted fonts (no CDN — works offline / hotspot mode)
+import '@fontsource/plus-jakarta-sans/400.css'
+import '@fontsource/plus-jakarta-sans/500.css'
+import '@fontsource/plus-jakarta-sans/600.css'
+import '@fontsource/plus-jakarta-sans/700.css'
+import '@fontsource/noto-sans/400.css'
+import '@fontsource/noto-sans/500.css'
+import '@fontsource/noto-sans/600.css'
+import '@fontsource/noto-sans/700.css'
+import 'material-icons/iconfont/filled.css'
+import 'material-icons/iconfont/outlined.css'
+
 import './index.css'
 import App from './App.tsx'
 

+ 165 - 109
frontend/src/pages/BrowsePage.tsx

@@ -1,4 +1,5 @@
 import { useState, useEffect, useMemo, useRef, useCallback, createContext, useContext } from 'react'
+import { useOutletContext } from 'react-router-dom'
 import { toast } from 'sonner'
 import {
   initPreviewCacheDB,
@@ -26,6 +27,7 @@ import {
   SheetHeader,
   SheetTitle,
 } from '@/components/ui/sheet'
+import { preExecutionOptions } from '@/lib/types'
 
 // Types
 interface PatternMetadata {
@@ -46,17 +48,9 @@ interface PreviewData {
 // Coordinates come as [theta, rho] tuples from the backend
 type Coordinate = [number, number]
 
-type SortOption = 'name' | 'date' | 'size' | 'favorites'
+type SortOption = 'name' | 'date' | 'size' | 'favorites' | 'plays' | 'last_played'
 type PreExecution = 'none' | 'adaptive' | 'clear_from_in' | 'clear_from_out' | 'clear_sideway'
 
-const preExecutionOptions: { value: PreExecution; label: string }[] = [
-  { value: 'adaptive', label: 'Adaptive' },
-  { value: 'clear_from_in', label: 'Clear From Center' },
-  { value: 'clear_from_out', label: 'Clear From Perimeter' },
-  { value: 'clear_sideway', label: 'Clear Sideways' },
-  { value: 'none', label: 'None' },
-]
-
 // Context for lazy loading previews
 interface PreviewContextType {
   requestPreview: (path: string) => void
@@ -66,6 +60,8 @@ interface PreviewContextType {
 const PreviewContext = createContext<PreviewContextType | null>(null)
 
 export function BrowsePage() {
+  const { isPlayOnlyActive } = useOutletContext<{ isPlayOnlyActive?: boolean }>() || {}
+
   // Data state
   const [patterns, setPatterns] = useState<PatternMetadata[]>([])
   const [previews, setPreviews] = useState<Record<string, PreviewData>>({})
@@ -104,6 +100,8 @@ export function BrowsePage() {
   const [allPatternHistories, setAllPatternHistories] = useState<Record<string, {
     actual_time_formatted: string | null
     timestamp: string | null
+    play_count: number
+    last_played: string | null
   }>>({})
 
   // Canvas and animation refs
@@ -242,7 +240,7 @@ export function BrowsePage() {
       // Fetch patterns and history in parallel
       const [data, historyData] = await Promise.all([
         apiClient.get<PatternMetadata[]>('/list_theta_rho_files_with_metadata'),
-        apiClient.get<Record<string, { actual_time_formatted: string | null; timestamp: string | null }>>('/api/pattern_history_all')
+        apiClient.get<Record<string, { actual_time_formatted: string | null; timestamp: string | null; play_count: number; last_played: string | null }>>('/api/pattern_history_all')
       ])
       setPatterns(data)
       setAllPatternHistories(historyData)
@@ -401,6 +399,28 @@ export function BrowsePage() {
           }
           break
         }
+        case 'plays': {
+          const aKey = a.path.split('/').pop() || ''
+          const bKey = b.path.split('/').pop() || ''
+          const aPlays = allPatternHistories[aKey]?.play_count ?? 0
+          const bPlays = allPatternHistories[bKey]?.play_count ?? 0
+          comparison = aPlays - bPlays
+          if (comparison === 0) {
+            comparison = a.name.localeCompare(b.name)
+          }
+          break
+        }
+        case 'last_played': {
+          const aKey = a.path.split('/').pop() || ''
+          const bKey = b.path.split('/').pop() || ''
+          const aTime = allPatternHistories[aKey]?.last_played || ''
+          const bTime = allPatternHistories[bKey]?.last_played || ''
+          comparison = aTime.localeCompare(bTime)
+          if (comparison === 0) {
+            comparison = a.name.localeCompare(b.name)
+          }
+          break
+        }
         default:
           return 0
       }
@@ -408,7 +428,7 @@ export function BrowsePage() {
     })
 
     return result
-  }, [patterns, selectedCategory, searchQuery, sortBy, sortAsc, favorites])
+  }, [patterns, selectedCategory, searchQuery, sortBy, sortAsc, favorites, allPatternHistories])
 
   // Batched preview loading - collects requests and fetches in batches
   const requestPreview = useCallback((path: string) => {
@@ -803,33 +823,46 @@ export function BrowsePage() {
     setCacheProgress(0)
   }
 
-  // Handle pattern file upload
+  // Handle pattern file upload (supports multiple files)
   const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
-    const file = e.target.files?.[0]
-    if (!file) return
+    const files = e.target.files
+    if (!files || files.length === 0) return
 
-    // Validate file extension
-    if (!file.name.endsWith('.thr')) {
-      toast.error('Please select a .thr file')
+    // Validate all files have .thr extension
+    const invalidFiles = Array.from(files).filter(f => !f.name.endsWith('.thr'))
+    if (invalidFiles.length > 0) {
+      toast.error(`Invalid file${invalidFiles.length > 1 ? 's' : ''}: ${invalidFiles.map(f => f.name).join(', ')}. Only .thr files are accepted.`)
       return
     }
 
     setIsUploading(true)
-    try {
-      await apiClient.uploadFile('/upload_theta_rho', file)
-      toast.success(`Pattern "${file.name}" uploaded successfully`)
+    let successCount = 0
+    let failCount = 0
+
+    for (const file of Array.from(files)) {
+      try {
+        await apiClient.uploadFile('/upload_theta_rho', file)
+        successCount++
+      } catch (error) {
+        console.error(`Upload error for ${file.name}:`, error)
+        failCount++
+        toast.error(`Failed to upload "${file.name}"`)
+      }
+    }
 
-      // Refresh patterns list using the same function as initial load
+    if (successCount > 0) {
+      toast.success(
+        successCount === 1
+          ? `Pattern "${files[0].name}" uploaded successfully`
+          : `${successCount} pattern${successCount > 1 ? 's' : ''} uploaded successfully`
+      )
       await fetchPatterns()
-    } catch (error) {
-      console.error('Upload error:', error)
-      toast.error(error instanceof Error ? error.message : 'Failed to upload pattern')
-    } finally {
-      setIsUploading(false)
-      // Reset file input
-      if (fileInputRef.current) {
-        fileInputRef.current.value = ''
-      }
+    }
+
+    setIsUploading(false)
+    // Reset file input
+    if (fileInputRef.current) {
+      fileInputRef.current.value = ''
     }
   }
 
@@ -846,13 +879,16 @@ export function BrowsePage() {
   return (
     <div className="flex flex-col w-full max-w-5xl mx-auto gap-3 sm:gap-6 py-3 sm:py-6 px-0 sm:px-4">
       {/* Hidden file input for pattern upload */}
-      <input
-        ref={fileInputRef}
-        type="file"
-        accept=".thr"
-        onChange={handleFileUpload}
-        className="hidden"
-      />
+      {!isPlayOnlyActive && (
+        <input
+          ref={fileInputRef}
+          type="file"
+          accept=".thr"
+          multiple
+          onChange={handleFileUpload}
+          className="hidden"
+        />
+      )}
 
       {/* Page Header */}
       <div className="flex items-start justify-between gap-4 pl-1">
@@ -862,19 +898,21 @@ export function BrowsePage() {
             {patterns.length} patterns available
           </p>
         </div>
-        <Button
-          variant="ghost"
-          onClick={() => fileInputRef.current?.click()}
-          disabled={isUploading}
-          className="gap-2 shrink-0 h-9 w-9 sm:h-11 sm:w-auto rounded-full px-0 sm:px-4 justify-center bg-card border border-border shadow-sm hover:bg-accent"
-        >
-          {isUploading ? (
-            <span className="material-icons-outlined animate-spin text-lg">sync</span>
-          ) : (
-            <span className="material-icons-outlined text-lg">add</span>
-          )}
-          <span className="hidden sm:inline">Add Pattern</span>
-        </Button>
+        {!isPlayOnlyActive && (
+          <Button
+            variant="ghost"
+            onClick={() => fileInputRef.current?.click()}
+            disabled={isUploading}
+            className="gap-2 shrink-0 h-9 w-9 sm:h-11 sm:w-auto rounded-full px-0 sm:px-4 justify-center bg-card border border-border shadow-sm hover:bg-accent"
+          >
+            {isUploading ? (
+              <span className="material-icons-outlined animate-spin text-lg">sync</span>
+            ) : (
+              <span className="material-icons-outlined text-lg">add</span>
+            )}
+            <span className="hidden sm:inline">Add Pattern</span>
+          </Button>
+        )}
       </div>
 
       {/* Filter Bar */}
@@ -922,7 +960,12 @@ export function BrowsePage() {
           </Select>
 
           {/* Sort - Icon on mobile, text on desktop */}
-          <Select value={sortBy} onValueChange={(v) => setSortBy(v as SortOption)}>
+          <Select value={sortBy} onValueChange={(v) => {
+            const option = v as SortOption
+            setSortBy(option)
+            // Most Played and Last Played should default to descending (highest first)
+            setSortAsc(option !== 'plays' && option !== 'last_played')
+          }}>
             <SelectTrigger className="h-9 w-9 sm:h-11 sm:w-auto rounded-full bg-card border-border shadow-sm text-xs sm:text-sm shrink-0 [&>svg]:hidden sm:[&>svg]:block px-0 sm:px-3 justify-center sm:justify-between [&>span:last-of-type]:hidden sm:[&>span:last-of-type]:inline gap-2">
               <span className="material-icons-outlined text-lg shrink-0 sm:hidden">sort</span>
               <SelectValue placeholder="Sort" />
@@ -932,6 +975,8 @@ export function BrowsePage() {
               <SelectItem value="name">Name</SelectItem>
               <SelectItem value="date">Modified</SelectItem>
               <SelectItem value="size">Size</SelectItem>
+              <SelectItem value="plays">Most Played</SelectItem>
+              <SelectItem value="last_played">Last Played</SelectItem>
             </SelectContent>
           </Select>
 
@@ -1016,6 +1061,7 @@ export function BrowsePage() {
                 isSelected={selectedPattern?.path === pattern.path}
                 isFavorite={favorites.has(pattern.path)}
                 playTime={allPatternHistories[pattern.path.split('/').pop() || '']?.actual_time_formatted || null}
+                playCount={allPatternHistories[pattern.path.split('/').pop() || '']?.play_count ?? 0}
                 onToggleFavorite={toggleFavorite}
                 onClick={() => handlePatternClick(pattern)}
               />
@@ -1109,27 +1155,37 @@ export function BrowsePage() {
                 </div>
               </div>
 
-              {/* Last Played Info */}
-              {patternHistory?.actual_time_formatted && (
-                <div className="mb-4 flex justify-between text-sm">
-                  <div className="flex items-center gap-2">
-                    <span className="material-icons-outlined text-muted-foreground text-base">schedule</span>
-                    <span className="text-muted-foreground">Last run:</span>
-                    <span className="font-semibold">{patternHistory.actual_time_formatted}</span>
-                  </div>
-                  {patternHistory.speed !== null && (
-                    <div className="flex items-center gap-2">
-                      <span className="material-icons-outlined text-muted-foreground text-base">speed</span>
-                      <span className="text-muted-foreground">Speed:</span>
-                      <span className="font-semibold">{patternHistory.speed}</span>
-                    </div>
-                  )}
-                </div>
-              )}
+              {/* Play History Info */}
+              {(() => {
+                const historyKey = selectedPattern.path.split('/').pop() || ''
+                const playCount = allPatternHistories[historyKey]?.play_count ?? 0
+                return (
+                  <>
+                    {(patternHistory?.actual_time_formatted || playCount > 0) && (
+                      <div className="mb-4 flex justify-between text-sm">
+                        {patternHistory?.actual_time_formatted && (
+                          <div className="flex items-center gap-2">
+                            <span className="material-icons-outlined text-muted-foreground text-base">schedule</span>
+                            <span className="text-muted-foreground">Last run:</span>
+                            <span className="font-semibold">{patternHistory.actual_time_formatted}</span>
+                          </div>
+                        )}
+                        {playCount > 0 && (
+                          <div className="flex items-center gap-2">
+                            <span className="material-icons-outlined text-muted-foreground text-base">play_circle</span>
+                            <span className="text-muted-foreground">Plays:</span>
+                            <span className="font-semibold">{playCount}</span>
+                          </div>
+                        )}
+                      </div>
+                    )}
+                  </>
+                )
+              })()}
 
-              {/* Pre-Execution Options */}
+              {/* Clear Options */}
               <div className="mb-6">
-                <Label className="text-sm font-semibold mb-3 block">Pre-Execution Action</Label>
+                <Label className="text-sm font-semibold mb-3 block">Clear</Label>
                 <div className="grid grid-cols-2 gap-2">
                   {preExecutionOptions.map((option) => (
                     <label
@@ -1152,6 +1208,9 @@ export function BrowsePage() {
                     </label>
                   ))}
                 </div>
+                <p className="text-xs text-muted-foreground mt-2">
+                  {preExecutionOptions.find(o => o.value === preExecution)?.description}
+                </p>
               </div>
 
               {/* Action Buttons */}
@@ -1328,11 +1387,12 @@ interface PatternCardProps {
   isSelected: boolean
   isFavorite: boolean
   playTime: string | null
+  playCount: number
   onToggleFavorite: (path: string, e: React.MouseEvent) => void
   onClick: () => void
 }
 
-function PatternCard({ pattern, isSelected, isFavorite, playTime, onToggleFavorite, onClick }: PatternCardProps) {
+function PatternCard({ pattern, isSelected, isFavorite, playTime, playCount, onToggleFavorite, onClick }: PatternCardProps) {
   const [imageLoaded, setImageLoaded] = useState(false)
   const [imageError, setImageError] = useState(false)
   const cardRef = useRef<HTMLButtonElement>(null)
@@ -1399,46 +1459,42 @@ function PatternCard({ pattern, isSelected, isFavorite, playTime, onToggleFavori
             </div>
           )}
         </div>
+      </div>
 
-        {/* Play time badge */}
-        {playTime && (
-          <div className="absolute -top-1 -right-1 bg-card/90 backdrop-blur-sm text-[10px] font-medium px-1.5 py-0.5 rounded-full border border-border shadow-sm">
-            {(() => {
-              // Parse time and convert to minutes only
-              // Try MM:SS or HH:MM:SS format first (e.g., "15:48" or "1:15:48")
-              const colonMatch = playTime.match(/^(?:(\d+):)?(\d+):(\d+)$/)
-              if (colonMatch) {
-                const hours = colonMatch[1] ? parseInt(colonMatch[1]) : 0
-                const minutes = parseInt(colonMatch[2])
-                const seconds = parseInt(colonMatch[3])
-                const totalMins = hours * 60 + minutes + (seconds >= 30 ? 1 : 0)
-                return totalMins > 0 ? `${totalMins}m` : '<1m'
-              }
-
-              // Try text-based formats
-              const match = playTime.match(/(\d+)h\s*(\d+)m|(\d+)\s*min|(\d+)m\s*(\d+)s|(\d+)\s*sec/)
-              if (match) {
-                if (match[1] && match[2]) {
-                  // "Xh Ym" format
-                  return `${parseInt(match[1]) * 60 + parseInt(match[2])}m`
-                } else if (match[3]) {
-                  // "X min" format
-                  return `${match[3]}m`
-                } else if (match[4] && match[5]) {
-                  // "Xm Ys" format - round to minutes
-                  const mins = parseInt(match[4])
-                  return mins > 0 ? `${mins}m` : '<1m'
-                } else if (match[6]) {
-                  // seconds only
-                  return '<1m'
+      {/* Stats row */}
+      {(playCount > 0 || playTime) && (
+        <div className="flex items-center w-full px-0.5 -mb-1 justify-between">
+          {playCount > 0 && (
+            <span className="flex items-center gap-0.5 text-xs text-muted-foreground" title={`Played ${playCount} time${playCount !== 1 ? 's' : ''}`}>
+              <span className="material-icons-outlined" style={{ fontSize: '13px' }}>play_circle</span>
+              {playCount}x
+            </span>
+          )}
+          {playTime && (
+            <span className="flex items-center gap-0.5 text-xs text-muted-foreground ml-auto" title={`Last run: ${playTime}`}>
+              <span className="material-icons-outlined" style={{ fontSize: '13px' }}>schedule</span>
+              {(() => {
+                const colonMatch = playTime.match(/^(?:(\d+):)?(\d+):(\d+)$/)
+                if (colonMatch) {
+                  const hours = colonMatch[1] ? parseInt(colonMatch[1]) : 0
+                  const minutes = parseInt(colonMatch[2])
+                  const seconds = parseInt(colonMatch[3])
+                  const totalMins = hours * 60 + minutes + (seconds >= 30 ? 1 : 0)
+                  return totalMins > 0 ? `${totalMins}m` : '<1m'
                 }
-              }
-              // Fallback: show original
-              return playTime
-            })()}
-          </div>
-        )}
-      </div>
+                const match = playTime.match(/(\d+)h\s*(\d+)m|(\d+)\s*min|(\d+)m\s*(\d+)s|(\d+)\s*sec/)
+                if (match) {
+                  if (match[1] && match[2]) return `${parseInt(match[1]) * 60 + parseInt(match[2])}m`
+                  else if (match[3]) return `${match[3]}m`
+                  else if (match[4] && match[5]) { const mins = parseInt(match[4]); return mins > 0 ? `${mins}m` : '<1m' }
+                  else if (match[6]) return '<1m'
+                }
+                return playTime
+              })()}
+            </span>
+          )}
+        </div>
+      )}
 
       {/* Name and favorite row */}
       <div className="flex items-center justify-between w-full gap-1 px-0.5">

+ 92 - 13
frontend/src/pages/LEDPage.tsx

@@ -78,6 +78,9 @@ export function LEDPage() {
   // Ref for debouncing color picker API calls
   const colorDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
 
+  // LED control mode
+  const [controlMode, setControlMode] = useState<'manual' | 'automated'>('automated')
+
   // Effect automation state
   const [idleEffect, setIdleEffect] = useState<EffectSettings | null>(null)
   const [playingEffect, setPlayingEffect] = useState<EffectSettings | null>(null)
@@ -89,14 +92,20 @@ export function LEDPage() {
   useEffect(() => {
     const fetchConfig = async () => {
       try {
-        const data = await apiClient.get<{ provider?: string; wled_ip?: string; dw_led_num_leds?: number; dw_led_gpio_pin?: number }>('/get_led_config')
+        const [configData, settingsData] = await Promise.all([
+          apiClient.get<{ provider?: string; wled_ip?: string; dw_led_num_leds?: number; dw_led_gpio_pin?: number }>('/get_led_config'),
+          apiClient.get<{ led?: { control_mode?: string } }>('/api/settings'),
+        ])
         // Map backend response fields to our interface
         setLedConfig({
-          provider: (data.provider as LedConfig['provider']) || 'none',
-          wled_ip: data.wled_ip,
-          num_leds: data.dw_led_num_leds,
-          gpio_pin: data.dw_led_gpio_pin,
+          provider: (configData.provider as LedConfig['provider']) || 'none',
+          wled_ip: configData.wled_ip,
+          num_leds: configData.dw_led_num_leds,
+          gpio_pin: configData.dw_led_gpio_pin,
         })
+        if (settingsData.led?.control_mode) {
+          setControlMode(settingsData.led.control_mode as 'manual' | 'automated')
+        }
       } catch (error) {
         console.error('Error fetching LED config:', error)
       } finally {
@@ -180,6 +189,16 @@ export function LEDPage() {
     }
   }
 
+  const handleControlModeChange = async (mode: 'manual' | 'automated') => {
+    setControlMode(mode)
+    try {
+      await apiClient.patch('/api/settings', { led: { control_mode: mode } })
+      toast.success(mode === 'manual' ? 'Manual / HA mode' : 'DW Automated mode')
+    } catch {
+      toast.error('Failed to update control mode')
+    }
+  }
+
   const handlePowerToggle = async () => {
     try {
       const data = await apiClient.post<{ connected?: boolean; power_on?: boolean; error?: string }>('/api/dw_leds/power', { state: 2 })
@@ -386,15 +405,69 @@ export function LEDPage() {
     )
   }
 
+  // Mode selector card (shared between WLED and DW LEDs views)
+  const modeSelector = (
+    <Card>
+      <CardHeader className="pb-3">
+        <CardTitle className="text-lg flex items-center gap-2">
+          <span className="material-icons-outlined text-muted-foreground">tune</span>
+          LED Control Mode
+        </CardTitle>
+        <CardDescription>
+          Choose how Dune Weaver manages LED effects during playback and idle states
+        </CardDescription>
+      </CardHeader>
+      <CardContent>
+        <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
+          <button
+            onClick={() => handleControlModeChange('manual')}
+            className={`p-4 rounded-lg border-2 text-left transition-all ${
+              controlMode === 'manual'
+                ? 'border-primary bg-primary/5'
+                : 'border-border hover:border-muted-foreground/30'
+            }`}
+          >
+            <div className="flex items-center gap-2 mb-1">
+              <span className="material-icons-outlined text-base">front_hand</span>
+              <span className="font-medium text-sm">Manual / Home Assistant</span>
+            </div>
+            <p className="text-xs text-muted-foreground">
+              Effects persist until changed. Still Sands turns off only.
+            </p>
+          </button>
+          <button
+            onClick={() => handleControlModeChange('automated')}
+            className={`p-4 rounded-lg border-2 text-left transition-all ${
+              controlMode === 'automated'
+                ? 'border-primary bg-primary/5'
+                : 'border-border hover:border-muted-foreground/30'
+            }`}
+          >
+            <div className="flex items-center gap-2 mb-1">
+              <span className="material-icons-outlined text-base">smart_toy</span>
+              <span className="font-medium text-sm">DW Automated</span>
+            </div>
+            <p className="text-xs text-muted-foreground">
+              Auto-switch effects on play/idle. Still Sands turns off and on.
+            </p>
+          </button>
+        </div>
+      </CardContent>
+    </Card>
+  )
+
   // WLED iframe view
   if (ledConfig.provider === 'wled' && ledConfig.wled_ip) {
     return (
-      <div className="flex flex-col w-full py-4" style={{ height: 'calc(100vh - 180px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))' }}>
-        <iframe
-          src={`http://${ledConfig.wled_ip}`}
-          className="w-full h-full rounded-lg border border-border"
-          title="WLED Control"
-        />
+      <div className="flex flex-col w-full max-w-5xl mx-auto gap-4 py-3 sm:py-6 px-0 sm:px-4">
+        {modeSelector}
+        <div style={{ height: 'calc(100vh - 380px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))' }}>
+          <iframe
+            src={`http://${ledConfig.wled_ip}`}
+            className="w-full h-full rounded-lg border border-border"
+            title="WLED Control"
+          />
+        </div>
       </div>
     )
   }
@@ -410,6 +483,8 @@ export function LEDPage() {
 
       <Separator />
 
+      {modeSelector}
+
       {/* Main Control Grid - 2 columns on large screens */}
       <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
         {/* Left Column - Primary Controls */}
@@ -636,7 +711,8 @@ export function LEDPage() {
             </CardContent>
           </Card>
 
-          {/* Auto Turn Off */}
+          {/* Auto Turn Off - hidden in manual mode */}
+          {controlMode === 'automated' && (
           <Card className="flex-1 flex flex-col">
             <CardHeader className="pb-3">
               <CardTitle className="text-lg flex items-center gap-2">
@@ -685,10 +761,12 @@ export function LEDPage() {
               )}
             </CardContent>
           </Card>
+          )}
         </div>
       </div>
 
-      {/* Automation Settings - Full Width */}
+      {/* Automation Settings - Full Width - hidden in manual mode */}
+      {controlMode === 'automated' && (
       <Card>
         <CardHeader className="pb-3">
           <CardTitle className="text-lg flex items-center gap-2">
@@ -763,6 +841,7 @@ export function LEDPage() {
           </div>
         </CardContent>
       </Card>
+      )}
     </div>
   )
 }

+ 135 - 60
frontend/src/pages/PlaylistsPage.tsx

@@ -1,4 +1,5 @@
 import { useState, useEffect, useMemo, useCallback, useRef } from 'react'
+import { useOutletContext } from 'react-router-dom'
 import { toast } from 'sonner'
 import { Trash2 } from 'lucide-react'
 import { apiClient } from '@/lib/apiClient'
@@ -31,6 +32,8 @@ import {
 } from '@/components/ui/dialog'
 
 export function PlaylistsPage() {
+  const { isPlayOnlyActive } = useOutletContext<{ isPlayOnlyActive?: boolean }>() || {}
+
   // Playlists state
   const [playlists, setPlaylists] = useState<string[]>([])
   const [selectedPlaylist, setSelectedPlaylist] = useState<string | null>(() => {
@@ -41,6 +44,10 @@ export function PlaylistsPage() {
 
   // All patterns for the picker modal
   const [allPatterns, setAllPatterns] = useState<PatternMetadata[]>([])
+  const [allPatternHistories, setAllPatternHistories] = useState<Record<string, {
+    play_count: number
+    last_played: string | null
+  }>>({})
   const [previews, setPreviews] = useState<Record<string, PreviewData>>({})
 
   // Pattern picker modal state
@@ -233,8 +240,12 @@ export function PlaylistsPage() {
 
   const fetchAllPatterns = async () => {
     try {
-      const data = await apiClient.get<PatternMetadata[]>('/list_theta_rho_files_with_metadata')
+      const [data, historyData] = await Promise.all([
+        apiClient.get<PatternMetadata[]>('/list_theta_rho_files_with_metadata'),
+        apiClient.get<Record<string, { play_count: number; last_played: string | null }>>('/api/pattern_history_all')
+      ])
       setAllPatterns(data)
+      setAllPatternHistories(historyData)
     } catch (error) {
       console.error('Error fetching patterns:', error)
     }
@@ -500,12 +511,34 @@ export function PlaylistsPage() {
           }
           break
         }
+        case 'plays': {
+          const aKey = a.path.split('/').pop() || ''
+          const bKey = b.path.split('/').pop() || ''
+          const aPlays = allPatternHistories[aKey]?.play_count ?? 0
+          const bPlays = allPatternHistories[bKey]?.play_count ?? 0
+          cmp = aPlays - bPlays
+          if (cmp === 0) {
+            cmp = a.name.localeCompare(b.name)
+          }
+          break
+        }
+        case 'last_played': {
+          const aKey = a.path.split('/').pop() || ''
+          const bKey = b.path.split('/').pop() || ''
+          const aTime = allPatternHistories[aKey]?.last_played || ''
+          const bTime = allPatternHistories[bKey]?.last_played || ''
+          cmp = aTime.localeCompare(bTime)
+          if (cmp === 0) {
+            cmp = a.name.localeCompare(b.name)
+          }
+          break
+        }
       }
       return sortAsc ? cmp : -cmp
     })
 
     return filtered
-  }, [allPatterns, searchQuery, selectedCategory, sortBy, sortAsc, favorites])
+  }, [allPatterns, searchQuery, selectedCategory, sortBy, sortAsc, favorites, allPatternHistories])
 
   // Get pattern name from path
   const getPatternName = (path: string) => {
@@ -542,17 +575,19 @@ export function PlaylistsPage() {
               <h2 className="text-lg font-semibold">My Playlists</h2>
               <p className="text-sm text-muted-foreground">{playlists.length} playlist{playlists.length !== 1 ? 's' : ''}</p>
             </div>
-            <Button
-              variant="ghost"
-              size="icon"
-              className="h-8 w-8"
-              onClick={() => {
-                setNewPlaylistName('')
-                setIsCreateModalOpen(true)
-              }}
-            >
-              <span className="material-icons-outlined text-xl">add</span>
-            </Button>
+            {!isPlayOnlyActive && (
+              <Button
+                variant="ghost"
+                size="icon"
+                className="h-8 w-8"
+                onClick={() => {
+                  setNewPlaylistName('')
+                  setIsCreateModalOpen(true)
+                }}
+              >
+                <span className="material-icons-outlined text-xl">add</span>
+              </Button>
+            )}
           </div>
 
           <nav className="flex-1 overflow-y-auto p-2 space-y-1 min-h-0">
@@ -580,32 +615,34 @@ export function PlaylistsPage() {
                   <span className="material-icons-outlined text-lg">playlist_play</span>
                   <span className="truncate text-sm font-medium">{name}</span>
                 </div>
-                <div className="flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity">
-                  <Button
-                    variant="ghost"
-                    size="icon-sm"
-                    className="h-7 w-7"
-                    onClick={(e) => {
-                      e.stopPropagation()
-                      setPlaylistToRename(name)
-                      setNewPlaylistName(name)
-                      setIsRenameModalOpen(true)
-                    }}
-                  >
-                    <span className="material-icons-outlined text-base">edit</span>
-                  </Button>
-                  <Button
-                    variant="ghost"
-                    size="icon-sm"
-                    className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/20"
-                    onClick={(e) => {
-                      e.stopPropagation()
-                      handleDeletePlaylist(name)
-                    }}
-                  >
-                    <Trash2 className="h-4 w-4" />
-                  </Button>
-                </div>
+                {!isPlayOnlyActive && (
+                  <div className="flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity">
+                    <Button
+                      variant="ghost"
+                      size="icon-sm"
+                      className="h-7 w-7"
+                      onClick={(e) => {
+                        e.stopPropagation()
+                        setPlaylistToRename(name)
+                        setNewPlaylistName(name)
+                        setIsRenameModalOpen(true)
+                      }}
+                    >
+                      <span className="material-icons-outlined text-base">edit</span>
+                    </Button>
+                    <Button
+                      variant="ghost"
+                      size="icon-sm"
+                      className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/20"
+                      onClick={(e) => {
+                        e.stopPropagation()
+                        handleDeletePlaylist(name)
+                      }}
+                    >
+                      <Trash2 className="h-4 w-4" />
+                    </Button>
+                  </div>
+                )}
               </div>
             ))
           )}
@@ -643,15 +680,17 @@ export function PlaylistsPage() {
                 )}
               </div>
             </div>
-            <Button
-              onClick={openPatternPicker}
-              disabled={!selectedPlaylist}
-              size="sm"
-              className="gap-2"
-            >
-              <span className="material-icons-outlined text-base">add</span>
-              <span className="hidden sm:inline">Add Patterns</span>
-            </Button>
+            {!isPlayOnlyActive && (
+              <Button
+                onClick={openPatternPicker}
+                disabled={!selectedPlaylist}
+                size="sm"
+                className="gap-2"
+              >
+                <span className="material-icons-outlined text-base">add</span>
+                <span className="hidden sm:inline">Add Patterns</span>
+              </Button>
+            )}
           </header>
 
           {/* Patterns List */}
@@ -675,10 +714,12 @@ export function PlaylistsPage() {
                   <p className="font-medium">Empty playlist</p>
                   <p className="text-sm">Add patterns to get started</p>
                 </div>
-                <Button variant="secondary" className="mt-2 gap-2" onClick={openPatternPicker}>
-                  <span className="material-icons-outlined text-base">add</span>
-                  Add Patterns
-                </Button>
+                {!isPlayOnlyActive && (
+                  <Button variant="secondary" className="mt-2 gap-2" onClick={openPatternPicker}>
+                    <span className="material-icons-outlined text-base">add</span>
+                    Add Patterns
+                  </Button>
+                )}
               </div>
             ) : (
               <div className="grid grid-cols-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3 sm:gap-4">
@@ -696,13 +737,15 @@ export function PlaylistsPage() {
                           alt={getPatternName(path)}
                         />
                       </div>
-                      <button
-                        className="absolute -top-0.5 -right-0.5 sm:-top-1 sm:-right-1 w-5 h-5 rounded-full bg-destructive hover:bg-destructive/90 text-destructive-foreground flex items-center justify-center opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shadow-sm z-10"
-                        onClick={() => handleRemovePattern(path)}
-                        title="Remove from playlist"
-                      >
-                        <span className="material-icons" style={{ fontSize: '12px' }}>close</span>
-                      </button>
+                      {!isPlayOnlyActive && (
+                        <button
+                          className="absolute -top-0.5 -right-0.5 sm:-top-1 sm:-right-1 w-5 h-5 rounded-full bg-destructive hover:bg-destructive/90 text-destructive-foreground flex items-center justify-center opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shadow-sm z-10"
+                          onClick={() => handleRemovePattern(path)}
+                          title="Remove from playlist"
+                        >
+                          <span className="material-icons" style={{ fontSize: '12px' }}>close</span>
+                        </button>
+                      )}
                     </div>
                     <p className="text-[10px] sm:text-xs truncate font-medium w-full text-center">{getPatternName(path)}</p>
                   </div>
@@ -801,7 +844,10 @@ export function PlaylistsPage() {
                       <SelectContent>
                         {preExecutionOptions.map(opt => (
                           <SelectItem key={opt.value} value={opt.value}>
-                            {opt.label}
+                            <div>
+                              <div>{opt.label}</div>
+                              <div className="text-xs text-muted-foreground font-normal">{opt.description}</div>
+                            </div>
                           </SelectItem>
                         ))}
                       </SelectContent>
@@ -955,6 +1001,8 @@ export function PlaylistsPage() {
                   <SelectItem value="name">Name</SelectItem>
                   <SelectItem value="date">Modified</SelectItem>
                   <SelectItem value="size">Size</SelectItem>
+                  <SelectItem value="plays">Most Played</SelectItem>
+                  <SelectItem value="last_played">Last Played</SelectItem>
                 </SelectContent>
               </Select>
 
@@ -973,6 +1021,33 @@ export function PlaylistsPage() {
 
               <div className="flex-1" />
 
+              {/* Select All / Deselect All toggle */}
+              <Button
+                variant="outline"
+                size="sm"
+                className="h-9 rounded-full bg-card shadow-sm text-sm gap-1.5"
+                onClick={() => {
+                  const allFilteredPaths = filteredPatterns.map(p => p.path)
+                  const allSelected = allFilteredPaths.every(p => selectedPatternPaths.has(p))
+                  setSelectedPatternPaths(prev => {
+                    const next = new Set(prev)
+                    if (allSelected) {
+                      allFilteredPaths.forEach(p => next.delete(p))
+                    } else {
+                      allFilteredPaths.forEach(p => next.add(p))
+                    }
+                    return next
+                  })
+                }}
+              >
+                <span className="material-icons-outlined text-base">
+                  {filteredPatterns.length > 0 && filteredPatterns.every(p => selectedPatternPaths.has(p.path)) ? 'deselect' : 'select_all'}
+                </span>
+                <span className="hidden sm:inline">
+                  {filteredPatterns.length > 0 && filteredPatterns.every(p => selectedPatternPaths.has(p.path)) ? 'Deselect All' : 'Select All'}
+                </span>
+              </Button>
+
               {/* Selection count - compact on mobile */}
               <div className="flex items-center gap-1 sm:gap-2 text-sm bg-card rounded-full px-2 sm:px-3 py-2 shadow-sm border">
                 <span className="material-icons-outlined text-base text-primary">check_circle</span>

+ 294 - 7
frontend/src/pages/SettingsPage.tsx

@@ -1,5 +1,5 @@
 import { useState, useEffect } from 'react'
-import { useSearchParams } from 'react-router-dom'
+import { useSearchParams, useNavigate, Link } from 'react-router-dom'
 import { toast } from 'sonner'
 import { apiClient } from '@/lib/apiClient'
 import { useOnBackendConnected } from '@/hooks/useBackendConnection'
@@ -26,6 +26,7 @@ import {
 } from '@/components/ui/select'
 import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
 import { SearchableSelect } from '@/components/ui/searchable-select'
+import { UpdateDialog } from '@/components/UpdateDialog'
 
 // Types
 
@@ -38,12 +39,14 @@ interface Settings {
   detected_table_type?: string
   effective_table_type?: string
   gear_ratio?: number
+  gear_ratio_override?: number | null
   x_steps_per_mm?: number
   y_steps_per_mm?: number
   available_table_types?: { value: string; label: string }[]
   // Homing settings
   homing_mode?: number
   angular_offset?: number
+  home_on_connect?: boolean
   auto_home_enabled?: boolean
   auto_home_after_patterns?: number
   hard_reset_theta?: boolean
@@ -99,6 +102,7 @@ interface MqttConfig {
 
 export function SettingsPage() {
   const [searchParams, setSearchParams] = useSearchParams()
+  const navigate = useNavigate()
   const sectionParam = searchParams.get('section')
 
   // Connection state
@@ -170,12 +174,19 @@ export function SettingsPage() {
   // Pattern search state for clearing patterns
   const [patternFiles, setPatternFiles] = useState<string[]>([])
 
+  // Security state
+  const [securityMode, setSecurityMode] = useState<'off' | 'lockdown' | 'play_only'>('off')
+  const [securityPassword, setSecurityPassword] = useState('')
+  const [securityPasswordConfirm, setSecurityPasswordConfirm] = useState('')
+  const [hasExistingPassword, setHasExistingPassword] = useState(false)
+
   // Version state
   const [versionInfo, setVersionInfo] = useState<{
     current: string
     latest: string
     update_available: boolean
   } | null>(null)
+  const [updateDialogOpen, setUpdateDialogOpen] = useState(false)
 
   // Helper to scroll to element with header offset
   const scrollToSection = (sectionId: string) => {
@@ -221,6 +232,7 @@ export function SettingsPage() {
       case 'machine':
       case 'homing':
       case 'clearing':
+      case 'security':
         // These all share settings data
         if (!loadedSections.has('_settings')) {
           setLoadedSections((prev) => new Set(prev).add('_settings'))
@@ -343,12 +355,14 @@ export function SettingsPage() {
         detected_table_type: data.machine?.detected_table_type,
         effective_table_type: data.machine?.effective_table_type,
         gear_ratio: data.machine?.gear_ratio,
+        gear_ratio_override: data.machine?.gear_ratio_override ?? null,
         x_steps_per_mm: data.machine?.x_steps_per_mm,
         y_steps_per_mm: data.machine?.y_steps_per_mm,
         available_table_types: data.machine?.available_table_types,
         // Homing settings
         homing_mode: data.homing?.mode,
         angular_offset: data.homing?.angular_offset_degrees,
+        home_on_connect: data.homing?.home_on_connect,
         auto_home_enabled: data.homing?.auto_home_enabled,
         auto_home_after_patterns: data.homing?.auto_home_after_patterns,
         hard_reset_theta: data.homing?.hard_reset_theta,
@@ -383,6 +397,11 @@ export function SettingsPage() {
           time_slots: data.scheduled_pause.time_slots || [],
         })
       }
+      // Set security settings
+      if (data.security) {
+        setSecurityMode(data.security.mode || 'off')
+        setHasExistingPassword(data.security.has_password || false)
+      }
       // Set MQTT config from the same response
       if (data.mqtt) {
         setMqttConfig({
@@ -629,6 +648,7 @@ export function SettingsPage() {
       await apiClient.patch('/api/settings', {
         machine: {
           table_type_override: settings.table_type_override || '',
+          gear_ratio_override: settings.gear_ratio_override ?? 0,
         },
       })
       toast.success('Machine settings saved')
@@ -646,6 +666,7 @@ export function SettingsPage() {
         homing: {
           mode: settings.homing_mode,
           angular_offset_degrees: settings.angular_offset,
+          home_on_connect: settings.home_on_connect,
           auto_home_enabled: settings.auto_home_enabled,
           auto_home_after_patterns: settings.auto_home_after_patterns,
           hard_reset_theta: settings.hard_reset_theta,
@@ -882,6 +903,35 @@ export function SettingsPage() {
                 Choose how the system connects on startup: Auto picks the first available port, Disabled requires manual connection, or select a specific port.
               </p>
             </div>
+
+            {/* Home on Connect */}
+            <div className="p-4 rounded-lg border space-y-3">
+              <div className="flex items-center justify-between">
+                <div>
+                  <p className="font-medium flex items-center gap-2">
+                    <span className="material-icons-outlined text-base">power</span>
+                    Home on Connect
+                  </p>
+                  <p className="text-xs text-muted-foreground mt-1">
+                    Automatically home when connecting on startup. Disable to connect without homing and home manually later.
+                  </p>
+                </div>
+                <Switch
+                  checked={settings.home_on_connect !== false}
+                  onCheckedChange={async (checked) => {
+                    setSettings({ ...settings, home_on_connect: checked })
+                    try {
+                      await apiClient.patch('/api/settings', {
+                        homing: { home_on_connect: checked },
+                      })
+                      toast.success(checked ? 'Home on connect enabled' : 'Home on connect disabled')
+                    } catch {
+                      toast.error('Failed to save setting')
+                    }
+                  }}
+                />
+              </div>
+            </div>
           </AccordionContent>
         </AccordionItem>
 
@@ -961,6 +1011,42 @@ export function SettingsPage() {
               </p>
             </div>
 
+            {/* Gear Ratio Override */}
+            <div className="space-y-3">
+              <Label>Gear Ratio Override</Label>
+              <div className="flex gap-3">
+                <Input
+                  type="number"
+                  step={0.25}
+                  min={0}
+                  placeholder="Auto-detect"
+                  value={settings.gear_ratio_override ?? ''}
+                  onChange={(e) =>
+                    setSettings({
+                      ...settings,
+                      gear_ratio_override: e.target.value ? parseFloat(e.target.value) : null,
+                    })
+                  }
+                  className="flex-1"
+                />
+                <Button
+                  onClick={handleSaveMachineSettings}
+                  disabled={isLoading === 'machine'}
+                  className="gap-2"
+                >
+                  {isLoading === 'machine' ? (
+                    <span className="material-icons-outlined animate-spin">sync</span>
+                  ) : (
+                    <span className="material-icons-outlined">save</span>
+                  )}
+                  Save
+                </Button>
+              </div>
+              <p className="text-xs text-muted-foreground">
+                Override the gear ratio used for angular/radial coupling compensation. Leave empty to auto-detect from table type (6.25 for mini, 10 for standard).
+              </p>
+            </div>
+
             <Alert className="flex items-start">
               <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
               <AlertDescription>
@@ -968,6 +1054,22 @@ export function SettingsPage() {
               </AlertDescription>
             </Alert>
 
+            <Link
+              to="/setup"
+              className="flex items-center justify-between w-full p-3 rounded-lg border hover:bg-muted/50 transition-colors"
+            >
+              <div className="flex items-center gap-3">
+                <span className="material-icons-outlined text-muted-foreground">build</span>
+                <div className="text-left">
+                  <p className="font-medium text-sm">Hardware Setup & Calibration</p>
+                  <p className="text-xs text-muted-foreground">
+                    Calibrate motor directions and edit FluidNC settings
+                  </p>
+                </div>
+              </div>
+              <span className="material-icons-outlined text-muted-foreground">arrow_forward</span>
+            </Link>
+
           </AccordionContent>
         </AccordionItem>
 
@@ -2173,6 +2275,186 @@ export function SettingsPage() {
           </AccordionContent>
         </AccordionItem>
 
+        {/* Security */}
+        <AccordionItem value="security" id="section-security" className="border rounded-lg px-4 overflow-visible bg-card">
+          <AccordionTrigger className="hover:no-underline">
+            <div className="flex items-center gap-3">
+              <span className="material-icons-outlined text-muted-foreground">
+                lock
+              </span>
+              <div className="text-left">
+                <div className="font-semibold">Security</div>
+                <div className="text-sm text-muted-foreground font-normal">
+                  App lock and access control
+                </div>
+              </div>
+            </div>
+          </AccordionTrigger>
+          <AccordionContent className="pt-4 pb-6 space-y-4">
+            <p className="text-sm text-muted-foreground">
+              Restrict access to the app to prevent unauthorized changes. Useful for shared spaces or when the table is accessible to children.
+            </p>
+
+            {/* Security Mode */}
+            <div className="space-y-3">
+              <Label className="text-sm font-medium">Security Mode</Label>
+              <RadioGroup
+                value={securityMode}
+                onValueChange={(value) => {
+                  const newMode = value as 'off' | 'lockdown' | 'play_only'
+                  if (newMode === 'off' && securityMode !== 'off') {
+                    if (!confirm('Turn off security? This will remove the password and unlock the app.')) return
+                  }
+                  setSecurityMode(newMode)
+                  // Clear password fields when switching modes
+                  setSecurityPassword('')
+                  setSecurityPasswordConfirm('')
+                }}
+                className="space-y-2"
+              >
+                <div className="flex items-start gap-3 p-3 rounded-lg border">
+                  <RadioGroupItem value="off" id="security-off" className="mt-0.5" />
+                  <div>
+                    <Label htmlFor="security-off" className="font-medium cursor-pointer">Off</Label>
+                    <p className="text-sm text-muted-foreground">No restrictions. Anyone can use the app.</p>
+                  </div>
+                </div>
+                <div className="flex items-start gap-3 p-3 rounded-lg border">
+                  <RadioGroupItem value="play_only" id="security-play-only" className="mt-0.5" />
+                  <div>
+                    <Label htmlFor="security-play-only" className="font-medium cursor-pointer">Play Only</Label>
+                    <p className="text-sm text-muted-foreground">Anyone can browse and play patterns. Settings require a password.</p>
+                  </div>
+                </div>
+                <div className="flex items-start gap-3 p-3 rounded-lg border">
+                  <RadioGroupItem value="lockdown" id="security-lockdown" className="mt-0.5" />
+                  <div>
+                    <Label htmlFor="security-lockdown" className="font-medium cursor-pointer">Full Lockdown</Label>
+                    <p className="text-sm text-muted-foreground">Password required to access the entire app.</p>
+                  </div>
+                </div>
+              </RadioGroup>
+            </div>
+
+            {/* Password fields (shown when mode != off) */}
+            {securityMode !== 'off' && (
+              <div className="space-y-3 pt-2">
+                <Separator />
+                {hasExistingPassword && (
+                  <p className="text-sm text-muted-foreground">
+                    A password is currently set. Enter a new password below to change it, or leave blank to keep the existing one.
+                  </p>
+                )}
+                <div className="space-y-2">
+                  <Label htmlFor="security-password">
+                    {hasExistingPassword ? 'New Password' : 'Password'}
+                  </Label>
+                  <Input
+                    id="security-password"
+                    type="password"
+                    placeholder={hasExistingPassword ? 'Leave blank to keep current' : 'Enter password'}
+                    value={securityPassword}
+                    onChange={(e) => setSecurityPassword(e.target.value)}
+                  />
+                </div>
+                <div className="space-y-2">
+                  <Label htmlFor="security-password-confirm">Confirm Password</Label>
+                  <Input
+                    id="security-password-confirm"
+                    type="password"
+                    placeholder="Confirm password"
+                    value={securityPasswordConfirm}
+                    onChange={(e) => setSecurityPasswordConfirm(e.target.value)}
+                  />
+                </div>
+                {securityPassword && securityPasswordConfirm && securityPassword !== securityPasswordConfirm && (
+                  <p className="text-sm text-destructive">Passwords do not match</p>
+                )}
+              </div>
+            )}
+
+            {/* Save button */}
+            <Button
+              onClick={async () => {
+                // Validate
+                if (securityMode !== 'off') {
+                  if (securityPassword && securityPassword !== securityPasswordConfirm) {
+                    toast.error('Passwords do not match')
+                    return
+                  }
+                  if (!hasExistingPassword && !securityPassword) {
+                    toast.error('Please set a password')
+                    return
+                  }
+                }
+
+                setIsLoading('security')
+                try {
+                  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+                  const payload: any = { security: { mode: securityMode } }
+                  if (securityPassword) {
+                    payload.security.password = securityPassword
+                  }
+                  await apiClient.patch('/api/settings', payload)
+                  toast.success('Security settings saved')
+                  setSecurityPassword('')
+                  setSecurityPasswordConfirm('')
+                  setHasExistingPassword(securityMode !== 'off')
+                  // Notify Layout to refetch security state
+                  window.dispatchEvent(new CustomEvent('security-updated'))
+                } catch {
+                  toast.error('Failed to save security settings')
+                } finally {
+                  setIsLoading(null)
+                }
+              }}
+              disabled={
+                isLoading === 'security' ||
+                (securityMode !== 'off' && securityPassword !== '' && securityPassword !== securityPasswordConfirm) ||
+                (securityMode !== 'off' && !hasExistingPassword && !securityPassword)
+              }
+              className="w-full gap-2"
+            >
+              {isLoading === 'security' ? (
+                <span className="material-icons-outlined animate-spin">sync</span>
+              ) : (
+                <span className="material-icons-outlined">save</span>
+              )}
+              Save Security Settings
+            </Button>
+          </AccordionContent>
+        </AccordionItem>
+
+        {/* WiFi */}
+        <AccordionItem value="wifi" id="section-wifi" className="border rounded-lg px-4 overflow-visible bg-card">
+          <AccordionTrigger className="hover:no-underline">
+            <div className="flex items-center gap-3">
+              <span className="material-icons-outlined text-muted-foreground">
+                wifi
+              </span>
+              <div className="text-left">
+                <div className="font-semibold">WiFi</div>
+                <div className="text-sm text-muted-foreground font-normal">
+                  Network connection settings
+                </div>
+              </div>
+            </div>
+          </AccordionTrigger>
+          <AccordionContent className="pt-4 pb-6 space-y-3">
+            <p className="text-sm text-muted-foreground">
+              Manage WiFi connections, scan for networks, and configure hotspot mode.
+            </p>
+            <Button
+              variant="outline"
+              className="w-full gap-2"
+              onClick={() => navigate('/wifi-setup')}
+            >
+              <span className="material-icons-outlined">settings</span>
+              Open WiFi Setup
+            </Button>
+          </AccordionContent>
+        </AccordionItem>
+
         {/* Software Version */}
         <AccordionItem value="version" id="section-version" className="border rounded-lg px-4 overflow-visible bg-card">
           <AccordionTrigger className="hover:no-underline">
@@ -2224,13 +2506,18 @@ export function SettingsPage() {
             </div>
 
             {versionInfo?.update_available && (
-              <Alert className="flex items-start">
-                <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
-                <AlertDescription>
-                  To update, SSH into your Raspberry Pi and run <code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono">dw update</code>
-                </AlertDescription>
-              </Alert>
+              <Button onClick={() => setUpdateDialogOpen(true)} className="w-full">
+                <span className="material-icons text-base mr-2">system_update</span>
+                Update Now
+              </Button>
             )}
+
+            <UpdateDialog
+              open={updateDialogOpen}
+              onOpenChange={setUpdateDialogOpen}
+              currentVersion={versionInfo?.current || ''}
+              latestVersion={versionInfo?.latest || ''}
+            />
           </AccordionContent>
         </AccordionItem>
       </Accordion>

+ 914 - 0
frontend/src/pages/SetupPage.tsx

@@ -0,0 +1,914 @@
+import { useState, useCallback } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { toast } from 'sonner'
+import { apiClient } from '@/lib/apiClient'
+import { useStatusStore } from '@/stores/useStatusStore'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Switch } from '@/components/ui/switch'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import { Badge } from '@/components/ui/badge'
+import { Separator } from '@/components/ui/separator'
+import {
+  Accordion,
+  AccordionContent,
+  AccordionItem,
+  AccordionTrigger,
+} from '@/components/ui/accordion'
+
+// ─── Types ───────────────────────────────────────────────────────────────────
+
+interface AxisConfig {
+  steps_per_mm: number | null
+  max_rate_mm_per_min: number | null
+  acceleration_mm_per_sec2: number | null
+  direction_pin: string | null
+  direction_inverted: boolean | null
+  homing_cycle: number | null
+  homing_positive_direction: boolean | null
+  homing_mpos_mm: number | null
+  homing_feed_mm_per_min: number | null
+  homing_seek_mm_per_min: number | null
+  homing_settle_ms: number | null
+  homing_seek_scaler: number | null
+  homing_feed_scaler: number | null
+  hard_limits: boolean | null
+  pulloff_mm: number | null
+}
+
+interface FluidNCConfig {
+  axes: { x: AxisConfig; y: AxisConfig }
+  start: { must_home: boolean | null }
+}
+
+// ─── Calibration Wizard ──────────────────────────────────────────────────────
+
+type WizardStep =
+  | 'precheck'
+  | 'home'
+  | 'test-y'
+  | 'test-x'
+  | 'fix'
+  | 'sanity-y'
+  | 'sanity-x'
+  | 'dip-check'
+  | 'complete'
+
+interface WizardState {
+  step: WizardStep
+  yCorrect: boolean | null
+  xCorrect: boolean | null
+  sending: boolean
+  fixing: boolean
+}
+
+const WIZARD_STEPS: { key: WizardStep; label: string }[] = [
+  { key: 'precheck', label: 'Pre-check' },
+  { key: 'home', label: 'Home' },
+  { key: 'test-y', label: 'Test Y' },
+  { key: 'test-x', label: 'Test X' },
+  { key: 'fix', label: 'Fix' },
+  { key: 'sanity-y', label: 'Verify Y' },
+  { key: 'sanity-x', label: 'Verify X' },
+  { key: 'complete', label: 'Done' },
+]
+
+function getStepIndex(step: WizardStep): number {
+  return WIZARD_STEPS.findIndex((s) => s.key === step)
+}
+
+function CalibrationWizard() {
+  const isConnected = useStatusStore((s) => s.status?.connection_status ?? false)
+  const isRunning = useStatusStore((s) => s.status?.is_running ?? false)
+
+  const [wizard, setWizard] = useState<WizardState>({
+    step: 'precheck',
+    yCorrect: null,
+    xCorrect: null,
+    sending: false,
+    fixing: false,
+  })
+
+  const sendCommand = useCallback(async (command: string, manageSending = true) => {
+    if (manageSending) setWizard((w) => ({ ...w, sending: true }))
+    try {
+      const res = await apiClient.post<{ success: boolean; responses: string[] }>(
+        '/api/fluidnc/command',
+        { command }
+      )
+      if (!res.success) {
+        toast.error('Command failed')
+      }
+    } catch (err) {
+      toast.error(`Error: ${err instanceof Error ? err.message : 'Unknown error'}`)
+    } finally {
+      if (manageSending) setWizard((w) => ({ ...w, sending: false }))
+    }
+  }, [])
+
+  const fixDirection = useCallback(
+    async (axis: 'x' | 'y') => {
+      setWizard((w) => ({ ...w, fixing: true }))
+      try {
+        // Toggle direction, save config
+        await apiClient.patch('/api/fluidnc/config', {
+          axes: { [axis]: { direction_inverted: true } },
+        })
+        toast.success(`${axis.toUpperCase()} direction toggled and saved`)
+      } catch (err) {
+        toast.error(`Fix failed: ${err instanceof Error ? err.message : 'Unknown'}`)
+      } finally {
+        setWizard((w) => ({ ...w, fixing: false }))
+      }
+    },
+    []
+  )
+
+  const restartController = useCallback(async () => {
+    setWizard((w) => ({ ...w, fixing: true }))
+    try {
+      await apiClient.post('/api/fluidnc/command', { command: '$Bye', timeout: 2.0 })
+      toast.success('Restart command sent. Reconnect when ready.')
+    } catch {
+      // $Bye causes disconnect, so errors are expected
+      toast.info('Restart command sent. The controller will reboot.')
+    } finally {
+      setWizard((w) => ({ ...w, fixing: false }))
+    }
+  }, [])
+
+  const waitForIdle = useCallback(async (timeoutMs = 30000) => {
+    const start = Date.now()
+    while (Date.now() - start < timeoutMs) {
+      await new Promise((r) => setTimeout(r, 1000))
+      try {
+        const res = await apiClient.post<{ success: boolean; responses: string[] }>(
+          '/api/fluidnc/command',
+          { command: '?', timeout: 2.0 }
+        )
+        if (res.responses?.some((r) => r.includes('Idle'))) return true
+      } catch {
+        // Ignore poll errors
+      }
+    }
+    return false
+  }, [])
+
+  const reset = () =>
+    setWizard({ step: 'precheck', yCorrect: null, xCorrect: null, sending: false, fixing: false })
+
+  const currentIndex = getStepIndex(wizard.step)
+
+  const canProceedFromPrecheck = isConnected && !isRunning
+
+  return (
+    <div className="space-y-6">
+      {/* Step indicator */}
+      <div className="flex items-center gap-1 overflow-x-auto pb-2">
+        {WIZARD_STEPS.map((s, i) => (
+          <div key={s.key} className="flex items-center gap-1">
+            <button
+              type="button"
+              title={s.label}
+              onClick={() => setWizard((w) => ({ ...w, step: s.key }))}
+              className={`flex items-center justify-center w-7 h-7 rounded-full text-xs font-medium shrink-0 cursor-pointer transition-opacity hover:opacity-80 ${
+                i < currentIndex
+                  ? 'bg-primary text-primary-foreground'
+                  : i === currentIndex
+                    ? 'bg-primary text-primary-foreground ring-2 ring-primary/30'
+                    : 'bg-muted text-muted-foreground'
+              }`}
+            >
+              {i < currentIndex ? (
+                <span className="material-icons-outlined text-sm">check</span>
+              ) : (
+                i + 1
+              )}
+            </button>
+            {i < WIZARD_STEPS.length - 1 && (
+              <div
+                className={`w-4 h-0.5 ${i < currentIndex ? 'bg-primary' : 'bg-muted'}`}
+              />
+            )}
+          </div>
+        ))}
+      </div>
+
+      {/* Step content */}
+      {wizard.step === 'precheck' && (
+        <div className="space-y-4">
+          <h3 className="font-semibold">Pre-check</h3>
+          <p className="text-sm text-muted-foreground">
+            Verify the table is connected and no pattern is running before calibrating motor directions.
+          </p>
+          <div className="space-y-2">
+            <div className="flex items-center gap-2">
+              <span
+                className={`material-icons-outlined text-base ${isConnected ? 'text-green-500' : 'text-red-500'}`}
+              >
+                {isConnected ? 'check_circle' : 'cancel'}
+              </span>
+              <span className="text-sm">Controller connected</span>
+            </div>
+            <div className="flex items-center gap-2">
+              <span
+                className={`material-icons-outlined text-base ${!isRunning ? 'text-green-500' : 'text-red-500'}`}
+              >
+                {!isRunning ? 'check_circle' : 'cancel'}
+              </span>
+              <span className="text-sm">No pattern running</span>
+            </div>
+          </div>
+          <Button
+            onClick={() => setWizard((w) => ({ ...w, step: 'home' }))}
+            disabled={!canProceedFromPrecheck}
+          >
+            Begin Calibration
+          </Button>
+        </div>
+      )}
+
+      {wizard.step === 'home' && (
+        <div className="space-y-4">
+          <h3 className="font-semibold">Home Table</h3>
+          <p className="text-sm text-muted-foreground">
+            Home the table to establish a known position before testing motor directions.
+            The ball will move to the home position.
+          </p>
+          <div className="flex gap-3">
+            <Button
+              onClick={async () => {
+                setWizard((w) => ({ ...w, sending: true }))
+                try {
+                  await apiClient.post('/send_home')
+                  toast.success('Homing complete')
+                  setWizard((w) => ({ ...w, sending: false, step: 'test-y' }))
+                } catch (err) {
+                  toast.error(`Homing failed: ${err instanceof Error ? err.message : 'Unknown error'}`)
+                  setWizard((w) => ({ ...w, sending: false }))
+                }
+              }}
+              disabled={wizard.sending}
+            >
+              {wizard.sending ? (
+                <span className="material-icons-outlined animate-spin mr-2 text-base">sync</span>
+              ) : (
+                <span className="material-icons-outlined mr-2 text-base">home</span>
+              )}
+              {wizard.sending ? 'Homing...' : 'Home Table'}
+            </Button>
+            <Button
+              variant="outline"
+              onClick={() => setWizard((w) => ({ ...w, step: 'test-y' }))}
+              disabled={wizard.sending}
+            >
+              Skip
+            </Button>
+          </div>
+        </div>
+      )}
+
+      {wizard.step === 'test-y' && (
+        <div className="space-y-4">
+          <h3 className="font-semibold">Test Y Axis (Radial)</h3>
+          <p className="text-sm text-muted-foreground">
+            This sends a small radial movement. Watch the ball and answer whether it moved <strong>outward</strong> (toward the perimeter).
+          </p>
+          <Button onClick={() => sendCommand('$J=G91 G21 Y5 F100.0')} disabled={wizard.sending}>
+            {wizard.sending ? (
+              <span className="material-icons-outlined animate-spin mr-2 text-base">sync</span>
+            ) : (
+              <span className="material-icons-outlined mr-2 text-base">play_arrow</span>
+            )}
+            Send Test Command
+          </Button>
+          <div className="flex gap-3 pt-2">
+            <Button
+              variant="outline"
+              onClick={() => setWizard((w) => ({ ...w, yCorrect: true, step: 'test-x' }))}
+              disabled={wizard.sending}
+            >
+              <span className="material-icons-outlined mr-1 text-base text-green-500">thumb_up</span>
+              Yes, moved outward
+            </Button>
+            <Button
+              variant="outline"
+              onClick={() => setWizard((w) => ({ ...w, yCorrect: false, step: 'test-x' }))}
+              disabled={wizard.sending}
+            >
+              <span className="material-icons-outlined mr-1 text-base text-red-500">thumb_down</span>
+              No, moved inward
+            </Button>
+          </div>
+        </div>
+      )}
+
+      {wizard.step === 'test-x' && (
+        <div className="space-y-4">
+          <h3 className="font-semibold">Test X Axis (Angular)</h3>
+          <p className="text-sm text-muted-foreground">
+            This sends a small angular movement. Watch the ball and answer whether it moved <strong>clockwise</strong> when viewed from above.
+          </p>
+          <Button onClick={() => sendCommand('$J=G91 G21 X5 F100.0')} disabled={wizard.sending}>
+            {wizard.sending ? (
+              <span className="material-icons-outlined animate-spin mr-2 text-base">sync</span>
+            ) : (
+              <span className="material-icons-outlined mr-2 text-base">play_arrow</span>
+            )}
+            Send Test Command
+          </Button>
+          <div className="flex gap-3 pt-2">
+            <Button
+              variant="outline"
+              onClick={() => {
+                setWizard((w) => ({
+                  ...w,
+                  xCorrect: true,
+                  step: w.yCorrect === false ? 'fix' : 'sanity-y',
+                }))
+              }}
+              disabled={wizard.sending}
+            >
+              <span className="material-icons-outlined mr-1 text-base text-green-500">thumb_up</span>
+              Yes, moved clockwise
+            </Button>
+            <Button
+              variant="outline"
+              onClick={() => setWizard((w) => ({ ...w, xCorrect: false, step: 'fix' }))}
+              disabled={wizard.sending}
+            >
+              <span className="material-icons-outlined mr-1 text-base text-red-500">thumb_down</span>
+              No, moved counter-clockwise
+            </Button>
+          </div>
+        </div>
+      )}
+
+      {wizard.step === 'fix' && (
+        <div className="space-y-4">
+          <h3 className="font-semibold">Fix Motor Directions</h3>
+          <p className="text-sm text-muted-foreground">
+            The following axes need their direction inverted. Click to auto-fix by toggling the <code>:low</code> flag on the direction pin.
+          </p>
+          <div className="space-y-3">
+            {wizard.yCorrect === false && (
+              <div className="flex items-center justify-between p-3 rounded-lg border">
+                <div>
+                  <p className="font-medium text-sm">Y Axis (Radial)</p>
+                  <p className="text-xs text-muted-foreground">Direction is inverted</p>
+                </div>
+                <Button
+                  size="sm"
+                  onClick={() => fixDirection('y')}
+                  disabled={wizard.fixing}
+                >
+                  {wizard.fixing ? 'Fixing...' : 'Fix Automatically'}
+                </Button>
+              </div>
+            )}
+            {wizard.xCorrect === false && (
+              <div className="flex items-center justify-between p-3 rounded-lg border">
+                <div>
+                  <p className="font-medium text-sm">X Axis (Angular)</p>
+                  <p className="text-xs text-muted-foreground">Direction is inverted</p>
+                </div>
+                <Button
+                  size="sm"
+                  onClick={() => fixDirection('x')}
+                  disabled={wizard.fixing}
+                >
+                  {wizard.fixing ? 'Fixing...' : 'Fix Automatically'}
+                </Button>
+              </div>
+            )}
+          </div>
+          <Alert>
+            <span className="material-icons-outlined text-base mr-2 shrink-0">warning</span>
+            <AlertDescription>
+              Direction pin changes require a controller restart to take effect. After fixing, restart the controller below, then re-run the wizard to verify.
+            </AlertDescription>
+          </Alert>
+          <div className="flex gap-3">
+            <Button variant="outline" onClick={restartController} disabled={wizard.fixing}>
+              <span className="material-icons-outlined mr-2 text-base">restart_alt</span>
+              Restart Controller
+            </Button>
+            <Button onClick={() => setWizard((w) => ({ ...w, step: 'sanity-y' }))}>
+              Continue to Verification
+            </Button>
+          </div>
+        </div>
+      )}
+
+      {wizard.step === 'sanity-y' && (
+        <div className="space-y-4">
+          <h3 className="font-semibold">Verify Y Axis (Larger Movement)</h3>
+          <p className="text-sm text-muted-foreground">
+            This moves the ball to center first, then sends a longer radial movement. The ball should move clearly from <strong>center toward the perimeter</strong>.
+          </p>
+          <Button
+            onClick={async () => {
+              setWizard((w) => ({ ...w, sending: true }))
+              try {
+                await apiClient.post('/move_to_center')
+                await waitForIdle(60000)
+                await sendCommand('$J=G91 G21 Y20 F100.0', false)
+                await waitForIdle()
+              } finally {
+                setWizard((w) => ({ ...w, sending: false }))
+              }
+            }}
+            disabled={wizard.sending}
+          >
+            {wizard.sending ? (
+              <span className="material-icons-outlined animate-spin mr-2 text-base">sync</span>
+            ) : (
+              <span className="material-icons-outlined mr-2 text-base">play_arrow</span>
+            )}
+            {wizard.sending ? 'Moving...' : 'Send Verification Command'}
+          </Button>
+          <div className="flex flex-wrap gap-3 pt-2">
+            <Button
+              onClick={() => setWizard((w) => ({ ...w, step: 'sanity-x' }))}
+              disabled={wizard.sending}
+            >
+              <span className="material-icons-outlined mr-1 text-base text-green-500">check</span>
+              Looks Correct
+            </Button>
+            <Button
+              variant="outline"
+              onClick={() => setWizard((w) => ({ ...w, step: 'dip-check' }))}
+              disabled={wizard.sending}
+            >
+              <span className="material-icons-outlined mr-1 text-base text-amber-500">warning</span>
+              Only Moved Halfway
+            </Button>
+            <Button
+              variant="outline"
+              onClick={reset}
+              disabled={wizard.sending}
+            >
+              Start Over
+            </Button>
+          </div>
+        </div>
+      )}
+
+      {wizard.step === 'sanity-x' && (
+        <div className="space-y-4">
+          <h3 className="font-semibold">Verify X Axis (Larger Movement)</h3>
+          <p className="text-sm text-muted-foreground">
+            This sends a larger angular movement. The ball should make roughly a <strong>full clockwise rotation</strong>.
+            It will also spiral inward slightly — this is normal due to the mechanical coupling between the angular and radial axes.
+          </p>
+          <Button
+            onClick={async () => {
+              setWizard((w) => ({ ...w, sending: true }))
+              try {
+                await sendCommand('$J=G91 G21 X50 F100.0', false)
+                await waitForIdle()
+              } finally {
+                setWizard((w) => ({ ...w, sending: false }))
+              }
+            }}
+            disabled={wizard.sending}
+          >
+            {wizard.sending ? (
+              <span className="material-icons-outlined animate-spin mr-2 text-base">sync</span>
+            ) : (
+              <span className="material-icons-outlined mr-2 text-base">play_arrow</span>
+            )}
+            {wizard.sending ? 'Moving...' : 'Send Verification Command'}
+          </Button>
+          <div className="flex flex-wrap gap-3 pt-2">
+            <Button
+              onClick={() => setWizard((w) => ({ ...w, step: 'complete' }))}
+              disabled={wizard.sending}
+            >
+              <span className="material-icons-outlined mr-1 text-base text-green-500">check</span>
+              Looks Correct
+            </Button>
+            <Button
+              variant="outline"
+              onClick={() => setWizard((w) => ({ ...w, step: 'dip-check' }))}
+              disabled={wizard.sending}
+            >
+              <span className="material-icons-outlined mr-1 text-base text-amber-500">warning</span>
+              Only Half a Revolution
+            </Button>
+            <Button
+              variant="outline"
+              onClick={reset}
+              disabled={wizard.sending}
+            >
+              Start Over
+            </Button>
+          </div>
+        </div>
+      )}
+
+      {wizard.step === 'dip-check' && (
+        <div className="space-y-4">
+          <h3 className="font-semibold">Check DIP Switches</h3>
+          <Alert variant="destructive">
+            <span className="material-icons-outlined text-base mr-2 shrink-0">power_off</span>
+            <AlertDescription>
+              <strong>Turn off the table completely</strong> before touching any hardware. Disconnect power before checking DIP switches.
+            </AlertDescription>
+          </Alert>
+          <p className="text-sm text-muted-foreground">
+            If the ball only moved <strong>half the expected distance</strong>, the stepper driver microstepping DIP switches are likely misconfigured.
+          </p>
+          <div className="rounded-lg border p-4 space-y-3 bg-muted/30">
+            <p className="text-sm font-medium">How to fix:</p>
+            <ol className="text-sm text-muted-foreground list-decimal list-inside space-y-2">
+              <li>Power off the table completely</li>
+              <li>Locate the DIP switches underneath each stepper driver</li>
+              <li>Set <strong>all DIP switches to OFF</strong> (this selects full-step or the driver's default microstepping)</li>
+              <li>Power the table back on and re-run the calibration wizard</li>
+            </ol>
+          </div>
+          <div className="flex gap-3">
+            <Button variant="outline" onClick={reset}>
+              Restart Wizard
+            </Button>
+          </div>
+        </div>
+      )}
+
+      {wizard.step === 'complete' && (
+        <div className="space-y-4">
+          <div className="flex items-center gap-2 text-green-600">
+            <span className="material-icons-outlined text-2xl">check_circle</span>
+            <h3 className="font-semibold text-lg">Calibration Complete</h3>
+          </div>
+          <p className="text-sm text-muted-foreground">
+            Both axes are moving in the correct directions. Your table is ready to use.
+          </p>
+          <Button variant="outline" onClick={reset}>
+            Run Again
+          </Button>
+        </div>
+      )}
+    </div>
+  )
+}
+
+// ─── FluidNC Config Editor ───────────────────────────────────────────────────
+
+const MOVEMENT_FIELDS: { key: keyof AxisConfig; label: string; unit: string }[] = [
+  { key: 'steps_per_mm', label: 'Steps per mm', unit: 'steps' },
+  { key: 'max_rate_mm_per_min', label: 'Max Rate', unit: 'mm/min' },
+  { key: 'acceleration_mm_per_sec2', label: 'Acceleration', unit: 'mm/s²' },
+]
+
+const HOMING_NUMBER_FIELDS: { key: keyof AxisConfig; label: string; unit: string }[] = [
+  { key: 'homing_cycle', label: 'Homing Cycle', unit: '-1 = disabled' },
+  { key: 'homing_mpos_mm', label: 'Homing MPos', unit: 'mm' },
+  { key: 'homing_feed_mm_per_min', label: 'Homing Feed Rate', unit: 'mm/min' },
+  { key: 'homing_seek_mm_per_min', label: 'Homing Seek Rate', unit: 'mm/min' },
+  { key: 'homing_settle_ms', label: 'Homing Settle Time', unit: 'ms' },
+  { key: 'homing_seek_scaler', label: 'Homing Seek Scaler', unit: '' },
+  { key: 'homing_feed_scaler', label: 'Homing Feed Scaler', unit: '' },
+  { key: 'pulloff_mm', label: 'Pulloff Distance', unit: 'mm' },
+]
+
+const HOMING_BOOL_FIELDS: { key: keyof AxisConfig; label: string }[] = [
+  { key: 'homing_positive_direction', label: 'Positive Direction' },
+]
+
+function ConfigEditor() {
+  const isConnected = useStatusStore((s) => s.status?.connection_status ?? false)
+
+  const [config, setConfig] = useState<FluidNCConfig | null>(null)
+  const [original, setOriginal] = useState<FluidNCConfig | null>(null)
+  const [loading, setLoading] = useState(false)
+  const [saving, setSaving] = useState(false)
+
+  const readConfig = useCallback(async () => {
+    setLoading(true)
+    try {
+      const res = await apiClient.get<{ success: boolean; settings: FluidNCConfig }>(
+        '/api/fluidnc/config'
+      )
+      if (res.success) {
+        setConfig(res.settings)
+        setOriginal(structuredClone(res.settings))
+        toast.success('Configuration loaded from controller')
+      }
+    } catch (err) {
+      toast.error(`Failed to read config: ${err instanceof Error ? err.message : 'Unknown'}`)
+    } finally {
+      setLoading(false)
+    }
+  }, [])
+
+  const saveConfig = useCallback(async () => {
+    if (!config || !original) return
+    setSaving(true)
+    try {
+      // Build diff: only send changed fields
+      const update: { axes?: Record<string, Record<string, unknown>>; start?: Record<string, unknown> } = {}
+
+      for (const axis of ['x', 'y'] as const) {
+        const changes: Record<string, unknown> = {}
+        const curr = config.axes[axis]
+        const orig = original.axes[axis]
+        for (const key of Object.keys(curr) as (keyof AxisConfig)[]) {
+          if (key === 'direction_pin') continue // raw pin, skip
+          if (curr[key] !== orig[key] && curr[key] !== null) {
+            changes[key] = curr[key]
+          }
+        }
+        if (Object.keys(changes).length > 0) {
+          if (!update.axes) update.axes = {}
+          update.axes[axis] = changes
+        }
+      }
+
+      if (config.start.must_home !== original.start.must_home && config.start.must_home !== null) {
+        update.start = { must_home: config.start.must_home }
+      }
+
+      if (!update.axes && !update.start) {
+        toast.info('No changes to save')
+        setSaving(false)
+        return
+      }
+
+      const res = await apiClient.patch<{
+        success: boolean
+        saved: boolean
+        changes_applied: string[]
+        restart_required: boolean
+      }>('/api/fluidnc/config', update)
+
+      if (res.success) {
+        setOriginal(structuredClone(config))
+        if (res.restart_required) {
+          toast.warning('Settings saved. Direction pin changes require a controller restart.')
+        } else if (res.saved) {
+          toast.success(`Saved ${res.changes_applied.length} setting(s) to controller`)
+        } else {
+          toast.warning('Settings applied but flash save may have failed')
+        }
+      }
+    } catch (err) {
+      toast.error(`Save failed: ${err instanceof Error ? err.message : 'Unknown'}`)
+    } finally {
+      setSaving(false)
+    }
+  }, [config, original])
+
+  const updateAxis = (axis: 'x' | 'y', key: keyof AxisConfig, value: unknown) => {
+    setConfig((prev) => {
+      if (!prev) return prev
+      return {
+        ...prev,
+        axes: {
+          ...prev.axes,
+          [axis]: { ...prev.axes[axis], [key]: value },
+        },
+      }
+    })
+  }
+
+  const hasChanges =
+    config && original ? JSON.stringify(config) !== JSON.stringify(original) : false
+
+  return (
+    <div className="space-y-6">
+      <div className="flex items-center gap-3">
+        <Button onClick={readConfig} disabled={loading || !isConnected}>
+          {loading ? (
+            <span className="material-icons-outlined animate-spin mr-2 text-base">sync</span>
+          ) : (
+            <span className="material-icons-outlined mr-2 text-base">download</span>
+          )}
+          {loading ? 'Reading...' : 'Read from Controller'}
+        </Button>
+        {config && (
+          <Button onClick={saveConfig} disabled={saving || !hasChanges}>
+            {saving ? (
+              <span className="material-icons-outlined animate-spin mr-2 text-base">sync</span>
+            ) : (
+              <span className="material-icons-outlined mr-2 text-base">save</span>
+            )}
+            {saving ? 'Saving...' : 'Save to Controller'}
+          </Button>
+        )}
+        {hasChanges && (
+          <Badge variant="secondary">Unsaved changes</Badge>
+        )}
+      </div>
+
+      {!isConnected && (
+        <Alert>
+          <span className="material-icons-outlined text-base mr-2 shrink-0">link_off</span>
+          <AlertDescription>
+            Connect to a controller first to read or modify FluidNC settings.
+          </AlertDescription>
+        </Alert>
+      )}
+
+      {config && (
+        <Accordion type="multiple" defaultValue={['axis-x', 'axis-y']}>
+          {/* Per-axis sections */}
+          {(['x', 'y'] as const).map((axis) => (
+            <AccordionItem key={axis} value={`axis-${axis}`} className="border rounded-lg px-4 mt-2 bg-card">
+              <AccordionTrigger className="hover:no-underline">
+                <div className="flex items-center gap-3">
+                  <span className="material-icons-outlined text-muted-foreground">
+                    {axis === 'x' ? 'rotate_right' : 'swap_vert'}
+                  </span>
+                  <div className="text-left">
+                    <div className="font-semibold">
+                      {axis.toUpperCase()} Axis — {axis === 'x' ? 'Angular' : 'Radial'}
+                    </div>
+                    <div className="text-sm text-muted-foreground font-normal">
+                      Movement, direction, and homing for the {axis === 'x' ? 'angular' : 'radial'} motor
+                    </div>
+                  </div>
+                </div>
+              </AccordionTrigger>
+              <AccordionContent className="pt-4 pb-6 space-y-6">
+                {/* Movement */}
+                <div className="space-y-3">
+                  <Label className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Movement</Label>
+                  <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
+                    {MOVEMENT_FIELDS.map((field) => (
+                      <div key={field.key} className="space-y-1">
+                        <Label className="text-xs text-muted-foreground">{field.label}</Label>
+                        <Input
+                          type="number"
+                          step="any"
+                          value={(config.axes[axis][field.key] as number | null) ?? ''}
+                          onChange={(e) =>
+                            updateAxis(
+                              axis,
+                              field.key,
+                              e.target.value ? parseFloat(e.target.value) : null
+                            )
+                          }
+                          placeholder={field.unit}
+                        />
+                      </div>
+                    ))}
+                  </div>
+                </div>
+
+                <Separator />
+
+                {/* Direction */}
+                <div className="space-y-3">
+                  <Label className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Direction</Label>
+                  <div className="flex items-center justify-between p-3 rounded-lg border">
+                    <div>
+                      {config.axes[axis].direction_pin !== null ? (
+                        <p className="text-xs text-muted-foreground font-mono">
+                          Pin: {config.axes[axis].direction_pin}
+                        </p>
+                      ) : (
+                        <p className="text-xs text-muted-foreground">
+                          Not available (may not be a stepstick driver)
+                        </p>
+                      )}
+                    </div>
+                    {config.axes[axis].direction_inverted !== null && (
+                      <div className="flex items-center gap-2">
+                        <Label className="text-sm">Inverted</Label>
+                        <Switch
+                          checked={config.axes[axis].direction_inverted ?? false}
+                          onCheckedChange={(checked) =>
+                            updateAxis(axis, 'direction_inverted', checked)
+                          }
+                        />
+                      </div>
+                    )}
+                  </div>
+                </div>
+
+                <Separator />
+
+                {/* Homing */}
+                <div className="space-y-3">
+                  <Label className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Homing</Label>
+                  <div className="space-y-3">
+                    {HOMING_BOOL_FIELDS.map((field) => (
+                      <div
+                        key={field.key}
+                        className="flex items-center justify-between p-2 rounded-lg border"
+                      >
+                        <Label className="text-sm">{field.label}</Label>
+                        <Switch
+                          checked={(config.axes[axis][field.key] as boolean) ?? false}
+                          onCheckedChange={(checked) => updateAxis(axis, field.key, checked)}
+                        />
+                      </div>
+                    ))}
+                  </div>
+                  <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
+                    {HOMING_NUMBER_FIELDS.map((field) => (
+                      <div key={field.key} className="space-y-1">
+                        <Label className="text-xs text-muted-foreground">{field.label}</Label>
+                        <Input
+                          type="number"
+                          step="any"
+                          value={(config.axes[axis][field.key] as number | null) ?? ''}
+                          onChange={(e) =>
+                            updateAxis(
+                              axis,
+                              field.key,
+                              e.target.value ? parseFloat(e.target.value) : null
+                            )
+                          }
+                          placeholder={field.unit}
+                        />
+                      </div>
+                    ))}
+                  </div>
+                </div>
+              </AccordionContent>
+            </AccordionItem>
+          ))}
+        </Accordion>
+      )}
+    </div>
+  )
+}
+
+// ─── Setup Page ──────────────────────────────────────────────────────────────
+
+export function SetupPage() {
+  const navigate = useNavigate()
+
+  return (
+    <div className="max-w-4xl mx-auto space-y-6 p-4 pb-32">
+      {/* Header */}
+      <div className="flex items-center gap-3">
+        <Button variant="ghost" size="icon" onClick={() => navigate('/settings')}>
+          <span className="material-icons-outlined">arrow_back</span>
+        </Button>
+        <div>
+          <h1 className="text-2xl font-bold">Hardware Setup</h1>
+          <p className="text-sm text-muted-foreground">
+            Calibrate motors and configure FluidNC settings
+          </p>
+        </div>
+      </div>
+
+      {/* Info banner */}
+      <Alert>
+        <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
+        <AlertDescription>
+          This page is for <strong>FluidNC-based boards with bipolar stepper motors</strong> (DLC32, MKS boards).
+          Not applicable to unipolar/28BYJ-48 setups.
+        </AlertDescription>
+      </Alert>
+
+      {/* Main content */}
+      <Accordion type="multiple" defaultValue={['calibration', 'config']}>
+        <AccordionItem value="calibration" className="border rounded-lg px-4 bg-card">
+          <AccordionTrigger className="hover:no-underline">
+            <div className="flex items-center gap-3">
+              <span className="material-icons-outlined text-muted-foreground">tune</span>
+              <div className="text-left">
+                <div className="font-semibold">Calibration Wizard</div>
+                <div className="text-sm text-muted-foreground font-normal">
+                  Verify and fix motor directions step by step
+                </div>
+              </div>
+            </div>
+          </AccordionTrigger>
+          <AccordionContent className="pt-4 pb-6">
+            <CalibrationWizard />
+          </AccordionContent>
+        </AccordionItem>
+
+        <AccordionItem value="config" className="border rounded-lg px-4 mt-2 bg-card">
+          <AccordionTrigger className="hover:no-underline">
+            <div className="flex items-center gap-3">
+              <span className="material-icons-outlined text-muted-foreground">settings</span>
+              <div className="text-left">
+                <div className="font-semibold">FluidNC Configuration</div>
+                <div className="text-sm text-muted-foreground font-normal">
+                  Read and edit curated controller settings
+                </div>
+              </div>
+            </div>
+          </AccordionTrigger>
+          <AccordionContent className="pt-4 pb-6" forceMount>
+            <Alert className="mb-4">
+              <span className="material-icons-outlined text-base mr-2 shrink-0">warning</span>
+              <AlertDescription>
+                These are low-level FluidNC firmware settings. Only modify them if you understand what each parameter does — incorrect values can cause erratic movement or prevent the table from working.
+              </AlertDescription>
+            </Alert>
+            <ConfigEditor />
+          </AccordionContent>
+        </AccordionItem>
+      </Accordion>
+    </div>
+  )
+}

+ 630 - 0
frontend/src/pages/WiFiSetupPage.tsx

@@ -0,0 +1,630 @@
+import { useState, useEffect, useCallback } from 'react'
+import { toast } from 'sonner'
+import { apiClient } from '@/lib/apiClient'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Card, CardContent } from '@/components/ui/card'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import { Badge } from '@/components/ui/badge'
+import { Separator } from '@/components/ui/separator'
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+
+interface WiFiNetwork {
+  ssid: string
+  signal: number
+  security: string
+  saved: boolean
+  active: boolean
+}
+
+interface WiFiStatus {
+  mode: string
+  ssid: string
+  ip: string
+  hostname: string
+}
+
+interface SavedConnection {
+  name: string
+  ssid: string
+}
+
+function SignalIcon({ signal }: { signal: number }) {
+  const bars = signal >= 75 ? 'signal_wifi_4_bar' :
+               signal >= 50 ? 'network_wifi_3_bar' :
+               signal >= 25 ? 'network_wifi_2_bar' :
+               'network_wifi_1_bar'
+  const color = signal >= 50 ? 'text-green-500' : signal >= 25 ? 'text-yellow-500' : 'text-red-500'
+  return <span className={`material-icons text-lg ${color}`}>{bars}</span>
+}
+
+export function WiFiSetupPage() {
+  const [status, setStatus] = useState<WiFiStatus | null>(null)
+  const [networks, setNetworks] = useState<WiFiNetwork[]>([])
+  const [savedConnections, setSavedConnections] = useState<SavedConnection[]>([])
+  const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork | null>(null)
+  const [password, setPassword] = useState('')
+  const [showPassword, setShowPassword] = useState(false)
+  const [isScanning, setIsScanning] = useState(false)
+  const [isConnecting, setIsConnecting] = useState(false)
+  const [isSaving, setIsSaving] = useState(false)
+  const [isManualEntry, setIsManualEntry] = useState(false)
+  const [manualSsid, setManualSsid] = useState('')
+  const [forgetSsid, setForgetSsid] = useState<string | null>(null)
+  const [apPassword, setApPassword] = useState('')
+  const [apPasswordInput, setApPasswordInput] = useState('')
+  const [showApPassword, setShowApPassword] = useState(false)
+  const [isSavingApPassword, setIsSavingApPassword] = useState(false)
+
+  const fetchStatus = useCallback(async () => {
+    try {
+      const data = await apiClient.get<WiFiStatus>('/api/wifi/status')
+      setStatus(data)
+    } catch {
+      // WiFi API may not be available (e.g., not on Pi)
+    }
+  }, [])
+
+  const fetchSaved = useCallback(async () => {
+    try {
+      const data = await apiClient.get<SavedConnection[]>('/api/wifi/saved')
+      setSavedConnections(data)
+    } catch {
+      // Silently fail
+    }
+  }, [])
+
+  const fetchApPassword = useCallback(async () => {
+    try {
+      const data = await apiClient.get<{ password: string }>('/api/wifi/hotspot/password')
+      setApPassword(data.password)
+      setApPasswordInput(data.password)
+    } catch {
+      // Silently fail
+    }
+  }, [])
+
+  const scanNetworks = useCallback(async () => {
+    setIsScanning(true)
+    try {
+      const data = await apiClient.get<WiFiNetwork[]>('/api/wifi/networks')
+      setNetworks(data)
+    } catch {
+      toast.error('Failed to scan networks')
+    } finally {
+      setIsScanning(false)
+    }
+  }, [])
+
+  useEffect(() => {
+    fetchStatus()
+    scanNetworks()
+    fetchSaved()
+    fetchApPassword()
+  }, [fetchStatus, scanNetworks, fetchSaved, fetchApPassword])
+
+  const needsPassword = isManualEntry || (selectedNetwork &&
+    selectedNetwork.security !== 'Open' &&
+    !selectedNetwork.saved)
+
+  const handleConnect = async () => {
+    const ssid = isManualEntry ? manualSsid.trim() : selectedNetwork?.ssid
+    if (!ssid) return
+
+    setIsConnecting(true)
+    try {
+      const result = await apiClient.post<{ success: boolean; message: string }>('/api/wifi/connect', {
+        ssid,
+        password: needsPassword ? password : '',
+      })
+
+      if (result.success) {
+        toast.success(result.message)
+        closeDialog()
+        fetchStatus()
+        fetchSaved()
+        scanNetworks()
+      }
+    } catch (err) {
+      const message = err instanceof Error ? err.message : 'Connection failed'
+      toast.error(message)
+    } finally {
+      setIsConnecting(false)
+    }
+  }
+
+  const handleSave = async () => {
+    const ssid = manualSsid.trim()
+    if (!ssid) return
+
+    setIsSaving(true)
+    try {
+      const result = await apiClient.post<{ success: boolean; message: string }>('/api/wifi/save', {
+        ssid,
+        password: password || '',
+      })
+      if (result.success) {
+        toast.success(result.message)
+        closeDialog()
+        fetchSaved()
+      }
+    } catch (err) {
+      const message = err instanceof Error ? err.message : 'Failed to save network'
+      toast.error(message)
+    } finally {
+      setIsSaving(false)
+    }
+  }
+
+  const handleForget = async () => {
+    if (!forgetSsid) return
+    const ssid = forgetSsid
+    setForgetSsid(null)
+    try {
+      await apiClient.post('/api/wifi/forget', { ssid })
+      toast.success(`Forgot '${ssid}'`)
+      fetchSaved()
+      scanNetworks()
+    } catch {
+      toast.error('Failed to forget network')
+    }
+  }
+
+  const isForgetActive = forgetSsid === status?.ssid
+  const otherSavedCount = savedConnections.filter(c => c.ssid !== forgetSsid).length
+  const willStartHotspot = isForgetActive && otherSavedCount === 0
+
+  const handleSaveApPassword = async () => {
+    setIsSavingApPassword(true)
+    try {
+      const result = await apiClient.post<{ success: boolean; message: string }>('/api/wifi/hotspot/password', {
+        password: apPasswordInput,
+      })
+      if (result.success) {
+        setApPassword(apPasswordInput)
+        toast.success(result.message)
+      }
+    } catch (err) {
+      const message = err instanceof Error ? err.message : 'Failed to update password'
+      toast.error(message)
+    } finally {
+      setIsSavingApPassword(false)
+    }
+  }
+
+  const openConnectDialog = (network: WiFiNetwork) => {
+    setSelectedNetwork(network)
+    setPassword('')
+    setShowPassword(false)
+  }
+
+  const closeDialog = () => {
+    if (!isConnecting && !isSaving) {
+      setSelectedNetwork(null)
+      setPassword('')
+      setShowPassword(false)
+      setIsManualEntry(false)
+      setManualSsid('')
+    }
+  }
+
+  const openManualEntry = () => {
+    setIsManualEntry(true)
+    setManualSsid('')
+    setPassword('')
+    setShowPassword(false)
+    setSelectedNetwork({ ssid: '', signal: 0, security: 'Manual', saved: false, active: false })
+  }
+
+  const isHotspotMode = status?.mode === 'hotspot'
+
+  return (
+    <div className="container max-w-lg mx-auto px-3 py-4 space-y-3">
+      {/* Hotspot Welcome Banner */}
+      {isHotspotMode && (
+        <Alert className="border-blue-200 bg-blue-50 dark:bg-blue-950/30 dark:border-blue-800">
+          <span className="material-icons text-blue-500 mr-2">wifi_tethering</span>
+          <AlertDescription>
+            <strong>Welcome to Dune Weaver!</strong>
+            <br />
+            Connect to your home WiFi network below to get started.
+          </AlertDescription>
+        </Alert>
+      )}
+
+      {/* Current Status */}
+      <Card>
+        <CardContent className="pt-4 pb-3 px-4">
+          <div className="flex items-center gap-2 mb-2">
+            <span className="material-icons-outlined text-base text-muted-foreground">info</span>
+            <span className="font-semibold text-sm">WiFi Status</span>
+          </div>
+          {status ? (
+            <div className="flex flex-wrap items-center justify-between gap-y-1 text-sm">
+              <div className="flex flex-wrap items-center gap-x-5 gap-y-1">
+                <div className="flex items-center gap-1.5">
+                  <span className="text-muted-foreground text-xs">Mode</span>
+                  <Badge variant={isHotspotMode ? 'secondary' : 'default'} className="text-xs px-1.5 py-0">
+                    {status.mode === 'hotspot' ? 'Hotspot' :
+                     status.mode === 'client' ? 'Connected' : status.mode}
+                  </Badge>
+                </div>
+                {status.ssid && (
+                  <div className="flex items-center gap-1.5">
+                    <span className="text-muted-foreground text-xs">Network</span>
+                    <span className="font-medium text-xs">{status.ssid}</span>
+                  </div>
+                )}
+                {status.ip && (
+                  <div className="flex items-center gap-1.5">
+                    <span className="text-muted-foreground text-xs">IP</span>
+                    <span className="font-mono text-xs">{status.ip}</span>
+                  </div>
+                )}
+              </div>
+              <div className="flex items-center gap-1.5">
+                <span className="text-muted-foreground text-xs">Host</span>
+                <span className="font-mono text-xs">{status.hostname}.local</span>
+              </div>
+            </div>
+          ) : (
+            <p className="text-sm text-muted-foreground">Loading status...</p>
+          )}
+          {/* Raspberry Pi note */}
+          {!isHotspotMode && (
+            <p className="text-xs text-muted-foreground mt-2">
+              WiFi management requires a Raspberry Pi.
+            </p>
+          )}
+        </CardContent>
+      </Card>
+
+      {/* Saved Networks */}
+      {savedConnections.length > 0 && (
+        <Card>
+          <CardContent className="pt-4 pb-2 px-4">
+            <div className="flex items-center gap-2 mb-2">
+              <span className="material-icons-outlined text-base text-muted-foreground">bookmark</span>
+              <span className="font-semibold text-sm">Saved Networks</span>
+            </div>
+            <div className="space-y-0.5">
+              {savedConnections.map((con) => (
+                <div
+                  key={con.name}
+                  className="flex items-center justify-between px-2.5 py-2 rounded-lg hover:bg-muted/50"
+                >
+                  <div className="flex items-center gap-2.5">
+                    <span className="material-icons text-muted-foreground text-lg">wifi</span>
+                    <span className="font-medium text-sm">{con.ssid}</span>
+                  </div>
+                  <Button
+                    variant="ghost"
+                    size="sm"
+                    className="h-7 w-7 p-0"
+                    onClick={() => setForgetSsid(con.ssid)}
+                  >
+                    <span className="material-icons text-sm text-destructive">delete</span>
+                  </Button>
+                </div>
+              ))}
+            </div>
+          </CardContent>
+        </Card>
+      )}
+
+      {/* Hotspot Password */}
+      <Card>
+        <CardContent className="pt-4 pb-3 px-4">
+          <div className="flex items-center gap-2 mb-2">
+            <span className="material-icons-outlined text-base text-muted-foreground">wifi_tethering</span>
+            <span className="font-semibold text-sm">Hotspot Password</span>
+            {!apPassword && (
+              <Badge variant="secondary" className="text-xs px-1.5 py-0">Open</Badge>
+            )}
+          </div>
+          <p className="text-xs text-muted-foreground mb-3">
+            Set a password for the Dune Weaver hotspot. Leave empty for an open network.
+          </p>
+          <div className="flex gap-2">
+            <div className="relative flex-1">
+              <Input
+                type={showApPassword ? 'text' : 'password'}
+                value={apPasswordInput}
+                onChange={(e) => setApPasswordInput(e.target.value)}
+                placeholder="No password (open)"
+                className="pr-8 h-8 text-sm"
+                onKeyDown={(e) => {
+                  if (e.key === 'Enter' && apPasswordInput !== apPassword) handleSaveApPassword()
+                }}
+              />
+              <button
+                type="button"
+                onClick={() => setShowApPassword(!showApPassword)}
+                className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
+              >
+                <span className="material-icons text-sm">
+                  {showApPassword ? 'visibility_off' : 'visibility'}
+                </span>
+              </button>
+            </div>
+            <Button
+              size="sm"
+              className="h-8"
+              onClick={handleSaveApPassword}
+              disabled={isSavingApPassword || apPasswordInput === apPassword || (apPasswordInput.length > 0 && apPasswordInput.length < 8)}
+            >
+              {isSavingApPassword ? (
+                <span className="material-icons text-sm animate-spin">refresh</span>
+              ) : (
+                'Save'
+              )}
+            </Button>
+          </div>
+          {apPasswordInput && apPasswordInput.length < 8 && apPasswordInput.length > 0 && (
+            <p className="text-xs text-destructive mt-1">
+              Password must be at least 8 characters
+            </p>
+          )}
+        </CardContent>
+      </Card>
+
+      {/* Help */}
+      {isHotspotMode && (
+        <>
+          <Separator />
+          <div className="text-center text-sm text-muted-foreground space-y-1">
+            <p>After connecting, access Dune Weaver at:</p>
+            <code className="text-xs bg-muted px-2 py-1 rounded">
+              http://{status?.hostname || 'duneweaver'}.local
+            </code>
+          </div>
+        </>
+      )}
+
+      {/* Available Networks */}
+      <Card>
+        <CardContent className="pt-4 pb-2 px-4">
+          <div className="flex items-center justify-between mb-2">
+            <div className="flex items-center gap-2">
+              <span className="material-icons-outlined text-base text-muted-foreground">wifi_find</span>
+              <span className="font-semibold text-sm">Networks</span>
+              <span className="text-xs text-muted-foreground">
+                {networks.length > 0
+                  ? `(${networks.length})`
+                  : ''}
+              </span>
+            </div>
+            <div className="flex items-center gap-1">
+              <Button
+                variant="ghost"
+                size="sm"
+                className="h-7 w-7 p-0"
+                onClick={openManualEntry}
+                title="Add network manually"
+              >
+                <span className="material-icons text-base">add</span>
+              </Button>
+              <Button
+                variant="ghost"
+                size="sm"
+                className="h-7 w-7 p-0"
+                onClick={scanNetworks}
+                disabled={isScanning}
+              >
+                <span className={`material-icons text-base ${isScanning ? 'animate-spin' : ''}`}>
+                  refresh
+                </span>
+              </Button>
+            </div>
+          </div>
+          <div className="space-y-0.5">
+            {networks.length === 0 && isScanning && (
+              <p className="text-sm text-muted-foreground text-center py-3">
+                Scanning for networks...
+              </p>
+            )}
+            {networks.length === 0 && !isScanning && (
+              <p className="text-sm text-muted-foreground text-center py-3">
+                No networks found. Try scanning again.
+              </p>
+            )}
+            {networks.map((network) => (
+              <button
+                key={network.ssid}
+                onClick={() => openConnectDialog(network)}
+                className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-left transition-colors hover:bg-muted/50
+                  ${network.active ? 'bg-green-50 dark:bg-green-950/20' : ''}`}
+              >
+                <SignalIcon signal={network.signal} />
+                <div className="flex-1 min-w-0">
+                  <p className="font-medium text-sm truncate">{network.ssid}</p>
+                  <p className="text-xs text-muted-foreground">
+                    {network.security}
+                    {network.saved && ' · Saved'}
+                    {network.active && ' · Connected'}
+                  </p>
+                </div>
+                <span className="text-xs text-muted-foreground">{network.signal}%</span>
+                {network.security !== 'Open' && (
+                  <span className="material-icons text-sm text-muted-foreground">lock</span>
+                )}
+              </button>
+            ))}
+          </div>
+        </CardContent>
+      </Card>
+
+      {/* Forget Confirmation Dialog */}
+      <Dialog open={forgetSsid !== null} onOpenChange={(open) => { if (!open) setForgetSsid(null) }}>
+        <DialogContent className="sm:max-w-md">
+          <DialogHeader>
+            <DialogTitle>Forget '{forgetSsid}'?</DialogTitle>
+            <DialogDescription>
+              {willStartHotspot ? (
+                <>
+                  This is your only saved network. Forgetting it will start the
+                  {' '}<strong>Dune Weaver</strong> hotspot. Connect to the Dune Weaver WiFi to access this page again.
+                </>
+              ) : isForgetActive ? (
+                <>
+                  You are currently connected to this network. The system will
+                  try the next saved network, or start the hotspot if none are in range.
+                </>
+              ) : (
+                'The saved credentials for this network will be removed.'
+              )}
+            </DialogDescription>
+          </DialogHeader>
+          <DialogFooter className="flex-row gap-2 sm:justify-end">
+            <Button variant="outline" onClick={() => setForgetSsid(null)}>
+              Cancel
+            </Button>
+            <Button variant="destructive" onClick={handleForget}>
+              Forget
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+
+      {/* Connect Dialog */}
+      <Dialog open={selectedNetwork !== null} onOpenChange={(open) => { if (!open) closeDialog() }}>
+        <DialogContent className="sm:max-w-md">
+          <DialogHeader>
+            <DialogTitle className="flex items-center gap-2">
+              {isManualEntry ? (
+                <>
+                  <span className="material-icons text-lg text-muted-foreground">add_circle_outline</span>
+                  Add Network
+                </>
+              ) : (
+                <>
+                  {selectedNetwork && <SignalIcon signal={selectedNetwork.signal} />}
+                  {selectedNetwork?.ssid}
+                </>
+              )}
+            </DialogTitle>
+            <DialogDescription>
+              {isManualEntry
+                ? 'Enter the network name and password'
+                : <>
+                    {selectedNetwork?.security !== 'Open' ? 'Secured network' : 'Open network'}
+                    {selectedNetwork?.saved && ' · Saved'}
+                  </>
+              }
+            </DialogDescription>
+          </DialogHeader>
+
+          <div className="space-y-4">
+            {isManualEntry && (
+              <div className="space-y-2">
+                <Label htmlFor="wifi-ssid">Network Name (SSID)</Label>
+                <Input
+                  id="wifi-ssid"
+                  type="text"
+                  value={manualSsid}
+                  onChange={(e) => setManualSsid(e.target.value)}
+                  placeholder="Enter network name"
+                  autoFocus
+                />
+              </div>
+            )}
+
+            {needsPassword && (
+              <div className="space-y-2">
+                <Label htmlFor="wifi-password">Password</Label>
+                <div className="relative">
+                  <Input
+                    id="wifi-password"
+                    type={showPassword ? 'text' : 'password'}
+                    value={password}
+                    onChange={(e) => setPassword(e.target.value)}
+                    placeholder="Enter WiFi password"
+                    autoFocus={!isManualEntry}
+                    onKeyDown={(e) => {
+                      if (e.key === 'Enter' && password) handleConnect()
+                    }}
+                  />
+                  <button
+                    type="button"
+                    onClick={() => setShowPassword(!showPassword)}
+                    className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
+                  >
+                    <span className="material-icons text-sm">
+                      {showPassword ? 'visibility_off' : 'visibility'}
+                    </span>
+                  </button>
+                </div>
+              </div>
+            )}
+
+            {isManualEntry ? (
+              <div className="flex gap-2">
+                <Button
+                  variant="outline"
+                  className="flex-1"
+                  onClick={handleSave}
+                  disabled={isSaving || isConnecting || !manualSsid.trim() || (!!needsPassword && !password)}
+                >
+                  {isSaving ? (
+                    <>
+                      <span className="material-icons text-sm animate-spin mr-2">refresh</span>
+                      Saving...
+                    </>
+                  ) : (
+                    <>
+                      <span className="material-icons text-sm mr-2">bookmark_add</span>
+                      Save
+                    </>
+                  )}
+                </Button>
+                <Button
+                  className="flex-1"
+                  onClick={handleConnect}
+                  disabled={isConnecting || isSaving || !manualSsid.trim() || (!!needsPassword && !password)}
+                >
+                  {isConnecting ? (
+                    <>
+                      <span className="material-icons text-sm animate-spin mr-2">refresh</span>
+                      Connecting...
+                    </>
+                  ) : (
+                    <>
+                      <span className="material-icons text-sm mr-2">wifi</span>
+                      Connect
+                    </>
+                  )}
+                </Button>
+              </div>
+            ) : (
+              <Button
+                className="w-full"
+                onClick={handleConnect}
+                disabled={isConnecting || (!!needsPassword && !password)}
+              >
+                {isConnecting ? (
+                  <>
+                    <span className="material-icons text-sm animate-spin mr-2">refresh</span>
+                    Connecting...
+                  </>
+                ) : (
+                  <>
+                    <span className="material-icons text-sm mr-2">wifi</span>
+                    Connect
+                  </>
+                )}
+              </Button>
+            )}
+          </div>
+        </DialogContent>
+      </Dialog>
+    </div>
+  )
+}

+ 2 - 0
frontend/src/stores/useStatusStore.ts

@@ -36,6 +36,8 @@ export interface StatusData {
   connection_status: boolean
   current_theta: number
   current_rho: number
+  firmware_version: string | null
+  table_type: string | null
 }
 
 interface StatusStore {

+ 4 - 0
frontend/vite.config.ts

@@ -113,6 +113,10 @@ export default defineConfig({
       },
       // All /api endpoints
       '/api': 'http://localhost:8080',
+      // Captive portal detection probes (hotspot mode)
+      '/hotspot-detect.html': 'http://localhost:8080',
+      '/generate_204': 'http://localhost:8080',
+      '/connecttest.txt': 'http://localhost:8080',
       // Static assets
       '/static': 'http://localhost:8080',
       // Preview images

+ 396 - 48
main.py

@@ -19,12 +19,15 @@ import signal
 import asyncio
 from contextlib import asynccontextmanager
 from modules.led.led_interface import LEDInterface
+from modules.screen.screen_controller import ScreenController
 from modules.led.idle_timeout_manager import idle_timeout_manager
 from modules.core.cache_manager import get_cache_path, generate_image_preview, get_pattern_metadata
 from modules.core.version_manager import version_manager
 from modules.core.log_handler import init_memory_handler, get_memory_handler
+from modules.wifi.router import router as wifi_router, captive_portal_router
 import json
 import base64
+import hashlib
 import time
 import subprocess
 
@@ -103,26 +106,29 @@ async def lifespan(app: FastAPI):
             # Connect without homing first (fast)
             await asyncio.to_thread(connection_manager.connect_device, False)
 
-            # If connected, perform homing in background
+            # If connected, perform homing in background (unless disabled)
             if state.conn and state.conn.is_connected():
-                logger.info("Device connected, starting homing in background...")
-                state.is_homing = True
-                try:
-                    success = await asyncio.to_thread(connection_manager.home)
-                    if not success:
-                        logger.warning("Background homing failed or was skipped")
-                        # If sensor homing failed, close connection and wait for user action
-                        if state.sensor_homing_failed:
-                            logger.error("Sensor homing failed - closing connection. User must check sensor or switch to crash homing.")
-                            if state.conn:
-                                await asyncio.to_thread(state.conn.close)
-                                state.conn = None
-                            return  # Don't proceed with auto-play
-                finally:
-                    state.is_homing = False
-                    logger.info("Background homing completed")
-
-                # After homing, check for auto_play mode
+                if not state.home_on_connect:
+                    logger.info("Device connected. Home-on-connect is disabled — skipping automatic homing.")
+                else:
+                    logger.info("Device connected, starting homing in background...")
+                    state.is_homing = True
+                    try:
+                        success = await asyncio.to_thread(connection_manager.home)
+                        if not success:
+                            logger.warning("Background homing failed or was skipped")
+                            # If sensor homing failed, close connection and wait for user action
+                            if state.sensor_homing_failed:
+                                logger.error("Sensor homing failed - closing connection. User must check sensor or switch to crash homing.")
+                                if state.conn:
+                                    await asyncio.to_thread(state.conn.close)
+                                    state.conn = None
+                                return  # Don't proceed with auto-play
+                    finally:
+                        state.is_homing = False
+                        logger.info("Background homing completed")
+
+                # After homing (or skip), check for auto_play mode
                 if state.auto_play_enabled and state.auto_play_playlist:
                     logger.info(f"Homing complete, checking auto_play playlist: {state.auto_play_playlist}")
                     try:
@@ -175,9 +181,12 @@ async def lifespan(app: FastAPI):
             # Initialize hardware and start idle effect (matches behavior of /set_led_config)
             status = state.led_controller.check_status()
             if status.get("connected", False):
-                state.led_controller.effect_idle(state.dw_led_idle_effect)
-                _start_idle_led_timeout()
-                logger.info("DW LEDs hardware initialized and idle effect started")
+                if state.led_automation_enabled:
+                    state.led_controller.effect_idle(state.dw_led_idle_effect)
+                    _start_idle_led_timeout()
+                    logger.info("DW LEDs hardware initialized and idle effect started")
+                else:
+                    logger.info("DW LEDs hardware initialized (manual mode, no auto-effect)")
             else:
                 error_msg = status.get("error", "Unknown error")
                 logger.warning(f"DW LED hardware initialization failed: {error_msg}")
@@ -192,6 +201,17 @@ async def lifespan(app: FastAPI):
         logger.warning(f"Failed to initialize LED controller: {str(e)}")
         state.led_controller = None
 
+    # Initialize screen controller for LCD backlight control
+    try:
+        state.screen_controller = ScreenController()
+        if state.screen_controller.available:
+            logger.info("Screen controller initialized (backlight control available)")
+        else:
+            logger.info("Screen controller initialized (no backlight device found)")
+    except Exception as e:
+        logger.warning(f"Failed to initialize screen controller: {e}")
+        state.screen_controller = None
+
     # Note: auto_play is now handled in connect_and_home() after homing completes
 
     try:
@@ -229,6 +249,9 @@ async def lifespan(app: FastAPI):
                 if not state.dw_led_idle_timeout_enabled:
                     continue
 
+                if not state.led_automation_enabled:
+                    continue
+
                 if not state.led_controller or not state.led_controller.is_configured:
                     continue
 
@@ -265,6 +288,65 @@ async def lifespan(app: FastAPI):
 
     asyncio.create_task(idle_timeout_monitor())
 
+    # Start Still Sands LED monitor for idle table
+    async def still_sands_led_monitor():
+        """Monitor Still Sands transitions when the table is idle and no playlist is running.
+
+        Handles the case where a Still Sands period starts/ends while the table is completely
+        idle (no pattern or playlist active). Without this, LEDs would stay on all night
+        if the table was idle when the quiet period began.
+        """
+        import time
+        from modules.core.pattern_manager import is_in_scheduled_pause_period, start_idle_led_timeout
+        from modules.led.idle_timeout_manager import idle_timeout_manager
+
+        was_in_still_sands = False
+        while True:
+            try:
+                await asyncio.sleep(30)  # Check every 30 seconds
+
+                # Skip if LED control during Still Sands is disabled
+                if not state.scheduled_pause_control_wled:
+                    was_in_still_sands = False
+                    continue
+
+                # Skip if no LED controller configured
+                if not state.led_controller or not state.led_controller.is_configured:
+                    was_in_still_sands = False
+                    continue
+
+                # Skip if a pattern or playlist is actively running —
+                # the pattern_manager handles Still Sands in that case
+                is_playing = bool(state.current_playing_file or state.current_playlist)
+                if is_playing:
+                    was_in_still_sands = False
+                    continue
+
+                in_still_sands = is_in_scheduled_pause_period()
+
+                if in_still_sands and not was_in_still_sands:
+                    # Entering Still Sands while idle — turn off LEDs
+                    status = state.led_controller.check_status()
+                    is_powered_on = status.get("power", False) or status.get("power_on", False)
+                    if is_powered_on:
+                        logger.info("Still Sands period started while idle, turning off LEDs")
+                        state.led_controller.set_power(0)
+                elif not in_still_sands and was_in_still_sands:
+                    # Leaving Still Sands while idle — restore idle effect and restart timeout
+                    if state.led_automation_enabled:
+                        logger.info("Still Sands period ended while idle, restoring idle LED effect")
+                        await start_idle_led_timeout(check_still_sands=False)
+                    else:
+                        logger.info("Manual mode: Still Sands ended, LEDs remain off")
+
+                was_in_still_sands = in_still_sands
+
+            except Exception as e:
+                logger.error(f"Error in Still Sands LED monitor: {e}")
+                await asyncio.sleep(60)  # Wait longer on error
+
+    asyncio.create_task(still_sands_led_monitor())
+
     yield  # This separates startup from shutdown code
 
     # Shutdown
@@ -286,6 +368,10 @@ app.add_middleware(
 templates = Jinja2Templates(directory="templates")
 app.mount("/static", StaticFiles(directory="static"), name="static")
 
+# Include WiFi management router
+app.include_router(wifi_router)
+app.include_router(captive_portal_router)
+
 # Global semaphore to limit concurrent preview processing
 # Prevents resource exhaustion when loading many previews simultaneously
 # Lazily initialized to avoid "attached to a different loop" errors
@@ -407,6 +493,7 @@ class ScheduledPauseSettingsUpdate(BaseModel):
 class HomingSettingsUpdate(BaseModel):
     mode: Optional[int] = None
     angular_offset_degrees: Optional[float] = None
+    home_on_connect: Optional[bool] = None  # Auto-home after connecting on startup
     auto_home_enabled: Optional[bool] = None
     auto_home_after_patterns: Optional[int] = None
     hard_reset_theta: Optional[bool] = None  # Enable hard reset ($Bye) when resetting theta
@@ -426,6 +513,7 @@ class DwLedSettingsUpdate(BaseModel):
 class LedSettingsUpdate(BaseModel):
     provider: Optional[str] = None  # "none", "wled", "dw_leds"
     wled_ip: Optional[str] = None
+    control_mode: Optional[str] = None  # "manual" or "automated"
     dw_led: Optional[DwLedSettingsUpdate] = None
 
 class MqttSettingsUpdate(BaseModel):
@@ -441,8 +529,16 @@ class MqttSettingsUpdate(BaseModel):
 
 class MachineSettingsUpdate(BaseModel):
     table_type_override: Optional[str] = None  # Override detected table type, or empty string/"auto" to clear
+    gear_ratio_override: Optional[float] = None  # Override gear ratio, or 0/negative to clear
     timezone: Optional[str] = None  # IANA timezone (e.g., "America/New_York", "UTC")
 
+class SecuritySettingsUpdate(BaseModel):
+    mode: Optional[str] = None  # "off", "lockdown", "play_only"
+    password: Optional[str] = None  # Write-only, stored as SHA-256 hash
+
+class SecurityVerifyRequest(BaseModel):
+    password: str
+
 class SettingsUpdate(BaseModel):
     """Request model for PATCH /api/settings - all fields optional for partial updates"""
     app: Optional[AppSettingsUpdate] = None
@@ -454,6 +550,7 @@ class SettingsUpdate(BaseModel):
     led: Optional[LedSettingsUpdate] = None
     mqtt: Optional[MqttSettingsUpdate] = None
     machine: Optional[MachineSettingsUpdate] = None
+    security: Optional[SecuritySettingsUpdate] = None
 
 # Store active WebSocket connections
 active_status_connections = set()
@@ -670,6 +767,7 @@ async def get_all_settings():
             "mode": state.homing,
             "user_override": state.homing_user_override,  # True if user explicitly set, False if auto-detected
             "angular_offset_degrees": state.angular_homing_offset_degrees,
+            "home_on_connect": state.home_on_connect,
             "auto_home_enabled": state.auto_home_enabled,
             "auto_home_after_patterns": state.auto_home_after_patterns,
             "hard_reset_theta": state.hard_reset_theta  # Enable hard reset when resetting theta
@@ -677,6 +775,7 @@ async def get_all_settings():
         "led": {
             "provider": state.led_provider,
             "wled_ip": state.wled_ip,
+            "control_mode": state.dw_led_control_mode,
             "dw_led": {
                 "num_leds": state.dw_led_num_leds,
                 "gpio_pin": state.dw_led_gpio_pin,
@@ -706,6 +805,7 @@ async def get_all_settings():
             "table_type_override": state.table_type_override,
             "effective_table_type": state.table_type_override or state.table_type,
             "gear_ratio": state.gear_ratio,
+            "gear_ratio_override": state.gear_ratio_override,
             "x_steps_per_mm": state.x_steps_per_mm,
             "y_steps_per_mm": state.y_steps_per_mm,
             "timezone": state.timezone,
@@ -717,6 +817,10 @@ async def get_all_settings():
                 {"value": "dune_weaver", "label": "Dune Weaver"},
                 {"value": "dune_weaver_pro", "label": "Dune Weaver Pro"}
             ]
+        },
+        "security": {
+            "mode": state.security_mode,
+            "has_password": bool(state.security_password_hash)
         }
     }
 
@@ -862,6 +966,8 @@ async def update_settings(settings_update: SettingsUpdate):
             state.homing_user_override = True  # User explicitly set preference
         if h.angular_offset_degrees is not None:
             state.angular_homing_offset_degrees = h.angular_offset_degrees
+        if h.home_on_connect is not None:
+            state.home_on_connect = h.home_on_connect
         if h.auto_home_enabled is not None:
             state.auto_home_enabled = h.auto_home_enabled
         if h.auto_home_after_patterns is not None:
@@ -879,6 +985,8 @@ async def update_settings(settings_update: SettingsUpdate):
                 led_reinit_needed = True
         if led.wled_ip is not None:
             state.wled_ip = led.wled_ip or None
+        if led.control_mode is not None:
+            state.dw_led_control_mode = led.control_mode
         if led.dw_led:
             dw = led.dw_led
             if dw.num_leds is not None:
@@ -933,6 +1041,23 @@ async def update_settings(settings_update: SettingsUpdate):
         if m.table_type_override is not None:
             # Empty string or "auto" clears the override
             state.table_type_override = None if m.table_type_override in ("", "auto") else m.table_type_override
+        if m.gear_ratio_override is not None:
+            # Zero or negative clears the override; positive value sets it
+            state.gear_ratio_override = None if m.gear_ratio_override <= 0 else m.gear_ratio_override
+            # Apply immediately to current gear_ratio
+            if state.gear_ratio_override is not None:
+                state.gear_ratio = state.gear_ratio_override
+            else:
+                # Cleared — revert to auto-detected value (table type, then env var)
+                effective_table_type = state.table_type_override or state.table_type
+                mini_types = ['dune_weaver_mini', 'dune_weaver_mini_pro', 'dune_weaver_mini_pro_byj', 'dune_weaver_gold']
+                state.gear_ratio = 6.25 if effective_table_type in mini_types else 10
+                env_gr = os.getenv('GEAR_RATIO')
+                if env_gr is not None:
+                    try:
+                        state.gear_ratio = float(env_gr)
+                    except ValueError:
+                        pass
         if m.timezone is not None:
             # Validate timezone by trying to create a ZoneInfo object
             try:
@@ -953,6 +1078,20 @@ async def update_settings(settings_update: SettingsUpdate):
                 logger.warning(f"Invalid timezone '{m.timezone}': {e}")
         updated_categories.append("machine")
 
+    # Security settings
+    if settings_update.security:
+        sec = settings_update.security
+        if sec.mode is not None:
+            if sec.mode not in ("off", "lockdown", "play_only"):
+                raise HTTPException(status_code=400, detail="Invalid security mode. Must be 'off', 'lockdown', or 'play_only'.")
+            state.security_mode = sec.mode
+            # When turning off, clear the password hash
+            if sec.mode == "off":
+                state.security_password_hash = ""
+        if sec.password is not None and sec.password != "":
+            state.security_password_hash = hashlib.sha256(sec.password.encode('utf-8')).hexdigest()
+        updated_categories.append("security")
+
     # Save state
     state.save()
 
@@ -969,6 +1108,14 @@ async def update_settings(settings_update: SettingsUpdate):
         "led_reinit_needed": led_reinit_needed
     }
 
+@app.post("/api/security/verify", tags=["settings"])
+async def verify_security_password(request: SecurityVerifyRequest):
+    """Verify a security password against the stored hash."""
+    if not state.security_password_hash:
+        return {"valid": False}
+    input_hash = hashlib.sha256(request.password.encode('utf-8')).hexdigest()
+    return {"valid": input_hash == state.security_password_hash}
+
 # ============================================================================
 # Multi-Table Identity Endpoints
 # ============================================================================
@@ -2160,6 +2307,7 @@ async def get_all_pattern_history():
 
     try:
         history_map = {}
+        play_counts = {}
         with open(EXECUTION_LOG_FILE, 'r') as f:
             for line in f:
                 line = line.strip()
@@ -2171,12 +2319,15 @@ async def get_all_pattern_history():
                     if entry.get('completed', False):
                         pattern_name = entry.get('pattern_name')
                         if pattern_name:
+                            play_counts[pattern_name] = play_counts.get(pattern_name, 0) + 1
                             # Keep the most recent match (last one in file wins)
                             history_map[pattern_name] = {
                                 "actual_time_seconds": entry.get('actual_time_seconds'),
                                 "actual_time_formatted": entry.get('actual_time_formatted'),
                                 "speed": entry.get('speed'),
-                                "timestamp": entry.get('timestamp')
+                                "timestamp": entry.get('timestamp'),
+                                "play_count": play_counts[pattern_name],
+                                "last_played": entry.get('timestamp')
                             }
                 except json.JSONDecodeError:
                     continue
@@ -3737,6 +3888,49 @@ async def dw_leds_get_idle_timeout():
         "remaining_minutes": remaining_minutes
     }
 
+# ── Screen (LCD backlight) control endpoints ──────────────────────
+
+@app.get("/api/screen/status")
+async def screen_status():
+    """Get screen controller status."""
+    if not state.screen_controller:
+        return {"available": False, "message": "Screen controller not initialized"}
+    return state.screen_controller.get_status()
+
+@app.post("/api/screen/power")
+async def screen_power(request: dict):
+    """Turn screen on/off. Body: {"on": true/false}"""
+    if not state.screen_controller or not state.screen_controller.available:
+        raise HTTPException(status_code=400, detail="Screen control not available")
+
+    on = request.get("on", True)
+    result = state.screen_controller.set_power(on)
+    if not result.get("success"):
+        raise HTTPException(status_code=500, detail=result.get("message", "Unknown error"))
+
+    # Publish updated state to MQTT
+    if state.mqtt_handler and state.mqtt_handler.is_enabled:
+        state.mqtt_handler._publish_screen_state()
+
+    return result
+
+@app.post("/api/screen/brightness")
+async def screen_brightness(request: dict):
+    """Set screen brightness. Body: {"value": 0-max_brightness}"""
+    if not state.screen_controller or not state.screen_controller.available:
+        raise HTTPException(status_code=400, detail="Screen control not available")
+
+    value = request.get("value", 128)
+    result = state.screen_controller.set_brightness(value)
+    if not result.get("success"):
+        raise HTTPException(status_code=500, detail=result.get("message", "Unknown error"))
+
+    # Publish updated state to MQTT
+    if state.mqtt_handler and state.mqtt_handler.is_enabled:
+        state.mqtt_handler._publish_screen_state()
+
+    return result
+
 @app.get("/table_control")
 async def table_control_page(request: Request):
     return get_redirect_response(request)
@@ -3807,22 +4001,29 @@ async def get_version_info(force_refresh: bool = False):
 
 @app.post("/api/update")
 async def trigger_update():
-    """Trigger software update by pulling latest Docker images and recreating containers."""
+    """Trigger software update by running `dw update` as a detached process.
+
+    The `dw` CLI handles pulling code and restarting the service.
+    We fire-and-forget so the response returns immediately before the
+    service goes down for restart.
+    """
     try:
         logger.info("Update triggered via API")
-        success, error_message, error_log = update_manager.update_software()
-
-        if success:
-            return JSONResponse(content={
-                "success": True,
-                "message": "Update started. Containers are being recreated with the latest images. The page will reload shortly."
-            })
-        else:
-            return JSONResponse(content={
-                "success": False,
-                "message": error_message or "Update failed",
-                "errors": error_log
-            })
+        dw_path = '/usr/local/bin/dw'
+        log_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'update.log')
+        logger.info(f"Running: {dw_path} update (log: {log_file})")
+        with open(log_file, 'w') as f:
+            subprocess.Popen(
+                [dw_path, 'update'],
+                stdout=f,
+                stderr=subprocess.STDOUT,
+                start_new_session=True,
+                cwd=os.path.dirname(os.path.abspath(__file__))
+            )
+        return JSONResponse(content={
+            "success": True,
+            "message": "Update started"
+        })
     except Exception as e:
         logger.error(f"Error triggering update: {e}")
         return JSONResponse(
@@ -3840,11 +4041,10 @@ async def shutdown_system():
         def delayed_shutdown():
             time.sleep(2)  # Give time for response to be sent
             try:
-                # Use systemctl to shutdown the host (via mounted systemd socket)
-                subprocess.run(["systemctl", "poweroff"], check=True)
-                logger.info("Host shutdown command executed successfully via systemctl")
+                subprocess.run(["sudo", "systemctl", "poweroff"], check=True)
+                logger.info("System shutdown command executed successfully")
             except FileNotFoundError:
-                logger.error("systemctl command not found - ensure systemd volumes are mounted")
+                logger.error("systemctl command not found")
             except Exception as e:
                 logger.error(f"Error executing host shutdown command: {e}")
 
@@ -3862,7 +4062,7 @@ async def shutdown_system():
 
 @app.post("/api/system/restart")
 async def restart_system():
-    """Restart the Docker containers using docker compose"""
+    """Restart the Dune Weaver service via systemctl."""
     try:
         logger.warning("Restart initiated via API")
 
@@ -3870,14 +4070,12 @@ async def restart_system():
         def delayed_restart():
             time.sleep(2)  # Give time for response to be sent
             try:
-                # Use docker restart directly with container name
-                # This is simpler and doesn't require the compose file path
-                subprocess.run(["docker", "restart", "dune-weaver-backend"], check=True)
-                logger.info("Docker restart command executed successfully")
+                subprocess.run(["sudo", "systemctl", "restart", "dune-weaver"], check=True)
+                logger.info("Service restart command executed successfully")
             except FileNotFoundError:
-                logger.error("docker command not found")
+                logger.error("systemctl command not found")
             except Exception as e:
-                logger.error(f"Error executing docker restart: {e}")
+                logger.error(f"Error executing service restart: {e}")
 
         import threading
         restart_thread = threading.Thread(target=delayed_restart)
@@ -3891,6 +4089,156 @@ async def restart_system():
             status_code=500
         )
 
+###############################################################################
+# FluidNC Config Endpoints
+###############################################################################
+
+class FluidNCCommandRequest(BaseModel):
+    command: str
+    timeout: Optional[float] = 3.0
+
+class FluidNCConfigUpdate(BaseModel):
+    axes: Optional[dict] = None   # { "x": { "steps_per_mm": 320, ... }, "y": {...} }
+    start: Optional[dict] = None  # { "must_home": false }
+
+
+@app.post("/api/fluidnc/command")
+async def fluidnc_command(request: FluidNCCommandRequest):
+    """Send a raw command to FluidNC and return the response lines."""
+    if not state.conn or not state.conn.is_connected():
+        raise HTTPException(status_code=400, detail="Not connected to controller")
+    if state.current_playing_file and not state.pause_requested:
+        raise HTTPException(status_code=409, detail="Cannot send commands while a pattern is running")
+
+    from modules.connection import fluidnc_config
+
+    try:
+        responses = await asyncio.to_thread(
+            fluidnc_config.send_command, request.command, request.timeout or 3.0
+        )
+        return {"success": True, "command": request.command, "responses": responses}
+    except ConnectionError as e:
+        raise HTTPException(status_code=400, detail=str(e))
+    except Exception as e:
+        logger.error(f"FluidNC command error: {e}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@app.get("/api/fluidnc/config")
+async def fluidnc_config_read():
+    """Read all curated FluidNC settings from the controller."""
+    if not state.conn or not state.conn.is_connected():
+        raise HTTPException(status_code=400, detail="Not connected to controller")
+
+    from modules.connection import fluidnc_config
+
+    try:
+        settings = await asyncio.to_thread(fluidnc_config.read_all_settings)
+        return {"success": True, "settings": settings}
+    except ConnectionError as e:
+        raise HTTPException(status_code=400, detail=str(e))
+    except Exception as e:
+        logger.error(f"FluidNC config read error: {e}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+# Mapping from UI flat keys back to FluidNC config tree paths.
+# direction_inverted is handled specially (toggles :low on the raw pin).
+_AXIS_KEY_TO_PATH = {
+    "steps_per_mm": "axes/{axis}/steps_per_mm",
+    "max_rate_mm_per_min": "axes/{axis}/max_rate_mm_per_min",
+    "acceleration_mm_per_sec2": "axes/{axis}/acceleration_mm_per_sec2",
+    "homing_cycle": "axes/{axis}/homing/cycle",
+    "homing_positive_direction": "axes/{axis}/homing/positive_direction",
+    "homing_mpos_mm": "axes/{axis}/homing/mpos_mm",
+    "homing_feed_mm_per_min": "axes/{axis}/homing/feed_mm_per_min",
+    "homing_seek_mm_per_min": "axes/{axis}/homing/seek_mm_per_min",
+    "homing_settle_ms": "axes/{axis}/homing/settle_ms",
+    "homing_seek_scaler": "axes/{axis}/homing/seek_scaler",
+    "homing_feed_scaler": "axes/{axis}/homing/feed_scaler",
+    "pulloff_mm": "axes/{axis}/motor0/pulloff_mm",
+}
+
+
+@app.patch("/api/fluidnc/config")
+async def fluidnc_config_write(update: FluidNCConfigUpdate):
+    """Write changed FluidNC settings and persist to flash."""
+    if not state.conn or not state.conn.is_connected():
+        raise HTTPException(status_code=400, detail="Not connected to controller")
+    if state.current_playing_file and not state.pause_requested:
+        raise HTTPException(status_code=409, detail="Cannot modify config while a pattern is running")
+
+    from modules.connection import fluidnc_config
+
+    changes_applied: list[str] = []
+    restart_required = False
+
+    def apply_changes():
+        nonlocal restart_required
+        # Apply axis settings
+        if update.axes:
+            for axis in ("x", "y"):
+                axis_data = update.axes.get(axis)
+                if not axis_data:
+                    continue
+                for key, value in axis_data.items():
+                    if key in ("direction_pin", "direction_inverted_raw"):
+                        # Skip derived/raw keys handled below
+                        continue
+
+                    if key == "direction_inverted":
+                        # Toggle :low on direction pin
+                        success, new_state = fluidnc_config.toggle_direction_pin(axis)
+                        if success:
+                            changes_applied.append(f"{axis}/direction_pin")
+                            restart_required = True
+                        continue
+
+                    path_template = _AXIS_KEY_TO_PATH.get(key)
+                    if path_template:
+                        path = path_template.format(axis=axis)
+                        str_value = str(value).lower() if isinstance(value, bool) else str(value)
+                        if fluidnc_config.write_setting(path, str_value):
+                            changes_applied.append(path)
+
+        # Apply global settings
+        if update.start:
+            for key, value in update.start.items():
+                path = f"start/{key}"
+                str_value = str(value).lower() if isinstance(value, bool) else str(value)
+                if fluidnc_config.write_setting(path, str_value):
+                    changes_applied.append(path)
+
+        # Sync steps_per_mm into app state so Machine Settings reflects changes
+        if update.axes:
+            x_steps = update.axes.get("x", {}).get("steps_per_mm")
+            y_steps = update.axes.get("y", {}).get("steps_per_mm")
+            if x_steps is not None:
+                state.x_steps_per_mm = float(x_steps)
+            if y_steps is not None:
+                state.y_steps_per_mm = float(y_steps)
+            if x_steps is not None or y_steps is not None:
+                state.save()
+
+        # Persist to flash
+        saved = fluidnc_config.save_config() if changes_applied else False
+        return saved
+
+    try:
+        saved = await asyncio.to_thread(apply_changes)
+        return {
+            "success": True,
+            "saved": saved,
+            "changes_applied": changes_applied,
+            "restart_required": restart_required,
+        }
+    except ConnectionError as e:
+        raise HTTPException(status_code=400, detail=str(e))
+    except Exception as e:
+        logger.error(f"FluidNC config write error: {e}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+
 def entrypoint():
     import uvicorn
     logger.info("Starting FastAPI server on port 8080...")

+ 28 - 16
modules/connection/connection_manager.py

@@ -1,6 +1,7 @@
 import threading
 import time
 import logging
+import re
 import serial
 import serial.tools.list_ports
 import websocket
@@ -579,13 +580,10 @@ def _detect_firmware():
 
                     if 'fluidnc' in response_lower:
                         firmware_type = 'fluidnc'
-                        # Try to extract version from response like "FluidNC v3.7.2"
-                        if 'v' in response_lower:
-                            parts = response.split()
-                            for part in parts:
-                                if part.lower().startswith('v') and any(c.isdigit() for c in part):
-                                    version = part
-                                    break
+                        # Extract version like "v3.7.2" from response
+                        ver_match = re.search(r'v(\d+\.\d+\.\d+)', response_lower)
+                        if ver_match:
+                            version = f"v{ver_match.group(1)}"
                         break
                     elif 'grbl' in response_lower and 'fluidnc' not in response_lower:
                         firmware_type = 'grbl'
@@ -876,6 +874,8 @@ def get_machine_steps(timeout=10):
 
     # Detect firmware type
     firmware_type, firmware_version = _detect_firmware()
+    state.firmware_type = firmware_type
+    state.firmware_version = firmware_version
 
     if firmware_type == 'fluidnc':
         if firmware_version:
@@ -899,23 +899,30 @@ def get_machine_steps(timeout=10):
         x_steps_per_mm, y_steps_per_mm = _get_steps_grbl()
     
     # Process results and determine table type
+    # Uses tolerance-based matching (±5) to handle firmware float variations
+    # (e.g., 287 vs 287.0) and checks both axes for reliable identification
+    def _steps_match(actual, expected, tolerance=5):
+        return abs(actual - expected) <= tolerance
+
     settings_complete = (x_steps_per_mm is not None and y_steps_per_mm is not None)
     if settings_complete:
-        if y_steps_per_mm == 180 and x_steps_per_mm == 256:
+        if _steps_match(y_steps_per_mm, 180) and _steps_match(x_steps_per_mm, 256):
             state.table_type = 'dune_weaver_mini'
-        elif y_steps_per_mm == 210 and x_steps_per_mm == 256:
+        elif _steps_match(y_steps_per_mm, 210) and _steps_match(x_steps_per_mm, 256):
             state.table_type = 'dune_weaver_mini_pro_byj'
-        elif (y_steps_per_mm == 270 or y_steps_per_mm == 250) and x_steps_per_mm == 200:
-            state.table_type = 'dune_weaver_gold'
-        elif y_steps_per_mm == 287:
-            state.table_type = 'dune_weaver'
-        elif y_steps_per_mm == 164:
+        elif _steps_match(y_steps_per_mm, 164) and _steps_match(x_steps_per_mm, 200):
             state.table_type = 'dune_weaver_mini_pro'
-        elif y_steps_per_mm >= 320:
+        elif (_steps_match(y_steps_per_mm, 270) or _steps_match(y_steps_per_mm, 250)) and _steps_match(x_steps_per_mm, 200):
+            state.table_type = 'dune_weaver_gold'
+        elif _steps_match(y_steps_per_mm, 620) and _steps_match(x_steps_per_mm, 320):
+            state.table_type = 'dune_weaver_pro_pulley'
+        elif _steps_match(y_steps_per_mm, 533) and _steps_match(x_steps_per_mm, 320):
             state.table_type = 'dune_weaver_pro'
+        elif _steps_match(y_steps_per_mm, 287) and _steps_match(x_steps_per_mm, 320):
+            state.table_type = 'dune_weaver'
         else:
             state.table_type = None
-            logger.warning(f"Unknown table type with Y steps/mm: {y_steps_per_mm}")
+            logger.warning(f"Unknown table type with X steps/mm: {x_steps_per_mm}, Y steps/mm: {y_steps_per_mm}")
 
         # Use override if set, otherwise use detected table type
         effective_table_type = state.table_type_override or state.table_type
@@ -940,6 +947,11 @@ def get_machine_steps(timeout=10):
         else:
             logger.info(f"Machine type detected: {state.table_type}, gear ratio: {state.gear_ratio} (hardcoded)")
 
+        # User UI override takes highest priority
+        if state.gear_ratio_override is not None:
+            state.gear_ratio = state.gear_ratio_override
+            logger.info(f"Gear ratio overridden to: {state.gear_ratio} (user override)")
+
         return True
     else:
         missing = []

+ 337 - 0
modules/connection/fluidnc_config.py

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

+ 91 - 38
modules/core/pattern_manager.py

@@ -237,15 +237,10 @@ def _get_timezone():
     else:
         # Fall back to system timezone detection
         try:
-            if os.path.exists('/etc/host-timezone'):
-                with open('/etc/host-timezone', 'r') as f:
-                    user_tz = f.read().strip()
-                    logger.info(f"Still Sands using timezone: {user_tz} (from host system)")
-            # Fallback to /etc/timezone if host-timezone doesn't exist
-            elif os.path.exists('/etc/timezone'):
+            if os.path.exists('/etc/timezone'):
                 with open('/etc/timezone', 'r') as f:
                     user_tz = f.read().strip()
-                    logger.info(f"Still Sands using timezone: {user_tz} (from container)")
+                    logger.info(f"Still Sands using timezone: {user_tz} (from system)")
             # Fallback to TZ environment variable
             elif os.environ.get('TZ'):
                 user_tz = os.environ.get('TZ')
@@ -353,6 +348,13 @@ async def start_idle_led_timeout(check_still_sands: bool = True):
     if not state.led_controller:
         return
 
+    if not state.led_automation_enabled:
+        # Manual mode: Still Sands can still turn OFF, but skip idle effect + timeout
+        if check_still_sands and is_in_scheduled_pause_period() and state.scheduled_pause_control_wled:
+            logger.info("Manual mode: Turning off LEDs during Still Sands period")
+            await state.led_controller.set_power_async(0)
+        return
+
     # Still Sands with LED control: turn off instead of idle effect
     if check_still_sands and is_in_scheduled_pause_period() and state.scheduled_pause_control_wled:
         logger.info("Turning off LED lights during Still Sands period")
@@ -1177,13 +1179,27 @@ async def _execute_pattern_internal(file_path):
     coordinates = await asyncio.to_thread(parse_theta_rho_file, file_path)
     total_coordinates = len(coordinates)
 
-    # Cache coordinates in state for frontend preview (avoids re-parsing large files)
-    state._current_coordinates = coordinates
-
     if total_coordinates < 2:
         logger.warning("Not enough coordinates for interpolation")
         return False
 
+    # Normalize theta values to avoid unnecessary revolutions at pattern start.
+    # Many community patterns have theta starting at high values (e.g., 498 rad ≈ 79 revolutions).
+    # Some patterns also start with two "0 0" origin points before jumping to a large theta.
+    # Detect this preamble and use the third coordinate as the reference instead.
+    ref_theta = coordinates[0][0]
+    if (len(coordinates) >= 3
+            and abs(coordinates[0][0]) < 1e-9 and abs(coordinates[0][1]) < 1e-9
+            and abs(coordinates[1][0]) < 1e-9 and abs(coordinates[1][1]) < 1e-9):
+        ref_theta = coordinates[2][0]
+    theta_offset = ref_theta - (ref_theta % (2 * pi))
+    if abs(theta_offset) > 1e-9:
+        coordinates = [(theta - theta_offset, rho) for theta, rho in coordinates]
+        logger.info(f"Normalized pattern theta by {theta_offset:.2f} rad ({theta_offset / (2 * pi):.1f} revolutions)")
+
+    # Cache coordinates in state for frontend preview (avoids re-parsing large files)
+    state._current_coordinates = coordinates
+
     # Pre-calculate rho-based weights for more accurate time estimation
     # Moves near center (low rho) are slower than perimeter moves due to
     # polar geometry - less linear distance per theta change at low rho
@@ -1229,7 +1245,7 @@ async def _execute_pattern_internal(file_path):
     smoothed_rate = None  # For exponential smoothing of time-per-unit-weight rate
     # For WLED: always trigger (uses hardcoded preset 2)
     # For DW_LED: only trigger if effect is configured
-    if state.led_controller and (state.led_provider == "wled" or state.dw_led_playing_effect):
+    if state.led_controller and state.led_automation_enabled and (state.led_provider == "wled" or state.dw_led_playing_effect):
         logger.info(f"Setting LED to playing effect: {state.dw_led_playing_effect}")
         await state.led_controller.effect_playing_async(state.dw_led_playing_effect)
         # Cancel idle timeout when playing starts
@@ -1340,19 +1356,18 @@ async def _execute_pattern_internal(file_path):
 
                 logger.info("Execution resumed...")
                 if state.led_controller:
-                    # Turn LED controller back on if it was turned off for scheduled pause
+                    # Always power LEDs back on if they were turned off for scheduled pause,
+                    # regardless of whether a playing effect is configured
+                    if wled_was_off_for_scheduled and state.led_automation_enabled:
+                        logger.info("Turning LED lights back on as Still Sands period ended")
+                        await state.led_controller.set_power_async(1)
+                        # CRITICAL: Give LED controller time to fully power on before sending more commands
+                        # Without this delay, rapid-fire requests can crash controllers on resource-constrained Pis
+                        await asyncio.sleep(0.5)
+                    # Apply playing effect if configured
                     # For WLED: always trigger (uses hardcoded preset 2)
                     # For DW_LED: only trigger if effect is configured
-                    should_trigger_led = state.led_provider == "wled" or state.dw_led_playing_effect
-                    if wled_was_off_for_scheduled:
-                        if should_trigger_led:
-                            logger.info("Turning LED lights back on as Still Sands period ended")
-                            await state.led_controller.set_power_async(1)
-                            # CRITICAL: Give LED controller time to fully power on before sending more commands
-                            # Without this delay, rapid-fire requests can crash controllers on resource-constrained Pis
-                            await asyncio.sleep(0.5)
-                        else:
-                            logger.info("No playing effect configured, keeping LEDs off after Still Sands")
+                    should_trigger_led = state.led_automation_enabled and (state.led_provider == "wled" or state.dw_led_playing_effect)
                     if should_trigger_led:
                         await state.led_controller.effect_playing_async(state.dw_led_playing_effect)
                     # Cancel idle timeout when resuming from pause
@@ -1429,7 +1444,9 @@ async def _execute_pattern_internal(file_path):
 
     # Set LED back to idle when pattern completes normally (not stopped early)
     # This also handles Still Sands: turns off LEDs if in scheduled pause period with LED control
-    if not state.stop_requested:
+    # Skip during clear pattern - the main pattern starts immediately after, so triggering
+    # idle LED here would cause a brief flicker (idle effect → playing effect in ~0.3s)
+    if not state.stop_requested and not state.is_clearing:
         await start_idle_led_timeout()
 
     return was_completed
@@ -1597,16 +1614,16 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
                     if result == 'completed':
                         logger.info("Still Sands period ended. Resuming playlist...")
                         if state.led_controller:
+                            # Always power LEDs back on if they were turned off for scheduled pause,
+                            # regardless of whether a playing effect is configured
+                            if wled_was_off_for_scheduled and state.led_automation_enabled:
+                                logger.info("Turning LED lights back on as Still Sands period ended")
+                                await state.led_controller.set_power_async(1)
+                                await asyncio.sleep(0.5)
+                            # Apply playing effect if configured
                             # For WLED: always trigger (uses hardcoded preset 2)
                             # For DW_LED: only trigger if effect is configured
-                            should_trigger_led = state.led_provider == "wled" or state.dw_led_playing_effect
-                            if wled_was_off_for_scheduled:
-                                if should_trigger_led:
-                                    logger.info("Turning LED lights back on as Still Sands period ended")
-                                    await state.led_controller.set_power_async(1)
-                                    await asyncio.sleep(0.5)
-                                else:
-                                    logger.info("No playing effect configured, keeping LEDs off after Still Sands")
+                            should_trigger_led = state.led_automation_enabled and (state.led_provider == "wled" or state.dw_led_playing_effect)
                             if should_trigger_led:
                                 await state.led_controller.effect_playing_async(state.dw_led_playing_effect)
                             idle_timeout_manager.cancel_timeout()
@@ -1618,14 +1635,31 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
                     # This will be set again when the next pattern starts
                     state.current_playing_file = None
                     # Trigger idle LED state during pause between patterns
-                    await start_idle_led_timeout(check_still_sands=False)
+                    await start_idle_led_timeout(check_still_sands=True)
                     state.original_pause_time = pause_time
                     pause_start = time.time()
+                    # Track Still Sands state for edge detection during long pauses
+                    was_in_still_sands = is_in_scheduled_pause_period() and state.scheduled_pause_control_wled
                     while time.time() - pause_start < pause_time:
                         state.pause_time_remaining = pause_start + pause_time - time.time()
-                        if state.skip_requested:
-                            logger.info("Pause interrupted by skip request")
+                        if state.skip_requested or state.stop_requested:
+                            if state.stop_requested:
+                                logger.info("Pause interrupted by stop request")
+                            else:
+                                logger.info("Pause interrupted by skip request")
                             break
+                        # Monitor Still Sands transitions during pause
+                        in_still_sands = is_in_scheduled_pause_period() and state.scheduled_pause_control_wled
+                        if in_still_sands and not was_in_still_sands:
+                            # Entering Still Sands period — turn off LEDs
+                            logger.info("Still Sands period started during pause, turning off LEDs")
+                            if state.led_controller:
+                                await state.led_controller.set_power_async(0)
+                        elif not in_still_sands and was_in_still_sands:
+                            # Leaving Still Sands period — restore idle effect
+                            logger.info("Still Sands period ended during pause, restoring idle LED effect")
+                            await start_idle_led_timeout(check_still_sands=False)
+                        was_in_still_sands = in_still_sands
                         await asyncio.sleep(1)
                     # Clear both pause state vars immediately (so UI updates right away)
                     state.pause_time_remaining = 0
@@ -1657,14 +1691,31 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
                     # Clear current_playing_file to report "idle" state to MQTT/HA during pause
                     state.current_playing_file = None
                     # Trigger idle LED state during pause between playlist cycles
-                    await start_idle_led_timeout(check_still_sands=False)
+                    await start_idle_led_timeout(check_still_sands=True)
                     state.original_pause_time = pause_time
                     pause_start = time.time()
+                    # Track Still Sands state for edge detection during long pauses
+                    was_in_still_sands = is_in_scheduled_pause_period() and state.scheduled_pause_control_wled
                     while time.time() - pause_start < pause_time:
                         state.pause_time_remaining = pause_start + pause_time - time.time()
-                        if state.skip_requested:
-                            logger.info("Pause interrupted by skip request")
+                        if state.skip_requested or state.stop_requested:
+                            if state.stop_requested:
+                                logger.info("Pause interrupted by stop request")
+                            else:
+                                logger.info("Pause interrupted by skip request")
                             break
+                        # Monitor Still Sands transitions during pause
+                        in_still_sands = is_in_scheduled_pause_period() and state.scheduled_pause_control_wled
+                        if in_still_sands and not was_in_still_sands:
+                            # Entering Still Sands period — turn off LEDs
+                            logger.info("Still Sands period started during pause, turning off LEDs")
+                            if state.led_controller:
+                                await state.led_controller.set_power_async(0)
+                        elif not in_still_sands and was_in_still_sands:
+                            # Leaving Still Sands period — restore idle effect
+                            logger.info("Still Sands period ended during pause, restoring idle LED effect")
+                            await start_idle_led_timeout(check_still_sands=False)
+                        was_in_still_sands = in_still_sands
                         await asyncio.sleep(1)
                     # Clear both pause state vars immediately (so UI updates right away)
                     state.pause_time_remaining = 0
@@ -1931,7 +1982,9 @@ def get_status():
         "original_pause_time": getattr(state, 'original_pause_time', None),
         "connection_status": state.conn.is_connected() if state.conn else False,
         "current_theta": state.current_theta,
-        "current_rho": state.current_rho
+        "current_rho": state.current_rho,
+        "firmware_version": state.firmware_version,
+        "table_type": state.table_type_override or state.table_type
     }
     
     # Add playlist information if available

+ 0 - 3
modules/core/playlist_manager.py

@@ -164,9 +164,6 @@ async def run_playlist(playlist_name, pause_time=0, clear_pattern=None, run_mode
         state.current_playlist_name = playlist_name
         state.playlist_mode = run_mode
         state.current_playlist_index = 0
-        # Set current_playing_file to the first pattern as a "preview" - this will be
-        # updated again when actual execution starts, but provides immediate UI feedback.
-        state.current_playing_file = file_paths[0] if file_paths else None
         _current_playlist_task = asyncio.create_task(
             pattern_manager.run_theta_rho_files(
                 file_paths,

+ 137 - 33
modules/core/state.py

@@ -5,6 +5,7 @@ import json
 import os
 import logging
 import uuid
+import base64
 from typing import Optional, Literal
 
 logger = logging.getLogger(__name__)
@@ -67,11 +68,19 @@ class AppState:
         # This indicates to the UI that sensor homing failed and user action is needed
         self.sensor_homing_failed = False
 
+        # Firmware info (runtime only, detected on connect, not persisted)
+        self.firmware_type = None  # 'fluidnc', 'grbl', or 'unknown'
+        self.firmware_version = None  # e.g., "v3.7.2"
+
         # Angular homing compass reference point
         # This is the angular offset in degrees where the sensor is placed
         # After homing, theta will be set to this value
         self.angular_homing_offset_degrees = 0.0
 
+        # Home on connect: whether to automatically home when connecting on startup
+        # When False, auto-connect still works but homing must be triggered manually
+        self.home_on_connect = True
+
         # Auto-homing settings for playlists
         # When enabled, performs homing after X patterns during playlist execution
         self.auto_home_enabled = False
@@ -84,6 +93,7 @@ class AppState:
         self.hard_reset_theta = False
 
         self.STATE_FILE = "state.json"
+        self.SETTINGS_FILE = "settings.json"
         self.mqtt_handler = None  # Will be set by the MQTT handler
         self.conn = None
         self.port = None
@@ -91,6 +101,7 @@ class AppState:
         self.wled_ip = None
         self.led_provider = "none"  # "wled", "dw_leds", or "none"
         self.led_controller = None
+        self.screen_controller = None
 
         # DW LED settings
         self.dw_led_num_leds = 60  # Number of LEDs in strip
@@ -109,9 +120,11 @@ class AppState:
         # Idle timeout settings
         self.dw_led_idle_timeout_enabled = False  # Enable automatic LED turn off after idle period
         self.dw_led_idle_timeout_minutes = 30  # Idle timeout duration in minutes
+        self.dw_led_control_mode = "automated"  # "manual" or "automated"
         self.dw_led_last_activity_time = None  # Last activity timestamp (runtime only, not persisted)
         self.table_type = None
         self.table_type_override = None  # User override for table type detection
+        self.gear_ratio_override = None  # User override for gear ratio (highest priority)
         self._playlist_mode = "loop"
         self._pause_time = 0
         self._clear_pattern = "none"
@@ -168,6 +181,10 @@ class AppState:
         self.mqtt_device_id = "dune_weaver"  # Device ID for Home Assistant
         self.mqtt_device_name = "Dune Weaver"  # Device display name
 
+        # Security settings
+        self.security_mode = "off"  # "off", "lockdown", "play_only"
+        self.security_password_hash = ""  # SHA-256 hex digest
+
         self.load()
 
     @property
@@ -432,6 +449,10 @@ class AppState:
             return 'skipped'
         return 'timeout'
 
+    @property
+    def led_automation_enabled(self) -> bool:
+        return self.dw_led_control_mode == "automated"
+
     @property
     def shuffle(self):
         return self._shuffle
@@ -440,31 +461,44 @@ class AppState:
     def shuffle(self, value):
         self._shuffle = value
 
-    def to_dict(self):
-        """Return a dictionary representation of the state."""
+    def to_state_dict(self):
+        """Return a dictionary of runtime/machine state (transient data)."""
         return {
             "stop_requested": self.stop_requested,
             "pause_requested": self._pause_requested,
             "current_playing_file": self._current_playing_file,
+            "current_playlist": self._current_playlist,
+            "current_playlist_name": self._current_playlist_name,
+            "current_playlist_index": self.current_playlist_index,
             "execution_progress": self.execution_progress,
             "is_clearing": self.is_clearing,
             "current_theta": self.current_theta,
             "current_rho": self.current_rho,
-            "speed": self._speed,
             "machine_x": self.machine_x,
             "machine_y": self.machine_y,
             "x_steps_per_mm": self.x_steps_per_mm,
             "y_steps_per_mm": self.y_steps_per_mm,
+            "port": self.port,
+            "patterns_since_last_home": self.patterns_since_last_home,
+        }
+
+    def to_settings_dict(self):
+        """Return a dictionary of user-configured settings (persisted intentionally)."""
+        # Base64-encode MQTT password for storage
+        mqtt_password_encoded = ""
+        if self.mqtt_password:
+            mqtt_password_encoded = base64.b64encode(self.mqtt_password.encode('utf-8')).decode('ascii')
+
+        return {
+            "speed": self._speed,
             "gear_ratio": self.gear_ratio,
             "homing": self.homing,
             "homing_user_override": self.homing_user_override,
             "angular_homing_offset_degrees": self.angular_homing_offset_degrees,
+            "home_on_connect": self.home_on_connect,
             "auto_home_enabled": self.auto_home_enabled,
             "auto_home_after_patterns": self.auto_home_after_patterns,
             "hard_reset_theta": self.hard_reset_theta,
-            "current_playlist": self._current_playlist,
-            "current_playlist_name": self._current_playlist_name,
-            "current_playlist_index": self.current_playlist_index,
             "playlist_mode": self._playlist_mode,
             "pause_time": self._pause_time,
             "clear_pattern": self._clear_pattern,
@@ -472,7 +506,6 @@ class AppState:
             "shuffle": self._shuffle,
             "custom_clear_from_in": self.custom_clear_from_in,
             "custom_clear_from_out": self.custom_clear_from_out,
-            "port": self.port,
             "preferred_port": self.preferred_port,
             "wled_ip": self.wled_ip,
             "led_provider": self.led_provider,
@@ -486,6 +519,7 @@ class AppState:
             "dw_led_playing_effect": self.dw_led_playing_effect,
             "dw_led_idle_timeout_enabled": self.dw_led_idle_timeout_enabled,
             "dw_led_idle_timeout_minutes": self.dw_led_idle_timeout_minutes,
+            "dw_led_control_mode": self.dw_led_control_mode,
             "app_name": self.app_name,
             "table_id": self.table_id,
             "table_name": self.table_name,
@@ -502,43 +536,75 @@ class AppState:
             "scheduled_pause_control_wled": self.scheduled_pause_control_wled,
             "scheduled_pause_finish_pattern": self.scheduled_pause_finish_pattern,
             "scheduled_pause_timezone": self.scheduled_pause_timezone,
+            "server_port": self.server_port,
             "timezone": self.timezone,
             "mqtt_enabled": self.mqtt_enabled,
             "mqtt_broker": self.mqtt_broker,
             "mqtt_port": self.mqtt_port,
             "mqtt_username": self.mqtt_username,
-            "mqtt_password": self.mqtt_password,
+            "mqtt_password": mqtt_password_encoded,
             "mqtt_client_id": self.mqtt_client_id,
             "mqtt_discovery_prefix": self.mqtt_discovery_prefix,
             "mqtt_device_id": self.mqtt_device_id,
             "mqtt_device_name": self.mqtt_device_name,
             "table_type_override": self.table_type_override,
+            "gear_ratio_override": self.gear_ratio_override,
+            "security_mode": self.security_mode,
+            "security_password_hash": self.security_password_hash,
         }
 
-    def from_dict(self, data):
-        """Update state from a dictionary."""
+    def to_dict(self):
+        """Return a combined dictionary (for backward compatibility)."""
+        combined = self.to_state_dict()
+        combined.update(self.to_settings_dict())
+        return combined
+
+    def from_state_dict(self, data):
+        """Update runtime state from a dictionary."""
         self.stop_requested = data.get("stop_requested", False)
         self._pause_requested = data.get("pause_requested", False)
         self._current_playing_file = data.get("current_playing_file", None)
+        self._current_playlist = data.get("current_playlist", None)
+        self._current_playlist_name = data.get("current_playlist_name", None)
+        self.current_playlist_index = data.get("current_playlist_index", None)
         self.execution_progress = data.get("execution_progress")
         self.is_clearing = data.get("is_clearing", False)
         self.current_theta = data.get("current_theta", 0)
         self.current_rho = data.get("current_rho", 0)
-        self._speed = data.get("speed", 150)
         self.machine_x = data.get("machine_x", 0.0)
         self.machine_y = data.get("machine_y", 0.0)
         self.x_steps_per_mm = data.get("x_steps_per_mm", 0.0)
         self.y_steps_per_mm = data.get("y_steps_per_mm", 0.0)
+        self.port = data.get("port", None)
+        self.patterns_since_last_home = data.get("patterns_since_last_home", 0)
+
+    @staticmethod
+    def _decode_mqtt_password(stored_value):
+        """Decode MQTT password from storage. Handles both base64-encoded and plain text (migration)."""
+        if not stored_value:
+            return ""
+        # Try base64 decode — valid base64 will decode cleanly
+        try:
+            decoded = base64.b64decode(stored_value, validate=True).decode('utf-8')
+            # Extra check: if the decoded string is printable, it was likely base64
+            if decoded.isprintable():
+                return decoded
+        except Exception:
+            pass
+        # Not valid base64 — treat as plain text (old format, will be re-encoded on next save)
+        return stored_value
+
+    def from_settings_dict(self, data):
+        """Update user settings from a dictionary."""
+        self._speed = data.get("speed", 150)
         self.gear_ratio = data.get('gear_ratio', 10)
         self.homing = data.get('homing', 0)
         self.homing_user_override = data.get('homing_user_override', False)
         self.angular_homing_offset_degrees = data.get('angular_homing_offset_degrees', 0.0)
+        self.home_on_connect = data.get('home_on_connect', True)
         self.auto_home_enabled = data.get('auto_home_enabled', False)
         self.auto_home_after_patterns = data.get('auto_home_after_patterns', 5)
         self.hard_reset_theta = data.get('hard_reset_theta', False)
-        self._current_playlist = data.get("current_playlist", None)
-        self._current_playlist_name = data.get("current_playlist_name", None)
-        self.current_playlist_index = data.get("current_playlist_index", None)
         self._playlist_mode = data.get("playlist_mode", "loop")
         self._pause_time = data.get("pause_time", 0)
         self._clear_pattern = data.get("clear_pattern", "none")
@@ -546,7 +612,6 @@ class AppState:
         self._shuffle = data.get("shuffle", False)
         self.custom_clear_from_in = data.get("custom_clear_from_in", None)
         self.custom_clear_from_out = data.get("custom_clear_from_out", None)
-        self.port = data.get("port", None)
         self.preferred_port = data.get("preferred_port", None)
         self.wled_ip = data.get('wled_ip', None)
         self.led_provider = data.get('led_provider', "none")
@@ -560,26 +625,21 @@ class AppState:
         # Load effect settings (handle both old string format and new dict format)
         idle_effect_data = data.get('dw_led_idle_effect', None)
         if isinstance(idle_effect_data, str):
-            # Old format: just effect name
             self.dw_led_idle_effect = None if idle_effect_data == "off" else {"effect_id": 0}
         else:
-            # New format: full dict or None
             self.dw_led_idle_effect = idle_effect_data
 
         playing_effect_data = data.get('dw_led_playing_effect', None)
         if isinstance(playing_effect_data, str):
-            # Old format: just effect name
             self.dw_led_playing_effect = None if playing_effect_data == "off" else {"effect_id": 0}
         else:
-            # New format: full dict or None
             self.dw_led_playing_effect = playing_effect_data
 
-        # Load idle timeout settings
         self.dw_led_idle_timeout_enabled = data.get('dw_led_idle_timeout_enabled', False)
         self.dw_led_idle_timeout_minutes = data.get('dw_led_idle_timeout_minutes', 30)
+        self.dw_led_control_mode = data.get('dw_led_control_mode', "automated")
 
         self.app_name = data.get("app_name", "Dune Weaver")
-        # Load or generate table_id (UUID persisted once generated)
         self.table_id = data.get("table_id", None)
         if self.table_id is None:
             self.table_id = str(uuid.uuid4())
@@ -597,25 +657,39 @@ class AppState:
         self.scheduled_pause_control_wled = data.get("scheduled_pause_control_wled", False)
         self.scheduled_pause_finish_pattern = data.get("scheduled_pause_finish_pattern", False)
         self.scheduled_pause_timezone = data.get("scheduled_pause_timezone", None)
+        self.server_port = data.get("server_port", 8080)
         self.timezone = data.get("timezone", "UTC")
         self.mqtt_enabled = data.get("mqtt_enabled", False)
         self.mqtt_broker = data.get("mqtt_broker", "")
         self.mqtt_port = data.get("mqtt_port", 1883)
         self.mqtt_username = data.get("mqtt_username", "")
-        self.mqtt_password = data.get("mqtt_password", "")
+        self.mqtt_password = self._decode_mqtt_password(data.get("mqtt_password", ""))
         self.mqtt_client_id = data.get("mqtt_client_id", "dune_weaver")
         self.mqtt_discovery_prefix = data.get("mqtt_discovery_prefix", "homeassistant")
         self.mqtt_device_id = data.get("mqtt_device_id", "dune_weaver")
         self.mqtt_device_name = data.get("mqtt_device_name", "Dune Weaver")
         self.table_type_override = data.get("table_type_override", None)
+        self.gear_ratio_override = data.get("gear_ratio_override", None)
+        self.security_mode = data.get("security_mode", "off")
+        self.security_password_hash = data.get("security_password_hash", "")
+
+    def from_dict(self, data):
+        """Update state from a combined dictionary (backward compatibility / migration)."""
+        self.from_state_dict(data)
+        self.from_settings_dict(data)
 
     def save(self):
-        """Save the current state to a JSON file."""
+        """Save current state and settings to their respective JSON files."""
         try:
             with open(self.STATE_FILE, "w") as f:
-                json.dump(self.to_dict(), f)
+                json.dump(self.to_state_dict(), f)
         except Exception as e:
             print(f"Error saving state to {self.STATE_FILE}: {e}")
+        try:
+            with open(self.SETTINGS_FILE, "w") as f:
+                json.dump(self.to_settings_dict(), f)
+        except Exception as e:
+            print(f"Error saving settings to {self.SETTINGS_FILE}: {e}")
 
     def save_debounced(self, delay: float = 2.0):
         """
@@ -644,17 +718,47 @@ class AppState:
         logger.debug("Debounced state save completed")
 
     def load(self):
-        """Load state from a JSON file. If the file doesn't exist, create it with default values."""
-        if not os.path.exists(self.STATE_FILE):
-            # File doesn't exist: create one with the current (default) state.
+        """Load state and settings from their JSON files, with migration from old single-file format."""
+        settings_exists = os.path.exists(self.SETTINGS_FILE)
+        state_exists = os.path.exists(self.STATE_FILE)
+
+        if not settings_exists and not state_exists:
+            # Fresh install: create both files with defaults
             self.save()
             return
-        try:
-            with open(self.STATE_FILE, "r") as f:
-                data = json.load(f)
-            self.from_dict(data)
-        except Exception as e:
-            print(f"Error loading state from {self.STATE_FILE}: {e}")
+
+        if not settings_exists and state_exists:
+            # Migration: old single-file format — read settings fields from state.json
+            try:
+                with open(self.STATE_FILE, "r") as f:
+                    old_data = json.load(f)
+                # Load everything from the combined old format
+                self.from_dict(old_data)
+                # Save to split into both files
+                self.save()
+                logger.info("Migrated settings from state.json to settings.json")
+                return
+            except Exception as e:
+                print(f"Error migrating state from {self.STATE_FILE}: {e}")
+                self.save()
+                return
+
+        # Normal load: read both files
+        if state_exists:
+            try:
+                with open(self.STATE_FILE, "r") as f:
+                    state_data = json.load(f)
+                self.from_state_dict(state_data)
+            except Exception as e:
+                print(f"Error loading state from {self.STATE_FILE}: {e}")
+
+        if settings_exists:
+            try:
+                with open(self.SETTINGS_FILE, "r") as f:
+                    settings_data = json.load(f)
+                self.from_settings_dict(settings_data)
+            except Exception as e:
+                print(f"Error loading settings from {self.SETTINGS_FILE}: {e}")
 
     def update_steps_per_mm(self, x_steps, y_steps):
         """Update and save steps per mm values."""

+ 5 - 3
modules/led/dw_led_controller.py

@@ -34,6 +34,8 @@ class DWLEDController:
         self.gpio_pin = gpio_pin
         self.brightness = brightness
         self.pixel_order = pixel_order
+        self.is_rgbw = 'W' in pixel_order.upper()
+        self._off_color = (0, 0, 0, 0) if self.is_rgbw else (0, 0, 0)
 
         # State
         self._powered_on = False
@@ -116,7 +118,7 @@ class DWLEDController:
             )
 
             # Create segment for the entire strip
-            self._segment = Segment(self._pixels, 0, self.num_leds)
+            self._segment = Segment(self._pixels, 0, self.num_leds, is_rgbw=self.is_rgbw)
             self._segment.speed = self._speed
             self._segment.intensity = self._intensity
             self._segment.palette_id = self._current_palette_id
@@ -188,7 +190,7 @@ class DWLEDController:
 
             # Turn off all pixels immediately when powering off
             if not self._powered_on and self._pixels:
-                self._pixels.fill((0, 0, 0))
+                self._pixels.fill(self._off_color)
                 self._pixels.show()
 
             # Start effect thread if not running
@@ -522,7 +524,7 @@ class DWLEDController:
 
         with self._lock:
             if self._pixels:
-                self._pixels.fill((0, 0, 0))
+                self._pixels.fill(self._off_color)
                 self._pixels.show()
                 self._pixels.deinit()
             self._pixels = None

+ 8 - 2
modules/led/dw_leds/segment.py

@@ -11,16 +11,18 @@ from .utils.palettes import color_from_palette, get_palette
 class Segment:
     """LED segment with effect support"""
 
-    def __init__(self, pixels, start: int, stop: int):
+    def __init__(self, pixels, start: int, stop: int, is_rgbw: bool = False):
         """
         Initialize a segment
         pixels: neopixel.NeoPixel object
         start: starting LED index
         stop: ending LED index (exclusive)
+        is_rgbw: True for RGBW strips (SK6812), writes 4-tuples instead of 3
         """
         self.pixels = pixels
         self.start = start
         self.stop = stop
+        self.is_rgbw = is_rgbw
         self.length = stop - start
 
         # Colors (up to 3 colors like WLED)
@@ -67,7 +69,11 @@ class Segment:
         r = get_r(color)
         g = get_g(color)
         b = get_b(color)
-        self.pixels[actual_idx] = (r, g, b)
+        if self.is_rgbw:
+            w = get_w(color)
+            self.pixels[actual_idx] = (r, g, b, w)
+        else:
+            self.pixels[actual_idx] = (r, g, b)
 
     def fill(self, color: int):
         """Fill entire segment with color"""

+ 99 - 2
modules/mqtt/handler.py

@@ -51,6 +51,10 @@ class MQTTHandler(BaseMQTTHandler):
         self.completion_topic = f"{self.device_id}/state/completion"
         self.time_remaining_topic = f"{self.device_id}/state/time_remaining"
 
+        # Screen control topics
+        self.screen_power_topic = f"{self.device_id}/screen/power/set"
+        self.screen_brightness_topic = f"{self.device_id}/screen/brightness/set"
+
         # LED control topics
         self.led_power_topic = f"{self.device_id}/led/power/set"
         self.led_brightness_topic = f"{self.device_id}/led/brightness/set"
@@ -166,6 +170,23 @@ class MQTTHandler(BaseMQTTHandler):
         }
         self._publish_discovery("button", "play", play_config)
 
+        # Skip Button
+        skip_config = {
+            "name": "Skip to next pattern",
+            "unique_id": f"{self.device_id}_skip",
+            "command_topic": f"{self.device_id}/command/skip",
+            "device": base_device,
+            "icon": "mdi:skip-next",
+            "entity_category": "config",
+            "enabled_by_default": True,
+            "availability": {
+                "topic": f"{self.device_id}/command/skip/available",
+                "payload_available": "true",
+                "payload_not_available": "false"
+            }
+        }
+        self._publish_discovery("button", "skip", skip_config)
+
         # Speed Control
         speed_config = {
             "name": f"{self.device_name} Speed",
@@ -383,6 +404,38 @@ class MQTTHandler(BaseMQTTHandler):
             }
             self._publish_discovery("light", "led_color", led_color_config)
 
+        # Screen Control Entities (only if screen controller is available)
+        if state.screen_controller and state.screen_controller.available:
+            screen_status = state.screen_controller.get_status()
+
+            # Screen Power Switch
+            screen_power_config = {
+                "name": f"{self.device_name} Screen Power",
+                "unique_id": f"{self.device_id}_screen_power",
+                "command_topic": self.screen_power_topic,
+                "state_topic": f"{self.device_id}/screen/power/state",
+                "payload_on": "ON",
+                "payload_off": "OFF",
+                "device": base_device,
+                "icon": "mdi:monitor",
+                "optimistic": False
+            }
+            self._publish_discovery("switch", "screen_power", screen_power_config)
+
+            # Screen Brightness Number
+            screen_brightness_config = {
+                "name": f"{self.device_name} Screen Brightness",
+                "unique_id": f"{self.device_id}_screen_brightness",
+                "command_topic": self.screen_brightness_topic,
+                "state_topic": f"{self.device_id}/screen/brightness/state",
+                "device": base_device,
+                "icon": "mdi:brightness-6",
+                "min": 0,
+                "max": screen_status.get("max_brightness", 255),
+                "mode": "slider"
+            }
+            self._publish_discovery("number", "screen_brightness", screen_brightness_config)
+
     def _publish_discovery(self, component: str, config_type: str, config: dict):
         """Helper method to publish HA discovery configs."""
         if not self.is_enabled:
@@ -407,8 +460,12 @@ class MQTTHandler(BaseMQTTHandler):
         self.client.publish(f"{self.device_id}/command/pause/available", 
                           "true" if running_state == "running" else "false", 
                           retain=True)
-        self.client.publish(f"{self.device_id}/command/play/available", 
-                          "true" if running_state == "paused" else "false", 
+        self.client.publish(f"{self.device_id}/command/play/available",
+                          "true" if running_state == "paused" else "false",
+                          retain=True)
+        # Skip is available when running and a playlist is active
+        self.client.publish(f"{self.device_id}/command/skip/available",
+                          "true" if running_state in ("running", "paused") and bool(self.state.current_playlist) else "false",
                           retain=True)
                           
     def _publish_pattern_state(self, current_file=None):
@@ -530,6 +587,19 @@ class MQTTHandler(BaseMQTTHandler):
         except Exception as e:
             logger.error(f"Error publishing LED state: {e}")
 
+    def _publish_screen_state(self):
+        """Helper to publish screen (LCD backlight) state to MQTT."""
+        if not state.screen_controller or not state.screen_controller.available:
+            return
+
+        try:
+            status = state.screen_controller.get_status()
+            power_state = "ON" if status["power_on"] else "OFF"
+            self.client.publish(f"{self.device_id}/screen/power/state", power_state, retain=True)
+            self.client.publish(f"{self.device_id}/screen/brightness/state", status["brightness"], retain=True)
+        except Exception as e:
+            logger.error(f"Error publishing screen state: {e}")
+
     def update_state(self, current_file=None, is_running=None, playlist=None, playlist_name=None):
         """Update state in Home Assistant. Only publishes the attributes that are explicitly passed."""
         if not self.is_enabled:
@@ -562,6 +632,7 @@ class MQTTHandler(BaseMQTTHandler):
                 (f"{self.device_id}/command/stop", 0),
                 (f"{self.device_id}/command/pause", 0),
                 (f"{self.device_id}/command/play", 0),
+                (f"{self.device_id}/command/skip", 0),
                 (f"{self.device_id}/playlist/mode/set", 0),
                 (f"{self.device_id}/playlist/pause_time/set", 0),
                 (f"{self.device_id}/playlist/clear_pattern/set", 0),
@@ -572,6 +643,8 @@ class MQTTHandler(BaseMQTTHandler):
                 (self.led_speed_topic, 0),
                 (self.led_intensity_topic, 0),
                 (self.led_color_topic, 0),
+                (self.screen_power_topic, 0),
+                (self.screen_brightness_topic, 0),
             ])
             # Publish discovery configurations
             self.setup_ha_discovery()
@@ -660,6 +733,14 @@ class MQTTHandler(BaseMQTTHandler):
                         asyncio.run_coroutine_threadsafe(callback(), self.main_loop)
                     else:
                         callback()
+            elif msg.topic == f"{self.device_id}/command/skip":
+                # Handle skip command - only if a playlist is running
+                if self.state.current_playlist:
+                    callback = self.callback_registry['skip']
+                    if asyncio.iscoroutinefunction(callback):
+                        asyncio.run_coroutine_threadsafe(callback(), self.main_loop)
+                    else:
+                        callback()
             elif msg.topic == f"{self.device_id}/playlist/mode/set":
                 mode = msg.payload.decode()
                 if mode in ["single", "loop"]:
@@ -753,6 +834,18 @@ class MQTTHandler(BaseMQTTHandler):
                                               json.dumps({"r": r, "g": g, "b": b}), retain=True)
                 except json.JSONDecodeError:
                     logger.error(f"Invalid JSON for color command: {msg.payload}")
+            elif msg.topic == self.screen_power_topic:
+                # Handle screen power command
+                payload = msg.payload.decode()
+                if state.screen_controller and state.screen_controller.available:
+                    state.screen_controller.set_power(payload == "ON")
+                    self._publish_screen_state()
+            elif msg.topic == self.screen_brightness_topic:
+                # Handle screen brightness command
+                brightness = int(msg.payload.decode())
+                if state.screen_controller and state.screen_controller.available:
+                    state.screen_controller.set_brightness(brightness)
+                    self._publish_screen_state()
             else:
                 # Handle other commands
                 payload = json.loads(msg.payload.decode())
@@ -786,6 +879,9 @@ class MQTTHandler(BaseMQTTHandler):
                 # Update LED state
                 self._publish_led_state()
 
+                # Update screen state
+                self._publish_screen_state()
+
                 # Publish keepalive status
                 status = {
                     "timestamp": time.time(),
@@ -828,6 +924,7 @@ class MQTTHandler(BaseMQTTHandler):
             self._publish_progress_state()
             self._publish_playlist_settings_state()
             self._publish_led_state()
+            self._publish_screen_state()
 
             # Setup Home Assistant discovery
             self.setup_ha_discovery()

+ 4 - 0
modules/mqtt/utils.py

@@ -20,12 +20,16 @@ def create_mqtt_callbacks() -> Dict[str, Callable]:
     def set_speed(speed):
         state.speed = speed
 
+    def skip_pattern():
+        state.skip_requested = True
+
     return {
         'run_pattern': run_theta_rho_file,  # async function
         'run_playlist': run_playlist,  # async function
         'stop': stop_actions,  # sync function
         'pause': pause_execution,  # sync function
         'resume': resume_execution,  # sync function
+        'skip': skip_pattern,  # sync function
         'home': home,
         'set_speed': set_speed
     }

+ 0 - 0
modules/screen/__init__.py


+ 125 - 0
modules/screen/screen_controller.py

@@ -0,0 +1,125 @@
+"""Screen (LCD backlight) controller via Linux sysfs.
+
+Mirrors the sysfs approach used in dune-weaver-touch/backend.py so the main
+FastAPI backend can control the attached touchscreen independently. On dev
+machines without /sys/class/backlight the controller reports available=False
+and all commands no-op gracefully.
+"""
+import logging
+import subprocess
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+
+class ScreenController:
+    def __init__(self):
+        self.brightness_path: str = ""
+        self.max_brightness: int = 255
+        self._current_brightness: int = 0
+        self._power_on: bool = True
+        self.available: bool = False
+
+        self._detect_backlight()
+
+    # ── Detection ──────────────────────────────────────────────
+
+    def _detect_backlight(self):
+        """Auto-detect the sysfs backlight device, path, and max brightness."""
+        backlight_base = Path("/sys/class/backlight")
+        if not backlight_base.exists():
+            logger.info("No /sys/class/backlight found — screen control unavailable")
+            return
+
+        try:
+            devices = [d.name for d in backlight_base.iterdir() if d.is_dir()]
+        except Exception as e:
+            logger.warning(f"Failed to list backlight devices: {e}")
+            return
+
+        if not devices:
+            logger.info("No backlight devices found")
+            return
+
+        device = devices[0]
+        self.brightness_path = f"/sys/class/backlight/{device}/brightness"
+        logger.info(f"Auto-detected backlight device: {device}")
+
+        # Read max_brightness
+        max_path = f"/sys/class/backlight/{device}/max_brightness"
+        try:
+            self.max_brightness = int(Path(max_path).read_text().strip())
+            logger.info(f"Max brightness: {self.max_brightness}")
+        except Exception as e:
+            self.max_brightness = 255
+            logger.warning(f"Failed to read max_brightness, defaulting to 255: {e}")
+
+        # Read current brightness
+        try:
+            self._current_brightness = int(Path(self.brightness_path).read_text().strip())
+            self._power_on = self._current_brightness > 0
+            logger.info(f"Current brightness: {self._current_brightness}/{self.max_brightness}")
+        except Exception as e:
+            logger.warning(f"Failed to read current brightness: {e}")
+
+        self.available = True
+
+    # ── Public API ─────────────────────────────────────────────
+
+    def set_brightness(self, value: int) -> dict:
+        """Set backlight brightness (0 to max_brightness)."""
+        if not self.available:
+            return {"success": False, "message": "Screen control not available"}
+
+        value = max(0, min(value, self.max_brightness))
+        try:
+            subprocess.run(
+                ["sudo", "sh", "-c", f"echo {value} > {self.brightness_path}"],
+                check=True, timeout=5
+            )
+            self._current_brightness = value
+            if value > 0:
+                self._power_on = True
+            return {"success": True, "brightness": value}
+        except Exception as e:
+            logger.error(f"Failed to set brightness: {e}")
+            return {"success": False, "message": str(e)}
+
+    def set_power(self, on: bool) -> dict:
+        """Turn screen on or off via framebuffer blank + backlight."""
+        if not self.available:
+            return {"success": False, "message": "Screen control not available"}
+
+        try:
+            if on:
+                # Restore brightness + unblank framebuffer
+                restore = self._current_brightness if self._current_brightness > 0 else self.max_brightness
+                subprocess.run(
+                    ["sudo", "sh", "-c",
+                     f"echo 0 > /sys/class/graphics/fb0/blank && echo {restore} > {self.brightness_path}"],
+                    check=True, timeout=5
+                )
+                self._current_brightness = restore
+                self._power_on = True
+            else:
+                # Zero brightness + blank framebuffer
+                subprocess.run(
+                    ["sudo", "sh", "-c",
+                     f"echo 0 > {self.brightness_path} && echo 1 > /sys/class/graphics/fb0/blank"],
+                    check=True, timeout=5
+                )
+                self._power_on = False
+
+            return {"success": True, "power_on": self._power_on}
+        except Exception as e:
+            logger.error(f"Failed to set screen power: {e}")
+            return {"success": False, "message": str(e)}
+
+    def get_status(self) -> dict:
+        """Return current screen state."""
+        return {
+            "available": self.available,
+            "power_on": self._power_on,
+            "brightness": self._current_brightness,
+            "max_brightness": self.max_brightness,
+        }

+ 15 - 36
modules/update/update_manager.py

@@ -52,13 +52,10 @@ def check_git_updates():
 def update_software():
     """Update the software to the latest version.
 
-    This runs inside the Docker container, so it:
-    1. Pulls latest code via git (mounted volume at /app)
-    2. Pulls new Docker image for the backend
-    3. Restarts the container to apply updates
+    Pulls latest code, installs updated Python dependencies,
+    and restarts the systemd service.
 
-    Note: For a complete update including container recreation,
-    run 'dw update' from the host machine instead.
+    For a full update (including frontend rebuild), run 'dw update' instead.
     """
     error_log = []
     logger.info("Starting software update process")
@@ -73,53 +70,35 @@ def update_software():
             error_log.append(error_message)
             return None
 
-    # Step 1: Pull latest code via git (works because /app is mounted from host)
+    # Step 1: Pull latest code via git
     logger.info("Pulling latest code from git...")
     git_result = run_command(
         ["git", "pull", "--ff-only"],
-        "Failed to pull latest code from git",
-        cwd="/app"
+        "Failed to pull latest code from git"
     )
     if git_result:
         logger.info("Git pull completed successfully")
 
-    # Step 2: Pull new Docker image for the backend only
-    # Note: There is no separate frontend image - it's either bundled or built locally
-    logger.info("Pulling latest Docker image...")
+    # Step 2: Install updated Python dependencies
+    logger.info("Installing updated dependencies...")
     run_command(
-        ["docker", "pull", "ghcr.io/tuanchris/dune-weaver:main"],
-        "Failed to pull backend Docker image"
+        [".venv/bin/pip", "install", "-r", "requirements.txt"],
+        "Failed to install updated dependencies"
     )
 
-    # Step 3: Restart the backend container to apply updates
-    # We can't recreate ourselves from inside the container, so we just restart
-    # For full container recreation with new images, use 'dw update' from host
-    logger.info("Restarting backend container...")
-
-    # Use docker restart which works from inside the container
+    # Step 3: Restart the service
+    logger.info("Restarting dune-weaver service...")
     restart_result = run_command(
-        ["docker", "restart", "dune-weaver-backend"],
-        "Failed to restart backend container"
+        ["sudo", "systemctl", "restart", "dune-weaver"],
+        "Failed to restart dune-weaver service"
     )
 
     if not restart_result:
-        # If docker restart fails, try a graceful approach
-        logger.info("Attempting graceful restart via compose...")
-        try:
-            # Just restart, don't try to recreate (which would fail)
-            subprocess.run(
-                ["docker", "compose", "restart", "backend"],
-                check=True,
-                cwd="/app"
-            )
-            logger.info("Container restarted successfully via compose")
-        except (subprocess.CalledProcessError, FileNotFoundError) as e:
-            logger.warning(f"Compose restart also failed: {e}")
-            error_log.append("Container restart failed - please run 'dw update' from host")
+        error_log.append("Service restart failed - please run 'dw restart' manually")
 
     if error_log:
         logger.error(f"Software update completed with errors: {error_log}")
-        return False, "Update completed with errors. For best results, run 'dw update' from the host machine.", error_log
+        return False, "Update completed with errors. Run 'dw update' for a full update.", error_log
 
     logger.info("Software update completed successfully")
     return True, None, None

+ 1 - 0
modules/wifi/__init__.py

@@ -0,0 +1 @@
+"""WiFi management module for Dune Weaver."""

+ 447 - 0
modules/wifi/manager.py

@@ -0,0 +1,447 @@
+"""
+WiFi management via NetworkManager (nmcli).
+
+Handles scanning, connecting, and managing WiFi connections.
+"""
+
+import subprocess
+import os
+import logging
+import asyncio
+
+logger = logging.getLogger(__name__)
+
+HOTSPOT_CON_NAME = "DuneWeaver-Hotspot"
+
+
+def run_nmcli(*args: str, timeout: int = 30) -> str:
+    """Run nmcli and return stdout."""
+    cmd = ["nmcli"] + list(args)
+    logger.debug(f"Running nmcli: {' '.join(cmd)}")
+    result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
+
+    if result.returncode != 0:
+        logger.warning(f"nmcli failed (rc={result.returncode}): {' '.join(cmd)}")
+        if result.stderr:
+            logger.warning(f"  stderr: {result.stderr.strip()}")
+
+    return result.stdout
+
+
+def run_nmcli_check(*args: str, timeout: int = 30) -> subprocess.CompletedProcess:
+    """Run nmcli and return the full CompletedProcess (for checking returncode)."""
+    cmd = ["nmcli"] + list(args)
+    logger.debug(f"Running nmcli: {' '.join(cmd)}")
+    result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
+
+    if result.returncode != 0:
+        logger.warning(f"nmcli failed (rc={result.returncode}): {' '.join(cmd)}")
+        if result.stderr:
+            logger.warning(f"  stderr: {result.stderr.strip()}")
+
+    return result
+
+
+def get_wifi_mode() -> str:
+    """Detect WiFi mode by querying NetworkManager active connections.
+
+    Uses 'nmcli con show --active' instead of 'nmcli dev show wlan0'
+    because the latter can behave differently in AP (hotspot) mode
+    across NM versions.
+    """
+    try:
+        output = run_nmcli("-t", "-f", "NAME,TYPE,DEVICE", "con", "show", "--active")
+        logger.info(f"Active connections: {output.strip()}")
+        for line in output.strip().splitlines():
+            parts = line.split(":")
+            if len(parts) >= 3 and parts[2] == "wlan0":
+                con_name = parts[0]
+                if con_name == HOTSPOT_CON_NAME:
+                    return "hotspot"
+                return "client"
+    except (subprocess.TimeoutExpired, Exception) as e:
+        logger.warning(f"Error detecting WiFi mode: {e}")
+    return "unknown"
+
+
+def get_current_ssid() -> str:
+    """Get the SSID of the currently connected WiFi network."""
+    try:
+        # Use active connections to find the wifi connection on wlan0
+        output = run_nmcli("-t", "-f", "NAME,TYPE,DEVICE", "con", "show", "--active")
+        for line in output.strip().splitlines():
+            parts = line.split(":")
+            if len(parts) >= 3 and parts[2] == "wlan0":
+                con_name = parts[0]
+                if con_name and con_name != HOTSPOT_CON_NAME:
+                    return con_name
+    except (subprocess.TimeoutExpired, Exception) as e:
+        logger.debug(f"Error getting SSID: {e}")
+    return ""
+
+
+def get_current_ip() -> str:
+    """Get the current IP address of the wlan0 interface."""
+    try:
+        output = run_nmcli("-t", "-f", "IP4.ADDRESS", "dev", "show", "wlan0")
+        logger.debug(f"IP output: {output.strip()}")
+        for line in output.strip().splitlines():
+            if "IP4.ADDRESS" in line:
+                addr = line.split(":", 1)[1] if ":" in line else ""
+                if addr:
+                    return addr.split("/")[0]
+    except (subprocess.TimeoutExpired, Exception) as e:
+        logger.debug(f"Error getting IP: {e}")
+    return ""
+
+
+def get_hostname() -> str:
+    """Get the system hostname via NetworkManager."""
+    try:
+        output = run_nmcli("general", "hostname")
+        name = output.strip()
+        if name:
+            return name
+    except Exception:
+        pass
+    return "duneweaver"
+
+
+def get_wifi_status() -> dict:
+    """Get comprehensive WiFi status."""
+    mode = get_wifi_mode()
+    ssid = get_current_ssid()
+    ip = get_current_ip()
+    hostname = get_hostname()
+
+    return {
+        "mode": mode,
+        "ssid": ssid,
+        "ip": ip,
+        "hostname": hostname,
+    }
+
+
+def scan_networks() -> list[dict]:
+    """Scan for available WiFi networks."""
+    try:
+        # Trigger rescan
+        run_nmcli("dev", "wifi", "rescan", "ifname", "wlan0")
+    except Exception:
+        pass
+
+    # Brief wait for scan results
+    import time
+    time.sleep(2)
+
+    try:
+        output = run_nmcli("-t", "-f", "SSID,SIGNAL,SECURITY,ACTIVE", "dev", "wifi", "list", "ifname", "wlan0")
+    except subprocess.TimeoutExpired:
+        logger.error("WiFi scan timed out")
+        return []
+
+    # Get saved connections for cross-reference
+    saved = get_saved_connections()
+    saved_ssids = {c["ssid"] for c in saved}
+
+    networks = []
+    seen_ssids = set()
+
+    for line in output.strip().splitlines():
+        if not line.strip():
+            continue
+        # nmcli -t uses : as delimiter, but SSID can contain colons
+        # Format: SSID:SIGNAL:SECURITY:ACTIVE
+        # Parse from the right since SSID is the only field that can contain ':'
+        parts = line.rsplit(":", 3)
+        if len(parts) < 4:
+            continue
+
+        ssid = parts[0].strip()
+        if not ssid or ssid in seen_ssids:
+            continue
+        seen_ssids.add(ssid)
+
+        try:
+            signal = int(parts[1])
+        except (ValueError, IndexError):
+            signal = 0
+
+        security = parts[2] if len(parts) > 2 else ""
+        active = parts[3].strip().lower() == "yes" if len(parts) > 3 else False
+
+        networks.append({
+            "ssid": ssid,
+            "signal": signal,
+            "security": security if security and security != "--" else "Open",
+            "saved": ssid in saved_ssids,
+            "active": active,
+        })
+
+    # Sort by signal strength (strongest first)
+    networks.sort(key=lambda n: n["signal"], reverse=True)
+    return networks
+
+
+def get_saved_connections() -> list[dict]:
+    """Get list of saved WiFi connections."""
+    try:
+        output = run_nmcli("-t", "-f", "NAME,TYPE", "con", "show")
+    except subprocess.TimeoutExpired:
+        return []
+
+    connections = []
+    for line in output.strip().splitlines():
+        if "wireless" not in line:
+            continue
+        name = line.split(":")[0]
+        if name == HOTSPOT_CON_NAME:
+            continue
+
+        # Get the SSID for this connection
+        try:
+            detail = run_nmcli("-t", "-f", "802-11-wireless.ssid", "con", "show", name)
+            ssid = ""
+            for detail_line in detail.strip().splitlines():
+                if "802-11-wireless.ssid" in detail_line:
+                    ssid = detail_line.split(":", 1)[1] if ":" in detail_line else name
+                    break
+            if not ssid:
+                ssid = name
+        except Exception:
+            ssid = name
+
+        connections.append({
+            "name": name,
+            "ssid": ssid,
+        })
+
+    return connections
+
+
+async def connect_to_network(ssid: str, password: str) -> dict:
+    """Connect to a WiFi network.
+
+    Uses explicit connection profile creation (nmcli con add) instead of
+    'nmcli dev wifi connect' because the latter fails on Pi Trixie with
+    'key-mgmt: property is missing' for WPA networks.
+    """
+    try:
+        # Delete any stale connection profile for this SSID (ignore if not found)
+        subprocess.run(
+            ["nmcli", "con", "delete", ssid],
+            capture_output=True, text=True, timeout=10,
+        )
+
+        # Create connection profile with explicit security settings
+        if password:
+            result = run_nmcli_check(
+                "con", "add",
+                "type", "wifi",
+                "ifname", "wlan0",
+                "con-name", ssid,
+                "ssid", ssid,
+                "wifi-sec.key-mgmt", "wpa-psk",
+                "wifi-sec.psk", password,
+                timeout=15,
+            )
+        else:
+            result = run_nmcli_check(
+                "con", "add",
+                "type", "wifi",
+                "ifname", "wlan0",
+                "con-name", ssid,
+                "ssid", ssid,
+                timeout=15,
+            )
+
+        if result.returncode != 0:
+            error_msg = result.stderr.strip() or "Failed to create connection"
+            logger.error(f"WiFi connection add failed: {error_msg}")
+            return {"success": False, "message": error_msg}
+
+        # Activate the connection
+        result = run_nmcli_check("con", "up", ssid, timeout=30)
+
+        if result.returncode != 0:
+            error_msg = result.stderr.strip() or "Failed to connect"
+            logger.error(f"WiFi connect failed: {error_msg}")
+            # Clean up the failed connection profile
+            run_nmcli_check("con", "delete", ssid, timeout=10)
+            return {"success": False, "message": error_msg}
+
+        logger.info(f"WiFi connection to '{ssid}' successful")
+
+        return {
+            "success": True,
+            "message": f"Connected to '{ssid}'.",
+        }
+
+    except subprocess.TimeoutExpired:
+        return {"success": False, "message": "Connection timed out"}
+    except Exception as e:
+        logger.error(f"WiFi connect error: {e}")
+        return {"success": False, "message": str(e)}
+
+
+def save_network(ssid: str, password: str) -> dict:
+    """Save a WiFi network profile without connecting.
+
+    Creates the connection profile so autohotspot can use it on next check/boot.
+    """
+    try:
+        # Delete any stale connection profile for this SSID (ignore if not found)
+        subprocess.run(
+            ["nmcli", "con", "delete", ssid],
+            capture_output=True, text=True, timeout=10,
+        )
+
+        if password:
+            result = run_nmcli_check(
+                "con", "add",
+                "type", "wifi",
+                "ifname", "wlan0",
+                "con-name", ssid,
+                "ssid", ssid,
+                "wifi-sec.key-mgmt", "wpa-psk",
+                "wifi-sec.psk", password,
+                timeout=15,
+            )
+        else:
+            result = run_nmcli_check(
+                "con", "add",
+                "type", "wifi",
+                "ifname", "wlan0",
+                "con-name", ssid,
+                "ssid", ssid,
+                timeout=15,
+            )
+
+        if result.returncode != 0:
+            error_msg = result.stderr.strip() or "Failed to save network"
+            logger.error(f"WiFi save failed: {error_msg}")
+            return {"success": False, "message": error_msg}
+
+        logger.info(f"Saved WiFi network '{ssid}' (not connecting)")
+        return {"success": True, "message": f"Saved '{ssid}'. Will connect automatically when in range."}
+
+    except subprocess.TimeoutExpired:
+        return {"success": False, "message": "Operation timed out"}
+    except Exception as e:
+        logger.error(f"WiFi save error: {e}")
+        return {"success": False, "message": str(e)}
+
+
+def forget_network(ssid: str) -> dict:
+    """Delete a saved WiFi connection by SSID.
+
+    If the forgotten network was the active connection, triggers the
+    autohotspot script to re-evaluate and fall back to hotspot mode.
+    """
+    # Check if this is the currently active connection
+    current_ssid = get_current_ssid()
+    was_active = current_ssid == ssid or current_ssid == ssid.replace(" ", "")
+
+    saved = get_saved_connections()
+    con_name = None
+    for con in saved:
+        if con["ssid"] == ssid:
+            con_name = con["name"]
+            break
+
+    if not con_name:
+        return {"success": False, "message": f"No saved connection found for '{ssid}'"}
+
+    try:
+        result = run_nmcli_check("con", "delete", con_name, timeout=15)
+        if result.returncode == 0:
+            logger.info(f"Forgot WiFi network '{ssid}' (connection: {con_name})")
+
+            # If we just forgot the active connection, re-run autohotspot
+            # so it can fall back to hotspot mode
+            if was_active:
+                _trigger_autohotspot()
+
+            return {"success": True, "message": f"Forgot '{ssid}'"}
+        else:
+            error_msg = result.stderr.strip() or "Failed to delete connection"
+            return {"success": False, "message": error_msg}
+    except Exception as e:
+        return {"success": False, "message": str(e)}
+
+
+def get_hotspot_password() -> dict:
+    """Get the current hotspot password (empty string if open network)."""
+    try:
+        output = run_nmcli("-s", "-t", "-f", "802-11-wireless-security.psk",
+                           "con", "show", HOTSPOT_CON_NAME)
+        for line in output.strip().splitlines():
+            if "802-11-wireless-security.psk" in line:
+                psk = line.split(":", 1)[1] if ":" in line else ""
+                return {"password": psk}
+        return {"password": ""}
+    except Exception as e:
+        logger.error(f"Error getting hotspot password: {e}")
+        return {"password": ""}
+
+
+def set_hotspot_password(password: str) -> dict:
+    """Set or remove the hotspot password.
+
+    If password is non-empty, enables WPA-PSK. If empty, removes security
+    (open network). Restarts the hotspot if it's currently active.
+    """
+    try:
+        if password:
+            if len(password) < 8:
+                return {"success": False, "message": "Password must be at least 8 characters"}
+            result = run_nmcli_check(
+                "con", "modify", HOTSPOT_CON_NAME,
+                "wifi-sec.key-mgmt", "wpa-psk",
+                "wifi-sec.psk", password,
+            )
+        else:
+            # Remove security settings entirely to make it an open network
+            result = run_nmcli_check(
+                "con", "modify", HOTSPOT_CON_NAME,
+                "remove", "802-11-wireless-security",
+            )
+
+        if result.returncode != 0:
+            error_msg = result.stderr.strip() or "Failed to update hotspot password"
+            return {"success": False, "message": error_msg}
+
+        # Restart hotspot if currently active so changes take effect
+        if get_wifi_mode() == "hotspot":
+            run_nmcli_check("con", "down", HOTSPOT_CON_NAME, timeout=10)
+            run_nmcli_check("con", "up", HOTSPOT_CON_NAME, timeout=10)
+
+        msg = "Hotspot password updated" if password else "Hotspot password removed (open network)"
+        logger.info(msg)
+        return {"success": True, "message": msg}
+    except Exception as e:
+        logger.error(f"Error setting hotspot password: {e}")
+        return {"success": False, "message": str(e)}
+
+
+def _trigger_autohotspot():
+    """Trigger an immediate autohotspot check.
+
+    Called when the active network is forgotten so the user doesn't have
+    to wait for the next 60s timer tick.
+    """
+    autohotspot_path = "/usr/local/bin/autohotspot"
+    try:
+        if os.path.exists(autohotspot_path):
+            logger.info("Triggering autohotspot --check after forgetting active network...")
+            subprocess.Popen(
+                [autohotspot_path, "--check"],
+                stdout=subprocess.DEVNULL,
+                stderr=subprocess.DEVNULL,
+            )
+        else:
+            logger.info("Autohotspot script not found, activating hotspot directly...")
+            run_nmcli("dev", "disconnect", "wlan0")
+            run_nmcli("con", "up", HOTSPOT_CON_NAME)
+    except Exception as e:
+        logger.error(f"Failed to trigger autohotspot: {e}")

+ 153 - 0
modules/wifi/router.py

@@ -0,0 +1,153 @@
+"""
+FastAPI router for WiFi management endpoints.
+"""
+
+from fastapi import APIRouter, HTTPException, Request
+from fastapi.responses import HTMLResponse
+from pydantic import BaseModel
+from typing import Optional
+import logging
+
+from . import manager
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api/wifi", tags=["wifi"])
+
+# Separate router for the captive portal handler (no prefix)
+captive_portal_router = APIRouter(tags=["wifi"])
+
+
+class WiFiConnectRequest(BaseModel):
+    ssid: str
+    password: Optional[str] = ""
+
+
+class WiFiForgetRequest(BaseModel):
+    ssid: str
+
+
+class HotspotPasswordRequest(BaseModel):
+    password: Optional[str] = ""
+
+
+@router.get("/status")
+async def wifi_status():
+    """Get current WiFi mode, connection, and IP."""
+    return manager.get_wifi_status()
+
+
+@router.get("/networks")
+async def wifi_networks():
+    """Scan for available WiFi networks."""
+    return manager.scan_networks()
+
+
+@router.post("/connect")
+async def wifi_connect(req: WiFiConnectRequest):
+    """Connect to a WiFi network and reboot."""
+    if not req.ssid:
+        raise HTTPException(status_code=400, detail="SSID is required")
+
+    result = await manager.connect_to_network(req.ssid, req.password or "")
+    if not result["success"]:
+        raise HTTPException(status_code=400, detail=result["message"])
+    return result
+
+
+@router.post("/save")
+async def wifi_save(req: WiFiConnectRequest):
+    """Save a WiFi network without connecting."""
+    if not req.ssid:
+        raise HTTPException(status_code=400, detail="SSID is required")
+
+    result = manager.save_network(req.ssid, req.password or "")
+    if not result["success"]:
+        raise HTTPException(status_code=400, detail=result["message"])
+    return result
+
+
+@router.post("/forget")
+async def wifi_forget(req: WiFiForgetRequest):
+    """Forget a saved WiFi network."""
+    if not req.ssid:
+        raise HTTPException(status_code=400, detail="SSID is required")
+
+    result = manager.forget_network(req.ssid)
+    if not result["success"]:
+        raise HTTPException(status_code=400, detail=result["message"])
+    return result
+
+
+@router.get("/saved")
+async def wifi_saved():
+    """Get list of saved WiFi connections."""
+    return manager.get_saved_connections()
+
+
+@router.get("/hotspot/password")
+async def get_hotspot_password():
+    """Get the current hotspot password."""
+    return manager.get_hotspot_password()
+
+
+@router.post("/hotspot/password")
+async def set_hotspot_password(req: HotspotPasswordRequest):
+    """Set or remove the hotspot password."""
+    result = manager.set_hotspot_password(req.password or "")
+    if not result["success"]:
+        raise HTTPException(status_code=400, detail=result["message"])
+    return result
+
+
+# --- Captive portal detection endpoints ---
+# These handle the well-known URLs that phones/tablets probe to detect captive portals.
+# In hotspot mode, DNS redirects all domains to the Pi, so these probes arrive here.
+# We serve a minimal HTML page that redirects to the WiFi setup page.
+
+CAPTIVE_PORTAL_HTML = """<!DOCTYPE html>
+<html>
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>Dune Weaver - WiFi Setup</title>
+    <style>
+        * { margin: 0; padding: 0; box-sizing: border-box; }
+        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+               display: flex; justify-content: center; align-items: center;
+               min-height: 100vh; background: #f8fafc; color: #0f172a; padding: 1rem; }
+        .card { background: #ffffff; border: 1px solid #e2e8f0; border-radius: 12px;
+                padding: 2rem; max-width: 400px; width: 100%; text-align: center;
+                box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
+        h1 { font-size: 1.5rem; margin-bottom: 0.5rem; font-weight: 600; }
+        p { color: #64748b; margin-bottom: 1.5rem; font-size: 0.9rem; }
+        a { display: inline-block; background: #2563eb; color: white; padding: 0.75rem 2rem;
+            border-radius: 8px; text-decoration: none; font-weight: 500; }
+        a:hover { background: #1d4ed8; }
+    </style>
+</head>
+<body>
+    <div class="card">
+        <h1>Welcome to Dune Weaver</h1>
+        <p>Connect to your home WiFi to get started.</p>
+        <a href="/wifi-setup">Set Up WiFi</a>
+    </div>
+</body>
+</html>"""
+
+
+@captive_portal_router.get("/hotspot-detect.html", response_class=HTMLResponse)
+@captive_portal_router.get("/generate_204", response_class=HTMLResponse)
+@captive_portal_router.get("/connecttest.txt", response_class=HTMLResponse)
+@captive_portal_router.get("/ncsi.txt", response_class=HTMLResponse)
+@captive_portal_router.get("/redirect", response_class=HTMLResponse)
+@captive_portal_router.get("/canonical.html", response_class=HTMLResponse)
+async def captive_portal_detect():
+    """Handle captive portal detection probes.
+
+    Phones and tablets check these well-known URLs after connecting to WiFi.
+    In hotspot mode, DNS resolves all domains to the Pi, so these probes
+    arrive at our server. Returning anything other than the expected response
+    triggers the OS to show a captive portal browser.
+    """
+    return HTMLResponse(content=CAPTIVE_PORTAL_HTML, status_code=200)

+ 61 - 52
nginx.conf → nginx/dune-weaver.conf

@@ -1,52 +1,61 @@
-server {
-    listen 80;
-    server_name _;
-
-    # Increase max upload size for pattern files
-    client_max_body_size 10M;
-
-    # Frontend - serve static files
-    location / {
-        root /usr/share/nginx/html;
-        index index.html;
-        try_files $uri $uri/ /index.html;
-    }
-
-    # API proxy to backend container
-    location /api/ {
-        proxy_pass http://backend:8080;
-        proxy_set_header Host $host;
-        proxy_set_header X-Real-IP $remote_addr;
-        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
-        proxy_set_header X-Forwarded-Proto $scheme;
-    }
-
-    # WebSocket proxy
-    location /ws/ {
-        proxy_pass http://backend:8080;
-        proxy_http_version 1.1;
-        proxy_set_header Upgrade $http_upgrade;
-        proxy_set_header Connection "upgrade";
-        proxy_set_header Host $host;
-        proxy_set_header X-Real-IP $remote_addr;
-        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
-        proxy_read_timeout 86400;
-    }
-
-    # Static files from backend (pattern previews, custom logos)
-    location /static/ {
-        proxy_pass http://backend:8080;
-        proxy_set_header Host $host;
-        proxy_set_header X-Real-IP $remote_addr;
-        proxy_cache_valid 200 1h;
-    }
-
-    # All backend API endpoints (legacy non-/api/ routes)
-    location ~ ^/(list_theta_rho_files|preview_thr_batch|get_theta_rho_coordinates|upload_theta_rho|pause_execution|resume_execution|stop_execution|force_stop|soft_reset|skip_pattern|set_speed|get_speed|restart|shutdown|run_pattern|run_playlist|connect_device|disconnect_device|home_device|clear_sand|move_to_position|get_playlists|save_playlist|delete_playlist|rename_playlist|reorder_playlist|delete_theta_rho_file|get_status|list_serial_ports|serial_status|cache-progress|rebuild_cache|get_led_config|set_led_config|get_wled_ip|set_wled_ip|led|send_home|send_coordinate|move_to_center|move_to_perimeter|run_theta_rho|connect|disconnect|list_theta_rho_files_with_metadata|list_all_playlists|get_playlist|create_playlist|modify_playlist|add_to_playlist|preview_thr|preview|add_to_queue|restart_connection|controller_restart|recover_sensor_homing|run_theta_rho_file|download|check_software_update|update_software) {
-        proxy_pass http://backend:8080;
-        proxy_set_header Host $host;
-        proxy_set_header X-Real-IP $remote_addr;
-        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
-        proxy_set_header X-Forwarded-Proto $scheme;
-    }
-}
+server {
+    listen 80;
+    server_name _;
+
+    # Increase max upload size for pattern files
+    client_max_body_size 10M;
+
+    # Frontend - serve static files
+    location / {
+        root INSTALL_DIR_PLACEHOLDER/static/dist;
+        index index.html;
+        try_files $uri $uri/ /index.html;
+    }
+
+    # Captive portal detection probes → backend serves redirect page
+    # In hotspot mode, DNS resolves all domains to the Pi.
+    # Phones probe these well-known paths to detect captive portals.
+    location ~ ^/(hotspot-detect\.html|generate_204|connecttest\.txt|ncsi\.txt|redirect|canonical\.html)$ {
+        proxy_pass http://127.0.0.1:8080;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+    }
+
+    # API proxy to backend
+    location /api/ {
+        proxy_pass http://127.0.0.1:8080;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+    }
+
+    # WebSocket proxy
+    location /ws/ {
+        proxy_pass http://127.0.0.1:8080;
+        proxy_http_version 1.1;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection "upgrade";
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_read_timeout 86400;
+    }
+
+    # Static files from backend (pattern previews, custom logos)
+    location /static/ {
+        proxy_pass http://127.0.0.1:8080;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_cache_valid 200 1h;
+    }
+
+    # All backend API endpoints (legacy non-/api/ routes)
+    location ~ ^/(list_theta_rho_files|preview_thr_batch|get_theta_rho_coordinates|upload_theta_rho|pause_execution|resume_execution|stop_execution|force_stop|soft_reset|skip_pattern|set_speed|get_speed|restart|shutdown|run_pattern|run_playlist|connect_device|disconnect_device|home_device|clear_sand|move_to_position|get_playlists|save_playlist|delete_playlist|rename_playlist|reorder_playlist|delete_theta_rho_file|get_status|list_serial_ports|serial_status|cache-progress|rebuild_cache|get_led_config|set_led_config|get_wled_ip|set_wled_ip|led|send_home|send_coordinate|move_to_center|move_to_perimeter|run_theta_rho|connect|disconnect|list_theta_rho_files_with_metadata|list_all_playlists|get_playlist|create_playlist|modify_playlist|add_to_playlist|preview_thr|preview|add_to_queue|restart_connection|controller_restart|recover_sensor_homing|run_theta_rho_file|download|check_software_update|update_software) {
+        proxy_pass http://127.0.0.1:8080;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+    }
+}

+ 17 - 5
scripts/pre-commit

@@ -1,5 +1,5 @@
 #!/bin/sh
-# Pre-commit hook: runs Ruff on staged Python files and frontend tests on staged TS/TSX files
+# Pre-commit hook: runs Ruff on staged Python files, frontend tests and build on staged TS/TSX/CSS files
 #
 # Install: cp scripts/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit
 
@@ -27,18 +27,30 @@ if [ -n "$STAGED_PY" ]; then
     fi
 fi
 
-# --- Frontend tests on staged TS/TSX files ---
-STAGED_FE=$(git diff --cached --name-only --diff-filter=ACM -- 'frontend/src/**/*.ts' 'frontend/src/**/*.tsx')
+# --- Frontend checks on staged UI files ---
+STAGED_FE=$(git diff --cached --name-only --diff-filter=ACM -- 'frontend/src/**/*.ts' 'frontend/src/**/*.tsx' 'frontend/src/**/*.css' 'frontend/index.html' 'frontend/vite.config.ts' 'frontend/tsconfig*.json' 'frontend/tailwind.config.*' 'frontend/components.json')
 
 if [ -n "$STAGED_FE" ]; then
-    printf "${YELLOW}Running frontend tests...${NC}\n"
     if [ -d "frontend/node_modules" ]; then
+        # Run tests first
+        printf "${YELLOW}Running frontend tests...${NC}\n"
         if ! (cd frontend && npx vitest run --reporter=dot 2>&1); then
             printf "${RED}Frontend tests failed. Fix them before committing.${NC}\n"
             exit 1
         fi
         printf "${GREEN}Frontend tests passed.${NC}\n"
+
+        # Build production bundle
+        printf "${YELLOW}Building frontend...${NC}\n"
+        if ! (cd frontend && npm run build 2>&1); then
+            printf "${RED}Frontend build failed. Fix errors before committing.${NC}\n"
+            exit 1
+        fi
+        printf "${GREEN}Frontend build succeeded.${NC}\n"
+
+        # Stage the updated build output so it's included in this commit
+        git add static/dist/
     else
-        printf "${YELLOW}frontend/node_modules not found, skipping tests. Run: cd frontend && npm install${NC}\n"
+        printf "${YELLOW}frontend/node_modules not found, skipping tests and build. Run: cd frontend && npm install${NC}\n"
     fi
 fi

+ 249 - 104
setup-pi.sh

@@ -11,8 +11,8 @@
 #   bash setup-pi.sh
 #
 # Options:
-#   --no-docker     Use Python venv instead of Docker
 #   --no-wifi-fix   Skip WiFi stability fix
+#   --no-hotspot    Skip autohotspot setup
 #   --help          Show help
 #
 
@@ -26,22 +26,22 @@ BLUE='\033[0;34m'
 NC='\033[0m' # No Color
 
 # Default options
-USE_DOCKER=true
 FIX_WIFI=true  # Applied by default for stability
+SETUP_HOTSPOT=true  # Autohotspot for first-time WiFi setup
 INSTALL_DIR="$HOME/dune-weaver"
 REPO_URL="https://github.com/tuanchris/dune-weaver"
 
 # Parse arguments
 while [[ $# -gt 0 ]]; do
     case $1 in
-        --no-docker)
-            USE_DOCKER=false
-            shift
-            ;;
         --no-wifi-fix)
             FIX_WIFI=false
             shift
             ;;
+        --no-hotspot)
+            SETUP_HOTSPOT=false
+            shift
+            ;;
         --help|-h)
             echo "Dune Weaver Raspberry Pi Setup Script"
             echo ""
@@ -52,8 +52,8 @@ while [[ $# -gt 0 ]]; do
             echo "  cd ~/dune-weaver && bash setup-pi.sh [OPTIONS]"
             echo ""
             echo "Options:"
-            echo "  --no-docker     Use Python venv instead of Docker"
             echo "  --no-wifi-fix   Skip WiFi stability fix (applied by default)"
+            echo "  --no-hotspot    Skip autohotspot setup"
             echo "  --help, -h      Show this help message"
             exit 0
             ;;
@@ -82,12 +82,59 @@ print_success() {
     echo -e "${GREEN}$1${NC}"
 }
 
-# Install essential packages (git, vim)
-install_essentials() {
-    print_step "Installing essential packages..."
+# Install system dependencies
+install_system_deps() {
+    print_step "Installing system dependencies..."
     sudo apt update
-    sudo DEBIAN_FRONTEND=noninteractive apt install -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" git vim
-    print_success "Essential packages installed"
+    sudo DEBIAN_FRONTEND=noninteractive apt install -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" \
+        python3-venv python3-pip python3-dev \
+        gcc g++ make swig unzip wget \
+        libjpeg-dev zlib1g-dev \
+        libgpiod-dev gpiod \
+        nginx git vim
+    print_success "System dependencies installed"
+}
+
+# Ensure lgpio C library is available for pip to build against
+install_lgpio() {
+    local syslib="/usr/lib/aarch64-linux-gnu"
+
+    # Raspberry Pi OS Trixie ships liblgpio.so.1 but no unversioned
+    # liblgpio.so symlink (normally provided by a -dev package).
+    # The build-time linker needs the unversioned name (-llgpio → liblgpio.so).
+    if [[ ! -e "$syslib/liblgpio.so" ]]; then
+        # Check if the versioned library exists (from Pi OS)
+        local versioned
+        versioned=$(ls "$syslib"/liblgpio.so.* 2>/dev/null | head -1)
+
+        if [[ -n "$versioned" ]]; then
+            print_step "Creating liblgpio.so build symlink..."
+            sudo ln -sf "$versioned" "$syslib/liblgpio.so"
+            sudo ldconfig
+            print_success "liblgpio.so symlink created (-> $(basename "$versioned"))"
+        else
+            # Not in system paths — build from source
+            print_step "Building lgpio C library from source..."
+            local tmpdir
+            tmpdir=$(mktemp -d)
+            cd "$tmpdir"
+            wget -q https://github.com/joan2937/lg/archive/master.zip
+            unzip -q master.zip
+            cd lg-master
+            make
+            sudo make install
+            cd /
+            rm -rf "$tmpdir"
+            # Symlink from /usr/local/lib into system path
+            if [[ -f /usr/local/lib/liblgpio.so ]]; then
+                sudo ln -sf /usr/local/lib/liblgpio.so "$syslib/liblgpio.so"
+            fi
+            sudo ldconfig
+            print_success "lgpio C library built and installed"
+        fi
+    else
+        echo "liblgpio.so already available for linking"
+    fi
 }
 
 # Check if running on Raspberry Pi
@@ -170,44 +217,12 @@ update_system() {
     print_success "System updated"
 }
 
-# Install Docker
-install_docker() {
-    print_step "Installing Docker..."
-
-    if command -v docker &> /dev/null; then
-        echo "Docker already installed: $(docker --version)"
-    else
-        curl -fsSL https://get.docker.com -o /tmp/get-docker.sh
-        sudo sh /tmp/get-docker.sh
-        rm /tmp/get-docker.sh
-        print_success "Docker installed"
-    fi
-
-    # Add user to docker group
-    if ! groups $USER | grep -q docker; then
-        print_step "Adding $USER to docker group..."
-        sudo usermod -aG docker $USER
-        DOCKER_GROUP_ADDED=true
-        print_warning "You'll need to log out and back in for docker group changes to take effect"
-    fi
-}
-
-# Install Python dependencies (non-Docker)
-install_python_deps() {
-    print_step "Installing Python dependencies..."
-
-    # Install system packages
-    sudo apt install -y python3-venv python3-pip git
-
-    print_success "Python dependencies installed"
-}
-
 # Verify we're in the dune-weaver directory
 ensure_repo() {
     print_step "Setting up dune-weaver repository..."
 
     # Check if we're already in the dune-weaver directory
-    if [[ -f "docker-compose.yml" ]] && [[ -f "main.py" ]]; then
+    if [[ -f "main.py" ]] && [[ -f "requirements.txt" ]]; then
         INSTALL_DIR="$(pwd)"
         print_success "Using existing repo at $INSTALL_DIR"
         return
@@ -229,29 +244,8 @@ ensure_repo() {
     print_success "Cloned to $INSTALL_DIR"
 }
 
-# Install dw CLI command
-install_cli() {
-    print_step "Installing 'dw' command..."
-
-    # Copy dw script to /usr/local/bin
-    sudo cp "$INSTALL_DIR/dw" /usr/local/bin/dw
-    sudo chmod +x /usr/local/bin/dw
-
-    print_success "'dw' command installed"
-}
-
-# Deploy with Docker
-deploy_docker() {
-    print_step "Deploying Dune Weaver with Docker Compose..."
-
-    cd "$INSTALL_DIR"
-    sudo docker compose up -d --quiet-pull
-
-    print_success "Docker deployment complete!"
-}
-
-# Deploy with Python venv
-deploy_python() {
+# Deploy native (venv + systemd + nginx)
+deploy_native() {
     print_step "Setting up Python virtual environment..."
 
     cd "$INSTALL_DIR"
@@ -265,31 +259,183 @@ deploy_python() {
     pip install --upgrade pip
     pip install -r requirements.txt
 
+    # Ensure nginx (www-data) can traverse to static files
+    # chmod o+x grants traversal only, not directory listing
+    local dir="$INSTALL_DIR"
+    while [[ "$dir" != "/" ]]; do
+        sudo chmod o+x "$dir"
+        dir=$(dirname "$dir")
+    done
+
+    # Configure nginx
+    print_step "Configuring nginx..."
+    sudo cp "$INSTALL_DIR/nginx/dune-weaver.conf" /etc/nginx/sites-available/dune-weaver.conf
+    sudo sed -i "s|INSTALL_DIR_PLACEHOLDER|$INSTALL_DIR|g" /etc/nginx/sites-available/dune-weaver.conf
+    sudo ln -sf /etc/nginx/sites-available/dune-weaver.conf /etc/nginx/sites-enabled/dune-weaver.conf
+    sudo rm -f /etc/nginx/sites-enabled/default
+    sudo nginx -t
+    sudo systemctl restart nginx
+    sudo systemctl enable nginx
+
     # Create systemd service
     print_step "Creating systemd service..."
-
-    sudo tee /etc/systemd/system/dune-weaver.service > /dev/null << EOF
-[Unit]
-Description=Dune Weaver Backend
-After=network.target
-
-[Service]
-ExecStart=$INSTALL_DIR/.venv/bin/python $INSTALL_DIR/main.py
-WorkingDirectory=$INSTALL_DIR
-Restart=always
-User=$USER
-Environment=PYTHONUNBUFFERED=1
-
-[Install]
-WantedBy=multi-user.target
-EOF
+    sudo cp "$INSTALL_DIR/dune-weaver.service" /etc/systemd/system/dune-weaver.service
+    sudo sed -i "s|USER_PLACEHOLDER|$USER|g" /etc/systemd/system/dune-weaver.service
+    sudo sed -i "s|INSTALL_DIR_PLACEHOLDER|$INSTALL_DIR|g" /etc/systemd/system/dune-weaver.service
 
     # Enable and start service
     sudo systemctl daemon-reload
     sudo systemctl enable dune-weaver
     sudo systemctl start dune-weaver
 
-    print_success "Python deployment complete!"
+    # Create sudoers entry for passwordless systemctl commands
+    print_step "Configuring sudo permissions..."
+    sudo tee /etc/sudoers.d/dune-weaver > /dev/null << EOF
+$USER ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart dune-weaver
+$USER ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop dune-weaver
+$USER ALL=(ALL) NOPASSWD: /usr/bin/systemctl start dune-weaver
+$USER ALL=(ALL) NOPASSWD: /usr/bin/systemctl poweroff
+$USER ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart nginx
+EOF
+    sudo chmod 0440 /etc/sudoers.d/dune-weaver
+
+    print_success "Native deployment complete!"
+}
+
+# Install dw CLI command
+install_cli() {
+    print_step "Installing 'dw' command..."
+
+    # Copy dw script to /usr/local/bin
+    sudo cp "$INSTALL_DIR/dw" /usr/local/bin/dw
+    sudo chmod +x /usr/local/bin/dw
+
+    print_success "'dw' command installed"
+}
+
+# Setup autohotspot
+setup_autohotspot() {
+    print_step "Setting up autohotspot..."
+
+    if [[ ! -f "$INSTALL_DIR/wifi/setup-wifi.sh" ]]; then
+        print_warning "wifi/setup-wifi.sh not found, skipping autohotspot setup"
+        return
+    fi
+
+    bash "$INSTALL_DIR/wifi/setup-wifi.sh"
+    print_success "Autohotspot setup complete"
+}
+
+# Configure UART for GPIO pin connection to DLC32/ESP32
+configure_uart() {
+    local CONFIG_FILE="/boot/firmware/config.txt"
+    if [[ ! -f "$CONFIG_FILE" ]]; then
+        CONFIG_FILE="/boot/config.txt"
+    fi
+
+    echo ""
+    echo -e "${GREEN}How is your Raspberry Pi connected to the sand table controller (DLC32/ESP32)?${NC}"
+    echo ""
+    echo "  1) USB cable"
+    echo "  2) UART over GPIO pins (TX/RX wired to header pins)"
+    echo ""
+    echo -e "  ${YELLOW}Note: USB is not reliable on Pi 3B+. Use UART for Pi 3B+.${NC}"
+    echo ""
+    read -p "Enter choice [1/2] (default: 1): " -n 1 -r uart_choice
+    echo ""
+
+    if [[ "$uart_choice" == "2" ]]; then
+        echo ""
+        echo -e "${YELLOW}============================================${NC}"
+        echo -e "${YELLOW}  UART Setup — raspi-config will run next${NC}"
+        echo -e "${YELLOW}============================================${NC}"
+        echo ""
+        echo -e "  When prompted, select:"
+        echo -e "    Login shell over serial?     →  ${GREEN}No${NC}"
+        echo -e "    Serial port hardware?        →  ${GREEN}Yes${NC}"
+        echo ""
+        read -p "Press Enter to continue..." -r
+        echo ""
+
+        # Disable serial console, enable serial hardware
+        if command -v raspi-config &> /dev/null; then
+            sudo raspi-config nonint do_serial 2
+            echo "Serial console disabled, serial hardware enabled"
+        else
+            print_warning "raspi-config not found, please run 'sudo raspi-config' manually"
+            print_warning "Go to: 3 Interface Options > I6 Serial Port > No (console) > Yes (hardware)"
+        fi
+
+        print_step "Configuring UART overlays..."
+
+        # Add UART overlays to config.txt if not already present
+        local needs_change=false
+        for overlay in "dtoverlay=pi3-miniuart-bt" "dtoverlay=miniuart-bt" "enable_uart=1"; do
+            if ! grep -q "^${overlay}$" "$CONFIG_FILE" 2>/dev/null; then
+                echo "$overlay" | sudo tee -a "$CONFIG_FILE" > /dev/null
+                needs_change=true
+            fi
+        done
+
+        if [[ "$needs_change" == "true" ]]; then
+            echo "Added UART overlays to $CONFIG_FILE"
+        else
+            echo "UART overlays already present in $CONFIG_FILE"
+        fi
+
+        NEEDS_REBOOT=true
+        print_success "UART configured. A reboot is required for changes to take effect."
+    else
+        # USB mode — check if UART config exists and offer to clean it up
+        local has_uart=false
+        for overlay in "dtoverlay=pi3-miniuart-bt" "dtoverlay=miniuart-bt" "enable_uart=1"; do
+            if grep -q "^${overlay}$" "$CONFIG_FILE" 2>/dev/null; then
+                has_uart=true
+                break
+            fi
+        done
+
+        if [[ "$has_uart" == "true" ]]; then
+            echo -e "${YELLOW}UART overlays found in $CONFIG_FILE from a previous setup.${NC}"
+            read -p "Remove them? (y/N): " -n 1 -r remove_uart
+            echo ""
+            if [[ "$remove_uart" =~ ^[Yy]$ ]]; then
+                sudo sed -i '/^dtoverlay=pi3-miniuart-bt$/d' "$CONFIG_FILE"
+                sudo sed -i '/^dtoverlay=miniuart-bt$/d' "$CONFIG_FILE"
+                sudo sed -i '/^enable_uart=1$/d' "$CONFIG_FILE"
+                NEEDS_REBOOT=true
+                print_success "UART overlays removed. A reboot is recommended."
+            fi
+        else
+            echo "USB connection selected, no UART changes needed."
+        fi
+    fi
+}
+
+# Remove software that is no longer needed on the Pi
+cleanup_unused() {
+    print_step "Cleaning up unused software..."
+
+    # Remove Node.js / npm if present from a prior install
+    if dpkg -l nodejs 2>/dev/null | grep -q '^ii'; then
+        echo "Removing Node.js (no longer needed — frontend is pre-built)..."
+        sudo apt purge -y nodejs || true
+        sudo rm -f /etc/apt/sources.list.d/nodesource.list \
+                    /etc/apt/keyrings/nodesource.gpg 2>/dev/null || true
+        sudo apt autoremove -y
+        print_success "Node.js removed"
+    fi
+
+    # Remove Docker if present from old Docker-based deployment
+    if dpkg -l docker-ce 2>/dev/null | grep -q '^ii'; then
+        echo "Removing Docker (old deployment method)..."
+        # Stop running containers
+        sudo docker stop $(sudo docker ps -aq) 2>/dev/null || true
+        sudo apt purge -y docker-ce docker-ce-cli containerd.io docker-compose-plugin 2>/dev/null || true
+        sudo rm -rf /var/lib/docker
+        sudo apt autoremove -y
+        print_success "Docker removed"
+    fi
 }
 
 # Get IP address
@@ -316,8 +462,8 @@ print_final_instructions() {
     echo -e "${GREEN}============================================${NC}"
     echo ""
     echo -e "Access the web interface at:"
-    echo -e "  ${BLUE}http://$IP:8080${NC}"
-    echo -e "  ${BLUE}http://$HOSTNAME.local:8080${NC}"
+    echo -e "  ${BLUE}http://$IP${NC}"
+    echo -e "  ${BLUE}http://$HOSTNAME.local${NC}"
     echo ""
 
     echo "Manage with the 'dw' command:"
@@ -326,15 +472,19 @@ print_final_instructions() {
     echo "  dw update      Pull latest and restart"
     echo "  dw stop        Stop Dune Weaver"
     echo "  dw status      Show status"
+    echo "  dw wifi help   WiFi and hotspot management"
     echo "  dw help        Show all commands"
     echo ""
 
-    if [[ "$DOCKER_GROUP_ADDED" == "true" ]]; then
-        print_warning "Please log out and back in for docker group changes to take effect"
+    if [[ "$SETUP_HOTSPOT" == "true" ]]; then
+        echo -e "${BLUE}Autohotspot:${NC} If no known WiFi is found on boot,"
+        echo "a 'Dune Weaver' hotspot will be created automatically."
+        echo "Connect to it and open the app to configure WiFi."
+        echo ""
     fi
 
     if [[ "$NEEDS_REBOOT" == "true" ]]; then
-        print_warning "A reboot is recommended to apply WiFi fixes"
+        print_warning "A reboot is required to apply configuration changes"
         read -p "Reboot now? (y/N) " -n 1 -r
         echo
         if [[ $REPLY =~ ^[Yy]$ ]]; then
@@ -354,19 +504,15 @@ main() {
     echo -e "${NC}"
     echo "Raspberry Pi Setup Script"
     echo ""
-
-    # Detect deployment method
-    if [[ "$USE_DOCKER" == "true" ]]; then
-        echo "Deployment method: Docker (recommended)"
-    else
-        echo "Deployment method: Python virtual environment"
-    fi
     echo "Install directory: $INSTALL_DIR"
     echo ""
 
+    # Ask connection type upfront (before long-running installs)
+    configure_uart
+
     # Run setup steps
     check_raspberry_pi
-    install_essentials
+    install_system_deps
     ensure_repo
     update_system
     disable_wlan_powersave
@@ -375,15 +521,14 @@ main() {
         apply_wifi_fix
     fi
 
-    if [[ "$USE_DOCKER" == "true" ]]; then
-        install_docker
-        deploy_docker
-    else
-        install_python_deps
-        deploy_python
+    if [[ "$SETUP_HOTSPOT" == "true" ]]; then
+        setup_autohotspot
     fi
 
+    install_lgpio
+    deploy_native
     install_cli
+    cleanup_unused
     print_final_instructions
 }
 

File diff suppressed because it is too large
+ 0 - 0
static/dist/assets/index-bDuzJAeR.js


File diff suppressed because it is too large
+ 0 - 0
static/dist/assets/index-iGL2FzQP.css


BIN
static/dist/assets/material-icons-Dr0goTwe.woff


BIN
static/dist/assets/material-icons-kAwBdRge.woff2


BIN
static/dist/assets/material-icons-outlined-BpWbwl2n.woff


BIN
static/dist/assets/material-icons-outlined-DZhiGvEA.woff2


BIN
static/dist/assets/noto-sans-cyrillic-400-normal-BDYvNhAR.woff


BIN
static/dist/assets/noto-sans-cyrillic-400-normal-CHP_ranX.woff2


BIN
static/dist/assets/noto-sans-cyrillic-500-normal-9zZ_jNuA.woff2


BIN
static/dist/assets/noto-sans-cyrillic-500-normal-BxM0HQjg.woff


BIN
static/dist/assets/noto-sans-cyrillic-600-normal-BRIw9PIU.woff


BIN
static/dist/assets/noto-sans-cyrillic-600-normal-KpAl9xZA.woff2


BIN
static/dist/assets/noto-sans-cyrillic-700-normal-D8UNalU-.woff


BIN
static/dist/assets/noto-sans-cyrillic-700-normal-DYZmzPmX.woff2


BIN
static/dist/assets/noto-sans-cyrillic-ext-400-normal-BjDhGU6t.woff2


BIN
static/dist/assets/noto-sans-cyrillic-ext-400-normal-d9FrwbiD.woff


BIN
static/dist/assets/noto-sans-cyrillic-ext-500-normal-Bw4G4pNe.woff


BIN
static/dist/assets/noto-sans-cyrillic-ext-500-normal-CuwgPeWW.woff2


BIN
static/dist/assets/noto-sans-cyrillic-ext-600-normal-Cwz1867h.woff


BIN
static/dist/assets/noto-sans-cyrillic-ext-600-normal-DlWr7wnj.woff2


BIN
static/dist/assets/noto-sans-cyrillic-ext-700-normal-D83T7awq.woff


BIN
static/dist/assets/noto-sans-cyrillic-ext-700-normal-OK-fZO_i.woff2


BIN
static/dist/assets/noto-sans-devanagari-400-normal-C3FccbrF.woff2


BIN
static/dist/assets/noto-sans-devanagari-400-normal-g9fsM2jL.woff


BIN
static/dist/assets/noto-sans-devanagari-500-normal-B62tDw8r.woff


BIN
static/dist/assets/noto-sans-devanagari-500-normal-VG35fhMU.woff2


BIN
static/dist/assets/noto-sans-devanagari-600-normal-Bly84zfI.woff


BIN
static/dist/assets/noto-sans-devanagari-600-normal-Ewgvvq1j.woff2


BIN
static/dist/assets/noto-sans-devanagari-700-normal-CT12sGlc.woff


BIN
static/dist/assets/noto-sans-devanagari-700-normal-DVs0dmkg.woff2


BIN
static/dist/assets/noto-sans-greek-400-normal-Be2BcUUc.woff


BIN
static/dist/assets/noto-sans-greek-400-normal-DCESwnT1.woff2


BIN
static/dist/assets/noto-sans-greek-500-normal-BAAA_uK7.woff


BIN
static/dist/assets/noto-sans-greek-500-normal-D_0l3T9g.woff2


BIN
static/dist/assets/noto-sans-greek-600-normal-C0bz_iEd.woff


BIN
static/dist/assets/noto-sans-greek-600-normal-CT9U7UAD.woff2


BIN
static/dist/assets/noto-sans-greek-700-normal-DDNJsN3F.woff


BIN
static/dist/assets/noto-sans-greek-700-normal-x3kNWF-0.woff2


BIN
static/dist/assets/noto-sans-greek-ext-400-normal-L11LEhi4.woff


BIN
static/dist/assets/noto-sans-greek-ext-400-normal-i2oSBwXz.woff2


BIN
static/dist/assets/noto-sans-greek-ext-500-normal-CbZNESfr.woff


BIN
static/dist/assets/noto-sans-greek-ext-500-normal-D6bOGD5V.woff2


BIN
static/dist/assets/noto-sans-greek-ext-600-normal-B4z4a2vi.woff2


BIN
static/dist/assets/noto-sans-greek-ext-600-normal-BjvVOqxV.woff


Some files were not shown because too many files changed in this diff