117 Коміти 2528a20e62 ... 9c585ffe16

Автор SHA1 Опис Дата
  tuanchris 9c585ffe16 update version 1 день тому
  tuanchris d2418bea9e Merge origin/main into feature/react-ui 2 днів тому
  tuanchris 714672344e feat(ui): add update notification indicator and version link 2 днів тому
  tuanchris 61c5f3bf60 fix(nginx): add missing endpoint proxies to prevent 405 errors 3 днів тому
  tuanchris c9218274db docs: add CONTRIBUTING.md and update README 4 днів тому
  tuanchris 918dad6ca2 fix(ui): correct "Clear Sideway" typo to "Clear Sideways" 4 днів тому
  tuanchris 82eb2e7b6c Merge PR #110: improve ETA accuracy with rho-weighted time estimation 4 днів тому
  tuanchris ada774d279 feat(ui+led): add log search, fix PWA toast positioning, and respect LED power state on Still Sands exit 4 днів тому
  PxT 294fa73f17 Improve ETA accuracy with rho-weighted time estimation 5 днів тому
  tuanchris f2240ed29e chore: add pre-commit hook for Ruff lint and frontend tests 5 днів тому
  tuanchris 49ee730824 fix(lint+test): remove unused imports, fix bare except, update delete button tests 5 днів тому
  tuanchris 629ec41ff7 fix(ui): wrap MQTT buttons on mobile to prevent overflow 6 днів тому
  tuanchris a36cfadb5a fix(lint): resolve Ruff errors in dune-weaver-touch 6 днів тому
  tuanchris 89b8a0ba98 fix(ui): add custom day selector and fix time input overlap in Still Sands 6 днів тому
  tuanchris 876ba57dd0 fix(pwa): iOS safe area handling and PWA icon transparency 6 днів тому
  tuanchris 94de37efa0 feat(ui): add draggable now playing button and optimize logo uploads 1 тиждень тому
  tuanchris 3166c3041f fix(touch): fix shuffle toggle and improve stop reliability 1 тиждень тому
  tuanchris 8def31749b fix(touch): fix syntax error in ModernPlaylistPage.qml 1 тиждень тому
  tuanchris 8bd5cec165 refactor(touch): remove console.log debug statements from QML 1 тиждень тому
  tuanchris 336e01590f refactor(touch): replace print statements with proper logging 1 тиждень тому
  tuanchris 56d6bb58d9 feat(touch): persist playlist settings with new defaults 1 тиждень тому
  tuanchris f1aa19916e fix(touch): increase icon sizes on TableControlPage buttons 1 тиждень тому
  tuanchris 238fc9593e feat(touch): add iconSize property to ModernControlButton 1 тиждень тому
  tuanchris 9fc16f9d10 fix(touch): remove unsupported power icon, make delete button red 1 тиждень тому
  tuanchris 165313004d fix(touch): fix invisible button icons in QML touch app 1 тиждень тому
  tuanchris 023631ee72 fix(frontend): replace all Material Icons with Lucide SVG icons in Layout 1 тиждень тому
  tuanchris 4f1db8baad fix(ui): replace all Material Icons with Lucide SVG icons in PlaylistsPage 1 тиждень тому
  tuanchris 3498e4b38f fix(ui): replace all Material Icons with Lucide SVG icons in TableControlPage 1 тиждень тому
  tuanchris 9133176a0c fix(touch): improve dark mode visibility for buttons and text 1 тиждень тому
  tuanchris a09c66fe77 fix(touch): improve text visibility in dark mode 1 тиждень тому
  tuanchris 05c2f8464a fix(ui): replace Material Icons delete button with Lucide SVG icon 1 тиждень тому
  tuanchris 67996c7a75 chore: remove dead /get_speed proxy and add missing /upload_theta_rho proxy 1 тиждень тому
  tuanchris 172295d0bf feat(touch): add full-page pattern selector and enhance playlist management 1 тиждень тому
  tuanchris 7ccd34a908 test(touch): disable touch monitoring to verify 100% CPU hypothesis 1 тиждень тому
  tuanchris c4673a7ebc fix(touch): add CPUQuota limit and fix evtest binary mode buffering 1 тиждень тому
  tuanchris 1881d1006f fix(touch): resolve 100% CPU on screen timeout with proper thread cleanup 1 тиждень тому
  tuanchris 6c3b242446 revert(touch): revert CPU optimization attempts - none resolved 100% CPU 1 тиждень тому
  tuanchris 8a4a0f8610 fix(touch): restore QEventLoop pattern for proper QTimer support 1 тиждень тому
  tuanchris edfb31d122 fix(touch): add proper logging to touch monitoring 1 тиждень тому
  tuanchris fa03459410 fix(touch): use proper qasync event wait pattern 1 тиждень тому
  tuanchris 2905feaf14 fix(dw): update touch app dependencies during dw update 1 тиждень тому
  tuanchris 4355486090 fix(touch): resolve 100% CPU usage with qasync event loop pattern 1 тиждень тому
  tuanchris 3d2ae5f4d6 fix(led): turn off LEDs during Still Sands when table goes idle 1 тиждень тому
  tuanchris c2b1851958 fix(touch): eliminate CPU busy-wait in touch monitor thread 1 тиждень тому
  tuanchris e0016f52b7 chore: stop tracking .planning directory 1 тиждень тому
  tuanchris f76bb4f11a docs(01): complete CPU optimization phase 1 тиждень тому
  tuanchris bd48ed481b perf(01-01): optimize timer, WebSocket, and preview cache in backend.py 1 тиждень тому
  tuanchris e2e0c82c22 perf(01-01): remove debug logs from remaining QML files 1 тиждень тому
  tuanchris e1b5994f76 perf(01-01): remove debug logs from ExecutionPage.qml 1 тиждень тому
  tuanchris bf3729e2dd perf(01-01): remove debug logs from ConnectionStatus.qml 1 тиждень тому
  tuanchris 455ed5cbec perf(01-01): remove remaining debug logs from main.qml 1 тиждень тому
  tuanchris e3b1f2aa22 perf(01-01): remove MouseArea console.log spam in main.qml 1 тиждень тому
  tuanchris 2da982b922 docs(01): create CPU optimization execution plan 1 тиждень тому
  tuanchris ca5e9a994d docs: create v3 roadmap with single-phase CPU optimization 1 тиждень тому
  tuanchris 5c38deaf43 docs: define v3 requirements for touch app CPU optimization 1 тиждень тому
  tuanchris e5265ac1ce fix: add GRBL Hold and Alarm state recovery during pattern execution 1 тиждень тому
  tuanchris 00c84c690c fix: resolve TypeScript errors in test files 1 тиждень тому
  tuanchris 12ca98147e feat: add sensor homing failure recovery popup 1 тиждень тому
  tuanchris cc7b91e3bc add gitignore 1 тиждень тому
  tuanchris 0f8aa5c9a9 docs(04-01): complete E2E and CI phase 1 тиждень тому
  tuanchris fa94d6a0a8 fix(04-01): fix E2E tests and add WebSocket mocking 1 тиждень тому
  tuanchris 92071351ea ci(04-01): extend workflow for frontend tests 1 тиждень тому
  tuanchris 449034aa07 test(04-01): update sample spec with infrastructure tests 1 тиждень тому
  tuanchris 8406243270 test(04-01): add table control E2E tests 1 тиждень тому
  tuanchris 647cb08fe1 test(04-01): add playlist flow E2E tests 1 тиждень тому
  tuanchris dd4baaf8dc test(04-01): add pattern flow E2E tests 1 тиждень тому
  tuanchris ddc463c108 test(04-01): add Playwright API mock utilities 1 тиждень тому
  tuanchris 868ec596d3 docs(03-01): complete integration tests phase 1 тиждень тому
  tuanchris bdf0f4791e test(03-01): add playback flow integration tests 1 тиждень тому
  tuanchris 313e47972b test(03-01): add playlist flow integration tests 1 тиждень тому
  tuanchris 286e40d569 test(03-01): add pattern flow integration tests 1 тиждень тому
  tuanchris beec9a99a4 chore(03-01): add integration test infrastructure 1 тиждень тому
  tuanchris 79ecc63b6a docs(02-01): complete component-tests phase 1 тиждень тому
  tuanchris 93e1413260 test(02-01): add component tests for critical pages 1 тиждень тому
  tuanchris f8f76bcad6 feat(02-01): add test utilities and expanded MSW handlers 1 тиждень тому
  tuanchris 0e5e9a1c3b docs(01): complete test-infrastructure phase 1 тиждень тому
  tuanchris 531f01b3e9 chore(01-01): ignore frontend test artifacts 1 тиждень тому
  tuanchris b77ee0ff27 chore(01-01): add @vitest/coverage-v8 for coverage reports 1 тиждень тому
  tuanchris 803dedaa12 test(01-01): add sample tests and TypeScript types 1 тиждень тому
  tuanchris f4f1f5e718 chore(01-01): configure MSW for API mocking 1 тиждень тому
  tuanchris 0400339bc9 chore(01-01): install and configure Playwright 1 тиждень тому
  tuanchris d187f6567c chore(01-01): install Vitest and React Testing Library 1 тиждень тому
  tuanchris 0938a971a8 fix: stop_execution and send_coordinate wait for idle before returning 1 тиждень тому
  tuanchris f6de1c6edf fix: preserve playlist state when async task is cancelled by TestClient 1 тиждень тому
  tuanchris 56cef98223 fix(test): fix WebSocket status format and add fast test speed 1 тиждень тому
  tuanchris 192609e86b fix(test): remove unsupported timeout arg from receive_json() 1 тиждень тому
  tuanchris c9d055a473 fix: move_to_center and move_to_perimeter wait for idle before returning 1 тиждень тому
  tuanchris a675178851 fix(test): wait for idle after move instead of sleep 1 тиждень тому
  tuanchris 821446656c fix(test): account for angular_homing_offset_degrees in homing test 1 тиждень тому
  tuanchris cdb06b4dfc docs: add testing guide README 1 тиждень тому
  tuanchris 8c862fb330 test(integration): add playback controls and playlist tests 1 тиждень тому
  tuanchris b609da10e9 test(integration): expand hardware tests with movement and pattern execution 1 тиждень тому
  tuanchris c7e93a4466 test(backend-testing-01): add integration test skeleton for hardware 1 тиждень тому
  tuanchris 3230a8a097 ci(backend-testing-01): add GitHub Actions workflow for tests 1 тиждень тому
  tuanchris a1c2252b89 test(backend-testing-01): add playlist API endpoint tests 1 тиждень тому
  tuanchris 1f77ebae05 test(backend-testing-01): add pattern API endpoint tests 1 тиждень тому
  tuanchris a911565ecc test(backend-testing-01): add status and info API endpoint tests 1 тиждень тому
  tuanchris aaa1d66ab0 chore(backend-testing-01): create unit conftest with mocked dependencies 1 тиждень тому
  tuanchris 7eb375e780 test(backend-testing-01): add connection_manager parsing tests 1 тиждень тому
  tuanchris 398f015ecb test(backend-testing-01): add pattern_manager parsing tests 1 тиждень тому
  tuanchris 5b1a44555d test(backend-testing-01): add playlist_manager CRUD tests 1 тиждень тому
  tuanchris 6b43ca971f chore(backend-testing-01): create root conftest.py with shared fixtures 1 тиждень тому
  tuanchris 8e28021f8c chore(backend-testing-01): configure pytest in pyproject.toml 1 тиждень тому
  tuanchris 5599bec83c chore(backend-testing-01): add testing dependencies 1 тиждень тому
  tuanchris 784374d2df chore(backend-testing-01): create test directory structure 1 тиждень тому
  tuanchris 282b3418a1 extend logs 1 тиждень тому
  tuanchris e9fffac6b2 Update frontend: remove Ctrl+X reference from soft reset UI 1 тиждень тому
  tuanchris 097347f5a5 Increase homing timeout from 90s to 120s 1 тиждень тому
  tuanchris 84703e3f89 Increase soft reset retries to 5 with exponential backoff 1 тиждень тому
  tuanchris 375c644444 Fix device init: check position before reset to avoid unnecessary homing 1 тиждень тому
  tuanchris 6893719141 Fix $Bye reset reliability and prevent position drift 1 тиждень тому
  tuanchris 42f1374fbe Add /dev/ttyS0 to ignored serial ports list 1 тиждень тому
  tuanchris 3c55e3f03e Fix shuffle not working when starting playlist from Home Assistant 1 тиждень тому
  tuanchris ef8787a6c1 Revert retry timeout - wait indefinitely for GRBL 'ok' 1 тиждень тому
  tuanchris fc4287a1cc Add retry logic for motion commands with 1-second timeout 1 тиждень тому
  tuanchris d103a6fb90 Fix NoneType context manager error in connection manager 1 тиждень тому
  tuanchris 2c75c085a7 update DAGGKAPRIFOL config 2 тижнів тому
94 змінених файлів з 11005 додано та 908 видалено
  1. 151 0
      .github/workflows/test.yml
  2. 7 1
      .gitignore
  3. 187 0
      CONTRIBUTING.md
  4. 64 154
      README.md
  5. 1 1
      VERSION
  6. 5 3
      docker-compose.yml
  7. 237 305
      dune-weaver-touch/backend.py
  8. 5 1
      dune-weaver-touch/dune-weaver-touch.service
  9. 6 6
      dune-weaver-touch/main.py
  10. 1 1
      dune-weaver-touch/models/pattern_model.py
  11. 18 2
      dune-weaver-touch/models/playlist_model.py
  12. 1 1
      dune-weaver-touch/png_cache_manager.py
  13. 0 2
      dune-weaver-touch/qml/components/BottomNavTab.qml
  14. 0 7
      dune-weaver-touch/qml/components/ConnectionStatus.qml
  15. 3 1
      dune-weaver-touch/qml/components/ModernControlButton.qml
  16. 1 2
      dune-weaver-touch/qml/components/ThemeManager.qml
  17. 0 18
      dune-weaver-touch/qml/main.qml
  18. 3 21
      dune-weaver-touch/qml/pages/ExecutionPage.qml
  19. 77 7
      dune-weaver-touch/qml/pages/ModernPatternListPage.qml
  20. 386 49
      dune-weaver-touch/qml/pages/ModernPlaylistPage.qml
  21. 211 4
      dune-weaver-touch/qml/pages/PatternDetailPage.qml
  22. 4 1
      dune-weaver-touch/qml/pages/PatternListPage.qml
  23. 401 0
      dune-weaver-touch/qml/pages/PatternSelectorPage.qml
  24. 36 16
      dune-weaver-touch/qml/pages/PlaylistPage.qml
  25. 59 4
      dune-weaver-touch/qml/pages/TableControlPage.qml
  26. 10 1
      dw
  27. 100 0
      firmware/dune_weaver_gold/config (DAGGKAPRIFOL).yaml
  28. 231 0
      frontend/e2e/mocks/api.ts
  29. 72 0
      frontend/e2e/pattern-flow.spec.ts
  30. 57 0
      frontend/e2e/playlist-flow.spec.ts
  31. 35 0
      frontend/e2e/sample.spec.ts
  32. 47 0
      frontend/e2e/table-control.spec.ts
  33. 808 16
      frontend/package-lock.json
  34. 17 2
      frontend/package.json
  35. 28 0
      frontend/playwright.config.ts
  36. 79 0
      frontend/src/__tests__/components/NowPlayingBar.test.tsx
  37. 218 0
      frontend/src/__tests__/integration/patternFlow.test.tsx
  38. 274 0
      frontend/src/__tests__/integration/playbackFlow.test.tsx
  39. 257 0
      frontend/src/__tests__/integration/playlistFlow.test.tsx
  40. 176 0
      frontend/src/__tests__/pages/BrowsePage.test.tsx
  41. 222 0
      frontend/src/__tests__/pages/PlaylistsPage.test.tsx
  42. 269 0
      frontend/src/__tests__/pages/TableControlPage.test.tsx
  43. 20 0
      frontend/src/__tests__/sample.test.tsx
  44. 1 1
      frontend/src/components/NowPlayingBar.tsx
  45. 436 37
      frontend/src/components/layout/Layout.tsx
  46. 0 10
      frontend/src/components/ui/sonner.tsx
  47. 6 1
      frontend/src/index.css
  48. 1 1
      frontend/src/lib/types.ts
  49. 1 1
      frontend/src/pages/BrowsePage.tsx
  50. 1 1
      frontend/src/pages/LEDPage.tsx
  51. 3 2
      frontend/src/pages/PlaylistsPage.tsx
  52. 86 9
      frontend/src/pages/SettingsPage.tsx
  53. 6 6
      frontend/src/pages/TableControlPage.tsx
  54. 115 0
      frontend/src/test/mocks/browser.ts
  55. 359 0
      frontend/src/test/mocks/handlers.ts
  56. 4 0
      frontend/src/test/mocks/server.ts
  57. 74 0
      frontend/src/test/mocks/websocket.ts
  58. 36 0
      frontend/src/test/setup.ts
  59. 113 0
      frontend/src/test/utils.tsx
  60. 1 1
      frontend/tsconfig.app.json
  61. 2 1
      frontend/vite.config.ts
  62. 21 0
      frontend/vitest.config.ts
  63. 227 25
      main.py
  64. 204 106
      modules/connection/connection_manager.py
  65. 26 3
      modules/core/log_handler.py
  66. 272 67
      modules/core/pattern_manager.py
  67. 8 0
      modules/core/playlist_manager.py
  68. 25 2
      modules/core/state.py
  69. 35 6
      modules/mqtt/handler.py
  70. 1 1
      nginx.conf
  71. 2 1
      package.json
  72. 39 0
      pyproject.toml
  73. 18 0
      requirements-dev.txt
  74. 44 0
      scripts/pre-commit
  75. BIN
      static/IMG_7404.gif
  76. BIN
      static/og-image.jpg
  77. 12 0
      static/site.webmanifest
  78. 190 0
      tests/README.md
  79. 1 0
      tests/__init__.py
  80. 165 0
      tests/conftest.py
  81. 0 0
      tests/fixtures/.gitkeep
  82. 1 0
      tests/integration/__init__.py
  83. 144 0
      tests/integration/conftest.py
  84. 475 0
      tests/integration/test_hardware.py
  85. 576 0
      tests/integration/test_playback_controls.py
  86. 529 0
      tests/integration/test_playlist.py
  87. 1 0
      tests/unit/__init__.py
  88. 189 0
      tests/unit/conftest.py
  89. 304 0
      tests/unit/test_api_patterns.py
  90. 304 0
      tests/unit/test_api_playlists.py
  91. 270 0
      tests/unit/test_api_status.py
  92. 282 0
      tests/unit/test_connection_manager.py
  93. 352 0
      tests/unit/test_pattern_manager.py
  94. 259 0
      tests/unit/test_playlist_manager.py

+ 151 - 0
.github/workflows/test.yml

@@ -0,0 +1,151 @@
+name: Tests
+
+on:
+  push:
+    branches: [ "main", "feature/*" ]
+    paths:
+      - '**.py'
+      - 'requirements*.txt'
+      - 'pyproject.toml'
+      - 'tests/**'
+      - 'frontend/**'
+      - '.github/workflows/test.yml'
+  pull_request:
+    branches: [ "main" ]
+    paths:
+      - '**.py'
+      - 'requirements*.txt'
+      - 'pyproject.toml'
+      - 'tests/**'
+      - 'frontend/**'
+      - '.github/workflows/test.yml'
+  # Allow manual trigger
+  workflow_dispatch:
+
+jobs:
+  # Backend tests
+  test:
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+
+      - name: Set up Python 3.11
+        uses: actions/setup-python@v5
+        with:
+          python-version: '3.11'
+          cache: 'pip'
+
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install -r requirements.txt
+          pip install -r requirements-dev.txt
+
+      - name: Run unit tests with coverage
+        env:
+          CI: true
+        run: |
+          pytest tests/unit/ -v --cov=modules --cov=main --cov-report=xml --cov-report=term-missing
+
+      - name: Upload coverage reports to Codecov
+        uses: codecov/codecov-action@v4
+        if: always()
+        with:
+          file: ./coverage.xml
+          fail_ci_if_error: false
+          verbose: true
+        env:
+          CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+
+  lint:
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+
+      - name: Set up Python 3.11
+        uses: actions/setup-python@v5
+        with:
+          python-version: '3.11'
+          cache: 'pip'
+
+      - name: Install linting dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install ruff
+
+      - name: Run Ruff linter
+        run: |
+          ruff check --output-format=github .
+        continue-on-error: true
+
+  # Frontend unit/integration tests
+  frontend-test:
+    runs-on: ubuntu-latest
+
+    defaults:
+      run:
+        working-directory: frontend
+
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+
+      - name: Set up Node.js
+        uses: actions/setup-node@v4
+        with:
+          node-version: '20'
+          cache: 'npm'
+          cache-dependency-path: frontend/package-lock.json
+
+      - name: Install dependencies
+        run: npm ci
+
+      - name: Run Vitest tests
+        run: npm test
+
+      - name: Run Vitest with coverage
+        run: npm run test:coverage
+        continue-on-error: true
+
+  # Frontend E2E tests
+  frontend-e2e:
+    runs-on: ubuntu-latest
+    timeout-minutes: 10
+
+    defaults:
+      run:
+        working-directory: frontend
+
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+
+      - name: Set up Node.js
+        uses: actions/setup-node@v4
+        with:
+          node-version: '20'
+          cache: 'npm'
+          cache-dependency-path: frontend/package-lock.json
+
+      - name: Install dependencies
+        run: npm ci
+
+      - name: Install Playwright browsers
+        run: npx playwright install --with-deps chromium
+
+      - name: Run Playwright tests
+        run: npm run test:e2e
+        env:
+          CI: true
+
+      - name: Upload Playwright report
+        uses: actions/upload-artifact@v4
+        if: failure()
+        with:
+          name: playwright-report
+          path: frontend/playwright-report/
+          retention-days: 7

+ 7 - 1
.gitignore

@@ -29,4 +29,10 @@ node_modules/
 static/custom/*
 static/custom/*
 !static/custom/.gitkeep
 !static/custom/.gitkeep
 .claude/
 .claude/
-static/dist/
+static/dist/
+.planning/
+.coverage
+# Frontend test artifacts
+frontend/coverage/
+frontend/playwright-report/
+frontend/test-results/

+ 187 - 0
CONTRIBUTING.md

@@ -0,0 +1,187 @@
+# Contributing to Dune Weaver
+
+Thanks for your interest in contributing to Dune Weaver! Whether it's a bug fix, a new feature, or improved docs, every contribution helps make kinetic sand tables more accessible.
+
+If you have questions or ideas, join the [#dev channel on Discord](https://discord.com/channels/864079106424832021/1329553521032560791) or browse the existing [Issues](https://github.com/tuanchris/dune-weaver/issues).
+
+## Development Setup
+
+### Prerequisites
+
+- Python 3.10+
+- Node.js 18+ and npm
+- Git
+
+### Clone and install
+
+```bash
+git clone https://github.com/tuanchris/dune-weaver.git
+cd dune-weaver
+
+# Python dependencies (use nonrpi on a dev machine, full requirements.txt on a Raspberry Pi)
+pip install -r requirements-nonrpi.txt
+
+# Testing/dev extras
+pip install -r requirements-dev.txt
+
+# Frontend + root dependencies
+npm install
+cd frontend && npm install && cd ..
+```
+
+### Start the dev server
+
+```bash
+npm run dev
+```
+
+This runs **both** servers concurrently:
+
+| Service  | URL                    | Notes                              |
+| -------- | ---------------------- | ---------------------------------- |
+| Frontend | `http://localhost:5173` | Vite dev server with hot reload   |
+| Backend  | `http://localhost:8080` | FastAPI (proxied by Vite in dev)  |
+
+Open `http://localhost:5173` in your browser. Vite proxies all API and WebSocket requests to the backend automatically.
+
+## Project Structure
+
+```
+dune-weaver/
+├── frontend/           # React 19 + TypeScript + Vite
+│   ├── src/
+│   │   ├── pages/      # Route-level page components
+│   │   ├── components/ # Shared UI and feature components
+│   │   ├── hooks/      # Custom React hooks
+│   │   ├── lib/        # Utilities and API client
+│   │   └── contexts/   # React contexts (multi-table, etc.)
+│   └── package.json
+├── modules/            # Backend modules
+│   ├── core/           # Pattern/playlist managers, state, scheduling
+│   ├── connection/     # Serial and WebSocket hardware communication
+│   ├── led/            # WLED integration
+│   └── mqtt/           # MQTT integration
+├── patterns/           # .thr pattern files (theta-rho coordinates)
+├── main.py             # FastAPI application entry point
+└── package.json        # Root scripts (dev, build, prepare)
+```
+
+## Running Tests
+
+### Backend (pytest)
+
+```bash
+pytest tests/unit/ -v
+pytest tests/unit/ -v --cov       # with coverage
+```
+
+### Frontend (Vitest)
+
+```bash
+cd frontend && npm test           # single run
+cd frontend && npm run test:watch # watch mode
+cd frontend && npm run test:coverage
+```
+
+### End-to-end (Playwright)
+
+```bash
+cd frontend && npx playwright install chromium   # first time only
+cd frontend && npm run test:e2e
+cd frontend && npm run test:e2e:ui               # interactive UI mode
+```
+
+### Hardware Integration Tests (pytest)
+
+Integration tests exercise real hardware — serial connections, homing, movement, and pattern execution. They are **skipped by default** and in CI.
+
+```bash
+# Run all integration tests (hardware must be connected via USB)
+pytest tests/integration/ --run-hardware -v
+
+# Run a specific suite
+pytest tests/integration/test_hardware.py --run-hardware -v
+
+# Show live output (useful for watching motor activity)
+pytest tests/integration/ --run-hardware -v -s
+```
+
+| Test file | What it covers | Approx. duration |
+| --------- | -------------- | ---------------- |
+| `test_hardware.py` | Serial connection, homing, movement, pattern execution | ~5–10 min |
+| `test_playback_controls.py` | Pause, resume, stop, skip, speed control | ~5 min |
+| `test_playlist.py` | Playlist modes, clear patterns, state updates | ~5 min |
+
+> **Safety:** These tests physically move the table. Make sure the ball path is clear and the table is powered on before running them.
+
+## Linting
+
+### Python — Ruff
+
+```bash
+ruff check .                # check for issues
+ruff check --fix .          # auto-fix what it can
+```
+
+### Frontend — ESLint
+
+```bash
+cd frontend && npm run lint
+```
+
+### Pre-commit hook
+
+A pre-commit hook runs Ruff on staged Python files and Vitest on staged TypeScript files automatically. It's installed when you run `npm install` at the repo root (via the `prepare` script). You can also install it manually:
+
+```bash
+cp scripts/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit
+```
+
+## Branch and Commit Conventions
+
+### Branching
+
+Create branches from `main` using these prefixes:
+
+- `feature/` — new functionality (e.g., `feature/pattern-editor`)
+- `fix/` — bug fixes (e.g., `fix/playlist-reorder`)
+- `chore/` — maintenance, tooling, docs (e.g., `chore/update-deps`)
+
+### Commit messages
+
+We follow [Conventional Commits](https://www.conventionalcommits.org/):
+
+```
+feat(ui): add dark mode toggle to settings page
+fix(backend): prevent crash when serial port disconnects
+chore: update Python dependencies
+```
+
+## Adding API Endpoints
+
+When you add a new backend endpoint, you **must** also register its path in the Vite proxy so it works during development:
+
+1. Add the route in `main.py` (or the appropriate module).
+2. Open `frontend/vite.config.ts` and add the endpoint path to the `server.proxy` object:
+   ```ts
+   '/my_new_endpoint': 'http://localhost:8080',
+   ```
+3. Restart the Vite dev server (`npm run dev`).
+
+> **Tip:** Endpoints under the `/api` prefix are already proxied by a single rule, so prefer using `/api/...` paths for new routes.
+
+## Submitting a Pull Request
+
+1. **Fork** the repository and clone your fork.
+2. Create a branch from `main` (see [Branch and Commit Conventions](#branch-and-commit-conventions)).
+3. Make sure all tests pass and linting is clean:
+   ```bash
+   ruff check .
+   cd frontend && npm run lint && npm test && cd ..
+   pytest tests/unit/ -v
+   ```
+4. Push your branch to your fork and open a PR against `main` on the upstream repo.
+5. Fill in a clear description of **what** changed and **why**.
+6. CI will run backend tests, frontend tests, E2E tests, and Ruff lint automatically.
+
+Thanks for helping make Dune Weaver better!

+ 64 - 154
README.md

@@ -1,192 +1,102 @@
 # Dune Weaver
 # Dune Weaver
 
 
-[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://buymeacoffee.com/tuanchris)
+[![Patreon](https://img.shields.io/badge/Patreon-F96854?style=for-the-badge&logo=patreon&logoColor=white)](https://www.patreon.com/cw/DuneWeaver)
 
 
-![Dune Weaver Gif](./static/IMG_7404.gif)
+![Dune Weaver](./static/og-image.jpg)
 
 
-Dune Weaver is a web-controlled kinetic sand table system that creates mesmerizing patterns in sand using a steel ball guided by magnets beneath the surface. This project seamlessly integrates hardware control with a modern web interface, featuring real-time pattern execution, playlist management, and synchronized lighting effects.
+**An open-source kinetic sand art table that creates mesmerizing patterns using a ball controlled by precision motors.**
 
 
-## 🌟 Key Features
+## Features
 
 
-- **Web-Based Control Interface**: Modern, responsive web UI for pattern management and table control
-- **Real-Time Pattern Execution**: Live preview and control of pattern drawing with progress tracking
-- **Playlist System**: Queue multiple patterns for continuous execution
-- **WLED Integration**: Synchronized lighting effects during pattern execution
-- **Pattern Library**: Browse, upload, and manage custom patterns with preview generation
-- **Polar Coordinate System**: Specialized θ-ρ coordinate system optimized for radial designs
-- **Auto-Update System**: GitHub-integrated version management with update notifications
+- **Modern React UI** — A responsive, touch-friendly web interface that installs as a PWA on any device
+- **Pattern Library** — Browse, upload, and manage hundreds of sand patterns with auto-generated previews
+- **Live Preview** — Watch your pattern come to life in real time with progress tracking
+- **Playlists** — Queue up multiple patterns with configurable pause times and automatic clearing between drawings
+- **LED Integration** — Synchronized lighting via native DW LEDs or WLED, with separate idle, playing, and scheduled modes
+- **Still Sands Scheduling** — Set quiet hours so the table pauses automatically on your schedule
+- **Multi-Table Support** — Control several sand tables from a single interface
+- **Home Assistant Integration** — Connect to Home Assistant or other home automation systems using MQTT
+- **Auto-Updates** — One-click software updates right from the settings page
+- **Add-Ons** — Optional [Desert Compass](https://duneweaver.com/docs) for auto-homing and [DW Touch](https://duneweaver.com/docs) for dedicated touchscreen control
 
 
-### **📚 Complete Documentation: [Dune Weaver Wiki](https://github.com/tuanchris/dune-weaver/wiki)**
+## How It Works
 
 
----
-
-The Dune Weaver comes in two versions:
-
-1. **Small Version (Mini Dune Weaver)**:
-   - Uses two **28BYJ-48 DC 5V stepper motors**.
-   - Controlled via **ULN2003 motor drivers**.
-   - Powered by an **ESP32**.
-
-2. **Larger Version (Dune Weaver)**:
-   - Uses two **NEMA 17 or NEMA 23 stepper motors**.
-   - Controlled via **TMC2209 or DRV8825 motor drivers**.
-   - Powered by an **Arduino UNO with a CNC shield**.
-
-Each version operates similarly but differs in power, precision, and construction cost.
-
-The sand table consists of two main bases:
-1. **Lower Base**: Houses all the electronic components, including motor drivers, and power connections.
-2. **Upper Base**: Contains the sand and the marble, which is moved by a magnet beneath.
-
-Both versions of the table use two stepper motors:
-
-- **Radial Axis Motor**: Controls the in-and-out movement of the arm.
-- **Angular Axis Motor**: Controls the rotational movement of the arm.
+The system is split across two devices connected via USB:
 
 
-The small version uses **28BYJ-48 motors** driven by **ULN2003 drivers**, while the larger version uses **NEMA 17 or NEMA 23 motors** with **TMC2209 or DRV8825 drivers**.: Controls the in-and-out movement of the arm.
-- **Angular Axis Motor**: Controls the rotational movement of the arm.
+```
+┌─────────────────┐         USB          ┌─────────────────┐
+│  Raspberry Pi   │ ◄──────────────────► │  DLC32 / ESP32  │
+│  (Dune Weaver   │                      │  (FluidNC)      │
+│   Backend)      │                      │                 │
+└─────────────────┘                      └─────────────────┘
+        │                                        │
+        │ Wi-Fi                                  │ Motor signals
+        ▼                                        ▼
+   Web Browser                            Stepper Motors
+   (Control UI)                           (Theta & Rho)
+```
 
 
-Each motor is connected to a motor driver that dictates step and direction. The motor drivers are, in turn, connected to the ESP32 board, which serves as the system's main controller. The entire table is powered by a single USB cable attached to the ESP32.
+The **Raspberry Pi** runs the web UI, manages pattern files and playlists, and converts patterns into G-code. The **DLC32/ESP32** running [FluidNC](https://github.com/bdring/FluidNC) firmware receives that G-code and drives the stepper motors in real time.
 
 
----
+## Hardware
 
 
-## Coordinate System
-Unlike traditional CNC machines that use an **X-Y coordinate system**, the sand table operates on a **theta-rho (θ, ρ) coordinate system**:
-- **Theta (θ)**: Represents the angular position of the arm, with **2π radians (360 degrees) for one full revolution**.
-- **Rho (ρ)**: Represents the radial distance of the marble from the center, with **0 at the center and 1 at the perimeter**.
+Dune Weaver comes in three premium models:
 
 
-This system allows the table to create intricate radial designs that differ significantly from traditional Cartesian-based CNC machines.
+| | [DW Pro](https://duneweaver.com/products/dwp) | [DW Mini Pro](https://duneweaver.com/products/dwmp) | [DW Gold](https://duneweaver.com/products/dwg) |
+|---|---|---|---|
+| **Size** | 75 cm (29.5") | 25 cm (10") | 45 cm (17") |
+| **Enclosure** | IKEA VITTSJÖ table | IKEA BLANDA bowl | IKEA TORSJÖ side table |
+| **Motors** | 2 × NEMA 17 | 2 × NEMA 17 | 2 × NEMA 17 |
+| **Controller** | DLC32 | DLC32 | DLC32 |
+| **Best for** | Living rooms | Desktops | Side-table accent piece |
 
 
----
+All models run the same software with [FluidNC](https://github.com/bdring/FluidNC) firmware — only the mechanical parts differ.
 
 
-## Homing and Position Tracking
-Unlike conventional CNC machines, the sand table **does not have a limit switch** for homing. Instead, it uses a **crash-homing method**:
-1. Upon power-on, the radial axis moves inward to its physical limit, ensuring the marble is positioned at the center.
-2. The software then assumes this as the **home position (0,0 in polar coordinates)**.
-3. The system continuously tracks all executed coordinates to maintain an accurate record of the marble’s position.
+Free 3D-printable models on MakerWorld: [DW OG](https://makerworld.com/en/models/841332-dune-weaver-a-3d-printed-kinetic-sand-table#profileId-787553) · [DW Mini](https://makerworld.com/en/models/896314-mini-dune-weaver-not-your-typical-marble-run#profileId-854412)
 
 
----
-
-## Mechanical Constraints and Software Adjustments
-### Coupled Angular and Radial Motion
-Due to the **hardware design choice**, the angular axis **does not move independently**. This means that when the angular motor moves one full revolution, the radial axis **also moves slightly**—either inwards or outwards, depending on the rotation direction.
+> **Build guides, BOMs, and wiring diagrams** are in the [Dune Weaver Docs](https://duneweaver.com/docs).
 
 
-To counteract this behavior, the software:
-- Monitors how many revolutions the angular axis has moved.
-- Applies an offset to the radial axis to compensate for unintended movements.
+## Quick Start
 
 
-This correction ensures that the table accurately follows the intended path without accumulating errors over time.
+The fastest way to get running on a Raspberry Pi:
 
 
----
-
-Each pattern file consists of lines with theta and rho values (in degrees and normalized units, respectively), separated by a space. Comments start with #.
-
-Example:
-
-```
-# Example pattern
-0 0.5
-90 0.7
-180 0.5
-270 0.7
+```bash
+curl -fsSL https://raw.githubusercontent.com/tuanchris/dune-weaver/main/setup-pi.sh | bash
 ```
 ```
 
 
-## API Endpoints
-
-The project exposes RESTful APIs for various actions. Here are some key endpoints:
- • List Serial Ports: /list_serial_ports (GET)
- • Connect to Serial: /connect (POST)
- • Upload Pattern: /upload_theta_rho (POST)
- • Run Pattern: /run_theta_rho (POST)
- • Stop Execution: /stop_execution (POST)
-
-## 🚀 Quick Start
-
-1. **Clone the repository**:
-   ```bash
-   git clone https://github.com/tuanchris/dune-weaver.git
-   cd dune-weaver
-   ```
+This installs Docker, clones the repo, and starts the application. Once it finishes, open **http://\<hostname\>.local** in your browser.
 
 
-2. **Install dependencies**:
+For full deployment options (Docker, manual install, development setup, Windows, and more), see the **[Deploying Backend](https://duneweaver.com/docs/deploying-backend)** guide.
 
 
-   **On Raspberry Pi (full hardware support):**
-   ```bash
-   pip install -r requirements.txt
-   npm install
-   ```
+### Polar coordinates
 
 
-   **On Windows/Linux/macOS (development/testing):**
-   ```bash
-   pip install -r requirements-nonrpi.txt
-   npm install
-   ```
-   > **Note**: The development installation excludes Raspberry Pi GPIO libraries. The application will run fully but DW LED features will be disabled. WLED integration will still work.
+The sand table uses **polar coordinates** instead of the typical X-Y grid:
 
 
-3. **Build CSS**:
-   ```bash
-   npm run build-css
-   ```
+- **Theta (θ)** — the angle in radians (2π = one full revolution)
+- **Rho (ρ)** — the distance from the center (0.0 = center, 1.0 = edge)
 
 
-4. **Start the application**:
-   ```bash
-   python main.py
-   ```
-
-5. **Open your browser** and navigate to `http://localhost:8080`
-
-## 📁 Project Structure
+Patterns are stored as `.thr` text files — one coordinate pair per line:
 
 
 ```
 ```
-dune-weaver/
-├── main.py                     # FastAPI application entry point
-├── VERSION                     # Current software version
-├── modules/
-│   ├── connection/             # Serial & WebSocket connection management
-│   ├── core/                   # Core business logic
-│   │   ├── cache_manager.py    # Pattern preview caching
-│   │   ├── pattern_manager.py  # Pattern file handling
-│   │   ├── playlist_manager.py # Playlist system
-│   │   ├── state.py           # Global state management
-│   │   └── version_manager.py  # GitHub version checking
-│   ├── led/                    # WLED integration
-│   ├── mqtt/                   # MQTT support
-│   └── update/                 # Software update management
-├── patterns/                   # Pattern files (.thr format)
-├── static/                     # Web assets (CSS, JS, images)
-├── templates/                  # HTML templates
-├── firmware/                   # Hardware controller firmware
-└── requirements.txt            # Python dependencies
+# A simple four-point star
+0.000 0.5
+1.571 0.7
+3.142 0.5
+4.712 0.7
 ```
 ```
 
 
-## 🔧 Configuration
-
-The application uses several configuration methods:
-- **Environment Variables**: `LOG_LEVEL`, connection settings
-- **State Persistence**: Settings saved to `state.json`
-- **Version Management**: Automatic GitHub release checking
-
-## 🌐 API Endpoints
+The same pattern file works on any table size thanks to the normalized coordinate system. You can create patterns by hand, generate them with code, or browse the built-in library.
 
 
-Core API endpoints for integration:
+## Documentation
 
 
-- **Pattern Management**: `/upload_theta_rho`, `/list_theta_rho_files`
-- **Execution Control**: `/run_theta_rho`, `/pause_execution`, `/stop_execution`
-- **Hardware Control**: `/connect`, `/send_home`, `/set_speed`
-- **Version Management**: `/api/version`, `/api/update`
-- **Real-time Updates**: WebSocket at `/ws/status`
+Full setup instructions, hardware assembly, firmware flashing, and advanced configuration:
 
 
-## 🤝 Contributing
+**[Dune Weaver Docs](https://duneweaver.com/docs)**
 
 
-We welcome contributions! Please check out our [Contributing Guide](https://github.com/tuanchris/dune-weaver/wiki/Contributing) for details.
+## Contributing
 
 
-## 📖 Documentation
-
-For detailed setup instructions, hardware assembly, and advanced configuration:
-
-**🔗 [Visit the Complete Wiki](https://github.com/tuanchris/dune-weaver/wiki)**
+We welcome contributions! See the [Contributing Guide](CONTRIBUTING.md) for how to get started.
 
 
 ---
 ---
 
 
-**Happy sand drawing with Dune Weaver! ✨**
-
+**Happy sand drawing!**

+ 1 - 1
VERSION

@@ -1 +1 @@
-4.0.0
+4.0.0

+ 5 - 3
docker-compose.yml

@@ -1,10 +1,9 @@
-# TODO: Revert image tags from feature-react-ui to main before merging to main branch
 services:
 services:
   frontend:
   frontend:
     build:
     build:
       context: ./frontend
       context: ./frontend
       dockerfile: Dockerfile
       dockerfile: Dockerfile
-    image: ghcr.io/tuanchris/dune-weaver-frontend:feature-react-ui
+    image: ghcr.io/tuanchris/dune-weaver-frontend:main
     restart: always
     restart: always
     cap_add:
     cap_add:
       - SYS_NICE  # Enable real-time thread priority for smooth UART communication
       - SYS_NICE  # Enable real-time thread priority for smooth UART communication
@@ -19,8 +18,11 @@ services:
 
 
   backend:
   backend:
     build: .
     build: .
-    image: ghcr.io/tuanchris/dune-weaver:feature-react-ui
+    image: ghcr.io/tuanchris/dune-weaver:main
     restart: always
     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:
     ports:
       - "8080:8080"
       - "8080:8080"
     # Environment variables for testing (uncomment to enable):
     # Environment variables for testing (uncomment to enable):

Різницю між файлами не показано, бо вона завелика
+ 237 - 305
dune-weaver-touch/backend.py


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

@@ -16,7 +16,11 @@ Environment=QT_QPA_EGLFS_ALWAYS_SET_MODE=1
 Environment=QT_QPA_EGLFS_HIDECURSOR=1
 Environment=QT_QPA_EGLFS_HIDECURSOR=1
 Environment=QT_QPA_EGLFS_INTEGRATION=eglfs_kms
 Environment=QT_QPA_EGLFS_INTEGRATION=eglfs_kms
 Environment=QT_QPA_EGLFS_KMS_ATOMIC=1
 Environment=QT_QPA_EGLFS_KMS_ATOMIC=1
-ExecStart=/home/pi/dune-weaver-touch/venv/bin/python /home/pi/dune-weaver-touch/main.py
+# 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
+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
 Restart=always
 Restart=always
 RestartSec=10
 RestartSec=10
 StartLimitInterval=200
 StartLimitInterval=200

+ 6 - 6
dune-weaver-touch/main.py

@@ -6,19 +6,19 @@ import time
 import signal
 import signal
 from pathlib import Path
 from pathlib import Path
 from PySide6.QtCore import QUrl, QTimer, QObject, QEvent
 from PySide6.QtCore import QUrl, QTimer, QObject, QEvent
-from PySide6.QtGui import QGuiApplication, QTouchEvent, QMouseEvent
-from PySide6.QtQml import QQmlApplicationEngine, qmlRegisterType, QQmlContext
+from PySide6.QtGui import QGuiApplication
+from PySide6.QtQml import QQmlApplicationEngine, qmlRegisterType
 from qasync import QEventLoop
 from qasync import QEventLoop
-
-# Load environment variables from .env file if it exists
 from dotenv import load_dotenv
 from dotenv import load_dotenv
-load_dotenv(Path(__file__).parent / ".env")
 
 
 from backend import Backend
 from backend import Backend
 from models.pattern_model import PatternModel
 from models.pattern_model import PatternModel
 from models.playlist_model import PlaylistModel
 from models.playlist_model import PlaylistModel
 from png_cache_manager import ensure_png_cache_startup
 from png_cache_manager import ensure_png_cache_startup
 
 
+# Load environment variables from .env file if it exists
+load_dotenv(Path(__file__).parent / ".env")
+
 # Configure logging
 # Configure logging
 logging.basicConfig(
 logging.basicConfig(
     level=logging.INFO,
     level=logging.INFO,
@@ -93,7 +93,7 @@ def is_pi5():
         with open('/proc/device-tree/model', 'r') as f:
         with open('/proc/device-tree/model', 'r') as f:
             model = f.read()
             model = f.read()
             return 'Pi 5' in model
             return 'Pi 5' in model
-    except:
+    except Exception:
         return False
         return False
 
 
 def main():
 def main():

+ 1 - 1
dune-weaver-touch/models/pattern_model.py

@@ -1,4 +1,4 @@
-from PySide6.QtCore import QAbstractListModel, Qt, Slot, Signal
+from PySide6.QtCore import QAbstractListModel, Qt, Slot
 from PySide6.QtQml import QmlElement
 from PySide6.QtQml import QmlElement
 from pathlib import Path
 from pathlib import Path
 
 

+ 18 - 2
dune-weaver-touch/models/playlist_model.py

@@ -1,4 +1,4 @@
-from PySide6.QtCore import QAbstractListModel, Qt, Slot, Signal
+from PySide6.QtCore import QAbstractListModel, Qt, Slot
 from PySide6.QtQml import QmlElement
 from PySide6.QtQml import QmlElement
 from pathlib import Path
 from pathlib import Path
 import json
 import json
@@ -67,7 +67,7 @@ class PlaylistModel(QAbstractListModel):
     
     
     @Slot(str, result=list)
     @Slot(str, result=list)
     def getPatternsForPlaylist(self, playlistName):
     def getPatternsForPlaylist(self, playlistName):
-        """Get the list of patterns for a given playlist"""
+        """Get the list of patterns for a given playlist (cleaned for display)"""
         if hasattr(self, '_playlist_data') and playlistName in self._playlist_data:
         if hasattr(self, '_playlist_data') and playlistName in self._playlist_data:
             patterns = self._playlist_data[playlistName]
             patterns = self._playlist_data[playlistName]
             if isinstance(patterns, list):
             if isinstance(patterns, list):
@@ -82,4 +82,20 @@ class PlaylistModel(QAbstractListModel):
                         clean_name = clean_name[:-4]
                         clean_name = clean_name[:-4]
                     cleaned_patterns.append(clean_name)
                     cleaned_patterns.append(clean_name)
                 return cleaned_patterns
                 return cleaned_patterns
+        return []
+
+    @Slot(str, result=list)
+    def getRawPatternsForPlaylist(self, playlistName):
+        """Get the raw list of patterns for a playlist (with full paths, for API calls)"""
+        if hasattr(self, '_playlist_data') and playlistName in self._playlist_data:
+            patterns = self._playlist_data[playlistName]
+            if isinstance(patterns, list):
+                return patterns
+        return []
+
+    @Slot(result=list)
+    def getAllPlaylistNames(self):
+        """Get a list of all playlist names"""
+        if hasattr(self, '_playlist_data'):
+            return sorted(list(self._playlist_data.keys()))
         return []
         return []

+ 1 - 1
dune-weaver-touch/png_cache_manager.py

@@ -7,7 +7,7 @@ import asyncio
 import os
 import os
 import logging
 import logging
 from pathlib import Path
 from pathlib import Path
-from typing import List, Tuple
+from typing import List
 try:
 try:
     from PIL import Image
     from PIL import Image
 except ImportError:
 except ImportError:

+ 0 - 2
dune-weaver-touch/qml/components/BottomNavTab.qml

@@ -32,7 +32,6 @@ Rectangle {
             property string iconValue: parent.parent.icon
             property string iconValue: parent.parent.icon
             text: {
             text: {
                 // Debug log the icon value
                 // Debug log the icon value
-                console.log("BottomNavTab icon value:", iconValue)
 
 
                 // Map icon names to Unicode symbols that work on Raspberry Pi
                 // Map icon names to Unicode symbols that work on Raspberry Pi
                 switch(iconValue) {
                 switch(iconValue) {
@@ -42,7 +41,6 @@ Rectangle {
                     case "play_arrow": return "▶"   // U+25B6 - Play without variant selector
                     case "play_arrow": return "▶"   // U+25B6 - Play without variant selector
                     case "lightbulb": return "☀"   // U+2600 - Sun symbol for LED
                     case "lightbulb": return "☀"   // U+2600 - Sun symbol for LED
                     default: {
                     default: {
-                        console.log("Unknown icon:", iconValue, "- using default")
                         return "□"  // U+25A1 - Simple box, universally supported
                         return "□"  // U+25A1 - Simple box, universally supported
                     }
                     }
                 }
                 }

+ 0 - 7
dune-weaver-touch/qml/components/ConnectionStatus.qml

@@ -12,12 +12,10 @@ Rectangle {
     // Direct property binding to backend.serialConnected
     // Direct property binding to backend.serialConnected
     color: {
     color: {
         if (!backend) {
         if (!backend) {
-            console.log("ConnectionStatus: No backend available")
             return "#FF5722"  // Red if no backend
             return "#FF5722"  // Red if no backend
         }
         }
         
         
         var connected = backend.serialConnected
         var connected = backend.serialConnected
-        console.log("ConnectionStatus: backend.serialConnected =", connected)
         
         
         if (connected === true) {
         if (connected === true) {
             return "#4CAF50"  // Green if connected
             return "#4CAF50"  // Green if connected
@@ -31,23 +29,18 @@ Rectangle {
         target: backend
         target: backend
         
         
         function onSerialConnectionChanged(connected) {
         function onSerialConnectionChanged(connected) {
-            console.log("ConnectionStatus: serialConnectionChanged signal received:", connected)
             // The color binding will automatically update
             // The color binding will automatically update
         }
         }
     }
     }
     
     
     // Debug logging
     // Debug logging
     Component.onCompleted: {
     Component.onCompleted: {
-        console.log("ConnectionStatus: Component completed, backend =", backend)
         if (backend) {
         if (backend) {
-            console.log("ConnectionStatus: initial serialConnected =", backend.serialConnected)
         }
         }
     }
     }
     
     
     onBackendChanged: {
     onBackendChanged: {
-        console.log("ConnectionStatus: backend changed to", backend)
         if (backend) {
         if (backend) {
-            console.log("ConnectionStatus: new backend serialConnected =", backend.serialConnected)
         }
         }
     }
     }
     
     

+ 3 - 1
dune-weaver-touch/qml/components/ModernControlButton.qml

@@ -9,6 +9,7 @@ Rectangle {
     property color buttonColor: "#2196F3"
     property color buttonColor: "#2196F3"
     property bool enabled: true
     property bool enabled: true
     property int fontSize: 16
     property int fontSize: 16
+    property int iconSize: -1  // -1 means use fontSize + 2
 
 
     signal clicked()
     signal clicked()
 
 
@@ -51,7 +52,8 @@ Rectangle {
         
         
         Text {
         Text {
             text: parent.parent.icon
             text: parent.parent.icon
-            font.pixelSize: parent.parent.fontSize + 2
+            font.pixelSize: parent.parent.iconSize > 0 ? parent.parent.iconSize : parent.parent.fontSize + 2
+            color: "white"
             visible: parent.parent.icon !== ""
             visible: parent.parent.icon !== ""
         }
         }
         
         

+ 1 - 2
dune-weaver-touch/qml/components/ThemeManager.qml

@@ -42,7 +42,7 @@ QtObject {
 
 
     // Placeholder colors
     // Placeholder colors
     property color placeholderBackground: darkMode ? "#2d2d2d" : "#f0f0f0"
     property color placeholderBackground: darkMode ? "#2d2d2d" : "#f0f0f0"
-    property color placeholderText: darkMode ? "#606060" : "#cccccc"
+    property color placeholderText: darkMode ? "#9a9a9a" : "#999999"
 
 
     // Preview background - lighter in dark mode for better pattern visibility
     // Preview background - lighter in dark mode for better pattern visibility
     property color previewBackground: darkMode ? "#707070" : "#f8f9fa"
     property color previewBackground: darkMode ? "#707070" : "#f8f9fa"
@@ -67,7 +67,6 @@ QtObject {
     onDarkModeChanged: {
     onDarkModeChanged: {
         // Save preference
         // Save preference
         settings.darkMode = darkMode
         settings.darkMode = darkMode
-        console.log("🎨 Dark mode:", darkMode ? "enabled" : "disabled")
     }
     }
 
 
     // Helper function to get contrast color
     // Helper function to get contrast color

+ 0 - 18
dune-weaver-touch/qml/main.qml

@@ -25,22 +25,17 @@ ApplicationWindow {
     property string currentPatternPreview: ""
     property string currentPatternPreview: ""
 
 
     onCurrentPageIndexChanged: {
     onCurrentPageIndexChanged: {
-        console.log("📱 currentPageIndex changed to:", currentPageIndex)
     }
     }
 
 
     onShouldNavigateToExecutionChanged: {
     onShouldNavigateToExecutionChanged: {
         if (shouldNavigateToExecution) {
         if (shouldNavigateToExecution) {
-            console.log("🎯 Navigating to execution page")
-            console.log("🎯 Current stack depth:", stackView.depth)
 
 
             // If we're in a sub-page (like PatternDetailPage), pop back to main view first
             // If we're in a sub-page (like PatternDetailPage), pop back to main view first
             if (stackView.depth > 1) {
             if (stackView.depth > 1) {
-                console.log("🎯 Popping back to main view first")
                 stackView.pop()
                 stackView.pop()
             }
             }
 
 
             // Then navigate to ExecutionPage tab (index 4)
             // Then navigate to ExecutionPage tab (index 4)
-            console.log("🎯 Setting currentPageIndex to 4")
             currentPageIndex = 4
             currentPageIndex = 4
             shouldNavigateToExecution = false
             shouldNavigateToExecution = false
         }
         }
@@ -50,14 +45,11 @@ ApplicationWindow {
         id: backend
         id: backend
         
         
         onExecutionStarted: function(patternName, patternPreview) {
         onExecutionStarted: function(patternName, patternPreview) {
-            console.log("🎯 QML: ExecutionStarted signal received! patternName='" + patternName + "', preview='" + patternPreview + "'")
-            console.log("🎯 Setting shouldNavigateToExecution = true")
             // Store pattern info for ExecutionPage
             // Store pattern info for ExecutionPage
             window.currentPatternName = patternName
             window.currentPatternName = patternName
             window.currentPatternPreview = patternPreview
             window.currentPatternPreview = patternPreview
             // Navigate to Execution tab (index 3) instead of pushing page
             // Navigate to Execution tab (index 3) instead of pushing page
             shouldNavigateToExecution = true
             shouldNavigateToExecution = true
-            console.log("🎯 shouldNavigateToExecution set to:", shouldNavigateToExecution)
         }
         }
         
         
         onErrorOccurred: function(error) {
         onErrorOccurred: function(error) {
@@ -72,16 +64,12 @@ ApplicationWindow {
         }
         }
         
         
         onScreenStateChanged: function(isOn) {
         onScreenStateChanged: function(isOn) {
-            console.log("🖥️ Screen state changed:", isOn ? "ON" : "OFF")
         }
         }
         
         
         onBackendConnectionChanged: function(connected) {
         onBackendConnectionChanged: function(connected) {
-            console.log("🔗 Backend connection changed:", connected)
             if (connected && stackView.currentItem.toString().indexOf("ConnectionSplash") !== -1) {
             if (connected && stackView.currentItem.toString().indexOf("ConnectionSplash") !== -1) {
-                console.log("✅ Backend connected, switching to main view")
                 stackView.replace(mainSwipeView)
                 stackView.replace(mainSwipeView)
             } else if (!connected && stackView.currentItem.toString().indexOf("ConnectionSplash") === -1) {
             } else if (!connected && stackView.currentItem.toString().indexOf("ConnectionSplash") === -1) {
-                console.log("❌ Backend disconnected, switching to splash screen")
                 stackView.replace(connectionSplash)
                 stackView.replace(connectionSplash)
             }
             }
         }
         }
@@ -95,17 +83,14 @@ ApplicationWindow {
         propagateComposedEvents: true
         propagateComposedEvents: true
         
         
         onPressed: {
         onPressed: {
-            console.log("🖥️ QML: Touch/press detected - resetting activity timer")
             backend.resetActivityTimer()
             backend.resetActivityTimer()
         }
         }
         
         
         onPositionChanged: {
         onPositionChanged: {
-            console.log("🖥️ QML: Mouse movement detected - resetting activity timer")
             backend.resetActivityTimer()
             backend.resetActivityTimer()
         }
         }
         
         
         onClicked: {
         onClicked: {
-            console.log("🖥️ QML: Click detected - resetting activity timer")
             backend.resetActivityTimer()
             backend.resetActivityTimer()
         }
         }
     }
     }
@@ -134,7 +119,6 @@ ApplicationWindow {
                 showRetryButton: backend.reconnectStatus === "Cannot connect to backend"
                 showRetryButton: backend.reconnectStatus === "Cannot connect to backend"
                 
                 
                 onRetryConnection: {
                 onRetryConnection: {
-                    console.log("🔄 Manual retry requested")
                     backend.retryConnection()
                     backend.retryConnection()
                 }
                 }
             }
             }
@@ -154,7 +138,6 @@ ApplicationWindow {
                     currentIndex: window.currentPageIndex
                     currentIndex: window.currentPageIndex
                     
                     
                     Component.onCompleted: {
                     Component.onCompleted: {
-                        console.log("📱 StackLayout created with currentIndex:", currentIndex, "bound to window.currentPageIndex:", window.currentPageIndex)
                     }
                     }
                     
                     
                     // Patterns Page
                     // Patterns Page
@@ -214,7 +197,6 @@ ApplicationWindow {
                     currentIndex: window.currentPageIndex
                     currentIndex: window.currentPageIndex
                     
                     
                     onTabClicked: function(index) {
                     onTabClicked: function(index) {
-                        console.log("📱 Tab clicked:", index)
                         window.currentPageIndex = index
                         window.currentPageIndex = index
                     }
                     }
                 }
                 }

+ 3 - 21
dune-weaver-touch/qml/pages/ExecutionPage.qml

@@ -14,17 +14,12 @@ Page {
     
     
     // Debug backend connection
     // Debug backend connection
     onBackendChanged: {
     onBackendChanged: {
-        console.log("ExecutionPage: backend changed to", backend)
         if (backend) {
         if (backend) {
-            console.log("ExecutionPage: backend.serialConnected =", backend.serialConnected)
-            console.log("ExecutionPage: backend.isConnected =", backend.isConnected)
         }
         }
     }
     }
     
     
     Component.onCompleted: {
     Component.onCompleted: {
-        console.log("ExecutionPage: Component completed, backend =", backend)
         if (backend) {
         if (backend) {
-            console.log("ExecutionPage: initial serialConnected =", backend.serialConnected)
         }
         }
     }
     }
     
     
@@ -33,20 +28,14 @@ Page {
         target: backend
         target: backend
 
 
         function onSerialConnectionChanged(connected) {
         function onSerialConnectionChanged(connected) {
-            console.log("ExecutionPage: received serialConnectionChanged signal:", connected)
         }
         }
 
 
         function onConnectionChanged() {
         function onConnectionChanged() {
-            console.log("ExecutionPage: received connectionChanged signal")
             if (backend) {
             if (backend) {
-                console.log("ExecutionPage: after connectionChanged, serialConnected =", backend.serialConnected)
             }
             }
         }
         }
 
 
         function onExecutionStarted(fileName, preview) {
         function onExecutionStarted(fileName, preview) {
-            console.log("🎯 ExecutionPage: executionStarted signal received!")
-            console.log("🎯 Pattern:", fileName)
-            console.log("🎯 Preview path:", preview)
             // Update preview directly from backend signal
             // Update preview directly from backend signal
             patternName = fileName
             patternName = fileName
             patternPreview = preview
             patternPreview = preview
@@ -125,9 +114,7 @@ Page {
                             if (patternPreview) {
                             if (patternPreview) {
                                 // Backend returns absolute path, just add file:// prefix
                                 // Backend returns absolute path, just add file:// prefix
                                 finalSource = "file://" + patternPreview
                                 finalSource = "file://" + patternPreview
-                                console.log("🖼️ Using backend patternPreview:", finalSource)
                             } else {
                             } else {
-                                console.log("🖼️ No preview from backend")
                             }
                             }
 
 
                             return finalSource
                             return finalSource
@@ -135,18 +122,13 @@ Page {
                         fillMode: Image.PreserveAspectFit
                         fillMode: Image.PreserveAspectFit
 
 
                         onStatusChanged: {
                         onStatusChanged: {
-                            console.log("📷 Image status:", status, "for source:", source)
                             if (status === Image.Error) {
                             if (status === Image.Error) {
-                                console.log("❌ Image failed to load:", source)
                             } else if (status === Image.Ready) {
                             } else if (status === Image.Ready) {
-                                console.log("✅ Image loaded successfully:", source)
                             } else if (status === Image.Loading) {
                             } else if (status === Image.Loading) {
-                                console.log("🔄 Image loading:", source)
                             }
                             }
                         }
                         }
 
 
                         onSourceChanged: {
                         onSourceChanged: {
-                            console.log("🔄 Image source changed to:", source)
                         }
                         }
                         
                         
                         Rectangle {
                         Rectangle {
@@ -429,10 +411,10 @@ Page {
                                     
                                     
                                     // Speed buttons
                                     // Speed buttons
                                     Repeater {
                                     Repeater {
-                                        model: ["100", "150", "200", "300", "500"]
-                                        
+                                        model: ["50", "100", "150", "200", "300", "500"]
+
                                         Rectangle {
                                         Rectangle {
-                                            width: (speedControlRow.width - 32) / 5  // Distribute evenly with spacing
+                                            width: (speedControlRow.width - 40) / 6  // Distribute evenly with spacing
                                             height: 50
                                             height: 50
                                             color: speedControlRow.currentSelection === modelData ? Components.ThemeManager.selectedBackground : Components.ThemeManager.buttonBackground
                                             color: speedControlRow.currentSelection === modelData ? Components.ThemeManager.selectedBackground : Components.ThemeManager.buttonBackground
                                             border.color: speedControlRow.currentSelection === modelData ? Components.ThemeManager.selectedBorder : Components.ThemeManager.buttonBorder
                                             border.color: speedControlRow.currentSelection === modelData ? Components.ThemeManager.selectedBorder : Components.ThemeManager.buttonBorder

+ 77 - 7
dune-weaver-touch/qml/pages/ModernPatternListPage.qml

@@ -11,6 +11,27 @@ Page {
     property var backend
     property var backend
     property var stackView
     property var stackView
     property bool searchExpanded: false
     property bool searchExpanded: false
+    property bool isRefreshing: false
+    property int patternCount: patternModel ? patternModel.rowCount() : 0
+
+    // Handle pattern refresh completion from backend
+    Connections {
+        target: backend
+        function onPatternsRefreshCompleted(success, message) {
+            if (patternModel) {
+                patternModel.refresh()
+            }
+            isRefreshing = false
+        }
+    }
+
+    // Update pattern count when model resets (rowCount() is not reactive)
+    Connections {
+        target: patternModel
+        function onModelReset() {
+            patternCount = patternModel.rowCount()
+        }
+    }
 
 
     Rectangle {
     Rectangle {
         anchors.fill: parent
         anchors.fill: parent
@@ -56,14 +77,52 @@ Page {
 
 
                 // Pattern count
                 // Pattern count
                 Label {
                 Label {
-                    text: patternModel.rowCount() + " patterns"
+                    text: patternCount + " patterns"
                     font.pixelSize: 12
                     font.pixelSize: 12
                     color: Components.ThemeManager.textTertiary
                     color: Components.ThemeManager.textTertiary
                     visible: !searchExpanded
                     visible: !searchExpanded
                 }
                 }
-                
-                Item { 
-                    Layout.fillWidth: true 
+
+                // Refresh button
+                Rectangle {
+                    Layout.preferredWidth: 32
+                    Layout.preferredHeight: 32
+                    radius: 16
+                    color: refreshMouseArea.pressed ? Components.ThemeManager.buttonBackgroundHover :
+                           (refreshMouseArea.containsMouse ? Components.ThemeManager.cardColor : "transparent")
+                    visible: !searchExpanded
+
+                    Text {
+                        id: refreshIcon
+                        anchors.centerIn: parent
+                        text: "↻"
+                        font.pixelSize: 16
+                        color: isRefreshing ? Components.ThemeManager.accentBlue : Components.ThemeManager.textSecondary
+
+                        SequentialAnimation on opacity {
+                            running: isRefreshing
+                            loops: Animation.Infinite
+                            NumberAnimation { to: 0.4; duration: 500 }
+                            NumberAnimation { to: 1.0; duration: 500 }
+                        }
+                    }
+
+                    MouseArea {
+                        id: refreshMouseArea
+                        anchors.fill: parent
+                        hoverEnabled: true
+                        enabled: !isRefreshing
+                        onClicked: {
+                            if (backend) {
+                                isRefreshing = true
+                                backend.refreshPatterns()
+                            }
+                        }
+                    }
+                }
+
+                Item {
+                    Layout.fillWidth: true
                     visible: !searchExpanded
                     visible: !searchExpanded
                 }
                 }
                 
                 
@@ -98,6 +157,7 @@ Page {
                             id: searchField
                             id: searchField
                             Layout.fillWidth: true
                             Layout.fillWidth: true
                             placeholderText: searchExpanded ? "Search patterns... (press Enter)" : "Search"
                             placeholderText: searchExpanded ? "Search patterns... (press Enter)" : "Search"
+                            placeholderTextColor: Components.ThemeManager.textTertiary
                             font.pixelSize: 14
                             font.pixelSize: 14
                             color: Components.ThemeManager.textPrimary
                             color: Components.ThemeManager.textPrimary
                             visible: searchExpanded || text.length > 0
                             visible: searchExpanded || text.length > 0
@@ -191,8 +251,7 @@ Page {
                 
                 
                 // Close button when expanded
                 // Close button when expanded
                 Button {
                 Button {
-                    text: "✕"
-                    font.pixelSize: 18
+                    id: searchCloseBtn
                     flat: true
                     flat: true
                     visible: searchExpanded
                     visible: searchExpanded
                     Layout.preferredWidth: 32
                     Layout.preferredWidth: 32
@@ -205,6 +264,17 @@ Page {
                         // Clear the filter when closing search
                         // Clear the filter when closing search
                         patternModel.filter("")
                         patternModel.filter("")
                     }
                     }
+                    contentItem: Text {
+                        text: "✕"
+                        font.pixelSize: 18
+                        color: Components.ThemeManager.textSecondary
+                        horizontalAlignment: Text.AlignHCenter
+                        verticalAlignment: Text.AlignVCenter
+                    }
+                    background: Rectangle {
+                        color: searchCloseBtn.pressed ? Components.ThemeManager.buttonBackgroundHover : "transparent"
+                        radius: 4
+                    }
                 }
                 }
             }
             }
         }
         }
@@ -254,7 +324,7 @@ Page {
         Item {
         Item {
             Layout.fillWidth: true
             Layout.fillWidth: true
             Layout.fillHeight: true
             Layout.fillHeight: true
-            visible: patternModel.rowCount() === 0 && searchField.text !== ""
+            visible: patternCount === 0 && searchField.text !== ""
 
 
             Column {
             Column {
                 anchors.centerIn: parent
                 anchors.centerIn: parent

+ 386 - 49
dune-weaver-touch/qml/pages/ModernPlaylistPage.qml

@@ -17,12 +17,13 @@ Page {
     property string selectedPlaylist: ""
     property string selectedPlaylist: ""
     property var selectedPlaylistData: null
     property var selectedPlaylistData: null
     property var currentPlaylistPatterns: []
     property var currentPlaylistPatterns: []
+    property var currentPlaylistRawPatterns: []  // Raw patterns with full paths for API calls
     
     
-    // Playlist execution settings
-    property real pauseTime: backend ? backend.pauseBetweenPatterns : 0
-    property string clearPattern: "adaptive"
-    property string runMode: "single"
-    property bool shuffle: false
+    // Playlist execution settings (loaded from backend/persisted settings)
+    property real pauseTime: backend ? backend.pauseBetweenPatterns : 10800
+    property string clearPattern: backend ? backend.playlistClearPattern : "adaptive"
+    property string runMode: backend ? backend.playlistRunMode : "loop"
+    property bool shuffle: backend ? backend.playlistShuffle : true
     
     
     PlaylistModel {
     PlaylistModel {
         id: playlistModel
         id: playlistModel
@@ -32,16 +33,24 @@ Page {
     onSelectedPlaylistChanged: {
     onSelectedPlaylistChanged: {
         if (selectedPlaylist) {
         if (selectedPlaylist) {
             currentPlaylistPatterns = playlistModel.getPatternsForPlaylist(selectedPlaylist)
             currentPlaylistPatterns = playlistModel.getPatternsForPlaylist(selectedPlaylist)
-            console.log("Loaded patterns for", selectedPlaylist + ":", currentPlaylistPatterns)
+            currentPlaylistRawPatterns = playlistModel.getRawPatternsForPlaylist(selectedPlaylist)
         } else {
         } else {
             currentPlaylistPatterns = []
             currentPlaylistPatterns = []
+            currentPlaylistRawPatterns = []
+        }
+    }
+
+    // Function to remove a pattern from the current playlist
+    function removePatternAtIndex(index) {
+        if (index >= 0 && index < currentPlaylistRawPatterns.length && backend) {
+            var updatedPatterns = currentPlaylistRawPatterns.slice()  // Create a copy
+            updatedPatterns.splice(index, 1)  // Remove the pattern at index
+            backend.updatePlaylistPatterns(selectedPlaylist, updatedPatterns)
         }
         }
     }
     }
     
     
     // Debug playlist loading
     // Debug playlist loading
     Component.onCompleted: {
     Component.onCompleted: {
-        console.log("ModernPlaylistPage completed, playlist count:", playlistModel.rowCount())
-        console.log("showingPlaylistDetail:", showingPlaylistDetail)
     }
     }
     
     
     // Function to navigate to playlist detail
     // Function to navigate to playlist detail
@@ -110,9 +119,24 @@ Page {
                         font.pixelSize: 12
                         font.pixelSize: 12
                         color: Components.ThemeManager.textTertiary
                         color: Components.ThemeManager.textTertiary
                     }
                     }
-                    
-                    Item { 
-                        Layout.fillWidth: true 
+
+                    Item {
+                        Layout.fillWidth: true
+                    }
+
+                    // Create new playlist button
+                    Text {
+                        text: "+"
+                        font.pixelSize: 32
+                        font.bold: true
+                        color: createPlaylistMouseArea.pressed ? "#1e40af" : "#2563eb"
+
+                        MouseArea {
+                            id: createPlaylistMouseArea
+                            anchors.fill: parent
+                            anchors.margins: -8  // Increase touch area
+                            onClicked: createPlaylistDialog.open()
+                        }
                     }
                     }
                 }
                 }
             }
             }
@@ -306,6 +330,20 @@ Page {
                         font.pixelSize: 12
                         font.pixelSize: 12
                         color: Components.ThemeManager.textTertiary
                         color: Components.ThemeManager.textTertiary
                     }
                     }
+
+                    // Delete playlist button
+                    Text {
+                        text: "✕"
+                        font.pixelSize: 20
+                        color: deletePlaylistMouseArea.pressed ? "#991b1b" : "#dc2626"
+
+                        MouseArea {
+                            id: deletePlaylistMouseArea
+                            anchors.fill: parent
+                            anchors.margins: -8
+                            onClicked: deletePlaylistDialog.open()
+                        }
+                    }
                 }
                 }
             }
             }
             
             
@@ -328,14 +366,43 @@ Page {
                             anchors.fill: parent
                             anchors.fill: parent
                             anchors.margins: 15
                             anchors.margins: 15
                             spacing: 10
                             spacing: 10
-                            
-                            Label {
-                                text: "Patterns"
-                                font.pixelSize: 14
-                                font.bold: true
-                                color: Components.ThemeManager.textPrimary
+
+                            RowLayout {
+                                Layout.fillWidth: true
+                                spacing: 10
+
+                                Label {
+                                    text: "Patterns"
+                                    font.pixelSize: 14
+                                    font.bold: true
+                                    color: Components.ThemeManager.textPrimary
+                                    Layout.fillWidth: true
+                                }
+
+                                // Add pattern button
+                                Text {
+                                    text: "+"
+                                    font.pixelSize: 24
+                                    font.bold: true
+                                    color: addPatternMouseArea.pressed ? "#1e40af" : "#2563eb"
+
+                                    MouseArea {
+                                        id: addPatternMouseArea
+                                        anchors.fill: parent
+                                        anchors.margins: -8
+                                        onClicked: {
+                                            // Navigate to full-page pattern selector
+                                            stackView.push("PatternSelectorPage.qml", {
+                                                backend: backend,
+                                                stackView: stackView,
+                                                playlistName: selectedPlaylist,
+                                                existingPatterns: currentPlaylistRawPatterns
+                                            })
+                                        }
+                                    }
+                                }
                             }
                             }
-                            
+
                             ScrollView {
                             ScrollView {
                                 Layout.fillWidth: true
                                 Layout.fillWidth: true
                                 Layout.fillHeight: true
                                 Layout.fillHeight: true
@@ -349,22 +416,25 @@ Page {
                                     
                                     
                                     delegate: Rectangle {
                                     delegate: Rectangle {
                                         width: patternListView.width
                                         width: patternListView.width
-                                        height: 35
+                                        height: 40
                                         color: index % 2 === 0 ? Components.ThemeManager.cardColor : Components.ThemeManager.surfaceColor
                                         color: index % 2 === 0 ? Components.ThemeManager.cardColor : Components.ThemeManager.surfaceColor
                                         radius: 6
                                         radius: 6
                                         border.color: Components.ThemeManager.borderColor
                                         border.color: Components.ThemeManager.borderColor
                                         border.width: 1
                                         border.width: 1
-                                        
+
                                         RowLayout {
                                         RowLayout {
                                             anchors.fill: parent
                                             anchors.fill: parent
-                                            anchors.margins: 10
-                                            spacing: 8
-                                            
+                                            anchors.leftMargin: 10
+                                            anchors.rightMargin: 5
+                                            anchors.topMargin: 4
+                                            anchors.bottomMargin: 4
+                                            spacing: 6
+
                                             Label {
                                             Label {
                                                 text: (index + 1) + "."
                                                 text: (index + 1) + "."
                                                 font.pixelSize: 11
                                                 font.pixelSize: 11
                                                 color: Components.ThemeManager.textSecondary
                                                 color: Components.ThemeManager.textSecondary
-                                                Layout.preferredWidth: 25
+                                                Layout.preferredWidth: 22
                                             }
                                             }
 
 
                                             Label {
                                             Label {
@@ -374,6 +444,23 @@ Page {
                                                 Layout.fillWidth: true
                                                 Layout.fillWidth: true
                                                 elide: Text.ElideRight
                                                 elide: Text.ElideRight
                                             }
                                             }
+
+                                            // Remove pattern button - aligned right
+                                            Text {
+                                                text: "✕"
+                                                font.pixelSize: 16
+                                                color: removePatternArea.pressed ? "#ef4444" : Components.ThemeManager.textTertiary
+                                                Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
+
+                                                MouseArea {
+                                                    id: removePatternArea
+                                                    anchors.fill: parent
+                                                    anchors.margins: -8  // Increase touch area
+                                                    onClicked: {
+                                                        removePatternAtIndex(index)
+                                                    }
+                                                }
+                                            }
                                         }
                                         }
                                     }
                                     }
                                 }
                                 }
@@ -458,21 +545,11 @@ Page {
                                         anchors.fill: parent
                                         anchors.fill: parent
                                         onClicked: {
                                         onClicked: {
                                             if (backend) {
                                             if (backend) {
-                                                console.log("Playing playlist:", selectedPlaylist, "with settings:", {
-                                                    pauseTime: pauseTime,
-                                                    clearPattern: clearPattern,
-                                                    runMode: runMode,
-                                                    shuffle: shuffle
-                                                })
                                                 backend.executePlaylist(selectedPlaylist, pauseTime, clearPattern, runMode, shuffle)
                                                 backend.executePlaylist(selectedPlaylist, pauseTime, clearPattern, runMode, shuffle)
-                                                
+
                                                 // Navigate to execution page
                                                 // Navigate to execution page
-                                                console.log("🎵 Navigating to execution page after playlist start")
                                                 if (mainWindow) {
                                                 if (mainWindow) {
-                                                    console.log("🎵 Setting shouldNavigateToExecution = true")
                                                     mainWindow.shouldNavigateToExecution = true
                                                     mainWindow.shouldNavigateToExecution = true
-                                                } else {
-                                                    console.log("🎵 ERROR: mainWindow is null, cannot navigate")
                                                 }
                                                 }
                                             }
                                             }
                                         }
                                         }
@@ -497,8 +574,9 @@ Page {
                                         id: shuffleMouseArea
                                         id: shuffleMouseArea
                                         anchors.fill: parent
                                         anchors.fill: parent
                                         onClicked: {
                                         onClicked: {
-                                            shuffle = !shuffle
-                                            console.log("Shuffle toggled:", shuffle)
+                                            // Don't assign directly to shuffle - that breaks the binding
+                                            // Just update backend and let the binding propagate the change
+                                            if (backend) backend.setPlaylistShuffle(!backend.playlistShuffle)
                                         }
                                         }
                                     }
                                     }
                                 }
                                 }
@@ -558,10 +636,10 @@ Page {
                                                 id: singleModeRadio
                                                 id: singleModeRadio
                                                 text: "Single"
                                                 text: "Single"
                                                 font.pixelSize: 11
                                                 font.pixelSize: 11
-                                                checked: true  // Default
+                                                checked: runMode === "single"
                                                 onClicked: {
                                                 onClicked: {
                                                     runMode = "single"
                                                     runMode = "single"
-                                                    console.log("Run mode set to:", runMode)
+                                                    if (backend) backend.setPlaylistRunMode("single")
                                                 }
                                                 }
 
 
                                                 contentItem: Text {
                                                 contentItem: Text {
@@ -577,10 +655,10 @@ Page {
                                                 id: loopModeRadio
                                                 id: loopModeRadio
                                                 text: "Loop"
                                                 text: "Loop"
                                                 font.pixelSize: 11
                                                 font.pixelSize: 11
-                                                checked: false
+                                                checked: runMode === "loop"
                                                 onClicked: {
                                                 onClicked: {
                                                     runMode = "loop"
                                                     runMode = "loop"
-                                                    console.log("Run mode set to:", runMode)
+                                                    if (backend) backend.setPlaylistRunMode("loop")
                                                 }
                                                 }
 
 
                                                 contentItem: Text {
                                                 contentItem: Text {
@@ -1003,10 +1081,10 @@ Page {
                                                 id: adaptiveRadio
                                                 id: adaptiveRadio
                                                 text: "Adaptive"
                                                 text: "Adaptive"
                                                 font.pixelSize: 11
                                                 font.pixelSize: 11
-                                                checked: true  // Default
+                                                checked: clearPattern === "adaptive"
                                                 onClicked: {
                                                 onClicked: {
                                                     clearPattern = "adaptive"
                                                     clearPattern = "adaptive"
-                                                    console.log("Clear pattern set to:", clearPattern)
+                                                    if (backend) backend.setPlaylistClearPattern("adaptive")
                                                 }
                                                 }
 
 
                                                 contentItem: Text {
                                                 contentItem: Text {
@@ -1022,10 +1100,10 @@ Page {
                                                 id: clearCenterRadio
                                                 id: clearCenterRadio
                                                 text: "Clear Center"
                                                 text: "Clear Center"
                                                 font.pixelSize: 11
                                                 font.pixelSize: 11
-                                                checked: false
+                                                checked: clearPattern === "clear_center"
                                                 onClicked: {
                                                 onClicked: {
                                                     clearPattern = "clear_center"
                                                     clearPattern = "clear_center"
-                                                    console.log("Clear pattern set to:", clearPattern)
+                                                    if (backend) backend.setPlaylistClearPattern("clear_center")
                                                 }
                                                 }
 
 
                                                 contentItem: Text {
                                                 contentItem: Text {
@@ -1041,10 +1119,10 @@ Page {
                                                 id: clearEdgeRadio
                                                 id: clearEdgeRadio
                                                 text: "Clear Edge"
                                                 text: "Clear Edge"
                                                 font.pixelSize: 11
                                                 font.pixelSize: 11
-                                                checked: false
+                                                checked: clearPattern === "clear_perimeter"
                                                 onClicked: {
                                                 onClicked: {
                                                     clearPattern = "clear_perimeter"
                                                     clearPattern = "clear_perimeter"
-                                                    console.log("Clear pattern set to:", clearPattern)
+                                                    if (backend) backend.setPlaylistClearPattern("clear_perimeter")
                                                 }
                                                 }
 
 
                                                 contentItem: Text {
                                                 contentItem: Text {
@@ -1060,10 +1138,10 @@ Page {
                                                 id: noneRadio
                                                 id: noneRadio
                                                 text: "None"
                                                 text: "None"
                                                 font.pixelSize: 11
                                                 font.pixelSize: 11
-                                                checked: false
+                                                checked: clearPattern === "none"
                                                 onClicked: {
                                                 onClicked: {
                                                     clearPattern = "none"
                                                     clearPattern = "none"
-                                                    console.log("Clear pattern set to:", clearPattern)
+                                                    if (backend) backend.setPlaylistClearPattern("none")
                                                 }
                                                 }
 
 
                                                 contentItem: Text {
                                                 contentItem: Text {
@@ -1086,4 +1164,263 @@ Page {
             }
             }
         }
         }
     }
     }
+
+    // ==================== Dialogs ====================
+
+    // Create Playlist Dialog
+    Popup {
+        id: createPlaylistDialog
+        modal: true
+        x: (parent.width - width) / 2
+        y: (parent.height - height) / 2
+        width: 320
+        height: 200
+        closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
+
+        background: Rectangle {
+            color: Components.ThemeManager.surfaceColor
+            radius: 16
+            border.color: Components.ThemeManager.borderColor
+            border.width: 1
+        }
+
+        contentItem: ColumnLayout {
+            anchors.fill: parent
+            anchors.margins: 20
+            spacing: 15
+
+            Label {
+                text: "Create New Playlist"
+                font.pixelSize: 18
+                font.bold: true
+                color: Components.ThemeManager.textPrimary
+                Layout.alignment: Qt.AlignHCenter
+            }
+
+            TextField {
+                id: newPlaylistNameField
+                Layout.fillWidth: true
+                Layout.preferredHeight: 45
+                placeholderText: "Enter playlist name..."
+                placeholderTextColor: Components.ThemeManager.textTertiary
+                font.pixelSize: 14
+                color: Components.ThemeManager.textPrimary
+
+                background: Rectangle {
+                    color: Components.ThemeManager.backgroundColor
+                    radius: 8
+                    border.color: newPlaylistNameField.activeFocus ? "#2563eb" : Components.ThemeManager.borderColor
+                    border.width: 1
+                }
+
+                onAccepted: {
+                    if (text.trim().length > 0 && backend) {
+                        backend.createPlaylist(text.trim())
+                    }
+                }
+            }
+
+            RowLayout {
+                Layout.fillWidth: true
+                spacing: 10
+
+                // Cancel button
+                Rectangle {
+                    Layout.fillWidth: true
+                    Layout.preferredHeight: 45
+                    radius: 8
+                    color: cancelCreateArea.pressed ? Components.ThemeManager.buttonBackgroundHover : Components.ThemeManager.cardColor
+                    border.color: Components.ThemeManager.borderColor
+                    border.width: 1
+
+                    Text {
+                        anchors.centerIn: parent
+                        text: "Cancel"
+                        color: Components.ThemeManager.textPrimary
+                        font.pixelSize: 14
+                    }
+
+                    MouseArea {
+                        id: cancelCreateArea
+                        anchors.fill: parent
+                        onClicked: {
+                            newPlaylistNameField.text = ""
+                            createPlaylistDialog.close()
+                        }
+                    }
+                }
+
+                // Create button
+                Rectangle {
+                    Layout.fillWidth: true
+                    Layout.preferredHeight: 45
+                    radius: 8
+                    color: createArea.pressed ? "#1e40af" : "#2563eb"
+                    opacity: newPlaylistNameField.text.trim().length > 0 ? 1.0 : 0.5
+
+                    Text {
+                        anchors.centerIn: parent
+                        text: "Create"
+                        color: "white"
+                        font.pixelSize: 14
+                        font.bold: true
+                    }
+
+                    MouseArea {
+                        id: createArea
+                        anchors.fill: parent
+                        enabled: newPlaylistNameField.text.trim().length > 0
+                        onClicked: {
+                            if (backend) {
+                                backend.createPlaylist(newPlaylistNameField.text.trim())
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
+        onOpened: {
+            newPlaylistNameField.text = ""
+            newPlaylistNameField.forceActiveFocus()
+        }
+    }
+
+    // Delete Playlist Confirmation Dialog
+    Popup {
+        id: deletePlaylistDialog
+        modal: true
+        x: (parent.width - width) / 2
+        y: (parent.height - height) / 2
+        width: 320
+        height: 180
+        closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
+
+        background: Rectangle {
+            color: Components.ThemeManager.surfaceColor
+            radius: 16
+            border.color: Components.ThemeManager.borderColor
+            border.width: 1
+        }
+
+        contentItem: ColumnLayout {
+            anchors.fill: parent
+            anchors.margins: 20
+            spacing: 15
+
+            Label {
+                text: "Delete Playlist?"
+                font.pixelSize: 18
+                font.bold: true
+                color: Components.ThemeManager.textPrimary
+                Layout.alignment: Qt.AlignHCenter
+            }
+
+            Label {
+                text: "Are you sure you want to delete\n\"" + selectedPlaylist + "\"?"
+                font.pixelSize: 14
+                color: Components.ThemeManager.textSecondary
+                horizontalAlignment: Text.AlignHCenter
+                Layout.alignment: Qt.AlignHCenter
+            }
+
+            RowLayout {
+                Layout.fillWidth: true
+                spacing: 10
+
+                // Cancel button
+                Rectangle {
+                    Layout.fillWidth: true
+                    Layout.preferredHeight: 45
+                    radius: 8
+                    color: cancelDeleteArea.pressed ? Components.ThemeManager.buttonBackgroundHover : Components.ThemeManager.cardColor
+                    border.color: Components.ThemeManager.borderColor
+                    border.width: 1
+
+                    Text {
+                        anchors.centerIn: parent
+                        text: "Cancel"
+                        color: Components.ThemeManager.textPrimary
+                        font.pixelSize: 14
+                    }
+
+                    MouseArea {
+                        id: cancelDeleteArea
+                        anchors.fill: parent
+                        onClicked: deletePlaylistDialog.close()
+                    }
+                }
+
+                // Delete button
+                Rectangle {
+                    Layout.fillWidth: true
+                    Layout.preferredHeight: 45
+                    radius: 8
+                    color: confirmDeleteArea.pressed ? "#991b1b" : "#dc2626"
+
+                    Text {
+                        anchors.centerIn: parent
+                        text: "Delete"
+                        color: "white"
+                        font.pixelSize: 14
+                        font.bold: true
+                    }
+
+                    MouseArea {
+                        id: confirmDeleteArea
+                        anchors.fill: parent
+                        onClicked: {
+                            if (backend && selectedPlaylist) {
+                                backend.deletePlaylist(selectedPlaylist)
+                            }
+                            deletePlaylistDialog.close()
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    // ==================== Backend Signal Handlers ====================
+
+    Connections {
+        target: backend
+
+        function onPlaylistCreated(success, message) {
+            if (success) {
+                playlistModel.refresh()
+            }
+            newPlaylistNameField.text = ""
+            createPlaylistDialog.close()
+        }
+
+        function onPlaylistDeleted(success, message) {
+            if (success) {
+                playlistModel.refresh()
+                showPlaylistList()  // Navigate back to list
+            }
+        }
+
+        function onPatternAddedToPlaylist(success, message) {
+            if (success) {
+                playlistModel.refresh()
+                // Refresh current playlist patterns if we're viewing one
+                if (selectedPlaylist) {
+                    currentPlaylistPatterns = playlistModel.getPatternsForPlaylist(selectedPlaylist)
+                    currentPlaylistRawPatterns = playlistModel.getRawPatternsForPlaylist(selectedPlaylist)
+                }
+            }
+        }
+
+        function onPlaylistModified(success, message) {
+            if (success) {
+                playlistModel.refresh()
+                // Refresh current playlist patterns
+                if (selectedPlaylist) {
+                    currentPlaylistPatterns = playlistModel.getPatternsForPlaylist(selectedPlaylist)
+                    currentPlaylistRawPatterns = playlistModel.getRawPatternsForPlaylist(selectedPlaylist)
+                }
+            }
+        }
+    }
 }
 }

+ 211 - 4
dune-weaver-touch/qml/pages/PatternDetailPage.qml

@@ -1,6 +1,7 @@
 import QtQuick 2.15
 import QtQuick 2.15
 import QtQuick.Controls 2.15
 import QtQuick.Controls 2.15
 import QtQuick.Layouts 1.15
 import QtQuick.Layouts 1.15
+import DuneWeaver 1.0
 import "../components"
 import "../components"
 import "../components" as Components
 import "../components" as Components
 
 
@@ -10,6 +11,12 @@ Page {
     property string patternPath: ""
     property string patternPath: ""
     property string patternPreview: ""
     property string patternPreview: ""
     property var backend: null
     property var backend: null
+    property bool showAddedFeedback: false
+
+    // Playlist model for selecting which playlist to add to
+    PlaylistModel {
+        id: playlistModel
+    }
 
 
     Rectangle {
     Rectangle {
         anchors.fill: parent
         anchors.fill: parent
@@ -141,7 +148,7 @@ Page {
                         height: 50
                         height: 50
                         radius: 8
                         radius: 8
                         color: playMouseArea.pressed ? "#1e40af" : (backend ? "#2563eb" : "#9ca3af")
                         color: playMouseArea.pressed ? "#1e40af" : (backend ? "#2563eb" : "#9ca3af")
-                        
+
                         Text {
                         Text {
                             anchors.centerIn: parent
                             anchors.centerIn: parent
                             text: "▶ Play Pattern"
                             text: "▶ Play Pattern"
@@ -149,7 +156,7 @@ Page {
                             font.pixelSize: 16
                             font.pixelSize: 16
                             font.bold: true
                             font.bold: true
                         }
                         }
-                        
+
                         MouseArea {
                         MouseArea {
                             id: playMouseArea
                             id: playMouseArea
                             anchors.fill: parent
                             anchors.fill: parent
@@ -160,13 +167,49 @@ Page {
                                     if (centerRadio.checked) preExecution = "clear_center"
                                     if (centerRadio.checked) preExecution = "clear_center"
                                     else if (perimeterRadio.checked) preExecution = "clear_perimeter"
                                     else if (perimeterRadio.checked) preExecution = "clear_perimeter"
                                     else if (noneRadio.checked) preExecution = "none"
                                     else if (noneRadio.checked) preExecution = "none"
-                                    
+
                                     backend.executePattern(patternName, preExecution)
                                     backend.executePattern(patternName, preExecution)
                                 }
                                 }
                             }
                             }
                         }
                         }
                     }
                     }
-                    
+
+                    // Add to Playlist Button
+                    Rectangle {
+                        width: parent.width
+                        height: 45
+                        radius: 8
+                        color: addToPlaylistArea.pressed ? "#065f46" : "#059669"
+
+                        Row {
+                            anchors.centerIn: parent
+                            spacing: 8
+
+                            Text {
+                                text: showAddedFeedback ? "✓" : "♪"
+                                font.pixelSize: 16
+                                color: "white"
+                            }
+
+                            Text {
+                                text: showAddedFeedback ? "Added!" : "Add to Playlist"
+                                color: "white"
+                                font.pixelSize: 14
+                                font.bold: true
+                            }
+                        }
+
+                        MouseArea {
+                            id: addToPlaylistArea
+                            anchors.fill: parent
+                            enabled: backend !== null && !showAddedFeedback
+                            onClicked: {
+                                playlistModel.refresh()  // Refresh playlist list
+                                playlistSelectorPopup.open()
+                            }
+                        }
+                    }
+
                     // Pre-Execution Options
                     // Pre-Execution Options
                     Rectangle {
                     Rectangle {
                         width: parent.width
                         width: parent.width
@@ -294,4 +337,168 @@ Page {
             }
             }
         }
         }
     }
     }
+
+    // ==================== Playlist Selector Popup ====================
+
+    Popup {
+        id: playlistSelectorPopup
+        modal: true
+        x: (parent.width - width) / 2
+        y: (parent.height - height) / 2
+        width: 320
+        height: Math.min(400, 120 + playlistModel.rowCount() * 50)
+        closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
+
+        background: Rectangle {
+            color: Components.ThemeManager.surfaceColor
+            radius: 16
+            border.color: Components.ThemeManager.borderColor
+            border.width: 1
+        }
+
+        contentItem: ColumnLayout {
+            anchors.fill: parent
+            anchors.margins: 15
+            spacing: 10
+
+            Label {
+                text: "Add to Playlist"
+                font.pixelSize: 18
+                font.bold: true
+                color: Components.ThemeManager.textPrimary
+                Layout.alignment: Qt.AlignHCenter
+            }
+
+            // Playlist list
+            ListView {
+                Layout.fillWidth: true
+                Layout.fillHeight: true
+                clip: true
+                model: playlistModel
+                spacing: 6
+
+                delegate: Rectangle {
+                    width: ListView.view.width
+                    height: 45
+                    radius: 8
+                    color: playlistItemArea.pressed ? Components.ThemeManager.selectedBackground : Components.ThemeManager.cardColor
+                    border.color: Components.ThemeManager.borderColor
+                    border.width: 1
+
+                    RowLayout {
+                        anchors.fill: parent
+                        anchors.margins: 12
+                        spacing: 10
+
+                        Text {
+                            text: "♪"
+                            font.pixelSize: 16
+                            color: "#2196F3"
+                        }
+
+                        Label {
+                            text: model.name
+                            font.pixelSize: 14
+                            color: Components.ThemeManager.textPrimary
+                            Layout.fillWidth: true
+                            elide: Text.ElideRight
+                        }
+
+                        Label {
+                            text: model.itemCount + " patterns"
+                            font.pixelSize: 11
+                            color: Components.ThemeManager.textTertiary
+                        }
+                    }
+
+                    MouseArea {
+                        id: playlistItemArea
+                        anchors.fill: parent
+                        onClicked: {
+                            if (backend) {
+                                backend.addPatternToPlaylist(model.name, patternName)
+                            }
+                            playlistSelectorPopup.close()
+                        }
+                    }
+                }
+            }
+
+            // Empty state
+            Column {
+                Layout.fillWidth: true
+                Layout.fillHeight: true
+                spacing: 10
+                visible: playlistModel.rowCount() === 0
+
+                Item { Layout.fillHeight: true }
+
+                Text {
+                    text: "♪"
+                    font.pixelSize: 32
+                    color: Components.ThemeManager.placeholderText
+                    anchors.horizontalCenter: parent.horizontalCenter
+                }
+
+                Label {
+                    text: "No playlists yet"
+                    anchors.horizontalCenter: parent.horizontalCenter
+                    color: Components.ThemeManager.textSecondary
+                    font.pixelSize: 14
+                }
+
+                Label {
+                    text: "Create a playlist first"
+                    anchors.horizontalCenter: parent.horizontalCenter
+                    color: Components.ThemeManager.textTertiary
+                    font.pixelSize: 12
+                }
+
+                Item { Layout.fillHeight: true }
+            }
+
+            // Cancel button
+            Rectangle {
+                Layout.fillWidth: true
+                Layout.preferredHeight: 40
+                radius: 8
+                color: cancelArea.pressed ? Components.ThemeManager.buttonBackgroundHover : Components.ThemeManager.cardColor
+                border.color: Components.ThemeManager.borderColor
+                border.width: 1
+
+                Text {
+                    anchors.centerIn: parent
+                    text: "Cancel"
+                    color: Components.ThemeManager.textPrimary
+                    font.pixelSize: 14
+                }
+
+                MouseArea {
+                    id: cancelArea
+                    anchors.fill: parent
+                    onClicked: playlistSelectorPopup.close()
+                }
+            }
+        }
+    }
+
+    // ==================== Backend Signal Handlers ====================
+
+    Connections {
+        target: backend
+
+        function onPatternAddedToPlaylist(success, message) {
+            if (success) {
+                // Show feedback
+                showAddedFeedback = true
+                feedbackTimer.start()
+            }
+        }
+    }
+
+    Timer {
+        id: feedbackTimer
+        interval: 2000  // Show "Added!" for 2 seconds
+        onTriggered: showAddedFeedback = false
+    }
 }
 }

+ 4 - 1
dune-weaver-touch/qml/pages/PatternListPage.qml

@@ -2,6 +2,7 @@ import QtQuick 2.15
 import QtQuick.Controls 2.15
 import QtQuick.Controls 2.15
 import QtQuick.Layouts 1.15
 import QtQuick.Layouts 1.15
 import "../components"
 import "../components"
+import "../components" as Components
 
 
 Page {
 Page {
     id: page
     id: page
@@ -15,6 +16,8 @@ Page {
                 id: searchField
                 id: searchField
                 Layout.fillWidth: true
                 Layout.fillWidth: true
                 placeholderText: "Search patterns..."
                 placeholderText: "Search patterns..."
+                placeholderTextColor: Components.ThemeManager.textTertiary
+                color: Components.ThemeManager.textPrimary
                 onTextChanged: patternModel.filter(text)
                 onTextChanged: patternModel.filter(text)
                 font.pixelSize: 16
                 font.pixelSize: 16
             }
             }
@@ -62,7 +65,7 @@ Page {
         anchors.centerIn: parent
         anchors.centerIn: parent
         text: "No patterns found"
         text: "No patterns found"
         visible: patternModel.rowCount() === 0 && searchField.text !== ""
         visible: patternModel.rowCount() === 0 && searchField.text !== ""
-        color: "#999"
+        color: Components.ThemeManager.textTertiary
         font.pixelSize: 18
         font.pixelSize: 18
     }
     }
 }
 }

+ 401 - 0
dune-weaver-touch/qml/pages/PatternSelectorPage.qml

@@ -0,0 +1,401 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+import DuneWeaver 1.0
+import "../components"
+import "../components" as Components
+
+Page {
+    id: page
+
+    property var backend: null
+    property var stackView: null
+    property string playlistName: ""
+    property var existingPatterns: []  // Raw pattern names already in playlist
+
+    // Track patterns added in this session for immediate visual feedback
+    property var sessionAddedPatterns: []
+
+    // Local pattern model for this page
+    PatternModel {
+        id: patternModel
+    }
+
+    // Search state
+    property bool searchExpanded: false
+    property int patternCount: patternModel ? patternModel.rowCount() : 0
+
+    // Update pattern count when model resets
+    Connections {
+        target: patternModel
+        function onModelReset() {
+            patternCount = patternModel.rowCount()
+        }
+    }
+
+    // Check if a pattern is already in the playlist
+    function isPatternInPlaylist(patternName) {
+        // Check original existing patterns
+        if (existingPatterns.indexOf(patternName) !== -1) {
+            return true
+        }
+        // Check patterns added during this session
+        if (sessionAddedPatterns.indexOf(patternName) !== -1) {
+            return true
+        }
+        return false
+    }
+
+    Rectangle {
+        anchors.fill: parent
+        color: Components.ThemeManager.backgroundColor
+    }
+
+    ColumnLayout {
+        anchors.fill: parent
+        spacing: 0
+
+        // Header with back button
+        Rectangle {
+            Layout.fillWidth: true
+            Layout.preferredHeight: 50
+            color: Components.ThemeManager.surfaceColor
+
+            // Bottom border
+            Rectangle {
+                anchors.bottom: parent.bottom
+                width: parent.width
+                height: 1
+                color: Components.ThemeManager.borderColor
+            }
+
+            RowLayout {
+                anchors.fill: parent
+                anchors.leftMargin: 15
+                anchors.rightMargin: 10
+                spacing: 10
+
+                // Back button
+                Button {
+                    text: "← Back"
+                    font.pixelSize: 14
+                    flat: true
+                    visible: !searchExpanded
+                    onClicked: stackView.pop()
+
+                    contentItem: Text {
+                        text: parent.text
+                        font: parent.font
+                        color: Components.ThemeManager.textPrimary
+                        horizontalAlignment: Text.AlignHCenter
+                        verticalAlignment: Text.AlignVCenter
+                    }
+                }
+
+                // Title
+                Label {
+                    text: "Add to \"" + playlistName + "\""
+                    font.pixelSize: 16
+                    font.bold: true
+                    color: Components.ThemeManager.textPrimary
+                    Layout.fillWidth: true
+                    elide: Text.ElideRight
+                    visible: !searchExpanded
+                }
+
+                // Pattern count
+                Label {
+                    text: patternCount + " patterns"
+                    font.pixelSize: 12
+                    color: Components.ThemeManager.textTertiary
+                    visible: !searchExpanded
+                }
+
+                Item {
+                    Layout.fillWidth: true
+                    visible: !searchExpanded
+                }
+
+                // Expandable search (matching ModernPatternListPage)
+                Rectangle {
+                    Layout.fillWidth: searchExpanded
+                    Layout.preferredWidth: searchExpanded ? parent.width - 60 : 120
+                    Layout.preferredHeight: 32
+                    radius: 16
+                    color: searchExpanded ? Components.ThemeManager.surfaceColor : Components.ThemeManager.cardColor
+                    border.color: searchExpanded ? "#2563eb" : Components.ThemeManager.borderColor
+                    border.width: 1
+
+                    Behavior on Layout.preferredWidth {
+                        NumberAnimation { duration: 200 }
+                    }
+
+                    RowLayout {
+                        anchors.fill: parent
+                        anchors.leftMargin: 10
+                        anchors.rightMargin: 10
+                        spacing: 5
+
+                        Text {
+                            text: "⌕"
+                            font.pixelSize: 16
+                            font.family: "sans-serif"
+                            color: searchExpanded ? "#2563eb" : Components.ThemeManager.textSecondary
+                        }
+
+                        TextField {
+                            id: searchField
+                            Layout.fillWidth: true
+                            placeholderText: searchExpanded ? "Search patterns... (press Enter)" : "Search"
+                            placeholderTextColor: Components.ThemeManager.textTertiary
+                            font.pixelSize: 14
+                            color: Components.ThemeManager.textPrimary
+                            visible: searchExpanded || text.length > 0
+
+                            property string lastSearchText: ""
+                            property bool hasUnappliedSearch: text !== lastSearchText && text.length > 0
+
+                            background: Rectangle {
+                                color: "transparent"
+                                border.color: searchField.hasUnappliedSearch ? "#f59e0b" : "transparent"
+                                border.width: searchField.hasUnappliedSearch ? 1 : 0
+                                radius: 4
+                            }
+
+                            onAccepted: {
+                                patternModel.filter(text)
+                                lastSearchText = text
+                                Qt.inputMethod.hide()
+                                focus = false
+                            }
+
+                            activeFocusOnPress: true
+                            selectByMouse: true
+                            inputMethodHints: Qt.ImhNoPredictiveText
+
+                            MouseArea {
+                                anchors.fill: parent
+                                onPressed: {
+                                    searchField.forceActiveFocus()
+                                    Qt.inputMethod.show()
+                                    mouse.accepted = false
+                                }
+                            }
+
+                            onActiveFocusChanged: {
+                                if (activeFocus) {
+                                    searchExpanded = true
+                                    Qt.inputMethod.show()
+                                } else {
+                                    if (text !== lastSearchText) {
+                                        patternModel.filter(text)
+                                        lastSearchText = text
+                                    }
+                                }
+                            }
+
+                            Keys.onReturnPressed: {
+                                Qt.inputMethod.hide()
+                                focus = false
+                            }
+
+                            Keys.onEscapePressed: {
+                                text = ""
+                                lastSearchText = ""
+                                patternModel.filter("")
+                                Qt.inputMethod.hide()
+                                focus = false
+                            }
+                        }
+
+                        Text {
+                            text: searchExpanded || searchField.text.length > 0 ? "Search" : ""
+                            font.pixelSize: 12
+                            color: Components.ThemeManager.textTertiary
+                            visible: !searchExpanded && searchField.text.length === 0
+                        }
+                    }
+
+                    MouseArea {
+                        anchors.fill: parent
+                        enabled: !searchExpanded
+                        onClicked: {
+                            searchExpanded = true
+                            searchField.forceActiveFocus()
+                            Qt.inputMethod.show()
+                        }
+                    }
+                }
+
+                // Close button when search expanded
+                Button {
+                    id: searchCloseBtn
+                    flat: true
+                    visible: searchExpanded
+                    Layout.preferredWidth: 32
+                    Layout.preferredHeight: 32
+                    onClicked: {
+                        searchExpanded = false
+                        searchField.text = ""
+                        searchField.lastSearchText = ""
+                        searchField.focus = false
+                        patternModel.filter("")
+                    }
+                    contentItem: Text {
+                        text: "✕"
+                        font.pixelSize: 18
+                        color: Components.ThemeManager.textSecondary
+                        horizontalAlignment: Text.AlignHCenter
+                        verticalAlignment: Text.AlignVCenter
+                    }
+                    background: Rectangle {
+                        color: searchCloseBtn.pressed ? Components.ThemeManager.buttonBackgroundHover : "transparent"
+                        radius: 4
+                    }
+                }
+            }
+        }
+
+        // Pattern Grid
+        GridView {
+            id: gridView
+            Layout.fillWidth: true
+            Layout.fillHeight: true
+            cellWidth: 200
+            cellHeight: 220
+            model: patternModel
+            clip: true
+
+            ScrollBar.vertical: ScrollBar {
+                active: true
+                policy: ScrollBar.AsNeeded
+            }
+
+            delegate: Item {
+                width: gridView.cellWidth - 10
+                height: gridView.cellHeight - 10
+
+                // Check if pattern is already in playlist
+                property bool isInPlaylist: isPatternInPlaylist(model.name)
+
+                ModernPatternCard {
+                    id: patternCard
+                    anchors.fill: parent
+                    name: model.name
+                    preview: model.preview
+
+                    onClicked: {
+                        // Use the tracking function for immediate visual feedback
+                        page.addPatternToPlaylist(model.name)
+                    }
+                }
+
+                // Selection overlay for patterns already in playlist
+                Rectangle {
+                    anchors.fill: parent
+                    color: "transparent"
+                    border.color: isInPlaylist ? "#2563eb" : "transparent"
+                    border.width: isInPlaylist ? 3 : 0
+                    radius: 12
+
+                    // Checkmark badge for selected patterns
+                    Rectangle {
+                        visible: isInPlaylist
+                        anchors.top: parent.top
+                        anchors.right: parent.right
+                        anchors.topMargin: 12
+                        anchors.rightMargin: 12
+                        width: 28
+                        height: 28
+                        radius: 14
+                        color: "#2563eb"
+
+                        Text {
+                            anchors.centerIn: parent
+                            text: "✓"
+                            font.pixelSize: 16
+                            font.bold: true
+                            color: "white"
+                        }
+                    }
+                }
+            }
+
+            // Add scroll animations
+            add: Transition {
+                NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 300 }
+                NumberAnimation { property: "scale"; from: 0.8; to: 1; duration: 300 }
+            }
+        }
+
+        // Empty state when searching
+        Item {
+            Layout.fillWidth: true
+            Layout.fillHeight: true
+            visible: patternCount === 0 && searchField.text !== ""
+
+            Column {
+                anchors.centerIn: parent
+                spacing: 20
+
+                Text {
+                    text: "⌕"
+                    font.pixelSize: 48
+                    anchors.horizontalCenter: parent.horizontalCenter
+                    color: Components.ThemeManager.placeholderText
+                }
+
+                Label {
+                    text: "No patterns found"
+                    anchors.horizontalCenter: parent.horizontalCenter
+                    color: Components.ThemeManager.textSecondary
+                    font.pixelSize: 18
+                }
+
+                Label {
+                    text: "Try a different search term"
+                    anchors.horizontalCenter: parent.horizontalCenter
+                    color: Components.ThemeManager.textTertiary
+                    font.pixelSize: 14
+                }
+            }
+        }
+    }
+
+    // Handle pattern added signal for live updates
+    Connections {
+        target: backend
+
+        function onPatternAddedToPlaylist(success, message) {
+            if (success) {
+                // Extract the pattern name from the message if possible
+                // The message format is typically "Pattern added to playlist"
+                // We'll track additions in sessionAddedPatterns instead
+
+                // Re-trigger binding evaluation by updating the array reference
+                var temp = sessionAddedPatterns.slice()
+                // Try to extract pattern name from recent action
+                // Since we don't get the pattern name directly, we need another approach
+                sessionAddedPatterns = temp
+            }
+        }
+    }
+
+    // Track which pattern was last clicked for visual feedback
+    property string lastClickedPattern: ""
+
+    // Override the click handler to track additions
+    Component.onCompleted: {
+    }
+
+    // Function to add pattern and track it
+    function addPatternToPlaylist(patternName) {
+        if (!isPatternInPlaylist(patternName) && backend) {
+            backend.addPatternToPlaylist(playlistName, patternName)
+            // Immediately add to session tracking for instant visual feedback
+            var temp = sessionAddedPatterns.slice()
+            temp.push(patternName)
+            sessionAddedPatterns = temp
+        }
+    }
+}

+ 36 - 16
dune-weaver-touch/qml/pages/PlaylistPage.qml

@@ -2,68 +2,88 @@ import QtQuick 2.15
 import QtQuick.Controls 2.15
 import QtQuick.Controls 2.15
 import QtQuick.Layouts 1.15
 import QtQuick.Layouts 1.15
 import DuneWeaver 1.0
 import DuneWeaver 1.0
+import "../components" as Components
 
 
 Page {
 Page {
+    background: Rectangle {
+        color: Components.ThemeManager.backgroundColor
+    }
+
     header: ToolBar {
     header: ToolBar {
+        background: Rectangle {
+            color: Components.ThemeManager.surfaceColor
+            border.color: Components.ThemeManager.borderColor
+            border.width: 1
+        }
+
         RowLayout {
         RowLayout {
             anchors.fill: parent
             anchors.fill: parent
             anchors.margins: 10
             anchors.margins: 10
-            
+
             Button {
             Button {
                 text: "← Back"
                 text: "← Back"
                 font.pixelSize: 14
                 font.pixelSize: 14
                 flat: true
                 flat: true
                 onClicked: stackView.pop()
                 onClicked: stackView.pop()
+                contentItem: Text {
+                    text: parent.text
+                    font: parent.font
+                    color: Components.ThemeManager.textPrimary
+                    horizontalAlignment: Text.AlignHCenter
+                    verticalAlignment: Text.AlignVCenter
+                }
             }
             }
-            
+
             Label {
             Label {
                 text: "Playlists"
                 text: "Playlists"
                 Layout.fillWidth: true
                 Layout.fillWidth: true
                 font.pixelSize: 20
                 font.pixelSize: 20
                 font.bold: true
                 font.bold: true
+                color: Components.ThemeManager.textPrimary
             }
             }
         }
         }
     }
     }
-    
+
     PlaylistModel {
     PlaylistModel {
         id: playlistModel
         id: playlistModel
     }
     }
-    
+
     ListView {
     ListView {
         anchors.fill: parent
         anchors.fill: parent
         anchors.margins: 20
         anchors.margins: 20
         model: playlistModel
         model: playlistModel
         spacing: 10
         spacing: 10
-        
+
         delegate: Rectangle {
         delegate: Rectangle {
             width: parent.width
             width: parent.width
             height: 80
             height: 80
-            color: mouseArea.pressed ? "#e0e0e0" : "#f5f5f5"
+            color: mouseArea.pressed ? Components.ThemeManager.buttonBackgroundHover : Components.ThemeManager.cardColor
             radius: 8
             radius: 8
-            border.color: "#d0d0d0"
-            
+            border.color: Components.ThemeManager.borderColor
+
             RowLayout {
             RowLayout {
                 anchors.fill: parent
                 anchors.fill: parent
                 anchors.margins: 15
                 anchors.margins: 15
                 spacing: 15
                 spacing: 15
-                
+
                 Column {
                 Column {
                     Layout.fillWidth: true
                     Layout.fillWidth: true
                     spacing: 5
                     spacing: 5
-                    
+
                     Label {
                     Label {
                         text: model.name
                         text: model.name
                         font.pixelSize: 16
                         font.pixelSize: 16
                         font.bold: true
                         font.bold: true
+                        color: Components.ThemeManager.textPrimary
                     }
                     }
-                    
+
                     Label {
                     Label {
                         text: model.itemCount + " patterns"
                         text: model.itemCount + " patterns"
-                        color: "#666"
+                        color: Components.ThemeManager.textSecondary
                         font.pixelSize: 14
                         font.pixelSize: 14
                     }
                     }
                 }
                 }
-                
+
                 Button {
                 Button {
                     text: "Play"
                     text: "Play"
                     Layout.preferredWidth: 80
                     Layout.preferredWidth: 80
@@ -72,7 +92,7 @@ Page {
                     enabled: false // TODO: Implement playlist execution
                     enabled: false // TODO: Implement playlist execution
                 }
                 }
             }
             }
-            
+
             MouseArea {
             MouseArea {
                 id: mouseArea
                 id: mouseArea
                 anchors.fill: parent
                 anchors.fill: parent
@@ -82,12 +102,12 @@ Page {
             }
             }
         }
         }
     }
     }
-    
+
     Label {
     Label {
         anchors.centerIn: parent
         anchors.centerIn: parent
         text: "No playlists found"
         text: "No playlists found"
         visible: playlistModel.rowCount() === 0
         visible: playlistModel.rowCount() === 0
-        color: "#999"
+        color: Components.ThemeManager.textTertiary
         font.pixelSize: 18
         font.pixelSize: 18
     }
     }
 }
 }

+ 59 - 4
dune-weaver-touch/qml/pages/TableControlPage.qml

@@ -18,17 +18,14 @@ Page {
         target: backend
         target: backend
         
         
         function onSerialPortsUpdated(ports) {
         function onSerialPortsUpdated(ports) {
-            console.log("Serial ports updated:", ports)
             serialPorts = ports
             serialPorts = ports
         }
         }
         
         
         function onSerialConnectionChanged(connected) {
         function onSerialConnectionChanged(connected) {
-            console.log("Serial connection changed:", connected)
             isSerialConnected = connected
             isSerialConnected = connected
         }
         }
         
         
         function onCurrentPortChanged(port) {
         function onCurrentPortChanged(port) {
-            console.log("Current port changed:", port)
             if (port) {
             if (port) {
                 selectedPort = port
                 selectedPort = port
             }
             }
@@ -36,7 +33,6 @@ Page {
         
         
         
         
         function onSettingsLoaded() {
         function onSettingsLoaded() {
-            console.log("Settings loaded")
             if (backend) {
             if (backend) {
                 autoPlayOnBoot = backend.autoPlayOnBoot
                 autoPlayOnBoot = backend.autoPlayOnBoot
                 isSerialConnected = backend.serialConnected
                 isSerialConnected = backend.serialConnected
@@ -213,6 +209,7 @@ Page {
                                 Layout.preferredHeight: 40
                                 Layout.preferredHeight: 40
                                 text: isSerialConnected ? "Disconnect" : "Connect"
                                 text: isSerialConnected ? "Disconnect" : "Connect"
                                 icon: isSerialConnected ? "◉" : "○"
                                 icon: isSerialConnected ? "◉" : "○"
+                                iconSize: 20
                                 buttonColor: isSerialConnected ? "#dc2626" : "#059669"
                                 buttonColor: isSerialConnected ? "#dc2626" : "#059669"
                                 fontSize: 11
                                 fontSize: 11
                                 enabled: isSerialConnected || selectedPort !== ""
                                 enabled: isSerialConnected || selectedPort !== ""
@@ -239,6 +236,7 @@ Page {
                                 Layout.preferredHeight: 35
                                 Layout.preferredHeight: 35
                                 text: "Refresh Ports"
                                 text: "Refresh Ports"
                                 icon: "↻"
                                 icon: "↻"
+                                iconSize: 18
                                 buttonColor: "#6b7280"
                                 buttonColor: "#6b7280"
                                 fontSize: 10
                                 fontSize: 10
                                 
                                 
@@ -279,6 +277,7 @@ Page {
                                 Layout.preferredHeight: 45
                                 Layout.preferredHeight: 45
                                 text: "Home"
                                 text: "Home"
                                 icon: "⌂"
                                 icon: "⌂"
+                                iconSize: 20
                                 buttonColor: "#2563eb"
                                 buttonColor: "#2563eb"
                                 fontSize: 12
                                 fontSize: 12
                                 enabled: isSerialConnected
                                 enabled: isSerialConnected
@@ -293,6 +292,7 @@ Page {
                                 Layout.preferredHeight: 45
                                 Layout.preferredHeight: 45
                                 text: "Center"
                                 text: "Center"
                                 icon: "◎"
                                 icon: "◎"
+                                iconSize: 20
                                 buttonColor: "#2563eb"
                                 buttonColor: "#2563eb"
                                 fontSize: 12
                                 fontSize: 12
                                 enabled: isSerialConnected
                                 enabled: isSerialConnected
@@ -307,6 +307,7 @@ Page {
                                 Layout.preferredHeight: 45
                                 Layout.preferredHeight: 45
                                 text: "Perimeter"
                                 text: "Perimeter"
                                 icon: "○"
                                 icon: "○"
+                                iconSize: 20
                                 buttonColor: "#2563eb"
                                 buttonColor: "#2563eb"
                                 fontSize: 12
                                 fontSize: 12
                                 enabled: isSerialConnected
                                 enabled: isSerialConnected
@@ -581,6 +582,60 @@ Page {
                     }
                     }
                 }
                 }
 
 
+                // System Controls Section
+                Rectangle {
+                    Layout.fillWidth: true
+                    Layout.preferredHeight: 100
+                    Layout.margins: 5
+                    radius: 8
+                    color: Components.ThemeManager.surfaceColor
+
+                    ColumnLayout {
+                        anchors.fill: parent
+                        anchors.margins: 15
+                        spacing: 10
+
+                        Label {
+                            text: "System Controls"
+                            font.pixelSize: 14
+                            font.bold: true
+                            color: Components.ThemeManager.textPrimary
+                        }
+
+                        RowLayout {
+                            Layout.fillWidth: true
+                            spacing: 8
+
+                            ModernControlButton {
+                                Layout.fillWidth: true
+                                Layout.preferredHeight: 45
+                                text: "Restart Backend"
+                                icon: "↻"
+                                iconSize: 20
+                                buttonColor: "#f59e0b"
+                                fontSize: 11
+
+                                onClicked: {
+                                    if (backend) backend.restartBackend()
+                                }
+                            }
+
+                            ModernControlButton {
+                                Layout.fillWidth: true
+                                Layout.preferredHeight: 45
+                                text: "Shutdown Pi"
+                                icon: ""
+                                buttonColor: "#dc2626"
+                                fontSize: 11
+
+                                onClicked: {
+                                    if (backend) backend.shutdownPi()
+                                }
+                            }
+                        }
+                    }
+                }
+
                 // Add some bottom spacing for better scrolling
                 // Add some bottom spacing for better scrolling
                 Item {
                 Item {
                     Layout.preferredHeight: 20
                     Layout.preferredHeight: 20

+ 10 - 1
dw

@@ -171,7 +171,16 @@ cmd_update() {
         echo ""
         echo ""
         echo -e "${BLUE}Updating touch app...${NC}"
         echo -e "${BLUE}Updating touch app...${NC}"
         cd "$touch_dir"
         cd "$touch_dir"
-        git pull
+
+        # Update Python dependencies (code already pulled with main repo)
+        if [[ -f "requirements.txt" ]] && [[ -d ".venv" ]]; then
+            echo "Updating touch app dependencies..."
+            source .venv/bin/activate
+            pip install -q -r requirements.txt
+            deactivate
+        fi
+
+        # Restart to apply changes
         sudo systemctl restart dune-weaver-touch
         sudo systemctl restart dune-weaver-touch
         echo -e "${GREEN}Touch app updated!${NC}"
         echo -e "${GREEN}Touch app updated!${NC}"
     fi
     fi

+ 100 - 0
firmware/dune_weaver_gold/config (DAGGKAPRIFOL).yaml

@@ -0,0 +1,100 @@
+board: MKS-DLC32 V2.1
+name: Dune Weaver Gold
+meta: By Tuan Nguyen (2025-12-25)
+kinematics: {}
+stepping:
+  engine: I2S_STATIC
+  idle_ms: 0
+  pulse_us: 4
+  dir_delay_us: 1
+  disable_delay_us: 0
+axes:
+  shared_stepper_disable_pin: i2so.0
+  x:
+    steps_per_mm: 200
+    max_rate_mm_per_min: 500
+    acceleration_mm_per_sec2: 10
+    max_travel_mm: 325
+    soft_limits: false
+    motor0:
+      limit_neg_pin: gpio.36
+      hard_limits: false
+      pulloff_mm: 2
+      stepstick:
+        step_pin: i2so.1
+        direction_pin: i2so.2
+        disable_pin: NO_PIN
+        ms1_pin: NO_PIN
+        ms2_pin: NO_PIN
+        ms3_pin: NO_PIN
+      limit_pos_pin: NO_PIN
+      limit_all_pin: NO_PIN
+  y:
+    steps_per_mm: 250
+    max_rate_mm_per_min: 500
+    acceleration_mm_per_sec2: 10
+    max_travel_mm: 6.25
+    soft_limits: false
+    motor0:
+      limit_neg_pin: gpio.35
+      hard_limits: false
+      pulloff_mm: 2
+      stepstick:
+        step_pin: i2so.5
+        direction_pin: i2so.6:low
+        disable_pin: NO_PIN
+        ms1_pin: NO_PIN
+        ms2_pin: NO_PIN
+        ms3_pin: NO_PIN
+      limit_pos_pin: NO_PIN
+      limit_all_pin: NO_PIN
+i2so:
+  bck_pin: gpio.16
+  data_pin: gpio.21
+  ws_pin: gpio.17
+sdcard:
+  cs_pin: gpio.15
+  card_detect_pin: NO_PIN
+control:
+  safety_door_pin: NO_PIN
+  reset_pin: NO_PIN
+  feed_hold_pin: NO_PIN
+  cycle_start_pin: NO_PIN
+  macro0_pin: gpio.33:pu:low
+  macro1_pin: NO_PIN
+  macro2_pin: NO_PIN
+  macro3_pin: NO_PIN
+  fault_pin: NO_PIN
+  estop_pin: NO_PIN
+macros:
+  macro0: G90
+coolant:
+  flood_pin: NO_PIN
+  mist_pin: NO_PIN
+  delay_ms: 0
+user_outputs:
+  analog0_pin: NO_PIN
+  analog1_pin: NO_PIN
+  analog2_pin: NO_PIN
+  analog3_pin: NO_PIN
+  analog0_hz: 5000
+  analog1_hz: 5000
+  analog2_hz: 5000
+  analog3_hz: 5000
+  digital0_pin: NO_PIN
+  digital1_pin: NO_PIN
+  digital2_pin: NO_PIN
+  digital3_pin: NO_PIN
+start:
+  must_home: false
+spi:
+  miso_pin: gpio.12
+  mosi_pin: gpio.13
+  sck_pin: gpio.14
+uart1:
+  txd_pin: gpio.19
+  rxd_pin: gpio.18
+  baud: 115200
+  mode: 8N1
+uart_channel1:
+  uart_num: 1

+ 231 - 0
frontend/e2e/mocks/api.ts

@@ -0,0 +1,231 @@
+import { Page, WebSocketRoute } from '@playwright/test'
+
+// Mock data
+export const mockPatterns = [
+  { path: 'patterns/star.thr', name: 'star.thr', category: 'geometric', date_modified: Date.now(), coordinates_count: 150 },
+  { path: 'patterns/spiral.thr', name: 'spiral.thr', category: 'organic', date_modified: Date.now(), coordinates_count: 200 },
+  { path: 'patterns/wave.thr', name: 'wave.thr', category: 'organic', date_modified: Date.now(), coordinates_count: 175 },
+]
+
+export const mockPlaylists = {
+  default: ['patterns/star.thr', 'patterns/spiral.thr'],
+  favorites: ['patterns/star.thr'],
+}
+
+// Mutable status for simulating playback
+let currentStatus = {
+  is_running: false,
+  is_paused: false,
+  current_file: null as string | null,
+  speed: 100,
+  progress: 0,
+  playlist_mode: false,
+  playlist_name: null as string | null,
+  queue: [] as string[],
+  connection_status: 'connected',
+  theta: 0,
+  rho: 0.5,
+}
+
+export function resetMockStatus() {
+  currentStatus = {
+    is_running: false,
+    is_paused: false,
+    current_file: null,
+    speed: 100,
+    progress: 0,
+    playlist_mode: false,
+    playlist_name: null,
+    queue: [],
+    connection_status: 'connected',
+    theta: 0,
+    rho: 0.5,
+  }
+}
+
+export async function setupApiMocks(page: Page) {
+  // Pattern endpoints
+  await page.route('**/list_theta_rho_files_with_metadata', async route => {
+    await route.fulfill({ json: mockPatterns })
+  })
+
+  await page.route('**/list_theta_rho_files', async route => {
+    await route.fulfill({ json: mockPatterns.map(p => ({ name: p.name, path: p.path })) })
+  })
+
+  await page.route('**/preview_thr_batch', async route => {
+    const request = route.request()
+    const body = request.postDataJSON() as { files: string[] }
+    const previews: Record<string, unknown> = {}
+    for (const file of body?.files || []) {
+      previews[file] = {
+        image_data: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
+        first_coordinate: { x: 0, y: 0 },
+        last_coordinate: { x: 100, y: 100 },
+      }
+    }
+    await route.fulfill({ json: previews })
+  })
+
+  await page.route('**/run_theta_rho', async route => {
+    const body = route.request().postDataJSON() as { file_name?: string; file?: string }
+    const file = body?.file_name || body?.file || null
+    currentStatus.is_running = true
+    currentStatus.current_file = file
+    await route.fulfill({ json: { success: true } })
+  })
+
+  // Playlist endpoints
+  await page.route('**/list_all_playlists', async route => {
+    await route.fulfill({ json: Object.keys(mockPlaylists) })
+  })
+
+  await page.route('**/get_playlist**', async route => {
+    const url = new URL(route.request().url())
+    const name = url.searchParams.get('name') || ''
+    await route.fulfill({
+      json: { name, files: mockPlaylists[name as keyof typeof mockPlaylists] || [] }
+    })
+  })
+
+  await page.route('**/run_playlist', async route => {
+    const body = route.request().postDataJSON() as { playlist_name?: string; name?: string }
+    // Support both playlist_name (actual API) and name (legacy)
+    const playlistName = body?.playlist_name || body?.name
+    const playlist = mockPlaylists[playlistName as keyof typeof mockPlaylists]
+    if (playlist && playlist.length > 0) {
+      currentStatus.is_running = true
+      currentStatus.playlist_mode = true
+      currentStatus.playlist_name = playlistName || null
+      currentStatus.current_file = playlist[0]
+      currentStatus.queue = playlist.slice(1)
+    }
+    await route.fulfill({ json: { success: true } })
+  })
+
+  // Playback control endpoints
+  await page.route('**/pause_execution', async route => {
+    currentStatus.is_paused = true
+    await route.fulfill({ json: { success: true } })
+  })
+
+  await page.route('**/resume_execution', async route => {
+    currentStatus.is_paused = false
+    await route.fulfill({ json: { success: true } })
+  })
+
+  await page.route('**/stop_execution', async route => {
+    currentStatus.is_running = false
+    currentStatus.is_paused = false
+    currentStatus.current_file = null
+    currentStatus.playlist_mode = false
+    currentStatus.queue = []
+    await route.fulfill({ json: { success: true } })
+  })
+
+  // Status endpoint
+  await page.route('**/serial_status', async route => {
+    await route.fulfill({ json: currentStatus })
+  })
+
+  // Table info (for TableContext)
+  await page.route('**/api/table-info', async route => {
+    await route.fulfill({
+      json: { id: 'test-table', name: 'Test Table', version: '1.0.0' }
+    })
+  })
+
+  // Settings
+  await page.route('**/api/settings', async route => {
+    await route.fulfill({ json: { app: { name: 'Dune Weaver' } } })
+  })
+
+  // Known tables
+  await page.route('**/api/known-tables', async route => {
+    await route.fulfill({ json: { tables: [] } })
+  })
+
+  // Pattern history
+  await page.route('**/api/pattern_history_all', async route => {
+    await route.fulfill({ json: {} })
+  })
+
+  // Logs endpoint
+  await page.route('**/api/logs**', async route => {
+    await route.fulfill({ json: { logs: [], total: 0, has_more: false } })
+  })
+
+  // Pattern history (individual)
+  await page.route('**/api/pattern_history/**', async route => {
+    await route.fulfill({ json: { actual_time_formatted: null, speed: null } })
+  })
+
+  // Serial ports
+  await page.route('**/list_serial_ports', async route => {
+    await route.fulfill({ json: [] })
+  })
+
+  // Static files - return 200 with placeholder
+  await page.route('**/static/**', async route => {
+    // Return a 1x1 transparent PNG for images
+    const base64Png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
+    await route.fulfill({
+      status: 200,
+      contentType: 'image/png',
+      body: Buffer.from(base64Png, 'base64'),
+    })
+  })
+
+  // WebSocket mocking - critical for bypassing the "Connecting to Backend" overlay
+  // The Layout component shows a blocking overlay until WebSocket connects
+  await page.routeWebSocket('**/ws/status', (ws: WebSocketRoute) => {
+    // Don't connect to server - we're mocking everything
+    // Send status updates to simulate backend status messages
+    const statusMessage = JSON.stringify({
+      type: 'status_update',
+      data: {
+        ...currentStatus,
+        connection_status: true,
+        is_homing: false,
+      }
+    })
+
+    // Send initial status immediately after connection
+    // The client's onopen handler will fire, setting isBackendConnected = true
+    setTimeout(() => {
+      ws.send(statusMessage)
+    }, 100)
+
+    // Send periodic updates
+    const interval = setInterval(() => {
+      ws.send(statusMessage)
+    }, 1000)
+
+    ws.onClose(() => {
+      clearInterval(interval)
+    })
+  })
+
+  // Mock other WebSocket endpoints
+  await page.routeWebSocket('**/ws/logs', (_ws: WebSocketRoute) => {
+    // Just accept the connection - don't need to send anything
+  })
+
+  await page.routeWebSocket('**/ws/cache-progress', (ws: WebSocketRoute) => {
+    // Send "not running" status
+    setTimeout(() => {
+      ws.send(JSON.stringify({
+        type: 'cache_progress',
+        data: { is_running: false, stage: 'idle' }
+      }))
+    }, 100)
+  })
+}
+
+export function getMockStatus() {
+  return { ...currentStatus }
+}
+
+export function setMockStatus(updates: Partial<typeof currentStatus>) {
+  Object.assign(currentStatus, updates)
+}

+ 72 - 0
frontend/e2e/pattern-flow.spec.ts

@@ -0,0 +1,72 @@
+import { test, expect } from '@playwright/test'
+import { setupApiMocks, resetMockStatus, getMockStatus } from './mocks/api'
+
+test.describe('Pattern Flow E2E', () => {
+  test.beforeEach(async ({ page }) => {
+    resetMockStatus()
+    await setupApiMocks(page)
+  })
+
+  test('displays pattern list on browse page', async ({ page }) => {
+    await page.goto('/')
+
+    // Wait for patterns to load
+    await expect(page.getByText('star.thr')).toBeVisible()
+    await expect(page.getByText('spiral.thr')).toBeVisible()
+    await expect(page.getByText('wave.thr')).toBeVisible()
+  })
+
+  test('can select pattern to view details', async ({ page }) => {
+    await page.goto('/')
+
+    // Wait for patterns to load
+    await expect(page.getByText('star.thr')).toBeVisible()
+
+    // Click on pattern
+    await page.getByText('star.thr').click()
+
+    // Detail panel should open (Sheet component)
+    // The sheet contains a "Play" button with exact text (not "Play Next")
+    await expect(page.getByRole('button', { name: 'play_arrow Play' })).toBeVisible({ timeout: 5000 })
+  })
+
+  test('can run pattern and UI shows running state', async ({ page }) => {
+    await page.goto('/')
+
+    // Wait for patterns
+    await expect(page.getByText('star.thr')).toBeVisible()
+
+    // Click pattern to open detail
+    await page.getByText('star.thr').click()
+
+    // Wait for detail panel
+    await page.waitForTimeout(500)
+
+    // Find and click run button
+    const runButton = page.getByRole('button', { name: /run|play/i }).first()
+    await expect(runButton).toBeVisible()
+    await runButton.click()
+
+    // Verify API was called and status updated
+    await page.waitForTimeout(500)
+    const status = getMockStatus()
+    expect(status.is_running).toBe(true)
+    expect(status.current_file).toContain('star')
+  })
+
+  test('search filters patterns correctly', async ({ page }) => {
+    await page.goto('/')
+
+    // Wait for patterns
+    await expect(page.getByText('star.thr')).toBeVisible()
+    await expect(page.getByText('spiral.thr')).toBeVisible()
+
+    // Type in search
+    const searchInput = page.getByPlaceholder(/search/i)
+    await searchInput.fill('spiral')
+
+    // Only spiral should be visible
+    await expect(page.getByText('spiral.thr')).toBeVisible()
+    await expect(page.getByText('star.thr')).not.toBeVisible()
+  })
+})

+ 57 - 0
frontend/e2e/playlist-flow.spec.ts

@@ -0,0 +1,57 @@
+import { test, expect } from '@playwright/test'
+import { setupApiMocks, resetMockStatus, getMockStatus } from './mocks/api'
+
+test.describe('Playlist Flow E2E', () => {
+  test.beforeEach(async ({ page }) => {
+    resetMockStatus()
+    await setupApiMocks(page)
+  })
+
+  test('navigates to playlists page and displays playlists', async ({ page }) => {
+    await page.goto('/playlists')
+
+    // Wait for playlists to load
+    await expect(page.getByText('default')).toBeVisible()
+    await expect(page.getByText('favorites')).toBeVisible()
+  })
+
+  test('can select and run a playlist', async ({ page }) => {
+    await page.goto('/playlists')
+
+    // Wait for playlists
+    await expect(page.getByText('default')).toBeVisible()
+
+    // Click playlist to select
+    await page.getByText('default').click()
+
+    // Wait for the playlist patterns to load
+    // The Play button should become enabled once patterns are loaded
+    await page.waitForTimeout(1000)
+
+    // Find and click run button by its title attribute
+    const runButton = page.locator('button[title="Run Playlist"]')
+    await expect(runButton).toBeVisible({ timeout: 5000 })
+    await runButton.click()
+
+    // Verify playlist is running
+    await page.waitForTimeout(500)
+    const status = getMockStatus()
+    expect(status.is_running).toBe(true)
+    expect(status.playlist_mode).toBe(true)
+    expect(status.playlist_name).toBe('default')
+  })
+
+  test('can navigate between browse and playlists', async ({ page }) => {
+    // Start on browse
+    await page.goto('/')
+    await expect(page.getByText('star.thr')).toBeVisible()
+
+    // Navigate to playlists via nav
+    await page.getByRole('link', { name: /playlists/i }).click()
+    await expect(page.getByText('default')).toBeVisible()
+
+    // Navigate back to browse
+    await page.getByRole('link', { name: /browse/i }).click()
+    await expect(page.getByText('star.thr')).toBeVisible()
+  })
+})

+ 35 - 0
frontend/e2e/sample.spec.ts

@@ -0,0 +1,35 @@
+import { test, expect } from '@playwright/test'
+import { setupApiMocks, resetMockStatus } from './mocks/api'
+
+test.describe('App Infrastructure', () => {
+  test.beforeEach(async ({ page }) => {
+    resetMockStatus()
+    await setupApiMocks(page)
+  })
+
+  test('app loads and renders header', async ({ page }) => {
+    await page.goto('/')
+
+    // Header should be visible with app name
+    await expect(page.getByText('Dune Weaver')).toBeVisible()
+  })
+
+  test('app renders bottom navigation', async ({ page }) => {
+    await page.goto('/')
+
+    // Bottom nav should have all navigation items
+    const nav = page.locator('nav')
+    await expect(nav).toBeVisible()
+  })
+
+  test('dark mode toggle works', async ({ page }) => {
+    await page.goto('/')
+
+    // Find and click theme toggle in menu
+    await page.getByRole('button', { name: /menu/i }).click()
+
+    // Look for dark/light mode option
+    const themeButton = page.getByText(/dark mode|light mode/i)
+    await expect(themeButton).toBeVisible()
+  })
+})

+ 47 - 0
frontend/e2e/table-control.spec.ts

@@ -0,0 +1,47 @@
+import { test, expect } from '@playwright/test'
+import { setupApiMocks, resetMockStatus } from './mocks/api'
+
+test.describe('Table Control E2E', () => {
+  test.beforeEach(async ({ page }) => {
+    resetMockStatus()
+    await setupApiMocks(page)
+
+    // Add route for send_home
+    await page.route('**/send_home', async route => {
+      await route.fulfill({ json: { success: true } })
+    })
+  })
+
+  test('displays control page with buttons', async ({ page }) => {
+    await page.goto('/table-control')
+
+    // Should show control buttons
+    await expect(page.getByRole('button', { name: /home/i })).toBeVisible()
+    await expect(page.getByRole('button', { name: /stop/i })).toBeVisible()
+  })
+
+  test('can trigger home action', async ({ page }) => {
+    await page.goto('/table-control')
+
+    // Find and click home button
+    const homeButton = page.getByRole('button', { name: /home/i })
+    await expect(homeButton).toBeVisible()
+
+    // Click should not throw error
+    await homeButton.click()
+
+    // Button should still be visible (action completed)
+    await expect(homeButton).toBeVisible()
+  })
+
+  test('navigation bar shows all pages', async ({ page }) => {
+    await page.goto('/table-control')
+
+    // All nav items should be visible
+    await expect(page.getByRole('link', { name: /browse/i })).toBeVisible()
+    await expect(page.getByRole('link', { name: /playlists/i })).toBeVisible()
+    await expect(page.getByRole('link', { name: /control/i })).toBeVisible()
+    await expect(page.getByRole('link', { name: /led/i })).toBeVisible()
+    await expect(page.getByRole('link', { name: /settings/i })).toBeVisible()
+  })
+})

Різницю між файлами не показано, бо вона завелика
+ 808 - 16
frontend/package-lock.json


+ 17 - 2
frontend/package.json

@@ -7,7 +7,13 @@
     "dev": "vite --host",
     "dev": "vite --host",
     "build": "tsc -b && vite build",
     "build": "tsc -b && vite build",
     "lint": "eslint .",
     "lint": "eslint .",
-    "preview": "vite preview --host"
+    "preview": "vite preview --host",
+    "test": "vitest run",
+    "test:watch": "vitest",
+    "test:ui": "vitest --ui",
+    "test:coverage": "vitest run --coverage",
+    "test:e2e": "playwright test",
+    "test:e2e:ui": "playwright test --ui"
   },
   },
   "dependencies": {
   "dependencies": {
     "@dnd-kit/core": "^6.3.1",
     "@dnd-kit/core": "^6.3.1",
@@ -41,11 +47,17 @@
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@eslint/js": "^9.39.1",
     "@eslint/js": "^9.39.1",
+    "@playwright/test": "^1.58.0",
+    "@testing-library/jest-dom": "^6.9.1",
+    "@testing-library/react": "^16.3.2",
+    "@testing-library/user-event": "^14.6.1",
     "@types/node": "^24.10.4",
     "@types/node": "^24.10.4",
     "@types/react": "^19.2.5",
     "@types/react": "^19.2.5",
     "@types/react-color": "^3.0.13",
     "@types/react-color": "^3.0.13",
     "@types/react-dom": "^19.2.3",
     "@types/react-dom": "^19.2.3",
     "@vitejs/plugin-react": "^5.1.1",
     "@vitejs/plugin-react": "^5.1.1",
+    "@vitest/coverage-v8": "^3.2.4",
+    "@vitest/ui": "^3.2.4",
     "autoprefixer": "^10.4.23",
     "autoprefixer": "^10.4.23",
     "class-variance-authority": "^0.7.1",
     "class-variance-authority": "^0.7.1",
     "clsx": "^2.1.1",
     "clsx": "^2.1.1",
@@ -53,7 +65,9 @@
     "eslint-plugin-react-hooks": "^7.0.1",
     "eslint-plugin-react-hooks": "^7.0.1",
     "eslint-plugin-react-refresh": "^0.4.24",
     "eslint-plugin-react-refresh": "^0.4.24",
     "globals": "^16.5.0",
     "globals": "^16.5.0",
+    "jsdom": "^27.0.1",
     "lucide-react": "^0.562.0",
     "lucide-react": "^0.562.0",
+    "msw": "^2.12.7",
     "postcss": "^8.5.6",
     "postcss": "^8.5.6",
     "shadcn": "^3.7.0",
     "shadcn": "^3.7.0",
     "tailwind-merge": "^3.4.0",
     "tailwind-merge": "^3.4.0",
@@ -62,6 +76,7 @@
     "typescript": "~5.9.3",
     "typescript": "~5.9.3",
     "typescript-eslint": "^8.46.4",
     "typescript-eslint": "^8.46.4",
     "vite": "^7.2.4",
     "vite": "^7.2.4",
-    "vite-plugin-pwa": "^1.2.0"
+    "vite-plugin-pwa": "^1.2.0",
+    "vitest": "^3.2.4"
   }
   }
 }
 }

+ 28 - 0
frontend/playwright.config.ts

@@ -0,0 +1,28 @@
+import { defineConfig, devices } from '@playwright/test'
+
+// Use a dedicated port for E2E tests to avoid conflicts with other dev servers
+const E2E_PORT = 5174
+
+export default defineConfig({
+  testDir: './e2e',
+  fullyParallel: true,
+  forbidOnly: !!process.env.CI,
+  retries: process.env.CI ? 2 : 0,
+  workers: process.env.CI ? 1 : undefined,
+  reporter: 'html',
+  use: {
+    baseURL: `http://localhost:${E2E_PORT}`,
+    trace: 'on-first-retry',
+  },
+  projects: [
+    {
+      name: 'chromium',
+      use: { ...devices['Desktop Chrome'] },
+    },
+  ],
+  webServer: {
+    command: `npm run dev -- --port ${E2E_PORT}`,
+    url: `http://localhost:${E2E_PORT}`,
+    reuseExistingServer: !process.env.CI,
+  },
+})

+ 79 - 0
frontend/src/__tests__/components/NowPlayingBar.test.tsx

@@ -0,0 +1,79 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { renderWithProviders } from '../../test/utils'
+import { NowPlayingBar } from '../../components/NowPlayingBar'
+
+describe('NowPlayingBar', () => {
+  const defaultProps = {
+    isVisible: true,
+    onClose: vi.fn(),
+  }
+
+  beforeEach(() => {
+    vi.clearAllMocks()
+  })
+
+  describe('Visibility', () => {
+    it('renders when visible', () => {
+      const { container } = renderWithProviders(<NowPlayingBar {...defaultProps} />)
+      // Component should render (even if empty initially)
+      expect(container).toBeTruthy()
+    })
+
+    it('does not render content when isVisible is false', () => {
+      const { container } = renderWithProviders(
+        <NowPlayingBar {...defaultProps} isVisible={false} />
+      )
+      // When not visible, should return null
+      expect(container.firstChild).toBeNull()
+    })
+
+    it('calls onClose callback', () => {
+      const onClose = vi.fn()
+      renderWithProviders(<NowPlayingBar {...defaultProps} onClose={onClose} />)
+      // onClose is passed correctly
+      expect(onClose).not.toHaveBeenCalled()
+    })
+  })
+
+  describe('Props Handling', () => {
+    it('accepts logsDrawerHeight prop', () => {
+      expect(() => {
+        renderWithProviders(
+          <NowPlayingBar {...defaultProps} logsDrawerHeight={300} />
+        )
+      }).not.toThrow()
+    })
+
+    it('accepts openExpanded prop', () => {
+      expect(() => {
+        renderWithProviders(
+          <NowPlayingBar {...defaultProps} openExpanded={true} />
+        )
+      }).not.toThrow()
+    })
+
+    it('accepts isLogsOpen prop', () => {
+      expect(() => {
+        renderWithProviders(
+          <NowPlayingBar {...defaultProps} isLogsOpen={true} />
+        )
+      }).not.toThrow()
+    })
+  })
+
+  describe('Component Structure', () => {
+    it('renders without crashing with all props', () => {
+      expect(() => {
+        renderWithProviders(
+          <NowPlayingBar
+            isVisible={true}
+            onClose={() => {}}
+            isLogsOpen={false}
+            logsDrawerHeight={256}
+            openExpanded={false}
+          />
+        )
+      }).not.toThrow()
+    })
+  })
+})

+ 218 - 0
frontend/src/__tests__/integration/patternFlow.test.tsx

@@ -0,0 +1,218 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { renderWithProviders, screen, waitFor, userEvent } from '../../test/utils'
+import { mockData, apiCallLog, resetApiCallLog } from '../../test/mocks/handlers'
+import { BrowsePage } from '../../pages/BrowsePage'
+
+describe('Pattern Flow Integration', () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+    resetApiCallLog()
+  })
+
+  describe('Browse -> Select -> Run Flow', () => {
+    it('displays pattern list from API', async () => {
+      renderWithProviders(<BrowsePage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+        expect(screen.getByText('spiral.thr')).toBeInTheDocument()
+      })
+    })
+
+    it('opens pattern detail when clicking pattern card', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<BrowsePage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+      })
+
+      // Click on pattern card
+      await user.click(screen.getByText('star.thr'))
+
+      // Detail sheet should open - pattern name appears twice (grid + sheet title)
+      await waitFor(() => {
+        const patternNames = screen.getAllByText('star.thr')
+        expect(patternNames.length).toBeGreaterThan(1)
+      })
+    })
+
+    it('runs pattern and verifies API call with correct file', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<BrowsePage />)
+
+      // Wait for patterns to load
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+      })
+
+      // Pre-condition: not running
+      expect(mockData.status.is_running).toBe(false)
+
+      // Click pattern to open detail sheet
+      await user.click(screen.getByText('star.thr'))
+
+      // Wait for sheet to open and find the main Play button (lg size, not the smaller ones)
+      await waitFor(() => {
+        // The main Play button has "Play" text and play_arrow icon
+        const buttons = screen.getAllByRole('button')
+        const playButton = buttons.find(btn =>
+          btn.textContent?.trim() === 'Play' ||
+          (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
+        )
+        expect(playButton).toBeTruthy()
+      })
+
+      // Click the main Play button
+      const buttons = screen.getAllByRole('button')
+      const playButton = buttons.find(btn =>
+        btn.textContent?.trim() === 'Play' ||
+        (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
+      )
+      await user.click(playButton!)
+
+      // Verify API was called with correct file
+      await waitFor(() => {
+        const runCall = apiCallLog.find(c => c.endpoint === '/run_theta_rho')
+        expect(runCall).toBeDefined()
+        expect(runCall?.body).toMatchObject({
+          file_name: expect.stringContaining('star')
+        })
+      })
+
+      // Verify mock state was updated
+      expect(mockData.status.is_running).toBe(true)
+      expect(mockData.status.current_file).toContain('star')
+    })
+
+    it('updates mock state after pattern starts running', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<BrowsePage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+      })
+
+      // Pre-condition: not running
+      expect(mockData.status.is_running).toBe(false)
+
+      // Click pattern to open detail
+      await user.click(screen.getByText('star.thr'))
+
+      // Wait for sheet to open and find main Play button
+      await waitFor(() => {
+        const buttons = screen.getAllByRole('button')
+        const playButton = buttons.find(btn =>
+          btn.textContent?.trim() === 'Play' ||
+          (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
+        )
+        expect(playButton).toBeTruthy()
+      })
+
+      // Click the main Play button
+      const buttons = screen.getAllByRole('button')
+      const playButton = buttons.find(btn =>
+        btn.textContent?.trim() === 'Play' ||
+        (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
+      )
+      await user.click(playButton!)
+
+      // Post-condition: running
+      await waitFor(() => {
+        expect(mockData.status.is_running).toBe(true)
+      })
+    })
+  })
+
+  describe('Search -> Filter -> Run Flow', () => {
+    it('filters patterns by search then runs filtered result', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<BrowsePage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+        expect(screen.getByText('spiral.thr')).toBeInTheDocument()
+      })
+
+      // Search for "spiral"
+      const searchInput = screen.getByPlaceholderText(/search/i)
+      await user.type(searchInput, 'spiral')
+
+      // Only spiral should be visible
+      await waitFor(() => {
+        expect(screen.getByText('spiral.thr')).toBeInTheDocument()
+        expect(screen.queryByText('star.thr')).not.toBeInTheDocument()
+      })
+
+      // Click and run the filtered pattern
+      await user.click(screen.getByText('spiral.thr'))
+
+      // Wait for sheet and find main Play button
+      await waitFor(() => {
+        const buttons = screen.getAllByRole('button')
+        const playButton = buttons.find(btn =>
+          btn.textContent?.trim() === 'Play' ||
+          (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
+        )
+        expect(playButton).toBeTruthy()
+      })
+
+      // Click main Play button
+      const buttons = screen.getAllByRole('button')
+      const playButton = buttons.find(btn =>
+        btn.textContent?.trim() === 'Play' ||
+        (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
+      )
+      await user.click(playButton!)
+
+      // Verify correct pattern was run
+      await waitFor(() => {
+        const runCall = apiCallLog.find(c => c.endpoint === '/run_theta_rho')
+        expect(runCall?.body).toMatchObject({
+          file_name: expect.stringContaining('spiral')
+        })
+      })
+    })
+  })
+
+  describe('API Call Verification', () => {
+    it('logs API call with timestamp and method', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<BrowsePage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+      })
+
+      // Run a pattern
+      await user.click(screen.getByText('star.thr'))
+
+      // Wait for sheet and find main Play button
+      await waitFor(() => {
+        const buttons = screen.getAllByRole('button')
+        const playButton = buttons.find(btn =>
+          btn.textContent?.trim() === 'Play' ||
+          (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
+        )
+        expect(playButton).toBeTruthy()
+      })
+
+      // Click main Play button
+      const allButtons = screen.getAllByRole('button')
+      const mainPlayButton = allButtons.find(btn =>
+        btn.textContent?.trim() === 'Play' ||
+        (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
+      )
+      await user.click(mainPlayButton!)
+
+      // Verify API call log structure
+      await waitFor(() => {
+        const runCall = apiCallLog.find(c => c.endpoint === '/run_theta_rho')
+        expect(runCall).toBeDefined()
+        expect(runCall?.method).toBe('POST')
+        expect(runCall?.timestamp).toBeDefined()
+        expect(typeof runCall?.timestamp).toBe('number')
+      })
+    })
+  })
+})

+ 274 - 0
frontend/src/__tests__/integration/playbackFlow.test.tsx

@@ -0,0 +1,274 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { renderWithProviders, screen, waitFor, userEvent } from '../../test/utils'
+import { mockData, apiCallLog, resetApiCallLog } from '../../test/mocks/handlers'
+import { BrowsePage } from '../../pages/BrowsePage'
+import { PlaylistsPage } from '../../pages/PlaylistsPage'
+import { TableControlPage } from '../../pages/TableControlPage'
+
+describe('Playback Flow Integration', () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+    resetApiCallLog()
+    localStorage.clear()
+  })
+
+  describe('Pattern Playback Lifecycle', () => {
+    it('starts pattern from browse page', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<BrowsePage />)
+
+      // Initial state: not running
+      expect(mockData.status.is_running).toBe(false)
+
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+      })
+
+      // Click pattern to open detail
+      await user.click(screen.getByText('star.thr'))
+
+      // Find and click main Play button
+      await waitFor(() => {
+        const buttons = screen.getAllByRole('button')
+        const playButton = buttons.find(btn =>
+          btn.textContent?.trim() === 'Play' ||
+          (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
+        )
+        expect(playButton).toBeTruthy()
+      })
+
+      const buttons = screen.getAllByRole('button')
+      const playButton = buttons.find(btn =>
+        btn.textContent?.trim() === 'Play' ||
+        (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
+      )
+      await user.click(playButton!)
+
+      // Verify state transition
+      await waitFor(() => {
+        expect(mockData.status.is_running).toBe(true)
+        expect(mockData.status.is_paused).toBe(false)
+        expect(mockData.status.current_file).toContain('star')
+      })
+    })
+
+    it('stops playback from table control page', async () => {
+      const user = userEvent.setup()
+
+      // Pre-set running state
+      mockData.status.is_running = true
+      mockData.status.current_file = 'patterns/star.thr'
+
+      renderWithProviders(<TableControlPage />)
+
+      // Find and click stop button
+      await waitFor(() => {
+        expect(screen.getByText('Stop')).toBeInTheDocument()
+      })
+
+      const stopButton = screen.getByText('Stop').closest('button')
+      expect(stopButton).toBeTruthy()
+      await user.click(stopButton!)
+
+      // Verify API call
+      await waitFor(() => {
+        const stopCall = apiCallLog.find(c => c.endpoint === '/stop_execution')
+        expect(stopCall).toBeDefined()
+      })
+
+      // Verify state transition
+      expect(mockData.status.is_running).toBe(false)
+      expect(mockData.status.current_file).toBeNull()
+    })
+  })
+
+  describe('Playlist Playback Lifecycle', () => {
+    it('runs playlist and populates queue', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('default')).toBeInTheDocument()
+      })
+
+      // Run default playlist
+      await user.click(screen.getByText('default'))
+
+      await waitFor(() => {
+        expect(screen.getByText(/2 patterns/i)).toBeInTheDocument()
+      })
+
+      // Find play button
+      const buttons = screen.getAllByRole('button')
+      const playButton = buttons.find(btn =>
+        btn.querySelector('.material-icons')?.textContent === 'play_arrow'
+      )
+      await user.click(playButton!)
+
+      // Verify playlist mode state
+      await waitFor(() => {
+        expect(mockData.status.is_running).toBe(true)
+        expect(mockData.status.playlist_mode).toBe(true)
+        expect(mockData.status.current_file).toBe('patterns/star.thr')
+        expect(mockData.status.queue).toContain('patterns/spiral.thr')
+      })
+    })
+
+    it('stops playlist playback and resets state', async () => {
+      const user = userEvent.setup()
+
+      // Pre-set playlist running state
+      mockData.status.is_running = true
+      mockData.status.playlist_mode = true
+      mockData.status.playlist_name = 'default'
+      mockData.status.current_file = 'patterns/star.thr'
+      mockData.status.queue = ['patterns/spiral.thr']
+
+      renderWithProviders(<TableControlPage />)
+
+      // Stop playback
+      await waitFor(() => {
+        expect(screen.getByText('Stop')).toBeInTheDocument()
+      })
+
+      const stopButton = screen.getByText('Stop').closest('button')
+      await user.click(stopButton!)
+
+      // Verify complete state reset
+      await waitFor(() => {
+        expect(mockData.status.is_running).toBe(false)
+        expect(mockData.status.playlist_mode).toBe(false)
+        expect(mockData.status.queue).toEqual([])
+        expect(mockData.status.current_file).toBeNull()
+      })
+    })
+  })
+
+  describe('State Transitions', () => {
+    it('transitions: idle -> running -> stopped', async () => {
+      const user = userEvent.setup()
+
+      // Step 1: Start from idle
+      expect(mockData.status.is_running).toBe(false)
+
+      renderWithProviders(<BrowsePage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+      })
+
+      // Step 2: Start playback
+      await user.click(screen.getByText('star.thr'))
+
+      await waitFor(() => {
+        const buttons = screen.getAllByRole('button')
+        const playButton = buttons.find(btn =>
+          btn.textContent?.trim() === 'Play' ||
+          (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
+        )
+        expect(playButton).toBeTruthy()
+      })
+
+      const buttons = screen.getAllByRole('button')
+      const playButton = buttons.find(btn =>
+        btn.textContent?.trim() === 'Play' ||
+        (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
+      )
+      await user.click(playButton!)
+
+      await waitFor(() => {
+        expect(mockData.status.is_running).toBe(true)
+      })
+
+      // Step 3: Verify API call sequence
+      const callSequence = apiCallLog.map(c => c.endpoint)
+      expect(callSequence).toContain('/run_theta_rho')
+    })
+
+    it('verifies complete API call sequence for playlist run', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('default')).toBeInTheDocument()
+      })
+
+      // Run playlist
+      await user.click(screen.getByText('default'))
+
+      await waitFor(() => {
+        expect(screen.getByText(/2 patterns/i)).toBeInTheDocument()
+      })
+
+      // Find play button
+      const buttons = screen.getAllByRole('button')
+      const playButton = buttons.find(btn =>
+        btn.querySelector('.material-icons')?.textContent === 'play_arrow'
+      )
+      await user.click(playButton!)
+
+      // Verify run_playlist was called (not run_theta_rho)
+      await waitFor(() => {
+        const runCall = apiCallLog.find(c => c.endpoint === '/run_playlist')
+        expect(runCall).toBeDefined()
+        expect(runCall?.body).toMatchObject({ playlist_name: 'default' })
+      })
+    })
+  })
+
+  describe('Playback Control Actions', () => {
+    it('stop_execution API resets all playback state', async () => {
+      const user = userEvent.setup()
+
+      // Pre-set running state
+      mockData.status.is_running = true
+      mockData.status.is_paused = false
+      mockData.status.playlist_mode = true
+      mockData.status.playlist_name = 'test'
+      mockData.status.current_file = 'patterns/test.thr'
+      mockData.status.queue = ['patterns/next.thr']
+
+      renderWithProviders(<TableControlPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('Stop')).toBeInTheDocument()
+      })
+
+      const stopButton = screen.getByText('Stop').closest('button')
+      await user.click(stopButton!)
+
+      // Verify all state was reset
+      await waitFor(() => {
+        expect(mockData.status.is_running).toBe(false)
+        expect(mockData.status.is_paused).toBe(false)
+        expect(mockData.status.playlist_mode).toBe(false)
+        expect(mockData.status.playlist_name).toBeNull()
+        expect(mockData.status.current_file).toBeNull()
+        expect(mockData.status.queue).toEqual([])
+      })
+    })
+
+    it('verifies stop API call is logged', async () => {
+      const user = userEvent.setup()
+
+      mockData.status.is_running = true
+      mockData.status.current_file = 'patterns/test.thr'
+
+      renderWithProviders(<TableControlPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('Stop')).toBeInTheDocument()
+      })
+
+      const stopButton = screen.getByText('Stop').closest('button')
+      await user.click(stopButton!)
+
+      await waitFor(() => {
+        const stopCall = apiCallLog.find(c => c.endpoint === '/stop_execution')
+        expect(stopCall).toBeDefined()
+        expect(stopCall?.method).toBe('POST')
+        expect(stopCall?.timestamp).toBeDefined()
+      })
+    })
+  })
+})

+ 257 - 0
frontend/src/__tests__/integration/playlistFlow.test.tsx

@@ -0,0 +1,257 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { renderWithProviders, screen, waitFor, userEvent } from '../../test/utils'
+import { mockData, apiCallLog, resetApiCallLog } from '../../test/mocks/handlers'
+import { PlaylistsPage } from '../../pages/PlaylistsPage'
+
+describe('Playlist Flow Integration', () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+    resetApiCallLog()
+    // Clear localStorage to start fresh
+    localStorage.clear()
+  })
+
+  describe('View and Select Playlist Flow', () => {
+    it('displays playlist list from API', async () => {
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('default')).toBeInTheDocument()
+        expect(screen.getByText('favorites')).toBeInTheDocument()
+        expect(screen.getByText('geometric')).toBeInTheDocument()
+      })
+    })
+
+    it('displays page title and count', async () => {
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('Playlists')).toBeInTheDocument()
+        expect(screen.getByText(/3 playlists/i)).toBeInTheDocument()
+      })
+    })
+
+    it('clicking playlist selects it and loads patterns', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('default')).toBeInTheDocument()
+      })
+
+      // Click on default playlist
+      await user.click(screen.getByText('default'))
+
+      // Should show playlist content with pattern count
+      await waitFor(() => {
+        // default playlist has 2 patterns
+        expect(screen.getByText(/2 patterns/i)).toBeInTheDocument()
+      })
+    })
+  })
+
+  describe('Run Playlist Flow', () => {
+    it('runs existing playlist and verifies API call', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('default')).toBeInTheDocument()
+      })
+
+      // Click playlist to select it
+      await user.click(screen.getByText('default'))
+
+      // Wait for patterns to load
+      await waitFor(() => {
+        expect(screen.getByText(/2 patterns/i)).toBeInTheDocument()
+      })
+
+      // Find the play button (circular button with play_arrow icon)
+      // It's a button that contains a play_arrow material icon
+      const buttons = screen.getAllByRole('button')
+      const playButton = buttons.find(btn =>
+        btn.querySelector('.material-icons')?.textContent === 'play_arrow'
+      )
+      expect(playButton).toBeTruthy()
+
+      await user.click(playButton!)
+
+      // Verify API call
+      await waitFor(() => {
+        const runCall = apiCallLog.find(c => c.endpoint === '/run_playlist')
+        expect(runCall).toBeDefined()
+        expect(runCall?.body).toMatchObject({
+          playlist_name: 'default'
+        })
+      })
+
+      // Verify state updated
+      expect(mockData.status.is_running).toBe(true)
+      expect(mockData.status.playlist_mode).toBe(true)
+      expect(mockData.status.playlist_name).toBe('default')
+    })
+
+    it('populates queue from playlist files when running', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('default')).toBeInTheDocument()
+      })
+
+      // default playlist has: ['patterns/star.thr', 'patterns/spiral.thr']
+      const initialPlaylist = mockData.playlists['default']
+      expect(initialPlaylist.length).toBe(2)
+
+      // Click and run
+      await user.click(screen.getByText('default'))
+
+      await waitFor(() => {
+        expect(screen.getByText(/2 patterns/i)).toBeInTheDocument()
+      })
+
+      // Find play button
+      const buttons = screen.getAllByRole('button')
+      const playButton = buttons.find(btn =>
+        btn.querySelector('.material-icons')?.textContent === 'play_arrow'
+      )
+      await user.click(playButton!)
+
+      // Verify queue was set correctly
+      await waitFor(() => {
+        expect(mockData.status.current_file).toBe('patterns/star.thr')
+        expect(mockData.status.queue).toContain('patterns/spiral.thr')
+      })
+    })
+  })
+
+  describe('Create Playlist Flow', () => {
+    it('creates new playlist via dialog', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<PlaylistsPage />)
+
+      // Wait for page to load
+      await waitFor(() => {
+        expect(screen.getByText('default')).toBeInTheDocument()
+      })
+
+      // Click create button (the + icon button in sidebar header)
+      const buttons = screen.getAllByRole('button')
+      const addButton = buttons.find(btn => {
+        const icon = btn.querySelector('.material-icons-outlined')
+        return icon?.textContent === 'add'
+      })
+
+      expect(addButton).toBeTruthy()
+      await user.click(addButton!)
+
+      // Fill in dialog
+      await waitFor(() => {
+        expect(screen.getByRole('dialog')).toBeInTheDocument()
+      })
+
+      const nameInput = screen.getByPlaceholderText(/favorites.*morning.*patterns/i)
+      await user.type(nameInput, 'my-test-playlist')
+
+      // Submit by clicking Create Playlist button
+      const submitButton = screen.getByRole('button', { name: /create playlist/i })
+      await user.click(submitButton)
+
+      // Verify API call
+      await waitFor(() => {
+        const createCall = apiCallLog.find(c => c.endpoint === '/create_playlist')
+        expect(createCall).toBeDefined()
+        expect(createCall?.body).toMatchObject({
+          playlist_name: 'my-test-playlist'
+        })
+      })
+
+      // Verify mockData was updated
+      expect(mockData.playlists['my-test-playlist']).toBeDefined()
+    })
+  })
+
+  describe('Delete Playlist Flow', () => {
+    it('deletes playlist after confirmation', async () => {
+      const user = userEvent.setup()
+
+      // Add a test playlist to delete
+      mockData.playlists['to-delete'] = ['patterns/star.thr']
+
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('to-delete')).toBeInTheDocument()
+      })
+
+      // Find and hover over the playlist item to reveal delete button
+      const playlistItem = screen.getByText('to-delete')
+
+      // The delete button is a sibling of the text
+      const parentDiv = playlistItem.closest('[class*="group"]')
+      expect(parentDiv).toBeTruthy()
+
+      // Find delete button within the same row (uses Trash2 lucide icon with text-destructive class)
+      const deleteButtons = parentDiv!.querySelectorAll('button')
+      const deleteButton = Array.from(deleteButtons).find(btn =>
+        btn.classList.contains('text-destructive') || btn.className.includes('text-destructive')
+      )
+
+      expect(deleteButton).toBeTruthy()
+
+      // Mock window.confirm
+      const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
+
+      await user.click(deleteButton!)
+
+      // Verify confirm was called
+      expect(confirmSpy).toHaveBeenCalled()
+
+      // Verify API call
+      await waitFor(() => {
+        const deleteCall = apiCallLog.find(c => c.endpoint === '/delete_playlist')
+        expect(deleteCall).toBeDefined()
+      })
+
+      // Verify mockData was updated
+      expect(mockData.playlists['to-delete']).toBeUndefined()
+
+      confirmSpy.mockRestore()
+    })
+  })
+
+  describe('Playlist State Verification', () => {
+    it('verifies run_playlist API call format', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('default')).toBeInTheDocument()
+      })
+
+      // Run playlist
+      await user.click(screen.getByText('default'))
+
+      await waitFor(() => {
+        expect(screen.getByText(/2 patterns/i)).toBeInTheDocument()
+      })
+
+      // Find play button
+      const buttons = screen.getAllByRole('button')
+      const playButton = buttons.find(btn =>
+        btn.querySelector('.material-icons')?.textContent === 'play_arrow'
+      )
+      await user.click(playButton!)
+
+      // Verify complete API call structure
+      await waitFor(() => {
+        const runCall = apiCallLog.find(c => c.endpoint === '/run_playlist')
+        expect(runCall).toBeDefined()
+        expect(runCall?.method).toBe('POST')
+        expect(runCall?.timestamp).toBeDefined()
+        expect(runCall?.body).toHaveProperty('playlist_name')
+      })
+    })
+  })
+})

+ 176 - 0
frontend/src/__tests__/pages/BrowsePage.test.tsx

@@ -0,0 +1,176 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { renderWithProviders, screen, waitFor, userEvent } from '../../test/utils'
+import { server } from '../../test/mocks/server'
+import { http, HttpResponse } from 'msw'
+import { BrowsePage } from '../../pages/BrowsePage'
+
+describe('BrowsePage', () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+  })
+
+  describe('Pattern Listing', () => {
+    it('renders pattern list from API', async () => {
+      renderWithProviders(<BrowsePage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+        expect(screen.getByText('spiral.thr')).toBeInTheDocument()
+        expect(screen.getByText('wave.thr')).toBeInTheDocument()
+      })
+    })
+
+    it('displays page title', async () => {
+      renderWithProviders(<BrowsePage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('Browse Patterns')).toBeInTheDocument()
+      })
+    })
+
+    it('handles empty pattern list', async () => {
+      server.use(
+        http.get('/list_theta_rho_files_with_metadata', () => {
+          return HttpResponse.json([])
+        })
+      )
+
+      renderWithProviders(<BrowsePage />)
+
+      await waitFor(() => {
+        expect(screen.getByText(/no patterns found/i)).toBeInTheDocument()
+      })
+    })
+
+    it('handles API error gracefully', async () => {
+      server.use(
+        http.get('/list_theta_rho_files_with_metadata', () => {
+          return HttpResponse.error()
+        })
+      )
+
+      renderWithProviders(<BrowsePage />)
+
+      // Should not crash - page should still render
+      await waitFor(() => {
+        expect(screen.getByText('Browse Patterns')).toBeInTheDocument()
+      })
+    })
+  })
+
+  describe('Pattern Selection', () => {
+    it('clicking pattern opens detail sheet', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<BrowsePage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+      })
+
+      // Click on a pattern
+      await user.click(screen.getByText('star.thr'))
+
+      // Sheet should open with pattern name in title
+      await waitFor(() => {
+        // The Sheet title contains the pattern name
+        const titles = screen.getAllByText('star.thr')
+        expect(titles.length).toBeGreaterThan(1) // One in card, one in sheet title
+      })
+    })
+  })
+
+  describe('Search and Filter', () => {
+    it('search filters patterns by name', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<BrowsePage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+      })
+
+      // Find and type in search input
+      const searchInput = screen.getByPlaceholderText(/search/i)
+      await user.type(searchInput, 'star')
+
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+        expect(screen.queryByText('spiral.thr')).not.toBeInTheDocument()
+      })
+    })
+
+    it('clearing search shows all patterns', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<BrowsePage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+      })
+
+      const searchInput = screen.getByPlaceholderText(/search/i)
+      await user.type(searchInput, 'star')
+
+      await waitFor(() => {
+        expect(screen.queryByText('spiral.thr')).not.toBeInTheDocument()
+      })
+
+      await user.clear(searchInput)
+
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+        expect(screen.getByText('spiral.thr')).toBeInTheDocument()
+      })
+    })
+
+    it('no results message shows clear filters button', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<BrowsePage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+      })
+
+      const searchInput = screen.getByPlaceholderText(/search/i)
+      await user.type(searchInput, 'nonexistentpattern')
+
+      await waitFor(() => {
+        expect(screen.getByText(/no patterns found/i)).toBeInTheDocument()
+        expect(screen.getByText(/clear filters/i)).toBeInTheDocument()
+      })
+    })
+  })
+
+  describe('Pattern Actions', () => {
+    it('clicking pattern opens sheet with pattern details', async () => {
+      const user = userEvent.setup()
+
+      renderWithProviders(<BrowsePage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+      })
+
+      // Click pattern to open detail sheet
+      await user.click(screen.getByText('star.thr'))
+
+      // Sheet should open - the pattern name appears twice (once in list, once in sheet title)
+      await waitFor(() => {
+        const patternNames = screen.getAllByText('star.thr')
+        expect(patternNames.length).toBeGreaterThan(1)
+      })
+    })
+
+    it('pattern cards are clickable', async () => {
+      const user = userEvent.setup()
+
+      renderWithProviders(<BrowsePage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+      })
+
+      // Pattern cards should be clickable
+      const patternCard = screen.getByText('star.thr')
+      await expect(user.click(patternCard)).resolves.not.toThrow()
+    })
+  })
+})

+ 222 - 0
frontend/src/__tests__/pages/PlaylistsPage.test.tsx

@@ -0,0 +1,222 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { renderWithProviders, screen, waitFor, userEvent } from '../../test/utils'
+import { server } from '../../test/mocks/server'
+import { http, HttpResponse } from 'msw'
+import { PlaylistsPage } from '../../pages/PlaylistsPage'
+
+describe('PlaylistsPage', () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+  })
+
+  describe('Playlist Listing', () => {
+    it('renders playlist names from API', async () => {
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('default')).toBeInTheDocument()
+        expect(screen.getByText('favorites')).toBeInTheDocument()
+        expect(screen.getByText('geometric')).toBeInTheDocument()
+      })
+    })
+
+    it('displays page title and description', async () => {
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('Playlists')).toBeInTheDocument()
+        expect(screen.getByText(/create and manage pattern playlists/i)).toBeInTheDocument()
+      })
+    })
+
+    it('shows playlist count', async () => {
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('3 playlists')).toBeInTheDocument()
+      })
+    })
+
+    it('handles empty playlist list', async () => {
+      server.use(
+        http.get('/list_all_playlists', () => {
+          return HttpResponse.json([])
+        })
+      )
+
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText(/no playlists yet/i)).toBeInTheDocument()
+      })
+    })
+  })
+
+  describe('Playlist Selection', () => {
+    it('clicking playlist loads its patterns', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('default')).toBeInTheDocument()
+      })
+
+      // Click on playlist
+      await user.click(screen.getByText('default'))
+
+      // Should show patterns from that playlist
+      // The patterns are displayed in the content area
+      await waitFor(() => {
+        // Playlist 'default' contains star.thr and spiral.thr based on mock data
+        expect(screen.getByText(/star/i)).toBeInTheDocument()
+      })
+    })
+  })
+
+  describe('Playlist CRUD', () => {
+    it('create button opens modal', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('default')).toBeInTheDocument()
+      })
+
+      // Find the add button (plus icon in sidebar header)
+      const addButtons = screen.getAllByRole('button')
+      const addButton = addButtons.find(btn => btn.querySelector('.material-icons-outlined')?.textContent?.includes('add'))
+
+      if (addButton) {
+        await user.click(addButton)
+
+        // Dialog should open
+        await waitFor(() => {
+          expect(screen.getByRole('dialog')).toBeInTheDocument()
+        })
+      }
+    })
+
+    it('create playlist calls API', async () => {
+      const user = userEvent.setup()
+      let createCalled = false
+      let createdName = ''
+
+      server.use(
+        http.post('/create_playlist', async ({ request }) => {
+          const body = await request.json() as { name?: string; playlist_name?: string }
+          createCalled = true
+          createdName = body.name || body.playlist_name || ''
+          return HttpResponse.json({ success: true })
+        })
+      )
+
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('default')).toBeInTheDocument()
+      })
+
+      // Find and click add button
+      const addButtons = screen.getAllByRole('button')
+      const addButton = addButtons.find(btn => btn.querySelector('.material-icons-outlined')?.textContent?.includes('add'))
+
+      if (addButton) {
+        await user.click(addButton)
+
+        // Wait for dialog and enter name
+        await waitFor(() => {
+          expect(screen.getByRole('dialog')).toBeInTheDocument()
+        })
+
+        const input = screen.getByRole('textbox')
+        await user.type(input, 'my-new-playlist')
+
+        // Click create button
+        const createButton = screen.getByRole('button', { name: /create/i })
+        await user.click(createButton)
+
+        await waitFor(() => {
+          expect(createCalled).toBe(true)
+          expect(createdName).toBe('my-new-playlist')
+        })
+      }
+    })
+
+    it('edit button opens rename modal', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('default')).toBeInTheDocument()
+      })
+
+      // Find edit button for a playlist (hover actions)
+      const editButtons = screen.getAllByRole('button')
+      const editButton = editButtons.find(btn =>
+        btn.querySelector('.material-icons-outlined')?.textContent?.includes('edit')
+      )
+
+      if (editButton) {
+        await user.click(editButton)
+
+        await waitFor(() => {
+          expect(screen.getByRole('dialog')).toBeInTheDocument()
+        })
+      }
+    })
+
+    it('delete buttons are present for each playlist', async () => {
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('geometric')).toBeInTheDocument()
+      })
+
+      // Find delete buttons (Trash2 icon) - each playlist item should have one
+      const deleteButtons = screen.getAllByRole('button').filter(btn =>
+        btn.classList.contains('text-destructive') || btn.className.includes('text-destructive')
+      )
+
+      // Should have at least one delete button (for each playlist)
+      expect(deleteButtons.length).toBeGreaterThan(0)
+    })
+  })
+
+  describe('Playlist Execution', () => {
+    it('run playlist button triggers API', async () => {
+      const user = userEvent.setup()
+      let runCalled = false
+      let playlistName = ''
+
+      server.use(
+        http.post('/run_playlist', async ({ request }) => {
+          const body = await request.json() as { playlist_name: string }
+          runCalled = true
+          playlistName = body.playlist_name
+          return HttpResponse.json({ success: true })
+        })
+      )
+
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('default')).toBeInTheDocument()
+      })
+
+      // Select a playlist first
+      await user.click(screen.getByText('default'))
+
+      // Wait for content to load and find run/play button
+      await waitFor(async () => {
+        const runButton = screen.getByRole('button', { name: /play|run|start/i })
+        expect(runButton).toBeInTheDocument()
+        await user.click(runButton)
+      })
+
+      await waitFor(() => {
+        expect(runCalled).toBe(true)
+        expect(playlistName).toBe('default')
+      })
+    })
+  })
+})

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

@@ -0,0 +1,269 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { renderWithProviders, screen, waitFor, userEvent } from '../../test/utils'
+import { server } from '../../test/mocks/server'
+import { http, HttpResponse } from 'msw'
+import { TableControlPage } from '../../pages/TableControlPage'
+
+describe('TableControlPage', () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+  })
+
+  describe('Rendering', () => {
+    it('renders page title and description', async () => {
+      renderWithProviders(<TableControlPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('Table Control')).toBeInTheDocument()
+        expect(screen.getByText(/manual controls for your sand table/i)).toBeInTheDocument()
+      })
+    })
+
+    it('renders primary action buttons', async () => {
+      renderWithProviders(<TableControlPage />)
+
+      await waitFor(() => {
+        // Home, Stop, Reset buttons should be visible
+        expect(screen.getByText('Home')).toBeInTheDocument()
+        expect(screen.getByText('Stop')).toBeInTheDocument()
+        expect(screen.getByText('Reset')).toBeInTheDocument()
+      })
+    })
+
+    it('renders position control buttons', async () => {
+      renderWithProviders(<TableControlPage />)
+
+      await waitFor(() => {
+        // Center and Perimeter buttons
+        expect(screen.getByText('Center')).toBeInTheDocument()
+        expect(screen.getByText('Perimeter')).toBeInTheDocument()
+      })
+    })
+
+    it('renders speed control section', async () => {
+      renderWithProviders(<TableControlPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('Speed')).toBeInTheDocument()
+        expect(screen.getByPlaceholderText(/mm\/s/i)).toBeInTheDocument()
+      })
+    })
+  })
+
+  describe('Homing Control', () => {
+    it('home button calls send_home API', async () => {
+      const user = userEvent.setup()
+      let homeCalled = false
+
+      server.use(
+        http.post('/send_home', () => {
+          homeCalled = true
+          return HttpResponse.json({ success: true })
+        })
+      )
+
+      renderWithProviders(<TableControlPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('Home')).toBeInTheDocument()
+      })
+
+      const homeButton = screen.getByText('Home').closest('button')!
+      await user.click(homeButton)
+
+      await waitFor(() => {
+        expect(homeCalled).toBe(true)
+      })
+    })
+  })
+
+  describe('Stop Control', () => {
+    it('stop button calls stop_execution API', async () => {
+      const user = userEvent.setup()
+      let stopCalled = false
+
+      server.use(
+        http.post('/stop_execution', () => {
+          stopCalled = true
+          return HttpResponse.json({ success: true })
+        })
+      )
+
+      renderWithProviders(<TableControlPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('Stop')).toBeInTheDocument()
+      })
+
+      const stopButton = screen.getByText('Stop').closest('button')!
+      await user.click(stopButton)
+
+      await waitFor(() => {
+        expect(stopCalled).toBe(true)
+      })
+    })
+  })
+
+  describe('Reset Control', () => {
+    it('reset button is clickable', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<TableControlPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('Reset')).toBeInTheDocument()
+      })
+
+      const resetButton = screen.getByText('Reset').closest('button')!
+      expect(resetButton).toBeEnabled()
+
+      // Click should not throw
+      await expect(user.click(resetButton)).resolves.not.toThrow()
+    })
+
+    it('reset button triggers dialog trigger', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<TableControlPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('Reset')).toBeInTheDocument()
+      })
+
+      // The Reset button is a DialogTrigger - check its aria attributes
+      const resetButton = screen.getByText('Reset').closest('button')!
+      expect(resetButton).toHaveAttribute('aria-haspopup', 'dialog')
+
+      await user.click(resetButton)
+
+      // After clicking, aria-expanded should change
+      await waitFor(() => {
+        // The button should have triggered the dialog
+        // Note: Radix Dialog renders to a portal, may need to check document.body
+        const dialog = document.querySelector('[role="dialog"]')
+        if (dialog) {
+          expect(dialog).toBeInTheDocument()
+        }
+      })
+    })
+  })
+
+  describe('Movement Controls', () => {
+    it('move to center button calls API', async () => {
+      const user = userEvent.setup()
+      let moveCalled = false
+
+      server.use(
+        http.post('/move_to_center', () => {
+          moveCalled = true
+          return HttpResponse.json({ success: true })
+        })
+      )
+
+      renderWithProviders(<TableControlPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('Center')).toBeInTheDocument()
+      })
+
+      const centerButton = screen.getByText('Center').closest('button')!
+      await user.click(centerButton)
+
+      await waitFor(() => {
+        expect(moveCalled).toBe(true)
+      })
+    })
+
+    it('move to perimeter button calls API', async () => {
+      const user = userEvent.setup()
+      let moveCalled = false
+
+      server.use(
+        http.post('/move_to_perimeter', () => {
+          moveCalled = true
+          return HttpResponse.json({ success: true })
+        })
+      )
+
+      renderWithProviders(<TableControlPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('Perimeter')).toBeInTheDocument()
+      })
+
+      const perimeterButton = screen.getByText('Perimeter').closest('button')!
+      await user.click(perimeterButton)
+
+      await waitFor(() => {
+        expect(moveCalled).toBe(true)
+      })
+    })
+  })
+
+  describe('Speed Control', () => {
+    it('speed input submits to API on Set click', async () => {
+      const user = userEvent.setup()
+      let speedSet: number | null = null
+
+      server.use(
+        http.post('/set_speed', async ({ request }) => {
+          const body = await request.json() as { speed: number }
+          speedSet = body.speed
+          return HttpResponse.json({ success: true })
+        })
+      )
+
+      renderWithProviders(<TableControlPage />)
+
+      await waitFor(() => {
+        expect(screen.getByPlaceholderText(/mm\/s/i)).toBeInTheDocument()
+      })
+
+      const speedInput = screen.getByPlaceholderText(/mm\/s/i)
+      await user.type(speedInput, '250')
+
+      // Find the Set button - it's near the speed input
+      const speedCard = speedInput.closest('.p-6')
+      const setButton = speedCard?.querySelector('button')
+      expect(setButton).toBeTruthy()
+      await user.click(setButton!)
+
+      await waitFor(() => {
+        expect(speedSet).toBe(250)
+      })
+    })
+
+    it('speed input submits on Enter key', async () => {
+      const user = userEvent.setup()
+      let speedSet: number | null = null
+
+      server.use(
+        http.post('/set_speed', async ({ request }) => {
+          const body = await request.json() as { speed: number }
+          speedSet = body.speed
+          return HttpResponse.json({ success: true })
+        })
+      )
+
+      renderWithProviders(<TableControlPage />)
+
+      await waitFor(() => {
+        expect(screen.getByPlaceholderText(/mm\/s/i)).toBeInTheDocument()
+      })
+
+      const speedInput = screen.getByPlaceholderText(/mm\/s/i)
+      await user.type(speedInput, '300{Enter}')
+
+      await waitFor(() => {
+        expect(speedSet).toBe(300)
+      })
+    })
+
+    it('shows speed badge with current speed', async () => {
+      renderWithProviders(<TableControlPage />)
+
+      await waitFor(() => {
+        // The speed badge shows "-- mm/s" when no speed is set
+        expect(screen.getByText(/mm\/s/)).toBeInTheDocument()
+      })
+    })
+  })
+})

+ 20 - 0
frontend/src/__tests__/sample.test.tsx

@@ -0,0 +1,20 @@
+import { render, screen } from '@testing-library/react'
+import { describe, it, expect } from 'vitest'
+
+// Simple component for testing infrastructure
+function SampleComponent({ message }: { message: string }) {
+  return <div data-testid="sample">{message}</div>
+}
+
+describe('Test Infrastructure', () => {
+  it('renders component with React Testing Library', () => {
+    render(<SampleComponent message="Hello Test" />)
+    expect(screen.getByTestId('sample')).toHaveTextContent('Hello Test')
+  })
+
+  it('has jest-dom matchers available', () => {
+    render(<SampleComponent message="Visible" />)
+    expect(screen.getByTestId('sample')).toBeInTheDocument()
+    expect(screen.getByTestId('sample')).toBeVisible()
+  })
+})

+ 1 - 1
frontend/src/components/NowPlayingBar.tsx

@@ -39,7 +39,7 @@ interface PlaybackStatus {
   progress: {
   progress: {
     current: number
     current: number
     total: number
     total: number
-    remaining_time: number
+    remaining_time: number | null
     elapsed_time: number
     elapsed_time: number
     percentage: number
     percentage: number
     last_completed_time?: {
     last_completed_time?: {

+ 436 - 37
frontend/src/components/layout/Layout.tsx

@@ -1,5 +1,5 @@
 import { Outlet, Link, useLocation } from 'react-router-dom'
 import { Outlet, Link, useLocation } from 'react-router-dom'
-import { useEffect, useState, useRef } from 'react'
+import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
 import { toast } from 'sonner'
 import { toast } from 'sonner'
 import { NowPlayingBar } from '@/components/NowPlayingBar'
 import { NowPlayingBar } from '@/components/NowPlayingBar'
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
@@ -66,6 +66,13 @@ export function Layout() {
   const [connectionAttempts, setConnectionAttempts] = useState(0)
   const [connectionAttempts, setConnectionAttempts] = useState(0)
   const wsRef = useRef<WebSocket | null>(null)
   const wsRef = useRef<WebSocket | null>(null)
 
 
+  // Sensor homing failure state
+  const [sensorHomingFailed, setSensorHomingFailed] = useState(false)
+  const [isRecoveringHoming, setIsRecoveringHoming] = useState(false)
+
+  // Update availability
+  const [updateAvailable, setUpdateAvailable] = useState(false)
+
   // Fetch app settings
   // Fetch app settings
   const fetchAppSettings = () => {
   const fetchAppSettings = () => {
     apiClient.get<{ app?: { name?: string; custom_logo?: string } }>('/api/settings')
     apiClient.get<{ app?: { name?: string; custom_logo?: string } }>('/api/settings')
@@ -95,6 +102,17 @@ export function Layout() {
     // Refetch when active table changes
     // Refetch when active table changes
   }, [activeTable?.id])
   }, [activeTable?.id])
 
 
+  // Check for software updates on mount
+  useEffect(() => {
+    apiClient.get<{ update_available?: boolean }>('/api/version')
+      .then((data) => {
+        if (data.update_available) {
+          setUpdateAvailable(true)
+        }
+      })
+      .catch(() => {})
+  }, [activeTable?.id])
+
   // Homing completion countdown timer
   // Homing completion countdown timer
   useEffect(() => {
   useEffect(() => {
     if (!homingJustCompleted || keepHomingLogsOpen) return
     if (!homingJustCompleted || keepHomingLogsOpen) return
@@ -124,6 +142,8 @@ export function Layout() {
   const startYRef = useRef(0)
   const startYRef = useRef(0)
   const startHeightRef = useRef(0)
   const startHeightRef = useRef(0)
 
 
+  const [logSearchQuery, setLogSearchQuery] = useState('')
+
   // Handle drawer resize
   // Handle drawer resize
   const handleResizeStart = (e: React.MouseEvent | React.TouchEvent) => {
   const handleResizeStart = (e: React.MouseEvent | React.TouchEvent) => {
     e.preventDefault()
     e.preventDefault()
@@ -172,6 +192,21 @@ export function Layout() {
   const [currentPlayingFile, setCurrentPlayingFile] = useState<string | null>(null) // Track current file for header button
   const [currentPlayingFile, setCurrentPlayingFile] = useState<string | null>(null) // Track current file for header button
   const wasPlayingRef = useRef<boolean | null>(null) // Track previous playing state (null = first message)
   const wasPlayingRef = useRef<boolean | null>(null) // Track previous playing state (null = first message)
 
 
+  // Draggable Now Playing button state
+  type SnapPosition = 'left' | 'center' | 'right'
+  const [nowPlayingButtonPos, setNowPlayingButtonPos] = useState<SnapPosition>(() => {
+    if (typeof window !== 'undefined') {
+      const saved = localStorage.getItem('nowPlayingButtonPos')
+      if (saved === 'left' || saved === 'center' || saved === 'right') return saved
+    }
+    return 'center'
+  })
+  const [isDraggingButton, setIsDraggingButton] = useState(false)
+  const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 })
+  const buttonRef = useRef<HTMLButtonElement>(null)
+  const dragStartRef = useRef<{ x: number; y: number; buttonX: number } | null>(null)
+  const wasDraggingRef = useRef(false) // Track if a meaningful drag occurred
+
   // Derive isCurrentlyPlaying from currentPlayingFile
   // Derive isCurrentlyPlaying from currentPlayingFile
   const isCurrentlyPlaying = Boolean(currentPlayingFile)
   const isCurrentlyPlaying = Boolean(currentPlayingFile)
 
 
@@ -189,8 +224,12 @@ export function Layout() {
   }, [])
   }, [])
   const [logs, setLogs] = useState<Array<{ timestamp: string; level: string; logger: string; message: string }>>([])
   const [logs, setLogs] = useState<Array<{ timestamp: string; level: string; logger: string; message: string }>>([])
   const [logLevelFilter, setLogLevelFilter] = useState<string>('ALL')
   const [logLevelFilter, setLogLevelFilter] = useState<string>('ALL')
+  const [logsTotal, setLogsTotal] = useState(0)
+  const [logsHasMore, setLogsHasMore] = useState(false)
+  const [isLoadingMoreLogs, setIsLoadingMoreLogs] = useState(false)
   const logsWsRef = useRef<WebSocket | null>(null)
   const logsWsRef = useRef<WebSocket | null>(null)
   const logsContainerRef = useRef<HTMLDivElement>(null)
   const logsContainerRef = useRef<HTMLDivElement>(null)
+  const logsLoadedCountRef = useRef(0) // Track how many logs we've loaded (for offset)
 
 
   // Check device connection status via WebSocket
   // Check device connection status via WebSocket
   // This effect runs once on mount and manages its own reconnection logic
   // This effect runs once on mount and manages its own reconnection logic
@@ -249,13 +288,20 @@ export function Layout() {
               // Detect transition from homing to not homing
               // Detect transition from homing to not homing
               if (wasHomingRef.current && !newIsHoming) {
               if (wasHomingRef.current && !newIsHoming) {
                 // Homing just completed - show completion state with countdown
                 // Homing just completed - show completion state with countdown
-                setHomingJustCompleted(true)
-                setHomingCountdown(5)
-                setHomingDismissed(false)
+                // But not if sensor homing failed (that shows a different dialog)
+                if (!data.data.sensor_homing_failed) {
+                  setHomingJustCompleted(true)
+                  setHomingCountdown(5)
+                  setHomingDismissed(false)
+                }
               }
               }
               wasHomingRef.current = newIsHoming
               wasHomingRef.current = newIsHoming
               setIsHoming(newIsHoming)
               setIsHoming(newIsHoming)
             }
             }
+            // Update sensor homing failure status
+            if (data.data.sensor_homing_failed !== undefined) {
+              setSensorHomingFailed(data.data.sensor_homing_failed)
+            }
             // Auto-open/close Now Playing bar based on playback state
             // Auto-open/close Now Playing bar based on playback state
             // Track current file - this is the most reliable indicator of playback
             // Track current file - this is the most reliable indicator of playback
             const currentFile = data.data.current_file || null
             const currentFile = data.data.current_file || null
@@ -315,6 +361,7 @@ export function Layout() {
         setCurrentPlayingFile(null) // Reset playback state for new table
         setCurrentPlayingFile(null) // Reset playback state for new table
         setIsConnected(false) // Reset connection status until new table reports
         setIsConnected(false) // Reset connection status until new table reports
         setIsBackendConnected(false) // Show connecting state
         setIsBackendConnected(false) // Show connecting state
+        setSensorHomingFailed(false) // Reset sensor homing failure state for new table
         connectWebSocket()
         connectWebSocket()
       }
       }
     })
     })
@@ -348,17 +395,21 @@ export function Layout() {
 
 
     let shouldConnect = true
     let shouldConnect = true
 
 
-    // Fetch initial logs
+    // Fetch initial logs (most recent)
     const fetchInitialLogs = async () => {
     const fetchInitialLogs = async () => {
       try {
       try {
         type LogEntry = { timestamp: string; level: string; logger: string; message: string }
         type LogEntry = { timestamp: string; level: string; logger: string; message: string }
-        const data = await apiClient.get<{ logs: LogEntry[] }>('/api/logs?limit=200')
+        type LogsResponse = { logs: LogEntry[]; total: number; has_more: boolean }
+        const data = await apiClient.get<LogsResponse>('/api/logs?limit=200')
         // Filter out empty/invalid log entries
         // Filter out empty/invalid log entries
         const validLogs = (data.logs || []).filter(
         const validLogs = (data.logs || []).filter(
           (log) => log && log.message && log.message.trim() !== ''
           (log) => log && log.message && log.message.trim() !== ''
         )
         )
         // API returns newest first, reverse to show oldest first (newest at bottom)
         // API returns newest first, reverse to show oldest first (newest at bottom)
         setLogs(validLogs.reverse())
         setLogs(validLogs.reverse())
+        setLogsTotal(data.total || 0)
+        setLogsHasMore(data.has_more || false)
+        logsLoadedCountRef.current = validLogs.length
         // Scroll to bottom after initial load
         // Scroll to bottom after initial load
         setTimeout(() => {
         setTimeout(() => {
           if (logsContainerRef.current) {
           if (logsContainerRef.current) {
@@ -416,18 +467,16 @@ export function Layout() {
           if (!log || !log.message || log.message.trim() === '') {
           if (!log || !log.message || log.message.trim() === '') {
             return
             return
           }
           }
-          setLogs((prev) => {
-            const newLogs = [...prev, log]
-            // Keep only last 500 logs to prevent memory issues
-            if (newLogs.length > 500) {
-              return newLogs.slice(-500)
-            }
-            return newLogs
-          })
-          // Auto-scroll to bottom
+          // Append new log - no limit, lazy loading handles old logs
+          setLogs((prev) => [...prev, log])
+          // Auto-scroll to bottom if user is near the bottom
           setTimeout(() => {
           setTimeout(() => {
             if (logsContainerRef.current) {
             if (logsContainerRef.current) {
-              logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight
+              const { scrollTop, scrollHeight, clientHeight } = logsContainerRef.current
+              // Only auto-scroll if user is within 100px of the bottom
+              if (scrollHeight - scrollTop - clientHeight < 100) {
+                logsContainerRef.current.scrollTop = scrollHeight
+              }
             }
             }
           }, 10)
           }, 10)
         } catch {
         } catch {
@@ -469,14 +518,80 @@ export function Layout() {
     // Also reconnect when active table changes
     // Also reconnect when active table changes
   }, [isLogsOpen, activeTable?.id])
   }, [isLogsOpen, activeTable?.id])
 
 
+  // Load older logs when user scrolls to top (lazy loading)
+  const loadOlderLogs = useCallback(async () => {
+    if (isLoadingMoreLogs || !logsHasMore) return
+
+    setIsLoadingMoreLogs(true)
+    try {
+      type LogEntry = { timestamp: string; level: string; logger: string; message: string }
+      type LogsResponse = { logs: LogEntry[]; total: number; has_more: boolean }
+      const offset = logsLoadedCountRef.current
+      const data = await apiClient.get<LogsResponse>(`/api/logs?limit=100&offset=${offset}`)
+
+      const validLogs = (data.logs || []).filter(
+        (log) => log && log.message && log.message.trim() !== ''
+      )
+
+      if (validLogs.length > 0) {
+        // Prepend older logs (they come newest-first, so reverse them)
+        setLogs((prev) => [...validLogs.reverse(), ...prev])
+        logsLoadedCountRef.current += validLogs.length
+        setLogsHasMore(data.has_more || false)
+        setLogsTotal(data.total || 0)
+
+        // Maintain scroll position after prepending
+        setTimeout(() => {
+          if (logsContainerRef.current) {
+            // Calculate approximate height of new content (rough estimate: 24px per log line)
+            const newContentHeight = validLogs.length * 24
+            logsContainerRef.current.scrollTop = newContentHeight
+          }
+        }, 10)
+      } else {
+        setLogsHasMore(false)
+      }
+    } catch {
+      // Ignore errors
+    } finally {
+      setIsLoadingMoreLogs(false)
+    }
+  }, [isLoadingMoreLogs, logsHasMore])
+
+  // Scroll event handler for lazy loading
+  useEffect(() => {
+    const container = logsContainerRef.current
+    if (!container || !isLogsOpen) return
+
+    const handleScroll = () => {
+      // Load more when scrolled to top (within 50px)
+      if (container.scrollTop < 50 && logsHasMore && !isLoadingMoreLogs) {
+        loadOlderLogs()
+      }
+    }
+
+    container.addEventListener('scroll', handleScroll)
+    return () => container.removeEventListener('scroll', handleScroll)
+  }, [isLogsOpen, logsHasMore, isLoadingMoreLogs, loadOlderLogs])
+
   const handleToggleLogs = () => {
   const handleToggleLogs = () => {
     setIsLogsOpen((prev) => !prev)
     setIsLogsOpen((prev) => !prev)
   }
   }
 
 
-  // Filter logs by level
-  const filteredLogs = logLevelFilter === 'ALL'
-    ? logs
-    : logs.filter((log) => log.level === logLevelFilter)
+  // Filter logs by level and search query
+  const filteredLogs = useMemo(() => {
+    let result = logLevelFilter === 'ALL'
+      ? logs
+      : logs.filter((log) => log.level === logLevelFilter)
+    if (logSearchQuery) {
+      const q = logSearchQuery.toLowerCase()
+      result = result.filter((log) =>
+        log.message?.toLowerCase().includes(q) ||
+        log.logger?.toLowerCase().includes(q)
+      )
+    }
+    return result
+  }, [logs, logLevelFilter, logSearchQuery])
 
 
   // Format timestamp safely
   // Format timestamp safely
   const formatTimestamp = (timestamp: string) => {
   const formatTimestamp = (timestamp: string) => {
@@ -560,6 +675,34 @@ export function Layout() {
     }
     }
   }
   }
 
 
+  // Handle sensor homing recovery
+  const handleSensorHomingRecovery = async (switchToCrashHoming: boolean) => {
+    setIsRecoveringHoming(true)
+    try {
+      const response = await apiClient.post<{
+        success: boolean
+        sensor_homing_failed?: boolean
+        message?: string
+      }>('/recover_sensor_homing', {
+        switch_to_crash_homing: switchToCrashHoming
+      })
+
+      if (response.success) {
+        toast.success(response.message || 'Homing completed successfully')
+        setSensorHomingFailed(false)
+      } else if (response.sensor_homing_failed) {
+        // Sensor homing failed again
+        toast.error(response.message || 'Sensor homing failed again')
+      } else {
+        toast.error(response.message || 'Recovery failed')
+      }
+    } catch {
+      toast.error('Failed to recover from sensor homing failure')
+    } finally {
+      setIsRecoveringHoming(false)
+    }
+  }
+
   // Update document title based on current page
   // Update document title based on current page
   useEffect(() => {
   useEffect(() => {
     const currentNav = navItems.find((item) => item.path === location.pathname)
     const currentNav = navItems.find((item) => item.path === location.pathname)
@@ -882,12 +1025,200 @@ export function Layout() {
     setCacheAllProgress(null)
     setCacheAllProgress(null)
   }
   }
 
 
+  // Now Playing button drag handlers
+  const getSnapPositions = useCallback(() => {
+    const padding = 16
+    const buttonWidth = buttonRef.current?.offsetWidth || 140
+    return {
+      left: padding + buttonWidth / 2,
+      center: window.innerWidth / 2,
+      right: window.innerWidth - padding - buttonWidth / 2,
+    }
+  }, [])
+
+  const handleButtonDragStart = useCallback((clientX: number, clientY: number) => {
+    if (!buttonRef.current) return
+    const rect = buttonRef.current.getBoundingClientRect()
+    const buttonCenterX = rect.left + rect.width / 2
+    dragStartRef.current = { x: clientX, y: clientY, buttonX: buttonCenterX }
+    wasDraggingRef.current = false // Reset drag flag
+    setIsDraggingButton(true)
+    setDragOffset({ x: 0, y: 0 })
+  }, [])
+
+  const handleButtonDragMove = useCallback((clientX: number) => {
+    if (!dragStartRef.current || !isDraggingButton) return
+    const deltaX = clientX - dragStartRef.current.x
+    // Mark as dragging if moved more than 8px (to distinguish from clicks)
+    if (Math.abs(deltaX) > 8) {
+      wasDraggingRef.current = true
+    }
+    setDragOffset({ x: deltaX, y: 0 })
+  }, [isDraggingButton])
+
+  const handleButtonDragEnd = useCallback(() => {
+    if (!dragStartRef.current || !buttonRef.current) {
+      setIsDraggingButton(false)
+      setDragOffset({ x: 0, y: 0 })
+      return
+    }
+
+    // Calculate current position
+    const currentX = dragStartRef.current.buttonX + dragOffset.x
+    const snapPositions = getSnapPositions()
+
+    // Find nearest snap position
+    const distances = {
+      left: Math.abs(currentX - snapPositions.left),
+      center: Math.abs(currentX - snapPositions.center),
+      right: Math.abs(currentX - snapPositions.right),
+    }
+
+    let nearest: SnapPosition = 'center'
+    let minDistance = distances.center
+    if (distances.left < minDistance) {
+      nearest = 'left'
+      minDistance = distances.left
+    }
+    if (distances.right < minDistance) {
+      nearest = 'right'
+    }
+
+    // Update position and persist
+    setNowPlayingButtonPos(nearest)
+    localStorage.setItem('nowPlayingButtonPos', nearest)
+
+    // Reset drag state
+    setIsDraggingButton(false)
+    setDragOffset({ x: 0, y: 0 })
+    dragStartRef.current = null
+  }, [dragOffset.x, getSnapPositions])
+
+  // Mouse drag handlers
+  useEffect(() => {
+    if (!isDraggingButton) return
+
+    const handleMouseMove = (e: MouseEvent) => {
+      e.preventDefault()
+      handleButtonDragMove(e.clientX)
+    }
+
+    const handleMouseUp = () => {
+      handleButtonDragEnd()
+    }
+
+    window.addEventListener('mousemove', handleMouseMove)
+    window.addEventListener('mouseup', handleMouseUp)
+
+    return () => {
+      window.removeEventListener('mousemove', handleMouseMove)
+      window.removeEventListener('mouseup', handleMouseUp)
+    }
+  }, [isDraggingButton, handleButtonDragMove, handleButtonDragEnd])
+
+  // Get button position style
+  const getButtonPositionStyle = useCallback((): React.CSSProperties => {
+    const baseStyle: React.CSSProperties = {
+      bottom: 'calc(4.5rem + env(safe-area-inset-bottom, 0px))',
+    }
+
+    if (isDraggingButton && dragStartRef.current) {
+      // During drag, use transform for smooth movement
+      const snapPositions = getSnapPositions()
+      const startX = snapPositions[nowPlayingButtonPos]
+      return {
+        ...baseStyle,
+        left: startX,
+        transform: `translateX(calc(-50% + ${dragOffset.x}px))`,
+        transition: 'none',
+        cursor: 'grabbing',
+      }
+    }
+
+    // Snapped positions
+    switch (nowPlayingButtonPos) {
+      case 'left':
+        return { ...baseStyle, left: '1rem', transform: 'translateX(0)' }
+      case 'right':
+        return { ...baseStyle, right: '1rem', left: 'auto', transform: 'translateX(0)' }
+      case 'center':
+      default:
+        return { ...baseStyle, left: '50%', transform: 'translateX(-50%)' }
+    }
+  }, [isDraggingButton, dragOffset.x, nowPlayingButtonPos, getSnapPositions])
+
   const cacheAllPercentage = cacheAllProgress?.total
   const cacheAllPercentage = cacheAllProgress?.total
     ? Math.round((cacheAllProgress.completed / cacheAllProgress.total) * 100)
     ? Math.round((cacheAllProgress.completed / cacheAllProgress.total) * 100)
     : 0
     : 0
 
 
   return (
   return (
     <div className="min-h-dvh bg-background flex flex-col">
     <div className="min-h-dvh bg-background flex flex-col">
+      {/* 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">
+          <div className="bg-background rounded-lg shadow-xl w-full max-w-md border border-destructive/30">
+            <div className="p-6">
+              <div className="text-center space-y-4">
+                <div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-destructive/10 mb-2">
+                  <span className="material-icons-outlined text-4xl text-destructive">
+                    error_outline
+                  </span>
+                </div>
+                <h2 className="text-xl font-semibold">Sensor Homing Failed</h2>
+                <p className="text-muted-foreground text-sm">
+                  The sensor homing process could not complete. The limit sensors may not be positioned correctly or may be malfunctioning.
+                </p>
+
+                <div className="bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg text-sm text-left">
+                  <p className="text-amber-600 dark:text-amber-400 font-medium mb-2">
+                    Troubleshooting steps:
+                  </p>
+                  <ul className="text-amber-600 dark:text-amber-400 space-y-1 list-disc list-inside">
+                    <li>Check that the limit sensors are properly connected</li>
+                    <li>Verify the sensor positions are correct</li>
+                    <li>Ensure nothing is blocking the sensor path</li>
+                    <li>Check for loose wiring connections</li>
+                  </ul>
+                </div>
+
+                <p className="text-muted-foreground text-sm">
+                  Connection will not be established until this is resolved.
+                </p>
+
+                {/* Action Buttons */}
+                {!isRecoveringHoming ? (
+                  <div className="flex flex-col gap-2 pt-2">
+                    <Button
+                      onClick={() => handleSensorHomingRecovery(false)}
+                      className="w-full gap-2"
+                    >
+                      <span className="material-icons text-base">refresh</span>
+                      Retry Sensor Homing
+                    </Button>
+                    <Button
+                      variant="secondary"
+                      onClick={() => handleSensorHomingRecovery(true)}
+                      className="w-full gap-2"
+                    >
+                      <span className="material-icons text-base">sync_alt</span>
+                      Switch to Crash Homing
+                    </Button>
+                    <p className="text-xs text-muted-foreground">
+                      Crash homing moves the arm to a physical stop without using sensors.
+                    </p>
+                  </div>
+                ) : (
+                  <div className="flex items-center justify-center gap-2 py-4">
+                    <span className="material-icons-outlined text-primary animate-spin">sync</span>
+                    <span className="text-muted-foreground">Attempting recovery...</span>
+                  </div>
+                )}
+              </div>
+            </div>
+          </div>
+        </div>
+      )}
+
       {/* Cache Progress Blocking Overlay */}
       {/* Cache Progress Blocking Overlay */}
       {cacheProgress?.is_running && (
       {cacheProgress?.is_running && (
         <div className="fixed inset-0 z-50 bg-background/95 backdrop-blur-sm flex flex-col items-center justify-center p-4">
         <div className="fixed inset-0 z-50 bg-background/95 backdrop-blur-sm flex flex-col items-center justify-center p-4">
@@ -1010,7 +1341,8 @@ export function Layout() {
       )}
       )}
 
 
       {/* Backend Connection / Homing Blocking Overlay */}
       {/* Backend Connection / Homing Blocking Overlay */}
-      {(!isBackendConnected || (isHoming && !homingDismissed) || homingJustCompleted) && (
+      {/* Don't show this overlay when sensor homing failed - that has its own dialog */}
+      {!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="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">
           <div className="w-full max-w-2xl space-y-6">
             {/* Status Header */}
             {/* Status Header */}
@@ -1239,6 +1571,14 @@ export function Layout() {
 
 
           {/* Desktop actions */}
           {/* Desktop actions */}
           <div className="hidden md:flex items-center gap-0 ml-2">
           <div className="hidden md:flex items-center gap-0 ml-2">
+            {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">
+                  <span className="material-icons-outlined text-xl">download</span>
+                  <span className="absolute top-1 right-1 w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
+                </span>
+              </Link>
+            )}
             <Popover>
             <Popover>
               <PopoverTrigger asChild>
               <PopoverTrigger asChild>
                 <Button
                 <Button
@@ -1290,6 +1630,14 @@ export function Layout() {
 
 
           {/* Mobile actions */}
           {/* Mobile actions */}
           <div className="flex md:hidden items-center gap-0 ml-2">
           <div className="flex md:hidden items-center gap-0 ml-2">
+            {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">
+                  <span className="material-icons-outlined text-xl">download</span>
+                  <span className="absolute top-1 right-1 w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
+                </span>
+              </Link>
+            )}
             <Popover open={isMobileMenuOpen} onOpenChange={setIsMobileMenuOpen}>
             <Popover open={isMobileMenuOpen} onOpenChange={setIsMobileMenuOpen}>
               <PopoverTrigger asChild>
               <PopoverTrigger asChild>
                 <Button
                 <Button
@@ -1358,17 +1706,16 @@ export function Layout() {
 
 
       {/* Main Content */}
       {/* Main Content */}
       <main
       <main
-        className={`container mx-auto px-4 transition-all duration-300 ${
-          !isLogsOpen && !isNowPlayingOpen ? 'pb-20' :
-          !isLogsOpen && isNowPlayingOpen ? 'pb-80' : ''
-        }`}
+        className="container mx-auto px-4 transition-all duration-300"
         style={{
         style={{
           paddingTop: 'calc(4.5rem + env(safe-area-inset-top, 0px))',
           paddingTop: 'calc(4.5rem + env(safe-area-inset-top, 0px))',
           paddingBottom: isLogsOpen
           paddingBottom: isLogsOpen
             ? isNowPlayingOpen
             ? isNowPlayingOpen
-              ? logsDrawerHeight + 256 + 64 // drawer + now playing + nav
-              : logsDrawerHeight + 64 // drawer + nav
-            : undefined
+              ? `calc(${logsDrawerHeight + 256 + 64}px + env(safe-area-inset-bottom, 0px))` // drawer + now playing + nav + safe area
+              : `calc(${logsDrawerHeight + 64}px + env(safe-area-inset-bottom, 0px))` // drawer + nav + safe area
+            : isNowPlayingOpen
+              ? 'calc(20rem + env(safe-area-inset-bottom, 0px))' // now playing bar + nav + safe area
+              : 'calc(8rem + env(safe-area-inset-bottom, 0px))' // floating pill + nav + safe area
         }}
         }}
       >
       >
         <Outlet />
         <Outlet />
@@ -1406,9 +1753,9 @@ export function Layout() {
             </div>
             </div>
 
 
             {/* Logs Header */}
             {/* Logs Header */}
-            <div className="flex items-center justify-between px-4 py-2 border-b bg-muted/50">
-              <div className="flex items-center gap-3">
-                <span className="text-sm font-medium">Application Logs</span>
+            <div className="flex items-center justify-between px-4 py-2 border-b bg-muted/50 gap-2">
+              <div className="flex items-center gap-2 sm:gap-3 flex-wrap min-w-0">
+                <span className="text-sm font-medium whitespace-nowrap">Application Logs</span>
                 <select
                 <select
                   value={logLevelFilter}
                   value={logLevelFilter}
                   onChange={(e) => setLogLevelFilter(e.target.value)}
                   onChange={(e) => setLogLevelFilter(e.target.value)}
@@ -1420,12 +1767,25 @@ export function Layout() {
                   <option value="WARNING">Warning</option>
                   <option value="WARNING">Warning</option>
                   <option value="ERROR">Error</option>
                   <option value="ERROR">Error</option>
                 </select>
                 </select>
+                <input
+                  type="text"
+                  value={logSearchQuery}
+                  onChange={(e) => setLogSearchQuery(e.target.value)}
+                  placeholder="Search logs..."
+                  className="text-xs bg-background border rounded px-2 py-1 w-28 sm:w-40"
+                />
+                {logSearchQuery && (
+                  <Button variant="ghost" size="icon-sm" onClick={() => setLogSearchQuery('')} className="rounded-full" title="Clear search">
+                    <span className="material-icons-outlined text-sm">close</span>
+                  </Button>
+                )}
                 <span className="text-xs text-muted-foreground">
                 <span className="text-xs text-muted-foreground">
-                  {filteredLogs.length} entries
+                  {filteredLogs.length}{logsTotal > 0 ? ` of ${logsTotal}` : ''} entries
+                  {logsHasMore && <span className="text-primary ml-1">↑ scroll for more</span>}
                 </span>
                 </span>
               </div>
               </div>
 
 
-              <div className="flex items-center gap-1">
+              <div className="flex items-center gap-1 shrink-0">
                 <Button
                 <Button
                   variant="ghost"
                   variant="ghost"
                   size="icon-sm"
                   size="icon-sm"
@@ -1461,6 +1821,19 @@ export function Layout() {
               ref={logsContainerRef}
               ref={logsContainerRef}
               className="h-[calc(100%-40px)] overflow-auto overscroll-contain p-3 font-mono text-xs space-y-0.5"
               className="h-[calc(100%-40px)] overflow-auto overscroll-contain p-3 font-mono text-xs space-y-0.5"
             >
             >
+              {/* Loading indicator for older logs */}
+              {isLoadingMoreLogs && (
+                <div className="flex items-center justify-center gap-2 py-2 text-muted-foreground">
+                  <span className="material-icons-outlined text-sm animate-spin">sync</span>
+                  <span>Loading older logs...</span>
+                </div>
+              )}
+              {/* Load more hint */}
+              {logsHasMore && !isLoadingMoreLogs && (
+                <div className="text-center py-2 text-muted-foreground text-xs">
+                  ↑ Scroll up to load older logs
+                </div>
+              )}
               {filteredLogs.length > 0 ? (
               {filteredLogs.length > 0 ? (
                 filteredLogs.map((log, i) => (
                 filteredLogs.map((log, i) => (
                   <div key={i} className="py-0.5 flex gap-2">
                   <div key={i} className="py-0.5 flex gap-2">
@@ -1486,12 +1859,38 @@ export function Layout() {
         )}
         )}
       </div>
       </div>
 
 
-      {/* Floating Now Playing Button - hidden when Now Playing bar is open */}
+      {/* Floating Now Playing Button - draggable, snaps to left/center/right */}
       {!isNowPlayingOpen && (
       {!isNowPlayingOpen && (
         <button
         <button
-          onClick={() => setIsNowPlayingOpen(true)}
-          className="fixed z-40 left-1/2 -translate-x-1/2 flex items-center gap-2 px-4 py-2 rounded-full bg-card border border-border shadow-lg transition-all hover:shadow-xl hover:scale-105 active:scale-95"
-          style={{ bottom: 'calc(4.5rem + env(safe-area-inset-bottom, 0px))' }}
+          ref={buttonRef}
+          onClick={() => {
+            // Only open if it wasn't a drag (to distinguish click from drag)
+            if (!wasDraggingRef.current) {
+              setIsNowPlayingOpen(true)
+            }
+            wasDraggingRef.current = false
+          }}
+          onMouseDown={(e) => {
+            e.preventDefault()
+            handleButtonDragStart(e.clientX, e.clientY)
+          }}
+          onTouchStart={(e) => {
+            const touch = e.touches[0]
+            handleButtonDragStart(touch.clientX, touch.clientY)
+          }}
+          onTouchMove={(e) => {
+            const touch = e.touches[0]
+            handleButtonDragMove(touch.clientX)
+          }}
+          onTouchEnd={() => {
+            handleButtonDragEnd()
+          }}
+          className={`fixed z-40 flex items-center gap-2 px-4 py-2 rounded-full bg-card border border-border shadow-lg select-none touch-none ${
+            isDraggingButton
+              ? 'cursor-grabbing scale-105 shadow-xl'
+              : 'cursor-grab transition-all duration-300 hover:shadow-xl hover:scale-105 active:scale-95'
+          }`}
+          style={getButtonPositionStyle()}
           aria-label={isCurrentlyPlaying ? 'Now Playing' : 'Not Playing'}
           aria-label={isCurrentlyPlaying ? 'Now Playing' : 'Not Playing'}
         >
         >
           <span className={`material-icons-outlined text-xl ${isCurrentlyPlaying ? 'text-primary' : 'text-muted-foreground'}`}>
           <span className={`material-icons-outlined text-xl ${isCurrentlyPlaying ? 'text-primary' : 'text-muted-foreground'}`}>

+ 0 - 10
frontend/src/components/ui/sonner.tsx

@@ -5,18 +5,12 @@ type ToasterProps = React.ComponentProps<typeof Sonner>
 
 
 const Toaster = ({ ...props }: ToasterProps) => {
 const Toaster = ({ ...props }: ToasterProps) => {
   const [theme, setTheme] = useState<"light" | "dark">("light")
   const [theme, setTheme] = useState<"light" | "dark">("light")
-  const [isStandalone, setIsStandalone] = useState(false)
 
 
   useEffect(() => {
   useEffect(() => {
     // Check initial theme
     // Check initial theme
     const isDark = document.documentElement.classList.contains("dark")
     const isDark = document.documentElement.classList.contains("dark")
     setTheme(isDark ? "dark" : "light")
     setTheme(isDark ? "dark" : "light")
 
 
-    // Check if running as PWA (standalone mode)
-    const standalone = window.matchMedia('(display-mode: standalone)').matches ||
-      (window.navigator as unknown as { standalone?: boolean }).standalone === true
-    setIsStandalone(standalone)
-
     // Watch for theme changes
     // Watch for theme changes
     const observer = new MutationObserver((mutations) => {
     const observer = new MutationObserver((mutations) => {
       mutations.forEach((mutation) => {
       mutations.forEach((mutation) => {
@@ -31,14 +25,10 @@ const Toaster = ({ ...props }: ToasterProps) => {
     return () => observer.disconnect()
     return () => observer.disconnect()
   }, [])
   }, [])
 
 
-  // Use larger offset for PWA to account for Dynamic Island/notch (59px typical + 16px padding)
-  const offset = isStandalone ? 75 : 16
-
   return (
   return (
     <Sonner
     <Sonner
       theme={theme}
       theme={theme}
       className="toaster group"
       className="toaster group"
-      offset={offset}
       toastOptions={{
       toastOptions={{
         classNames: {
         classNames: {
           toast:
           toast:

+ 6 - 1
frontend/src/index.css

@@ -103,6 +103,11 @@ body {
   min-height: 100dvh; /* Use dynamic viewport height for mobile */
   min-height: 100dvh; /* Use dynamic viewport height for mobile */
 }
 }
 
 
+/* Push Sonner toasts below the Dynamic Island / notch in PWA mode */
+[data-sonner-toaster][data-y-position="top"] {
+  top: env(safe-area-inset-top, 0px) !important;
+}
+
 /* Safe area utilities for iOS notch/Dynamic Island/home indicator */
 /* Safe area utilities for iOS notch/Dynamic Island/home indicator */
 .pt-safe {
 .pt-safe {
   padding-top: env(safe-area-inset-top, 0px);
   padding-top: env(safe-area-inset-top, 0px);
@@ -196,7 +201,7 @@ body {
   }
   }
 
 
   [data-now-playing-bar="expanded"] {
   [data-now-playing-bar="expanded"] {
-    height: calc(100vh - 64px - 64px);
+    height: calc(100vh - 64px - 64px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
   }
   }
 }
 }
 
 

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

@@ -28,6 +28,6 @@ export const preExecutionOptions: { value: PreExecution; label: string }[] = [
   { value: 'adaptive', label: 'Adaptive' },
   { value: 'adaptive', label: 'Adaptive' },
   { value: 'clear_from_in', label: 'Clear From Center' },
   { value: 'clear_from_in', label: 'Clear From Center' },
   { value: 'clear_from_out', label: 'Clear From Perimeter' },
   { value: 'clear_from_out', label: 'Clear From Perimeter' },
-  { value: 'clear_sideway', label: 'Clear Sideway' },
+  { value: 'clear_sideway', label: 'Clear Sideways' },
   { value: 'none', label: 'None' },
   { value: 'none', label: 'None' },
 ]
 ]

+ 1 - 1
frontend/src/pages/BrowsePage.tsx

@@ -53,7 +53,7 @@ const preExecutionOptions: { value: PreExecution; label: string }[] = [
   { value: 'adaptive', label: 'Adaptive' },
   { value: 'adaptive', label: 'Adaptive' },
   { value: 'clear_from_in', label: 'Clear From Center' },
   { value: 'clear_from_in', label: 'Clear From Center' },
   { value: 'clear_from_out', label: 'Clear From Perimeter' },
   { value: 'clear_from_out', label: 'Clear From Perimeter' },
-  { value: 'clear_sideway', label: 'Clear Sideway' },
+  { value: 'clear_sideway', label: 'Clear Sideways' },
   { value: 'none', label: 'None' },
   { value: 'none', label: 'None' },
 ]
 ]
 
 

+ 1 - 1
frontend/src/pages/LEDPage.tsx

@@ -389,7 +389,7 @@ export function LEDPage() {
   // WLED iframe view
   // WLED iframe view
   if (ledConfig.provider === 'wled' && ledConfig.wled_ip) {
   if (ledConfig.provider === 'wled' && ledConfig.wled_ip) {
     return (
     return (
-      <div className="flex flex-col w-full h-[calc(100vh-180px)] py-4">
+      <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
         <iframe
           src={`http://${ledConfig.wled_ip}`}
           src={`http://${ledConfig.wled_ip}`}
           className="w-full h-full rounded-lg border border-border"
           className="w-full h-full rounded-lg border border-border"

+ 3 - 2
frontend/src/pages/PlaylistsPage.tsx

@@ -1,5 +1,6 @@
 import { useState, useEffect, useMemo, useCallback, useRef } from 'react'
 import { useState, useEffect, useMemo, useCallback, useRef } from 'react'
 import { toast } from 'sonner'
 import { toast } from 'sonner'
+import { Trash2 } from 'lucide-react'
 import { apiClient } from '@/lib/apiClient'
 import { apiClient } from '@/lib/apiClient'
 import {
 import {
   initPreviewCacheDB,
   initPreviewCacheDB,
@@ -519,7 +520,7 @@ export function PlaylistsPage() {
   }
   }
 
 
   return (
   return (
-    <div className="flex flex-col w-full max-w-5xl mx-auto gap-4 sm:gap-6 py-3 sm:py-6 px-0 sm:px-4 h-[calc(100dvh-11rem)] overflow-hidden">
+    <div className="flex flex-col w-full max-w-5xl mx-auto gap-4 sm:gap-6 py-3 sm:py-6 px-0 sm:px-4 overflow-hidden" style={{ height: 'calc(100dvh - 14rem - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))' }}>
       {/* Page Header */}
       {/* Page Header */}
       <div className="space-y-0.5 sm:space-y-1 shrink-0 pl-1">
       <div className="space-y-0.5 sm:space-y-1 shrink-0 pl-1">
         <h1 className="text-xl font-semibold tracking-tight">Playlists</h1>
         <h1 className="text-xl font-semibold tracking-tight">Playlists</h1>
@@ -602,7 +603,7 @@ export function PlaylistsPage() {
                       handleDeletePlaylist(name)
                       handleDeletePlaylist(name)
                     }}
                     }}
                   >
                   >
-                    <span className="material-icons-outlined text-base">delete</span>
+                    <Trash2 className="h-4 w-4" />
                   </Button>
                   </Button>
                 </div>
                 </div>
               </div>
               </div>

+ 86 - 9
frontend/src/pages/SettingsPage.tsx

@@ -46,6 +46,7 @@ interface Settings {
   angular_offset?: number
   angular_offset?: number
   auto_home_enabled?: boolean
   auto_home_enabled?: boolean
   auto_home_after_patterns?: number
   auto_home_after_patterns?: number
+  hard_reset_theta?: boolean
   // Pattern clearing settings
   // Pattern clearing settings
   clear_pattern_speed?: number
   clear_pattern_speed?: number
   custom_clear_from_in?: string
   custom_clear_from_in?: string
@@ -350,6 +351,7 @@ export function SettingsPage() {
         angular_offset: data.homing?.angular_offset_degrees,
         angular_offset: data.homing?.angular_offset_degrees,
         auto_home_enabled: data.homing?.auto_home_enabled,
         auto_home_enabled: data.homing?.auto_home_enabled,
         auto_home_after_patterns: data.homing?.auto_home_after_patterns,
         auto_home_after_patterns: data.homing?.auto_home_after_patterns,
+        hard_reset_theta: data.homing?.hard_reset_theta,
         // Pattern clearing settings
         // Pattern clearing settings
         clear_pattern_speed: data.patterns?.clear_pattern_speed,
         clear_pattern_speed: data.patterns?.clear_pattern_speed,
         custom_clear_from_in: data.patterns?.custom_clear_from_in,
         custom_clear_from_in: data.patterns?.custom_clear_from_in,
@@ -646,6 +648,7 @@ export function SettingsPage() {
           angular_offset_degrees: settings.angular_offset,
           angular_offset_degrees: settings.angular_offset,
           auto_home_enabled: settings.auto_home_enabled,
           auto_home_enabled: settings.auto_home_enabled,
           auto_home_after_patterns: settings.auto_home_after_patterns,
           auto_home_after_patterns: settings.auto_home_after_patterns,
+          hard_reset_theta: settings.hard_reset_theta,
         },
         },
       })
       })
       toast.success('Homing configuration saved')
       toast.success('Homing configuration saved')
@@ -713,7 +716,7 @@ export function SettingsPage() {
       ...stillSandsSettings,
       ...stillSandsSettings,
       time_slots: [
       time_slots: [
         ...stillSandsSettings.time_slots,
         ...stillSandsSettings.time_slots,
-        { start_time: '22:00', end_time: '06:00', days: 'daily' },
+        { start_time: '22:00', end_time: '06:00', days: 'daily', custom_days: [] },
       ],
       ],
     })
     })
   }
   }
@@ -1087,6 +1090,31 @@ export function SettingsPage() {
               )}
               )}
             </div>
             </div>
 
 
+            {/* Machine Reset on Theta Normalization */}
+            <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">restart_alt</span>
+                    Reset Machine on Theta Normalization
+                  </p>
+                  <p className="text-xs text-muted-foreground mt-1">
+                    Also reset the machine controller when normalizing theta
+                  </p>
+                </div>
+                <Switch
+                  checked={settings.hard_reset_theta || false}
+                  onCheckedChange={(checked) =>
+                    setSettings({ ...settings, hard_reset_theta: checked })
+                  }
+                />
+              </div>
+              <p className="text-xs text-muted-foreground">
+                When disabled (default), theta normalization only adjusts the angle mathematically.
+                When enabled, also resets the machine controller to clear position counters.
+              </p>
+            </div>
+
             <Button
             <Button
               onClick={handleSaveHomingConfig}
               onClick={handleSaveHomingConfig}
               disabled={isLoading === 'homing'}
               disabled={isLoading === 'homing'}
@@ -1630,7 +1658,7 @@ export function SettingsPage() {
               </div>
               </div>
             )}
             )}
 
 
-            <div className="flex gap-3">
+            <div className="flex flex-wrap gap-3">
               <Button
               <Button
                 onClick={handleSaveMqttConfig}
                 onClick={handleSaveMqttConfig}
                 disabled={isLoading === 'mqtt'}
                 disabled={isLoading === 'mqtt'}
@@ -1805,7 +1833,7 @@ export function SettingsPage() {
                         <SelectItem value="adaptive">Adaptive</SelectItem>
                         <SelectItem value="adaptive">Adaptive</SelectItem>
                         <SelectItem value="clear_from_in">Clear From Center</SelectItem>
                         <SelectItem value="clear_from_in">Clear From Center</SelectItem>
                         <SelectItem value="clear_from_out">Clear From Perimeter</SelectItem>
                         <SelectItem value="clear_from_out">Clear From Perimeter</SelectItem>
-                        <SelectItem value="clear_sideway">Clear Sideway</SelectItem>
+                        <SelectItem value="clear_sideway">Clear Sideways</SelectItem>
                         <SelectItem value="random">Random</SelectItem>
                         <SelectItem value="random">Random</SelectItem>
                       </SelectContent>
                       </SelectContent>
                     </Select>
                     </Select>
@@ -2027,8 +2055,8 @@ export function SettingsPage() {
                             </Button>
                             </Button>
                           </div>
                           </div>
 
 
-                          <div className="grid grid-cols-2 gap-3">
-                            <div className="space-y-1.5 min-w-0">
+                          <div className="grid grid-cols-[1fr_1fr] gap-2">
+                            <div className="space-y-1.5 min-w-0 overflow-hidden">
                               <Label className="text-xs">Start Time</Label>
                               <Label className="text-xs">Start Time</Label>
                               <Input
                               <Input
                                 type="time"
                                 type="time"
@@ -2036,10 +2064,10 @@ export function SettingsPage() {
                                 onChange={(e) =>
                                 onChange={(e) =>
                                   updateTimeSlot(index, { start_time: e.target.value })
                                   updateTimeSlot(index, { start_time: e.target.value })
                                 }
                                 }
-                                className="text-xs"
+                                className="text-xs w-full"
                               />
                               />
                             </div>
                             </div>
-                            <div className="space-y-1.5 min-w-0">
+                            <div className="space-y-1.5 min-w-0 overflow-hidden">
                               <Label className="text-xs">End Time</Label>
                               <Label className="text-xs">End Time</Label>
                               <Input
                               <Input
                                 type="time"
                                 type="time"
@@ -2047,7 +2075,7 @@ export function SettingsPage() {
                                 onChange={(e) =>
                                 onChange={(e) =>
                                   updateTimeSlot(index, { end_time: e.target.value })
                                   updateTimeSlot(index, { end_time: e.target.value })
                                 }
                                 }
-                                className="text-xs"
+                                className="text-xs w-full"
                               />
                               />
                             </div>
                             </div>
                           </div>
                           </div>
@@ -2059,6 +2087,7 @@ export function SettingsPage() {
                               onValueChange={(value) =>
                               onValueChange={(value) =>
                                 updateTimeSlot(index, {
                                 updateTimeSlot(index, {
                                   days: value as TimeSlot['days'],
                                   days: value as TimeSlot['days'],
+                                  ...(value !== 'custom' ? { custom_days: [] } : {}),
                                 })
                                 })
                               }
                               }
                             >
                             >
@@ -2073,6 +2102,45 @@ export function SettingsPage() {
                               </SelectContent>
                               </SelectContent>
                             </Select>
                             </Select>
                           </div>
                           </div>
+
+                          {slot.days === 'custom' && (
+                            <div className="space-y-1.5">
+                              <Label className="text-xs">Select Days</Label>
+                              <div className="flex flex-wrap gap-1.5">
+                                {[
+                                  { key: 'monday', label: 'Mon' },
+                                  { key: 'tuesday', label: 'Tue' },
+                                  { key: 'wednesday', label: 'Wed' },
+                                  { key: 'thursday', label: 'Thu' },
+                                  { key: 'friday', label: 'Fri' },
+                                  { key: 'saturday', label: 'Sat' },
+                                  { key: 'sunday', label: 'Sun' },
+                                ].map((day) => {
+                                  const isSelected = slot.custom_days?.includes(day.key)
+                                  return (
+                                    <button
+                                      key={day.key}
+                                      type="button"
+                                      onClick={() => {
+                                        const currentDays = slot.custom_days || []
+                                        const newDays = isSelected
+                                          ? currentDays.filter((d) => d !== day.key)
+                                          : [...currentDays, day.key]
+                                        updateTimeSlot(index, { custom_days: newDays })
+                                      }}
+                                      className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
+                                        isSelected
+                                          ? 'bg-primary text-primary-foreground border-primary'
+                                          : 'bg-background text-muted-foreground border-input hover:bg-accent'
+                                      }`}
+                                    >
+                                      {day.label}
+                                    </button>
+                                  )
+                                })}
+                              </div>
+                            </div>
+                          )}
                         </div>
                         </div>
                       ))}
                       ))}
                     </div>
                     </div>
@@ -2140,7 +2208,16 @@ export function SettingsPage() {
               <div className="flex-1">
               <div className="flex-1">
                 <p className="font-medium">Latest Version</p>
                 <p className="font-medium">Latest Version</p>
                 <p className={`text-sm ${versionInfo?.update_available ? 'text-green-600 dark:text-green-400 font-medium' : 'text-muted-foreground'}`}>
                 <p className={`text-sm ${versionInfo?.update_available ? 'text-green-600 dark:text-green-400 font-medium' : 'text-muted-foreground'}`}>
-                  {versionInfo?.latest ? `v${versionInfo.latest}` : 'Checking...'}
+                  {versionInfo?.latest ? (
+                    <a
+                      href={`https://github.com/tuanchris/dune-weaver/releases/tag/v${versionInfo.latest}`}
+                      target="_blank"
+                      rel="noopener noreferrer"
+                      className="underline underline-offset-2 hover:opacity-80 transition-opacity"
+                    >
+                      v{versionInfo.latest}
+                    </a>
+                  ) : 'Checking...'}
                   {versionInfo?.update_available && ' (Update available!)'}
                   {versionInfo?.update_available && ' (Update available!)'}
                 </p>
                 </p>
               </div>
               </div>

+ 6 - 6
frontend/src/pages/TableControlPage.tsx

@@ -357,10 +357,10 @@ export function TableControlPage() {
     if (!serialConnected || serialLoading) return
     if (!serialConnected || serialLoading) return
 
 
     setSerialLoading(true)
     setSerialLoading(true)
-    addSerialHistory('cmd', '[Ctrl+X] Soft Reset')
+    addSerialHistory('cmd', '[Soft Reset]')
 
 
     try {
     try {
-      // Send Ctrl+X (0x18) - GRBL soft reset command
+      // Send soft reset command (backend auto-detects: $Bye for FluidNC, Ctrl+X for GRBL)
       const data = await apiClient.post<{ responses?: string[]; detail?: string }>('/api/debug-serial/send', { port: selectedSerialPort, command: '\x18' })
       const data = await apiClient.post<{ responses?: string[]; detail?: string }>('/api/debug-serial/send', { port: selectedSerialPort, command: '\x18' })
       if (data.responses && data.responses.length > 0) {
       if (data.responses && data.responses.length > 0) {
         data.responses.forEach((line: string) => addSerialHistory('resp', line))
         data.responses.forEach((line: string) => addSerialHistory('resp', line))
@@ -470,13 +470,13 @@ export function TableControlPage() {
                         </Button>
                         </Button>
                       </DialogTrigger>
                       </DialogTrigger>
                     </TooltipTrigger>
                     </TooltipTrigger>
-                    <TooltipContent>Send Ctrl+X soft reset</TooltipContent>
+                    <TooltipContent>Send soft reset to controller</TooltipContent>
                   </Tooltip>
                   </Tooltip>
                   <DialogContent className="sm:max-w-md">
                   <DialogContent className="sm:max-w-md">
                     <DialogHeader>
                     <DialogHeader>
                       <DialogTitle>Reset Controller?</DialogTitle>
                       <DialogTitle>Reset Controller?</DialogTitle>
                       <DialogDescription>
                       <DialogDescription>
-                        This will send a soft reset (Ctrl+X) to the controller.
+                        This will send a soft reset to the controller.
                       </DialogDescription>
                       </DialogDescription>
                     </DialogHeader>
                     </DialogHeader>
                     <Alert className="flex items-center border-amber-500/50">
                     <Alert className="flex items-center border-amber-500/50">
@@ -736,7 +736,7 @@ export function TableControlPage() {
                       ) : (
                       ) : (
                         <span className="material-icons-outlined text-2xl">swap_horiz</span>
                         <span className="material-icons-outlined text-2xl">swap_horiz</span>
                       )}
                       )}
-                      <span className="text-xs">Clear Sideway</span>
+                      <span className="text-xs">Clear Sideways</span>
                     </Button>
                     </Button>
                   </TooltipTrigger>
                   </TooltipTrigger>
                   <TooltipContent>Clear with side-to-side motion</TooltipContent>
                   <TooltipContent>Clear with side-to-side motion</TooltipContent>
@@ -827,7 +827,7 @@ export function TableControlPage() {
                     variant="secondary"
                     variant="secondary"
                     onClick={handleSerialReset}
                     onClick={handleSerialReset}
                     disabled={serialLoading}
                     disabled={serialLoading}
-                    title="Send Ctrl+X soft reset"
+                    title="Send soft reset to controller"
                   >
                   >
                     <span className="material-icons-outlined sm:mr-1">restart_alt</span>
                     <span className="material-icons-outlined sm:mr-1">restart_alt</span>
                     <span className="hidden sm:inline">Reset</span>
                     <span className="hidden sm:inline">Reset</span>

+ 115 - 0
frontend/src/test/mocks/browser.ts

@@ -0,0 +1,115 @@
+import { vi } from 'vitest'
+
+// Mock IntersectionObserver
+export class MockIntersectionObserver implements IntersectionObserver {
+  callback: IntersectionObserverCallback
+  elements: Set<Element> = new Set()
+
+  // Required IntersectionObserver properties
+  readonly root: Element | Document | null = null
+  readonly rootMargin: string = '0px'
+  readonly thresholds: ReadonlyArray<number> = [0]
+
+  constructor(callback: IntersectionObserverCallback) {
+    this.callback = callback
+  }
+
+  observe(element: Element) {
+    this.elements.add(element)
+    // Immediately trigger as visible for testing
+    this.callback(
+      [{ target: element, isIntersecting: true, intersectionRatio: 1 } as IntersectionObserverEntry],
+      this
+    )
+  }
+
+  unobserve(element: Element) {
+    this.elements.delete(element)
+  }
+
+  disconnect() {
+    this.elements.clear()
+  }
+
+  takeRecords(): IntersectionObserverEntry[] {
+    return []
+  }
+}
+
+// Mock matchMedia
+export function createMockMatchMedia(matches: boolean = false) {
+  return vi.fn().mockImplementation((query: string) => ({
+    matches,
+    media: query,
+    onchange: null,
+    addListener: vi.fn(),
+    removeListener: vi.fn(),
+    addEventListener: vi.fn(),
+    removeEventListener: vi.fn(),
+    dispatchEvent: vi.fn(),
+  }))
+}
+
+// Mock ResizeObserver
+export class MockResizeObserver {
+  callback: ResizeObserverCallback
+
+  constructor(callback: ResizeObserverCallback) {
+    this.callback = callback
+  }
+
+  observe() {}
+  unobserve() {}
+  disconnect() {}
+}
+
+// Setup all browser mocks
+export function setupBrowserMocks() {
+  vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
+  vi.stubGlobal('ResizeObserver', MockResizeObserver)
+  vi.stubGlobal('matchMedia', createMockMatchMedia())
+
+  // Mock canvas context
+  HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue({
+    fillRect: vi.fn(),
+    clearRect: vi.fn(),
+    beginPath: vi.fn(),
+    moveTo: vi.fn(),
+    lineTo: vi.fn(),
+    stroke: vi.fn(),
+    fill: vi.fn(),
+    arc: vi.fn(),
+    drawImage: vi.fn(),
+    save: vi.fn(),
+    restore: vi.fn(),
+    scale: vi.fn(),
+    translate: vi.fn(),
+    rotate: vi.fn(),
+    setTransform: vi.fn(),
+    getImageData: vi.fn().mockReturnValue({ data: new Uint8ClampedArray(4) }),
+    putImageData: vi.fn(),
+    createLinearGradient: vi.fn().mockReturnValue({ addColorStop: vi.fn() }),
+    createRadialGradient: vi.fn().mockReturnValue({ addColorStop: vi.fn() }),
+    measureText: vi.fn().mockReturnValue({ width: 0 }),
+    fillText: vi.fn(),
+    strokeText: vi.fn(),
+  })
+
+  // Mock localStorage
+  const localStorageMock = {
+    store: {} as Record<string, string>,
+    getItem: vi.fn((key: string) => localStorageMock.store[key] || null),
+    setItem: vi.fn((key: string, value: string) => { localStorageMock.store[key] = value }),
+    removeItem: vi.fn((key: string) => { delete localStorageMock.store[key] }),
+    clear: vi.fn(() => { localStorageMock.store = {} }),
+    get length() { return Object.keys(localStorageMock.store).length },
+    key: vi.fn((i: number) => Object.keys(localStorageMock.store)[i] || null),
+  }
+  vi.stubGlobal('localStorage', localStorageMock)
+
+  return { localStorage: localStorageMock }
+}
+
+export function cleanupBrowserMocks() {
+  vi.unstubAllGlobals()
+}

+ 359 - 0
frontend/src/test/mocks/handlers.ts

@@ -0,0 +1,359 @@
+import { http, HttpResponse } from 'msw'
+import type { PatternMetadata, PreviewData } from '@/lib/types'
+
+// ============================================
+// API Call Tracking for Integration Tests
+// ============================================
+
+// Track API calls for integration test verification
+export const apiCallLog: Array<{
+  endpoint: string
+  method: string
+  body?: unknown
+  timestamp: number
+}> = []
+
+export function resetApiCallLog() {
+  apiCallLog.length = 0
+}
+
+// Helper to log API calls
+function logApiCall(endpoint: string, method: string, body?: unknown) {
+  apiCallLog.push({
+    endpoint,
+    method,
+    body,
+    timestamp: Date.now(),
+  })
+}
+
+// ============================================
+// Mock Data Store (mutable for test scenarios)
+// ============================================
+
+export const mockData = {
+  patterns: [
+    { path: 'patterns/star.thr', name: 'star.thr', category: 'geometric', date_modified: Date.now(), coordinates_count: 150 },
+    { path: 'patterns/spiral.thr', name: 'spiral.thr', category: 'organic', date_modified: Date.now() - 86400000, coordinates_count: 200 },
+    { path: 'patterns/wave.thr', name: 'wave.thr', category: 'organic', date_modified: Date.now() - 172800000, coordinates_count: 175 },
+    { path: 'patterns/custom/my_design.thr', name: 'my_design.thr', category: 'custom', date_modified: Date.now() - 259200000, coordinates_count: 100 },
+  ] as PatternMetadata[],
+
+  playlists: {
+    default: ['patterns/star.thr', 'patterns/spiral.thr'],
+    favorites: ['patterns/star.thr'],
+    geometric: ['patterns/star.thr', 'patterns/wave.thr'],
+  } as Record<string, string[]>,
+
+  status: {
+    is_running: false,
+    is_paused: false,
+    current_file: null as string | null,
+    speed: 100,
+    progress: 0,
+    playlist_mode: false,
+    playlist_name: null as string | null,
+    queue: [] as string[],
+    connection_status: 'connected',
+    theta: 0,
+    rho: 0.5,
+  },
+}
+
+// Reset mock data between tests
+export function resetMockData() {
+  mockData.patterns = [
+    { path: 'patterns/star.thr', name: 'star.thr', category: 'geometric', date_modified: Date.now(), coordinates_count: 150 },
+    { path: 'patterns/spiral.thr', name: 'spiral.thr', category: 'organic', date_modified: Date.now() - 86400000, coordinates_count: 200 },
+    { path: 'patterns/wave.thr', name: 'wave.thr', category: 'organic', date_modified: Date.now() - 172800000, coordinates_count: 175 },
+    { path: 'patterns/custom/my_design.thr', name: 'my_design.thr', category: 'custom', date_modified: Date.now() - 259200000, coordinates_count: 100 },
+  ]
+  mockData.playlists = {
+    default: ['patterns/star.thr', 'patterns/spiral.thr'],
+    favorites: ['patterns/star.thr'],
+    geometric: ['patterns/star.thr', 'patterns/wave.thr'],
+  }
+  mockData.status = {
+    is_running: false,
+    is_paused: false,
+    current_file: null,
+    speed: 100,
+    progress: 0,
+    playlist_mode: false,
+    playlist_name: null,
+    queue: [],
+    connection_status: 'connected',
+    theta: 0,
+    rho: 0.5,
+  }
+}
+
+// ============================================
+// Handlers
+// ============================================
+
+export const handlers = [
+  // ----------------
+  // Pattern Endpoints
+  // ----------------
+  http.get('/list_theta_rho_files', () => {
+    return HttpResponse.json(mockData.patterns.map(p => ({ name: p.name, path: p.path })))
+  }),
+
+  http.get('/list_theta_rho_files_with_metadata', () => {
+    return HttpResponse.json(mockData.patterns)
+  }),
+
+  http.post('/preview_thr_batch', async ({ request }) => {
+    const body = await request.json() as { files?: string[]; file_names?: string[] }
+    const files = body.files || body.file_names || []
+    const previews: Record<string, PreviewData> = {}
+    for (const file of files) {
+      previews[file] = {
+        image_data: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
+        first_coordinate: { x: 0, y: 0 },
+        last_coordinate: { x: 100, y: 100 },
+      }
+    }
+    return HttpResponse.json(previews)
+  }),
+
+  http.post('/get_theta_rho_coordinates', async () => {
+    // Return mock coordinates for pattern preview
+    return HttpResponse.json({
+      coordinates: Array.from({ length: 50 }, (_, i) => ({
+        theta: i * 7.2,
+        rho: 0.5 + Math.sin(i * 0.2) * 0.3,
+      })),
+    })
+  }),
+
+  http.post('/run_theta_rho', async ({ request }) => {
+    const body = await request.json() as { file_name?: string; file?: string; pre_execution?: string }
+    const file = body.file_name || body.file
+    logApiCall('/run_theta_rho', 'POST', body)
+    mockData.status.is_running = true
+    mockData.status.current_file = file || null
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/delete_theta_rho_file', async ({ request }) => {
+    const body = await request.json() as { file_path: string }
+    mockData.patterns = mockData.patterns.filter(p => p.path !== body.file_path)
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/upload_theta_rho', async () => {
+    return HttpResponse.json({ success: true, path: 'patterns/custom/uploaded.thr' })
+  }),
+
+  http.get('/api/pattern_history_all', () => {
+    return HttpResponse.json({})
+  }),
+
+  http.get('/api/pattern_history/:path', () => {
+    return HttpResponse.json({ executions: [] })
+  }),
+
+  // ----------------
+  // Playlist Endpoints
+  // ----------------
+  http.get('/list_all_playlists', () => {
+    return HttpResponse.json(Object.keys(mockData.playlists))
+  }),
+
+  http.get('/get_playlist', ({ request }) => {
+    const url = new URL(request.url)
+    const name = url.searchParams.get('name')
+    if (name && mockData.playlists[name]) {
+      return HttpResponse.json({ name, files: mockData.playlists[name] })
+    }
+    return HttpResponse.json({ name: name || '', files: [] })
+  }),
+
+  http.post('/create_playlist', async ({ request }) => {
+    const body = await request.json() as { name: string; playlist_name?: string; files?: string[] }
+    const name = body.playlist_name || body.name
+    logApiCall('/create_playlist', 'POST', body)
+    mockData.playlists[name] = body.files || []
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/modify_playlist', async ({ request }) => {
+    const body = await request.json() as { name?: string; playlist_name?: string; files: string[] }
+    const name = body.playlist_name || body.name || ''
+    logApiCall('/modify_playlist', 'POST', body)
+    if (mockData.playlists[name]) {
+      mockData.playlists[name] = body.files
+    }
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/rename_playlist', async ({ request }) => {
+    const body = await request.json() as { old_name: string; new_name: string }
+    logApiCall('/rename_playlist', 'POST', body)
+    if (mockData.playlists[body.old_name]) {
+      mockData.playlists[body.new_name] = mockData.playlists[body.old_name]
+      delete mockData.playlists[body.old_name]
+    }
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.delete('/delete_playlist', async ({ request }) => {
+    const body = await request.json() as { name?: string; playlist_name?: string }
+    const name = body.playlist_name || body.name || ''
+    logApiCall('/delete_playlist', 'DELETE', body)
+    delete mockData.playlists[name]
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/run_playlist', async ({ request }) => {
+    const body = await request.json() as { name?: string; playlist_name?: string }
+    const name = body.playlist_name || body.name || ''
+    logApiCall('/run_playlist', 'POST', body)
+    const playlist = mockData.playlists[name]
+    if (playlist && playlist.length > 0) {
+      mockData.status.is_running = true
+      mockData.status.playlist_mode = true
+      mockData.status.playlist_name = name
+      mockData.status.current_file = playlist[0]
+      mockData.status.queue = playlist.slice(1)
+    }
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/reorder_playlist', async ({ request }) => {
+    const body = await request.json() as { from_index: number; to_index: number }
+    // Reorder the current queue
+    const queue = [...mockData.status.queue]
+    const [item] = queue.splice(body.from_index, 1)
+    queue.splice(body.to_index, 0, item)
+    mockData.status.queue = queue
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/add_to_playlist', async ({ request }) => {
+    const body = await request.json() as { playlist_name: string; file_path: string }
+    if (!mockData.playlists[body.playlist_name]) {
+      mockData.playlists[body.playlist_name] = []
+    }
+    mockData.playlists[body.playlist_name].push(body.file_path)
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/add_to_queue', async ({ request }) => {
+    const body = await request.json() as { file: string; position?: 'next' | 'end' }
+    if (body.position === 'next') {
+      mockData.status.queue.unshift(body.file)
+    } else {
+      mockData.status.queue.push(body.file)
+    }
+    return HttpResponse.json({ success: true })
+  }),
+
+  // ----------------
+  // Playback Control Endpoints
+  // ----------------
+  http.post('/pause_execution', () => {
+    logApiCall('/pause_execution', 'POST')
+    mockData.status.is_paused = true
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/resume_execution', () => {
+    logApiCall('/resume_execution', 'POST')
+    mockData.status.is_paused = false
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/stop_execution', () => {
+    logApiCall('/stop_execution', 'POST')
+    mockData.status.is_running = false
+    mockData.status.is_paused = false
+    mockData.status.current_file = null
+    mockData.status.playlist_mode = false
+    mockData.status.playlist_name = null
+    mockData.status.queue = []
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/force_stop', () => {
+    mockData.status.is_running = false
+    mockData.status.is_paused = false
+    mockData.status.current_file = null
+    mockData.status.playlist_mode = false
+    mockData.status.queue = []
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/skip_pattern', () => {
+    logApiCall('/skip_pattern', 'POST')
+    if (mockData.status.queue.length > 0) {
+      mockData.status.current_file = mockData.status.queue.shift() || null
+    } else {
+      mockData.status.is_running = false
+      mockData.status.current_file = null
+    }
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/set_speed', async ({ request }) => {
+    const body = await request.json() as { speed: number }
+    mockData.status.speed = body.speed
+    return HttpResponse.json({ success: true })
+  }),
+
+  // ----------------
+  // Table Control Endpoints
+  // ----------------
+  http.post('/send_home', () => {
+    mockData.status.theta = 0
+    mockData.status.rho = 0
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/soft_reset', () => {
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/move_to_center', () => {
+    mockData.status.rho = 0
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/move_to_perimeter', () => {
+    mockData.status.rho = 1
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/send_coordinate', async ({ request }) => {
+    const body = await request.json() as { theta: number; rho: number }
+    mockData.status.theta = body.theta
+    mockData.status.rho = body.rho
+    return HttpResponse.json({ success: true })
+  }),
+
+  // ----------------
+  // Status Endpoints
+  // ----------------
+  http.get('/serial_status', () => {
+    return HttpResponse.json(mockData.status)
+  }),
+
+  http.get('/list_serial_ports', () => {
+    return HttpResponse.json(['/dev/ttyUSB0', '/dev/ttyUSB1'])
+  }),
+
+  // Debug serial endpoints
+  http.post('/api/debug-serial/open', () => {
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/api/debug-serial/close', () => {
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/api/debug-serial/send', () => {
+    return HttpResponse.json({ success: true, response: 'OK' })
+  }),
+]

+ 4 - 0
frontend/src/test/mocks/server.ts

@@ -0,0 +1,4 @@
+import { setupServer } from 'msw/node'
+import { handlers } from './handlers'
+
+export const server = setupServer(...handlers)

+ 74 - 0
frontend/src/test/mocks/websocket.ts

@@ -0,0 +1,74 @@
+import { vi } from 'vitest'
+
+type MessageHandler = (event: MessageEvent) => void
+
+export class MockWebSocket {
+  static instances: MockWebSocket[] = []
+
+  url: string
+  readyState: number = WebSocket.CONNECTING
+  onopen: (() => void) | null = null
+  onclose: (() => void) | null = null
+  onmessage: MessageHandler | null = null
+  onerror: ((error: Event) => void) | null = null
+
+  constructor(url: string) {
+    this.url = url
+    MockWebSocket.instances.push(this)
+
+    // Simulate connection after microtask
+    setTimeout(() => {
+      this.readyState = WebSocket.OPEN
+      this.onopen?.()
+    }, 0)
+  }
+
+  send(_data: string) {
+    // Mock implementation - can be extended to handle specific messages
+  }
+
+  close() {
+    this.readyState = WebSocket.CLOSED
+    this.onclose?.()
+  }
+
+  // Helper to simulate receiving a message
+  simulateMessage(data: unknown) {
+    if (this.onmessage) {
+      const event = new MessageEvent('message', {
+        data: JSON.stringify(data),
+      })
+      this.onmessage(event)
+    }
+  }
+
+  // Helper to simulate connection error
+  simulateError() {
+    if (this.onerror) {
+      this.onerror(new Event('error'))
+    }
+  }
+
+  static CONNECTING = 0
+  static OPEN = 1
+  static CLOSING = 2
+  static CLOSED = 3
+}
+
+// Install mock WebSocket globally
+export function setupMockWebSocket() {
+  MockWebSocket.instances = []
+  vi.stubGlobal('WebSocket', MockWebSocket)
+}
+
+// Get the most recent WebSocket instance
+export function getLastWebSocket(): MockWebSocket | undefined {
+  return MockWebSocket.instances[MockWebSocket.instances.length - 1]
+}
+
+// Clean up mock WebSocket
+export function cleanupMockWebSocket() {
+  MockWebSocket.instances.forEach(ws => ws.close())
+  MockWebSocket.instances = []
+  vi.unstubAllGlobals()
+}

+ 36 - 0
frontend/src/test/setup.ts

@@ -0,0 +1,36 @@
+import '@testing-library/jest-dom/vitest'
+import { cleanup } from '@testing-library/react'
+import { afterAll, afterEach, beforeAll, beforeEach } from 'vitest'
+import { setupBrowserMocks, cleanupBrowserMocks } from './mocks/browser'
+import { setupMockWebSocket, cleanupMockWebSocket } from './mocks/websocket'
+import { server } from './mocks/server'
+import { resetMockData, resetApiCallLog } from './mocks/handlers'
+
+// Setup browser mocks FIRST (before MSW starts)
+// This ensures WebSocket mock is in place before MSW tries to intercept
+beforeAll(() => {
+  setupBrowserMocks()
+  setupMockWebSocket()
+  // Use 'warn' instead of 'error' to avoid failing on WebSocket requests
+  // that are handled by our mock WebSocket, not MSW
+  server.listen({ onUnhandledRequest: 'warn' })
+})
+
+// Reset state between tests
+beforeEach(() => {
+  resetMockData()
+  resetApiCallLog()
+})
+
+// Cleanup after each test
+afterEach(() => {
+  cleanup()
+  server.resetHandlers()
+})
+
+// Teardown after all tests
+afterAll(() => {
+  server.close()
+  cleanupMockWebSocket()
+  cleanupBrowserMocks()
+})

+ 113 - 0
frontend/src/test/utils.tsx

@@ -0,0 +1,113 @@
+import { render } from '@testing-library/react'
+import type { RenderOptions } from '@testing-library/react'
+import { BrowserRouter, MemoryRouter, Routes, Route } from 'react-router'
+import type { ReactElement, ReactNode } from 'react'
+import type { PatternMetadata, PreviewData } from '@/lib/types'
+import { TableProvider } from '@/contexts/TableContext'
+import { Layout } from '@/components/layout/Layout'
+import { BrowsePage } from '@/pages/BrowsePage'
+import { PlaylistsPage } from '@/pages/PlaylistsPage'
+import { TableControlPage } from '@/pages/TableControlPage'
+
+// Wrapper component with required providers
+function AllProviders({ children }: { children: ReactNode }) {
+  return <BrowserRouter>{children}</BrowserRouter>
+}
+
+// Integration test wrapper - full app with routing
+export function IntegrationWrapper({
+  children,
+  initialEntries = ['/']
+}: {
+  children: ReactNode
+  initialEntries?: string[]
+}) {
+  return (
+    <MemoryRouter initialEntries={initialEntries}>
+      <TableProvider>
+        {children}
+      </TableProvider>
+    </MemoryRouter>
+  )
+}
+
+// Custom render that includes providers
+export function renderWithProviders(
+  ui: ReactElement,
+  options?: Omit<RenderOptions, 'wrapper'>
+) {
+  return render(ui, { wrapper: AllProviders, ...options })
+}
+
+// Mock data generators
+export function createMockPatterns(count: number = 5): PatternMetadata[] {
+  return Array.from({ length: count }, (_, i) => ({
+    path: `patterns/pattern${i + 1}.thr`,
+    name: `pattern${i + 1}.thr`,
+    category: i % 2 === 0 ? 'geometric' : 'organic',
+    date_modified: Date.now() - i * 86400000, // Each day older
+    coordinates_count: 100 + i * 50,
+  }))
+}
+
+export function createMockPreview(): PreviewData {
+  // 1x1 transparent PNG as base64
+  return {
+    image_data: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
+    first_coordinate: { x: 0, y: 0 },
+    last_coordinate: { x: 100, y: 100 },
+  }
+}
+
+export function createMockStatus(overrides: Partial<{
+  is_running: boolean
+  is_paused: boolean
+  current_file: string | null
+  speed: number
+  progress: number
+  playlist_mode: boolean
+  playlist_name: string | null
+  queue: string[]
+}> = {}) {
+  return {
+    is_running: false,
+    is_paused: false,
+    current_file: null,
+    speed: 100,
+    progress: 0,
+    playlist_mode: false,
+    playlist_name: null,
+    queue: [],
+    connection_status: 'connected',
+    ...overrides,
+  }
+}
+
+export function createMockPlaylists(): string[] {
+  return ['default', 'favorites', 'geometric', 'relaxing']
+}
+
+// Render full app for integration tests
+export function renderApp(options?: {
+  initialRoute?: string
+}) {
+  const initialEntries = options?.initialRoute ? [options.initialRoute] : ['/']
+
+  return render(
+    <MemoryRouter initialEntries={initialEntries}>
+      <TableProvider>
+        <Routes>
+          <Route path="/" element={<Layout />}>
+            <Route index element={<BrowsePage />} />
+            <Route path="playlists" element={<PlaylistsPage />} />
+            <Route path="table-control" element={<TableControlPage />} />
+          </Route>
+        </Routes>
+      </TableProvider>
+    </MemoryRouter>
+  )
+}
+
+// Re-export everything from testing-library
+export * from '@testing-library/react'
+export { default as userEvent } from '@testing-library/user-event'

+ 1 - 1
frontend/tsconfig.app.json

@@ -5,7 +5,7 @@
     "useDefineForClassFields": true,
     "useDefineForClassFields": true,
     "lib": ["ES2022", "DOM", "DOM.Iterable"],
     "lib": ["ES2022", "DOM", "DOM.Iterable"],
     "module": "ESNext",
     "module": "ESNext",
-    "types": ["vite/client"],
+    "types": ["vite/client", "vitest/globals"],
     "skipLibCheck": true,
     "skipLibCheck": true,
 
 
     /* Bundler mode */
     /* Bundler mode */

+ 2 - 1
frontend/vite.config.ts

@@ -137,12 +137,12 @@ export default defineConfig({
       '/move_to_perimeter': 'http://localhost:8080',
       '/move_to_perimeter': 'http://localhost:8080',
       // Speed
       // Speed
       '/set_speed': 'http://localhost:8080',
       '/set_speed': 'http://localhost:8080',
-      '/get_speed': 'http://localhost:8080',
       // Connection
       // Connection
       '/serial_status': 'http://localhost:8080',
       '/serial_status': 'http://localhost:8080',
       '/list_serial_ports': 'http://localhost:8080',
       '/list_serial_ports': 'http://localhost:8080',
       '/connect': 'http://localhost:8080',
       '/connect': 'http://localhost:8080',
       '/disconnect': 'http://localhost:8080',
       '/disconnect': 'http://localhost:8080',
+      '/recover_sensor_homing': 'http://localhost:8080',
       // Patterns
       // Patterns
       '/list_theta_rho_files': 'http://localhost:8080',
       '/list_theta_rho_files': 'http://localhost:8080',
       '/list_theta_rho_files_with_metadata': 'http://localhost:8080',
       '/list_theta_rho_files_with_metadata': 'http://localhost:8080',
@@ -150,6 +150,7 @@ export default defineConfig({
       '/preview_thr_batch': 'http://localhost:8080',
       '/preview_thr_batch': 'http://localhost:8080',
       '/get_theta_rho_coordinates': 'http://localhost:8080',
       '/get_theta_rho_coordinates': 'http://localhost:8080',
       '/delete_theta_rho_file': 'http://localhost:8080',
       '/delete_theta_rho_file': 'http://localhost:8080',
+      '/upload_theta_rho': 'http://localhost:8080',
       // Playlists
       // Playlists
       '/list_all_playlists': 'http://localhost:8080',
       '/list_all_playlists': 'http://localhost:8080',
       '/get_playlist': 'http://localhost:8080',
       '/get_playlist': 'http://localhost:8080',

+ 21 - 0
frontend/vitest.config.ts

@@ -0,0 +1,21 @@
+/// <reference types="vitest" />
+import { defineConfig, mergeConfig } from 'vitest/config'
+import viteConfig from './vite.config'
+
+export default mergeConfig(
+  viteConfig,
+  defineConfig({
+    test: {
+      globals: true,
+      environment: 'jsdom',
+      setupFiles: ['./src/test/setup.ts'],
+      include: ['src/**/*.{test,spec}.{ts,tsx}'],
+      coverage: {
+        provider: 'v8',
+        reporter: ['text', 'json', 'html'],
+        include: ['src/**/*.{ts,tsx}'],
+        exclude: ['src/**/*.{test,spec}.{ts,tsx}', 'src/test/**'],
+      },
+    },
+  })
+)

+ 227 - 25
main.py

@@ -1,11 +1,10 @@
 from fastapi import FastAPI, UploadFile, File, HTTPException, BackgroundTasks, WebSocket, WebSocketDisconnect, Request
 from fastapi import FastAPI, UploadFile, File, HTTPException, BackgroundTasks, WebSocket, WebSocketDisconnect, Request
-from fastapi.responses import JSONResponse, FileResponse, Response
+from fastapi.responses import JSONResponse, FileResponse
 from fastapi.staticfiles import StaticFiles
 from fastapi.staticfiles import StaticFiles
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.templating import Jinja2Templates
 from fastapi.templating import Jinja2Templates
 from pydantic import BaseModel
 from pydantic import BaseModel
-from typing import List, Optional, Tuple, Dict, Any, Union
-import atexit
+from typing import List, Optional
 import os
 import os
 import logging
 import logging
 from datetime import datetime, time
 from datetime import datetime, time
@@ -47,7 +46,8 @@ logging.basicConfig(
 )
 )
 
 
 # Initialize memory log handler for web UI log viewer
 # Initialize memory log handler for web UI log viewer
-init_memory_handler(max_entries=500)
+# Increased to 5000 entries to support lazy loading in the UI
+init_memory_handler(max_entries=5000)
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -116,6 +116,13 @@ async def lifespan(app: FastAPI):
                     success = await asyncio.to_thread(connection_manager.home)
                     success = await asyncio.to_thread(connection_manager.home)
                     if not success:
                     if not success:
                         logger.warning("Background homing failed or was skipped")
                         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:
                 finally:
                     state.is_homing = False
                     state.is_homing = False
                     logger.info("Background homing completed")
                     logger.info("Background homing completed")
@@ -407,6 +414,7 @@ class HomingSettingsUpdate(BaseModel):
     angular_offset_degrees: Optional[float] = None
     angular_offset_degrees: Optional[float] = None
     auto_home_enabled: Optional[bool] = None
     auto_home_enabled: Optional[bool] = None
     auto_home_after_patterns: Optional[int] = None
     auto_home_after_patterns: Optional[int] = None
+    hard_reset_theta: Optional[bool] = None  # Enable hard reset ($Bye) when resetting theta
 
 
 class DwLedSettingsUpdate(BaseModel):
 class DwLedSettingsUpdate(BaseModel):
     num_leds: Optional[int] = None
     num_leds: Optional[int] = None
@@ -572,26 +580,32 @@ async def websocket_logs_endpoint(websocket: WebSocket):
 
 
 # API endpoint to retrieve logs
 # API endpoint to retrieve logs
 @app.get("/api/logs", tags=["logs"])
 @app.get("/api/logs", tags=["logs"])
-async def get_logs(limit: int = 100, level: str = None):
+async def get_logs(limit: int = 100, level: str = None, offset: int = 0):
     """
     """
-    Retrieve application logs from memory buffer.
+    Retrieve application logs from memory buffer with pagination.
 
 
     Args:
     Args:
-        limit: Maximum number of log entries to return (default: 100, max: 500)
+        limit: Maximum number of log entries to return (default: 100)
         level: Filter by log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
         level: Filter by log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
+        offset: Number of entries to skip from newest (for lazy loading older logs)
 
 
     Returns:
     Returns:
         List of log entries with timestamp, level, logger, and message.
         List of log entries with timestamp, level, logger, and message.
+        Also returns total count and whether there are more logs available.
     """
     """
     handler = get_memory_handler()
     handler = get_memory_handler()
     if not handler:
     if not handler:
-        return {"logs": [], "error": "Log handler not initialized"}
+        return {"logs": [], "count": 0, "total": 0, "has_more": False, "error": "Log handler not initialized"}
 
 
-    # Clamp limit to reasonable range
-    limit = max(1, min(limit, 500))
+    # Clamp limit to reasonable range (no max limit for lazy loading)
+    limit = max(1, limit)
+    offset = max(0, offset)
 
 
-    logs = handler.get_logs(limit=limit, level=level)
-    return {"logs": logs, "count": len(logs)}
+    logs = handler.get_logs(limit=limit, level=level, offset=offset)
+    total = handler.get_total_count(level=level)
+    has_more = offset + len(logs) < total
+
+    return {"logs": logs, "count": len(logs), "total": total, "has_more": has_more}
 
 
 
 
 @app.delete("/api/logs", tags=["logs"])
 @app.delete("/api/logs", tags=["logs"])
@@ -662,7 +676,8 @@ async def get_all_settings():
             "user_override": state.homing_user_override,  # True if user explicitly set, False if auto-detected
             "user_override": state.homing_user_override,  # True if user explicitly set, False if auto-detected
             "angular_offset_degrees": state.angular_homing_offset_degrees,
             "angular_offset_degrees": state.angular_homing_offset_degrees,
             "auto_home_enabled": state.auto_home_enabled,
             "auto_home_enabled": state.auto_home_enabled,
-            "auto_home_after_patterns": state.auto_home_after_patterns
+            "auto_home_after_patterns": state.auto_home_after_patterns,
+            "hard_reset_theta": state.hard_reset_theta  # Enable hard reset when resetting theta
         },
         },
         "led": {
         "led": {
             "provider": state.led_provider,
             "provider": state.led_provider,
@@ -743,6 +758,18 @@ async def get_dynamic_manifest():
                 "sizes": "512x512",
                 "sizes": "512x512",
                 "type": "image/png",
                 "type": "image/png",
                 "purpose": "any"
                 "purpose": "any"
+            },
+            {
+                "src": f"{icon_base}/android-chrome-192x192.png",
+                "sizes": "192x192",
+                "type": "image/png",
+                "purpose": "maskable"
+            },
+            {
+                "src": f"{icon_base}/android-chrome-512x512.png",
+                "sizes": "512x512",
+                "type": "image/png",
+                "purpose": "maskable"
             }
             }
         ],
         ],
         "start_url": "/",
         "start_url": "/",
@@ -844,6 +871,8 @@ async def update_settings(settings_update: SettingsUpdate):
             state.auto_home_enabled = h.auto_home_enabled
             state.auto_home_enabled = h.auto_home_enabled
         if h.auto_home_after_patterns is not None:
         if h.auto_home_after_patterns is not None:
             state.auto_home_after_patterns = h.auto_home_after_patterns
             state.auto_home_after_patterns = h.auto_home_after_patterns
+        if h.hard_reset_theta is not None:
+            state.hard_reset_theta = h.hard_reset_theta
         updated_categories.append("homing")
         updated_categories.append("homing")
 
 
     # LED settings
     # LED settings
@@ -1863,6 +1892,77 @@ async def send_home():
         logger.error(f"Failed to send home command: {str(e)}")
         logger.error(f"Failed to send home command: {str(e)}")
         raise HTTPException(status_code=500, detail=str(e))
         raise HTTPException(status_code=500, detail=str(e))
 
 
+class SensorHomingRecoveryRequest(BaseModel):
+    switch_to_crash_homing: bool = False
+
+@app.post("/recover_sensor_homing")
+async def recover_sensor_homing(request: SensorHomingRecoveryRequest):
+    """
+    Recover from sensor homing failure.
+
+    If switch_to_crash_homing is True, changes homing mode to crash homing (mode 0)
+    and saves the setting. Then attempts to reconnect and home the device.
+
+    If switch_to_crash_homing is False, just clears the failure flag and retries
+    with sensor homing.
+    """
+    try:
+        # Clear the sensor homing failure flag first
+        state.sensor_homing_failed = False
+
+        if request.switch_to_crash_homing:
+            # Switch to crash homing mode
+            logger.info("Switching to crash homing mode per user request")
+            state.homing = 0
+            state.homing_user_override = True
+            state.save()
+
+        # If already connected, just perform homing
+        if state.conn and state.conn.is_connected():
+            logger.info("Device already connected, performing homing...")
+            state.is_homing = True
+            try:
+                success = await asyncio.to_thread(connection_manager.home)
+                if not success:
+                    # Check if sensor homing failed again
+                    if state.sensor_homing_failed:
+                        return {
+                            "success": False,
+                            "sensor_homing_failed": True,
+                            "message": "Sensor homing failed again. Please check sensor position or switch to crash homing."
+                        }
+                    return {"success": False, "message": "Homing failed"}
+                return {"success": True, "message": "Homing completed successfully"}
+            finally:
+                state.is_homing = False
+        else:
+            # Need to reconnect
+            logger.info("Reconnecting device and performing homing...")
+            state.is_homing = True
+            try:
+                # connect_device includes homing
+                await asyncio.to_thread(connection_manager.connect_device, True)
+
+                # Check if sensor homing failed during connection
+                if state.sensor_homing_failed:
+                    return {
+                        "success": False,
+                        "sensor_homing_failed": True,
+                        "message": "Sensor homing failed. Please check sensor position or switch to crash homing."
+                    }
+
+                if state.conn and state.conn.is_connected():
+                    return {"success": True, "message": "Connected and homed successfully"}
+                else:
+                    return {"success": False, "message": "Failed to establish connection"}
+            finally:
+                state.is_homing = False
+
+    except Exception as e:
+        logger.error(f"Error during sensor homing recovery: {e}")
+        state.is_homing = False
+        raise HTTPException(status_code=500, detail=str(e))
+
 @app.post("/run_theta_rho_file/{file_name}")
 @app.post("/run_theta_rho_file/{file_name}")
 async def run_specific_theta_rho_file(file_name: str):
 async def run_specific_theta_rho_file(file_name: str):
     file_path = os.path.join(pattern_manager.THETA_RHO_DIR, file_name)
     file_path = os.path.join(pattern_manager.THETA_RHO_DIR, file_name)
@@ -1930,6 +2030,12 @@ async def move_to_center():
         logger.info("Moving device to center position")
         logger.info("Moving device to center position")
         await pattern_manager.reset_theta()
         await pattern_manager.reset_theta()
         await pattern_manager.move_polar(0, 0)
         await pattern_manager.move_polar(0, 0)
+
+        # Wait for machine to reach idle before returning
+        idle = await connection_manager.check_idle_async(timeout=60)
+        if not idle:
+            logger.warning("Machine did not reach idle after move to center")
+
         return {"success": True}
         return {"success": True}
     except HTTPException:
     except HTTPException:
         raise
         raise
@@ -1949,8 +2055,15 @@ async def move_to_perimeter():
         # Clear stop_requested to ensure manual move works after pattern stop
         # Clear stop_requested to ensure manual move works after pattern stop
         state.stop_requested = False
         state.stop_requested = False
 
 
+        logger.info("Moving device to perimeter position")
         await pattern_manager.reset_theta()
         await pattern_manager.reset_theta()
         await pattern_manager.move_polar(0, 1)
         await pattern_manager.move_polar(0, 1)
+
+        # Wait for machine to reach idle before returning
+        idle = await connection_manager.check_idle_async(timeout=60)
+        if not idle:
+            logger.warning("Machine did not reach idle after move to perimeter")
+
         return {"success": True}
         return {"success": True}
     except HTTPException:
     except HTTPException:
         raise
         raise
@@ -2130,6 +2243,12 @@ async def send_coordinate(request: CoordinateRequest):
     try:
     try:
         logger.debug(f"Sending coordinate: theta={request.theta}, rho={request.rho}")
         logger.debug(f"Sending coordinate: theta={request.theta}, rho={request.rho}")
         await pattern_manager.move_polar(request.theta, request.rho)
         await pattern_manager.move_polar(request.theta, request.rho)
+
+        # Wait for machine to reach idle before returning
+        idle = await connection_manager.check_idle_async(timeout=60)
+        if not idle:
+            logger.warning("Machine did not reach idle after send_coordinate")
+
         return {"success": True}
         return {"success": True}
     except Exception as e:
     except Exception as e:
         logger.error(f"Failed to send coordinate: {str(e)}")
         logger.error(f"Failed to send coordinate: {str(e)}")
@@ -2303,13 +2422,15 @@ async def set_speed(request: SpeedRequest):
         if not (state.conn.is_connected() if state.conn else False):
         if not (state.conn.is_connected() if state.conn else False):
             logger.warning("Attempted to change speed without a connection")
             logger.warning("Attempted to change speed without a connection")
             raise HTTPException(status_code=400, detail="Connection not established")
             raise HTTPException(status_code=400, detail="Connection not established")
-        
+
         if request.speed <= 0:
         if request.speed <= 0:
             logger.warning(f"Invalid speed value received: {request.speed}")
             logger.warning(f"Invalid speed value received: {request.speed}")
             raise HTTPException(status_code=400, detail="Invalid speed value")
             raise HTTPException(status_code=400, detail="Invalid speed value")
-        
+
         state.speed = request.speed
         state.speed = request.speed
         return {"success": True, "speed": request.speed}
         return {"success": True, "speed": request.speed}
+    except HTTPException:
+        raise  # Re-raise HTTPException as-is
     except Exception as e:
     except Exception as e:
         logger.error(f"Failed to set speed: {str(e)}")
         logger.error(f"Failed to set speed: {str(e)}")
         raise HTTPException(status_code=500, detail=str(e))
         raise HTTPException(status_code=500, detail=str(e))
@@ -2492,6 +2613,20 @@ async def skip_pattern():
     if not state.current_playlist:
     if not state.current_playlist:
         raise HTTPException(status_code=400, detail="No playlist is currently running")
         raise HTTPException(status_code=400, detail="No playlist is currently running")
     state.skip_requested = True
     state.skip_requested = True
+
+    # If the playlist task isn't running (e.g., cancelled by TestClient),
+    # proactively advance state. Otherwise, let the running task handle it
+    # to avoid race conditions with the task's index management.
+    from modules.core import playlist_manager
+    task = playlist_manager._current_playlist_task
+    task_not_running = task is None or task.done()
+
+    if task_not_running and state.current_playlist_index is not None:
+        next_index = state.current_playlist_index + 1
+        if next_index < len(state.current_playlist):
+            state.current_playlist_index = next_index
+            state.current_playing_file = state.current_playlist[next_index]
+
     return {"success": True}
     return {"success": True}
 
 
 @app.post("/reorder_playlist")
 @app.post("/reorder_playlist")
@@ -2678,7 +2813,61 @@ async def set_app_name(request: dict):
 
 
 CUSTOM_BRANDING_DIR = os.path.join("static", "custom")
 CUSTOM_BRANDING_DIR = os.path.join("static", "custom")
 ALLOWED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}
 ALLOWED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}
-MAX_LOGO_SIZE = 5 * 1024 * 1024  # 5MB
+MAX_LOGO_SIZE = 10 * 1024 * 1024  # 10MB
+MAX_LOGO_DIMENSION = 512  # Max width/height for optimized logo
+
+
+def optimize_logo_image(content: bytes, original_ext: str) -> tuple[bytes, str]:
+    """Optimize logo image by resizing and converting to WebP.
+
+    Args:
+        content: Original image bytes
+        original_ext: Original file extension (e.g., '.png', '.jpg')
+
+    Returns:
+        Tuple of (optimized_bytes, new_extension)
+
+    For SVG files, returns the original content unchanged.
+    For raster images, resizes to MAX_LOGO_DIMENSION and converts to WebP.
+    """
+    # SVG files are already lightweight vectors - keep as-is
+    if original_ext.lower() == ".svg":
+        return content, original_ext
+
+    try:
+        from PIL import Image
+        import io
+
+        with Image.open(io.BytesIO(content)) as img:
+            # Convert to RGBA for transparency support
+            if img.mode in ('P', 'LA') or (img.mode == 'RGBA' and 'transparency' in img.info):
+                img = img.convert('RGBA')
+            elif img.mode != 'RGBA':
+                img = img.convert('RGB')
+
+            # Resize if larger than max dimension (maintain aspect ratio)
+            width, height = img.size
+            if width > MAX_LOGO_DIMENSION or height > MAX_LOGO_DIMENSION:
+                ratio = min(MAX_LOGO_DIMENSION / width, MAX_LOGO_DIMENSION / height)
+                new_size = (int(width * ratio), int(height * ratio))
+                img = img.resize(new_size, Image.Resampling.LANCZOS)
+                logger.info(f"Logo resized from {width}x{height} to {new_size[0]}x{new_size[1]}")
+
+            # Save as WebP with good quality/size balance
+            output = io.BytesIO()
+            img.save(output, format='WEBP', quality=85, method=6)
+            optimized_bytes = output.getvalue()
+
+            original_size = len(content)
+            new_size = len(optimized_bytes)
+            reduction = ((original_size - new_size) / original_size) * 100
+            logger.info(f"Logo optimized: {original_size:,} bytes -> {new_size:,} bytes ({reduction:.1f}% reduction)")
+
+            return optimized_bytes, ".webp"
+
+    except Exception as e:
+        logger.warning(f"Logo optimization failed, using original: {str(e)}")
+        return content, original_ext
 
 
 def generate_favicon_from_logo(logo_path: str, output_dir: str) -> bool:
 def generate_favicon_from_logo(logo_path: str, output_dir: str) -> bool:
     """Generate circular favicons with transparent background from the uploaded logo.
     """Generate circular favicons with transparent background from the uploaded logo.
@@ -2746,6 +2935,8 @@ def generate_pwa_icons_from_logo(logo_path: str, output_dir: str) -> bool:
     """Generate square PWA app icons from the uploaded logo.
     """Generate square PWA app icons from the uploaded logo.
 
 
     Creates square icons (no circular crop) - OS will apply its own mask.
     Creates square icons (no circular crop) - OS will apply its own mask.
+    Composites onto a solid background to avoid transparency issues
+    (iOS fills transparent areas with white on home screen icons).
 
 
     Generates:
     Generates:
     - apple-touch-icon.png (180x180)
     - apple-touch-icon.png (180x180)
@@ -2778,8 +2969,12 @@ def generate_pwa_icons_from_logo(logo_path: str, output_dir: str) -> bool:
 
 
             for filename, size in icon_sizes.items():
             for filename, size in icon_sizes.items():
                 resized = img.resize((size, size), Image.Resampling.LANCZOS)
                 resized = img.resize((size, size), Image.Resampling.LANCZOS)
+                # Composite onto solid background to eliminate transparency
+                # (iOS shows white behind transparent areas on home screen)
+                background = Image.new('RGB', (size, size), (10, 10, 10))  # #0a0a0a theme color
+                background.paste(resized, (0, 0), resized)  # Use resized as its own alpha mask
                 icon_path = os.path.join(output_dir, filename)
                 icon_path = os.path.join(output_dir, filename)
-                resized.save(icon_path, format='PNG')
+                background.save(icon_path, format='PNG')
                 logger.info(f"Generated PWA icon: {filename}")
                 logger.info(f"Generated PWA icon: {filename}")
 
 
         return True
         return True
@@ -2792,10 +2987,14 @@ async def upload_logo(file: UploadFile = File(...)):
     """Upload a custom logo image.
     """Upload a custom logo image.
 
 
     Supported formats: PNG, JPG, JPEG, GIF, WebP, SVG
     Supported formats: PNG, JPG, JPEG, GIF, WebP, SVG
-    Maximum size: 5MB
+    Maximum upload size: 10MB
+
+    Images are automatically optimized:
+    - Resized to max 512x512 pixels
+    - Converted to WebP format for smaller file size
+    - SVG files are kept as-is (already lightweight)
 
 
-    The uploaded file will be stored and used as the application logo.
-    A favicon will be automatically generated from the logo.
+    A favicon and PWA icons will be automatically generated from the logo.
     """
     """
     try:
     try:
         # Validate file extension
         # Validate file extension
@@ -2827,19 +3026,22 @@ async def upload_logo(file: UploadFile = File(...)):
             if os.path.exists(old_favicon_path):
             if os.path.exists(old_favicon_path):
                 os.remove(old_favicon_path)
                 os.remove(old_favicon_path)
 
 
+        # Optimize the image (resize + convert to WebP for smaller file size)
+        optimized_content, optimized_ext = optimize_logo_image(content, file_ext)
+
         # Generate a unique filename to prevent caching issues
         # Generate a unique filename to prevent caching issues
         import uuid
         import uuid
-        filename = f"logo-{uuid.uuid4().hex[:8]}{file_ext}"
+        filename = f"logo-{uuid.uuid4().hex[:8]}{optimized_ext}"
         file_path = os.path.join(CUSTOM_BRANDING_DIR, filename)
         file_path = os.path.join(CUSTOM_BRANDING_DIR, filename)
 
 
-        # Save the logo file
+        # Save the optimized logo file
         with open(file_path, "wb") as f:
         with open(file_path, "wb") as f:
-            f.write(content)
+            f.write(optimized_content)
 
 
         # Generate favicon and PWA icons from logo (for non-SVG files)
         # Generate favicon and PWA icons from logo (for non-SVG files)
         favicon_generated = False
         favicon_generated = False
         pwa_icons_generated = False
         pwa_icons_generated = False
-        if file_ext != ".svg":
+        if optimized_ext != ".svg":
             favicon_generated = generate_favicon_from_logo(file_path, CUSTOM_BRANDING_DIR)
             favicon_generated = generate_favicon_from_logo(file_path, CUSTOM_BRANDING_DIR)
             pwa_icons_generated = generate_pwa_icons_from_logo(file_path, CUSTOM_BRANDING_DIR)
             pwa_icons_generated = generate_pwa_icons_from_logo(file_path, CUSTOM_BRANDING_DIR)
 
 
@@ -3162,7 +3364,7 @@ async def preview_thr_batch(request: dict):
     # Convert results to dictionary
     # Convert results to dictionary
     results = dict(file_results)
     results = dict(file_results)
 
 
-    logger.info(f"Total batch processing time: {time.time() - start:.2f}s for {len(file_names)} files")
+    logger.debug(f"Total batch processing time: {time.time() - start:.2f}s for {len(file_names)} files")
     return JSONResponse(content=results, headers=headers)
     return JSONResponse(content=results, headers=headers)
 
 
 @app.get("/playlists")
 @app.get("/playlists")

+ 204 - 106
modules/connection/connection_manager.py

@@ -7,14 +7,13 @@ import websocket
 import asyncio
 import asyncio
 import os
 import os
 
 
-from modules.core import pattern_manager
 from modules.core.state import state
 from modules.core.state import state
 from modules.led.led_interface import LEDInterface
 from modules.led.led_interface import LEDInterface
 from modules.led.idle_timeout_manager import idle_timeout_manager
 from modules.led.idle_timeout_manager import idle_timeout_manager
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
-IGNORE_PORTS = ['/dev/cu.debug-console', '/dev/cu.Bluetooth-Incoming-Port']
+IGNORE_PORTS = ['/dev/cu.debug-console', '/dev/cu.Bluetooth-Incoming-Port', '/dev/ttyS0']
 
 
 # Ports to deprioritize during auto-connect (shown in UI but not auto-selected)
 # Ports to deprioritize during auto-connect (shown in UI but not auto-selected)
 DEPRIORITIZED_PORTS = ['/dev/ttyS0']
 DEPRIORITIZED_PORTS = ['/dev/ttyS0']
@@ -113,7 +112,7 @@ class SerialConnection(BaseConnection):
         # Schedule async position update if event loop exists, otherwise skip
         # Schedule async position update if event loop exists, otherwise skip
         # This avoids creating nested event loops which causes RuntimeError
         # This avoids creating nested event loops which causes RuntimeError
         try:
         try:
-            loop = asyncio.get_running_loop()
+            asyncio.get_running_loop()
             # We're in async context - schedule as task (fire-and-forget)
             # We're in async context - schedule as task (fire-and-forget)
             asyncio.create_task(update_machine_position())
             asyncio.create_task(update_machine_position())
             logger.debug("Scheduled async machine position update")
             logger.debug("Scheduled async machine position update")
@@ -176,7 +175,7 @@ class WebSocketConnection(BaseConnection):
         # Schedule async position update if event loop exists, otherwise skip
         # Schedule async position update if event loop exists, otherwise skip
         # This avoids creating nested event loops which causes RuntimeError
         # This avoids creating nested event loops which causes RuntimeError
         try:
         try:
-            loop = asyncio.get_running_loop()
+            asyncio.get_running_loop()
             # We're in async context - schedule as task (fire-and-forget)
             # We're in async context - schedule as task (fire-and-forget)
             asyncio.create_task(update_machine_position())
             asyncio.create_task(update_machine_position())
             logger.debug("Scheduled async machine position update")
             logger.debug("Scheduled async machine position update")
@@ -188,6 +187,7 @@ class WebSocketConnection(BaseConnection):
         with self.lock:
         with self.lock:
             if self.ws:
             if self.ws:
                 self.ws.close()
                 self.ws.close()
+                self.ws = None
 
 
 def list_serial_ports():
 def list_serial_ports():
     """Return a list of available serial ports."""
     """Return a list of available serial ports."""
@@ -197,10 +197,9 @@ def list_serial_ports():
     return available_ports
     return available_ports
 
 
 def device_init(homing=True):
 def device_init(homing=True):
-    # Perform soft reset first to ensure controller is in a known state
-    # This resets position counters to 0 before we query them
-    logger.info("Performing soft reset before device initialization...")
-    perform_soft_reset_sync()
+    # IMPORTANT: Query machine position BEFORE reset to determine if homing is needed
+    # If machine wasn't power cycled, it retains position and we can skip homing
+    # Reset ($Bye) zeroes position counters, so we must check BEFORE reset
 
 
     try:
     try:
         if get_machine_steps():
         if get_machine_steps():
@@ -209,28 +208,46 @@ def device_init(homing=True):
             logger.fatal("Failed to get machine steps")
             logger.fatal("Failed to get machine steps")
             state.conn.close()
             state.conn.close()
             return False
             return False
-    except:
+    except Exception:
         logger.fatal("Not GRBL firmware")
         logger.fatal("Not GRBL firmware")
         state.conn.close()
         state.conn.close()
         return False
         return False
 
 
-    # Reset work coordinate offsets for a clean start
-    # This ensures we're using work coordinates (G54) starting from 0
-    reset_work_coordinates()
-
+    # Check machine position BEFORE reset to decide if homing is needed
     machine_x, machine_y = get_machine_position()
     machine_x, machine_y = get_machine_position()
+    needs_homing = False
+
     if machine_x != state.machine_x or machine_y != state.machine_y:
     if machine_x != state.machine_x or machine_y != state.machine_y:
-        logger.info(f'x, y; {machine_x}, {machine_y}')
-        logger.info(f'State x, y; {state.machine_x}, {state.machine_y}')
-        if homing:
-            success = home()
-            if not success:
-                logger.error("Homing failed during device initialization")
+        logger.info(f'Machine position mismatch - machine: ({machine_x}, {machine_y}), saved: ({state.machine_x}, {state.machine_y})')
+        needs_homing = homing
     else:
     else:
-        logger.info('Machine position known, skipping home')
+        logger.info('Machine position matches saved state, skipping home')
         logger.info(f'Theta: {state.current_theta}, rho: {state.current_rho}')
         logger.info(f'Theta: {state.current_theta}, rho: {state.current_rho}')
-        logger.info(f'x, y; {machine_x}, {machine_y}')
-        logger.info(f'State x, y; {state.machine_x}, {state.machine_y}')
+        logger.info(f'Position: ({machine_x}, {machine_y})')
+
+    # Now perform soft reset to ensure controller is in a clean state
+    # This clears any pending commands and resets position counters to 0
+    logger.info("Performing soft reset for clean controller state...")
+    perform_soft_reset_sync()
+    time.sleep(1)  # Extra stabilization after controller restart
+
+    # Reset work coordinate offsets for a clean start
+    # This ensures we're using work coordinates (G54) starting from 0
+    reset_work_coordinates()
+
+    # Home if position was mismatched (machine may have been power cycled)
+    if needs_homing:
+        logger.info("Homing required due to position mismatch...")
+        success = home()
+        if not success:
+            logger.error("Homing failed during device initialization")
+            # If sensor homing failed, close connection and return False
+            # This prevents auto-connection from completing until user takes action
+            if state.sensor_homing_failed:
+                logger.error("Sensor homing failed - closing connection. User must check sensor or switch to crash homing.")
+                state.conn.close()
+                state.conn = None
+                return False
 
 
     time.sleep(2)  # Allow time for the connection to establish
     time.sleep(2)  # Allow time for the connection to establish
     return True
     return True
@@ -489,7 +506,7 @@ async def send_grbl_coordinates(x, y, speed=600, timeout=30, home=False):
             while time.time() - response_start < response_timeout:
             while time.time() - response_start < response_timeout:
                 # Check overall timeout
                 # Check overall timeout
                 if time.time() - overall_start_time > timeout:
                 if time.time() - overall_start_time > timeout:
-                    logger.error(f"Overall timeout waiting for 'ok' response")
+                    logger.error("Overall timeout waiting for 'ok' response")
                     return False
                     return False
 
 
                 response = await asyncio.to_thread(state.conn.readline)
                 response = await asyncio.to_thread(state.conn.readline)
@@ -700,7 +717,6 @@ def _get_steps_fluidnc():
                         try:
                         try:
                             homing_cycle = int(float(response.split('=')[1].strip()))
                             homing_cycle = int(float(response.split('=')[1].strip()))
                             # cycle >= 1 means homing is enabled in firmware
                             # cycle >= 1 means homing is enabled in firmware
-                            firmware_homing = 1 if homing_cycle >= 1 else 0
                             logger.info(f"Firmware homing setting (cycle): {homing_cycle}, using user preference: {state.homing}")
                             logger.info(f"Firmware homing setting (cycle): {homing_cycle}, using user preference: {state.homing}")
                         except (ValueError, IndexError):
                         except (ValueError, IndexError):
                             pass
                             pass
@@ -836,14 +852,12 @@ def get_machine_steps(timeout=10):
         time.sleep(0.2)
         time.sleep(0.2)
         ready_check_attempts = 5
         ready_check_attempts = 5
         controller_ready = False
         controller_ready = False
-        in_alarm = False
         for _ in range(ready_check_attempts):
         for _ in range(ready_check_attempts):
             if state.conn.in_waiting() > 0:
             if state.conn.in_waiting() > 0:
                 response = state.conn.readline()
                 response = state.conn.readline()
                 if response and ('<' in response or 'Idle' in response or 'Alarm' in response):
                 if response and ('<' in response or 'Idle' in response or 'Alarm' in response):
                     controller_ready = True
                     controller_ready = True
                     if 'Alarm' in response:
                     if 'Alarm' in response:
-                        in_alarm = True
                         logger.info(f"Controller in ALARM state (likely limit switch active), proceeding with settings query: {response.strip()}")
                         logger.info(f"Controller in ALARM state (likely limit switch active), proceeding with settings query: {response.strip()}")
                     else:
                     else:
                         logger.debug(f"Controller ready, status: {response}")
                         logger.debug(f"Controller ready, status: {response}")
@@ -891,7 +905,7 @@ def get_machine_steps(timeout=10):
             state.table_type = 'dune_weaver_mini'
             state.table_type = 'dune_weaver_mini'
         elif y_steps_per_mm == 210 and x_steps_per_mm == 256:
         elif y_steps_per_mm == 210 and x_steps_per_mm == 256:
             state.table_type = 'dune_weaver_mini_pro_byj'
             state.table_type = 'dune_weaver_mini_pro_byj'
-        elif y_steps_per_mm == 270 and x_steps_per_mm == 200:
+        elif (y_steps_per_mm == 270 or y_steps_per_mm == 250) and x_steps_per_mm == 200:
             state.table_type = 'dune_weaver_gold'
             state.table_type = 'dune_weaver_gold'
         elif y_steps_per_mm == 287:
         elif y_steps_per_mm == 287:
             state.table_type = 'dune_weaver'
             state.table_type = 'dune_weaver'
@@ -929,12 +943,14 @@ def get_machine_steps(timeout=10):
         return True
         return True
     else:
     else:
         missing = []
         missing = []
-        if x_steps_per_mm is None: missing.append("X steps/mm")
-        if y_steps_per_mm is None: missing.append("Y steps/mm")
+        if x_steps_per_mm is None:
+            missing.append("X steps/mm")
+        if y_steps_per_mm is None:
+            missing.append("Y steps/mm")
         logger.error(f"Failed to get all machine parameters after {timeout}s. Missing: {', '.join(missing)}")
         logger.error(f"Failed to get all machine parameters after {timeout}s. Missing: {', '.join(missing)}")
         return False
         return False
 
 
-def home(timeout=90):
+def home(timeout=120):
     """
     """
     Perform homing sequence based on configured mode:
     Perform homing sequence based on configured mode:
 
 
@@ -949,7 +965,8 @@ def home(timeout=90):
         - Set theta to compass offset, rho=0
         - Set theta to compass offset, rho=0
 
 
     Args:
     Args:
-        timeout: Maximum time in seconds to wait for homing to complete (default: 90)
+        timeout: Maximum time in seconds to wait for homing to complete (default: 120)
+                 Increased from 90s to allow buffer after soft reset recovery
     """
     """
     import threading
     import threading
     import math
     import math
@@ -978,12 +995,20 @@ def home(timeout=90):
                 state.homed_x = False
                 state.homed_x = False
                 state.homed_y = False
                 state.homed_y = False
 
 
+                # Clear any stale data from previous operations
+                try:
+                    while state.conn.in_waiting() > 0:
+                        stale = state.conn.readline()
+                        logger.debug(f"Cleared stale data before homing: {stale}")
+                except Exception:
+                    pass
+
                 # Send $H command
                 # Send $H command
                 state.conn.send("$H\n")
                 state.conn.send("$H\n")
                 logger.info("Sent $H command, waiting for homing messages...")
                 logger.info("Sent $H command, waiting for homing messages...")
 
 
                 # Wait for [MSG:Homed:X] and [MSG:Homed:Y] messages
                 # Wait for [MSG:Homed:X] and [MSG:Homed:Y] messages
-                max_wait_time = 30  # 30 seconds timeout for homing messages
+                max_wait_time = 60  # 60 seconds - boot recovery needs more time
                 start_time = time.time()
                 start_time = time.time()
 
 
                 while (time.time() - start_time) < max_wait_time:
                 while (time.time() - start_time) < max_wait_time:
@@ -1024,10 +1049,51 @@ def home(timeout=90):
                     homing_complete.set()
                     homing_complete.set()
                     return
                     return
 
 
-                # Skip zeroing if X homed but Y failed - moving Y to 0 would crash it
-                # (Y controls rho/radial position which is unknown if Y didn't home)
+                # If X homed but Y failed, fallback to crash homing for Y
                 if state.homed_x and not state.homed_y:
                 if state.homed_x and not state.homed_y:
-                    logger.warning("Skipping position zeroing - X homed but Y failed (would crash Y axis)")
+                    logger.warning("Sensor homing incomplete (Y failed) - falling back to crash homing")
+
+                    # Perform crash homing as fallback
+                    logger.info(f"Executing crash homing fallback at {homing_speed} mm/min")
+
+                    loop = asyncio.new_event_loop()
+                    asyncio.set_event_loop(loop)
+                    try:
+                        if effective_table_type == 'dune_weaver_mini':
+                            result = loop.run_until_complete(send_grbl_coordinates(0, -30, homing_speed, home=True))
+                            if not result:
+                                logger.error("Crash homing fallback failed")
+                                homing_complete.set()
+                                return
+                        else:
+                            result = loop.run_until_complete(send_grbl_coordinates(0, -22, homing_speed, home=True))
+                            if not result:
+                                logger.error("Crash homing fallback failed")
+                                homing_complete.set()
+                                return
+                    finally:
+                        loop.close()
+
+                    # Wait for idle after crash homing
+                    logger.info("Waiting for device to reach idle state after crash homing fallback...")
+                    idle_reached = check_idle()
+                    if not idle_reached:
+                        logger.error("Device did not reach idle state after crash homing fallback")
+                        homing_complete.set()
+                        return
+
+                    # Set position like crash homing does
+                    state.current_theta = 0
+                    state.current_rho = 0
+                    logger.info("Crash homing fallback completed - theta=0, rho=0")
+
+                elif not state.homed_x and not state.homed_y:
+                    # Neither axis homed - this is a failure, don't proceed
+                    # Set sensor_homing_failed flag to notify UI for user action
+                    logger.error("Sensor homing failed - neither axis homed. User action required.")
+                    state.sensor_homing_failed = True
+                    homing_complete.set()
+                    return
                 else:
                 else:
                     # Send x0 y0 to zero both positions using send_grbl_coordinates
                     # Send x0 y0 to zero both positions using send_grbl_coordinates
                     logger.info(f"Zeroing positions with x0 y0 f{homing_speed}")
                     logger.info(f"Zeroing positions with x0 y0 f{homing_speed}")
@@ -1038,7 +1104,7 @@ def home(timeout=90):
                     try:
                     try:
                         # Send G1 X0 Y0 F{homing_speed}
                         # Send G1 X0 Y0 F{homing_speed}
                         result = loop.run_until_complete(send_grbl_coordinates(0, 0, homing_speed))
                         result = loop.run_until_complete(send_grbl_coordinates(0, 0, homing_speed))
-                        if result == False:
+                        if not result:
                             logger.error("Position zeroing failed - send_grbl_coordinates returned False")
                             logger.error("Position zeroing failed - send_grbl_coordinates returned False")
                             homing_complete.set()
                             homing_complete.set()
                             return
                             return
@@ -1071,14 +1137,14 @@ def home(timeout=90):
                 try:
                 try:
                     if effective_table_type == 'dune_weaver_mini':
                     if effective_table_type == 'dune_weaver_mini':
                         result = loop.run_until_complete(send_grbl_coordinates(0, -30, homing_speed, home=True))
                         result = loop.run_until_complete(send_grbl_coordinates(0, -30, homing_speed, home=True))
-                        if result == False:
+                        if not result:
                             logger.error("Crash homing failed - send_grbl_coordinates returned False")
                             logger.error("Crash homing failed - send_grbl_coordinates returned False")
                             homing_complete.set()
                             homing_complete.set()
                             return
                             return
                         state.machine_y -= 30
                         state.machine_y -= 30
                     else:
                     else:
                         result = loop.run_until_complete(send_grbl_coordinates(0, -22, homing_speed, home=True))
                         result = loop.run_until_complete(send_grbl_coordinates(0, -22, homing_speed, home=True))
-                        if result == False:
+                        if not result:
                             logger.error("Crash homing failed - send_grbl_coordinates returned False")
                             logger.error("Crash homing failed - send_grbl_coordinates returned False")
                             homing_complete.set()
                             homing_complete.set()
                             return
                             return
@@ -1115,6 +1181,8 @@ def home(timeout=90):
                 logger.error(f"Error updating machine position after homing: {e}")
                 logger.error(f"Error updating machine position after homing: {e}")
 
 
             homing_success = True
             homing_success = True
+            # Clear sensor_homing_failed flag on successful homing
+            state.sensor_homing_failed = False
             homing_complete.set()
             homing_complete.set()
 
 
         except Exception as e:
         except Exception as e:
@@ -1159,7 +1227,7 @@ def check_idle():
             try:
             try:
                 # Try to schedule in existing event loop if available
                 # Try to schedule in existing event loop if available
                 try:
                 try:
-                    loop = asyncio.get_running_loop()
+                    asyncio.get_running_loop()
                     # Create a task but don't await it (fire and forget)
                     # Create a task but don't await it (fire and forget)
                     asyncio.create_task(update_machine_position())
                     asyncio.create_task(update_machine_position())
                     logger.debug("Scheduled machine position update task")
                     logger.debug("Scheduled machine position update task")
@@ -1269,7 +1337,7 @@ async def update_machine_position():
             logger.error(f"Error updating machine position: {e}")
             logger.error(f"Error updating machine position: {e}")
 
 
 
 
-def perform_soft_reset_sync():
+def perform_soft_reset_sync(max_retries: int = 5):
     """
     """
     Synchronous version of soft reset for use during device initialization.
     Synchronous version of soft reset for use during device initialization.
 
 
@@ -1277,6 +1345,22 @@ def perform_soft_reset_sync():
     Triggers a software reset which clears position counters to 0.
     Triggers a software reset which clears position counters to 0.
     This is more reliable than G92 which only sets a work coordinate offset
     This is more reliable than G92 which only sets a work coordinate offset
     without changing the actual machine position (MPos).
     without changing the actual machine position (MPos).
+
+    IMPORTANT: Position is only reset to (0,0) if confirmation is received.
+    This prevents position drift from accumulating over long operation periods.
+
+    Uses exponential backoff for retries:
+    - Attempt 1: 5s timeout
+    - Attempt 2: 7.5s timeout, 1s delay before retry
+    - Attempt 3: 11s timeout, 2s delay before retry
+    - Attempt 4: 17s timeout, 4s delay before retry
+    - Attempt 5: 25s timeout, 8s delay before retry
+
+    Args:
+        max_retries: Maximum number of reset attempts (default 5)
+
+    Returns:
+        True if reset confirmed, False if all attempts failed
     """
     """
     if not state.conn or not state.conn.is_connected():
     if not state.conn or not state.conn.is_connected():
         logger.warning("Cannot perform soft reset: no active connection")
         logger.warning("Cannot perform soft reset: no active connection")
@@ -1286,87 +1370,101 @@ def perform_soft_reset_sync():
         # Detect firmware type to use appropriate reset command
         # Detect firmware type to use appropriate reset command
         firmware_type, version = _detect_firmware()
         firmware_type, version = _detect_firmware()
         logger.info(f"Detected firmware: {firmware_type} {version or ''}")
         logger.info(f"Detected firmware: {firmware_type} {version or ''}")
-
         logger.info(f"Performing soft reset (was: X={state.machine_x:.2f}, Y={state.machine_y:.2f})")
         logger.info(f"Performing soft reset (was: X={state.machine_x:.2f}, Y={state.machine_y:.2f})")
 
 
-        # Clear any pending data first
-        if isinstance(state.conn, SerialConnection) and state.conn.ser:
-            state.conn.ser.reset_input_buffer()
+        for attempt in range(max_retries):
+            # Exponential backoff: 5s * 1.5^attempt → 5s, 7.5s, 11s, 17s, 25s
+            timeout = 5.0 * (1.5 ** attempt)
+            logger.info(f"Reset attempt {attempt + 1}/{max_retries} (timeout: {timeout:.1f}s)")
 
 
-        # Send appropriate reset command based on firmware
-        if firmware_type == 'fluidnc':
-            # FluidNC uses $Bye for soft reset
-            if isinstance(state.conn, SerialConnection) and state.conn.ser:
-                state.conn.ser.write(b'$Bye\n')
-                state.conn.ser.flush()
-                logger.info(f"$Bye sent directly via serial to {state.port}")
-            else:
-                state.conn.send('$Bye\n')
-                logger.info("$Bye sent via connection abstraction")
-        else:
-            # GRBL uses Ctrl+X (0x18) for soft reset
+            # Clear any pending data first
             if isinstance(state.conn, SerialConnection) and state.conn.ser:
             if isinstance(state.conn, SerialConnection) and state.conn.ser:
-                state.conn.ser.write(b'\x18')
-                state.conn.ser.flush()
-                logger.info(f"Ctrl+X (0x18) sent directly via serial to {state.port}")
+                state.conn.ser.reset_input_buffer()
+
+            # Send appropriate reset command based on firmware
+            if firmware_type == 'fluidnc':
+                # FluidNC uses $Bye for soft reset
+                if isinstance(state.conn, SerialConnection) and state.conn.ser:
+                    state.conn.ser.write(b'$Bye\n')
+                    state.conn.ser.flush()
+                    logger.info(f"$Bye sent directly via serial to {state.port}")
+                else:
+                    state.conn.send('$Bye\n')
+                    logger.info("$Bye sent via connection abstraction")
             else:
             else:
-                state.conn.send('\x18')
-                logger.info("Ctrl+X (0x18) sent via connection abstraction")
-
-        # Wait for controller to fully restart
-        # FluidNC sequence: [MSG:INFO: Restarting] -> ... -> "Grbl 3.9 [FluidNC...]"
-        # GRBL sequence: "Grbl 1.1h ['$' for help]"
-        start_time = time.time()
-        reset_confirmed = False
-        while time.time() - start_time < 5.0:  # 5 second timeout for full reboot
-            try:
-                response = state.conn.readline()
-                if response:
-                    logger.debug(f"Reset response: {response}")
-                    # Wait for the "Grbl" startup banner - this means fully ready
-                    if response.startswith("Grbl") or "fluidnc" in response.lower():
-                        reset_confirmed = True
-                        logger.info(f"Controller restart complete: {response}")
-                        break
-            except Exception:
-                pass
-            time.sleep(0.05)
-
-        # Small delay to let controller fully stabilize
-        time.sleep(0.2)
-
-        # Unlock controller in case it's in alarm state after reset
-        if reset_confirmed:
-            logger.info("Sending $X to unlock controller after reset")
-            state.conn.send("$X\n")
-            # Wait for ok response
-            unlock_start = time.time()
-            while time.time() - unlock_start < 1.0:
+                # GRBL uses Ctrl+X (0x18) for soft reset
+                if isinstance(state.conn, SerialConnection) and state.conn.ser:
+                    state.conn.ser.write(b'\x18')
+                    state.conn.ser.flush()
+                    logger.info(f"Ctrl+X (0x18) sent directly via serial to {state.port}")
+                else:
+                    state.conn.send('\x18')
+                    logger.info("Ctrl+X (0x18) sent via connection abstraction")
+
+            # Wait for controller to fully restart
+            # FluidNC sequence: [MSG:INFO: Restarting] -> ... -> "Grbl 3.9 [FluidNC...]"
+            # GRBL sequence: "Grbl 1.1h ['$' for help]"
+            start_time = time.time()
+            reset_confirmed = False
+            while time.time() - start_time < timeout:
                 try:
                 try:
                     response = state.conn.readline()
                     response = state.conn.readline()
                     if response:
                     if response:
-                        logger.debug(f"$X response: {response}")
-                        if response.lower() == "ok":
-                            logger.info("Controller unlocked")
+                        logger.debug(f"Reset response: {response}")
+                        # Wait for the "Grbl" startup banner - this means fully ready
+                        if response.startswith("Grbl") or "fluidnc" in response.lower():
+                            reset_confirmed = True
+                            logger.info(f"Controller restart complete: {response}")
                             break
                             break
                 except Exception:
                 except Exception:
                     pass
                     pass
                 time.sleep(0.05)
                 time.sleep(0.05)
 
 
-        # Reset state positions to 0 after soft reset
-        state.machine_x = 0.0
-        state.machine_y = 0.0
-
-        if reset_confirmed:
-            logger.info(f"Machine position reset to 0 via {'$Bye' if firmware_type == 'fluidnc' else 'Ctrl+X'} soft reset")
-        else:
-            logger.warning("Soft reset sent but no confirmation received, position set to 0 anyway")
+            if reset_confirmed:
+                # Small delay to let controller fully stabilize
+                time.sleep(0.2)
 
 
-        # Save the reset position
-        state.save()
-        logger.info(f"Machine position saved: {state.machine_x}, {state.machine_y}")
+                # Unlock controller in case it's in alarm state after reset
+                logger.info("Sending $X to unlock controller after reset")
+                state.conn.send("$X\n")
+                # Wait for ok response
+                unlock_start = time.time()
+                while time.time() - unlock_start < 1.0:
+                    try:
+                        response = state.conn.readline()
+                        if response:
+                            logger.debug(f"$X response: {response}")
+                            if response.lower() == "ok":
+                                logger.info("Controller unlocked")
+                                break
+                    except Exception:
+                        pass
+                    time.sleep(0.05)
+
+                # Only reset state positions when confirmation received
+                state.machine_x = 0.0
+                state.machine_y = 0.0
+                reset_cmd = '$Bye' if firmware_type == 'fluidnc' else 'Ctrl+X'
+                logger.info(f"Machine position reset to 0 via {reset_cmd} soft reset")
+
+                # Save the reset position
+                state.save()
+                logger.info(f"Machine position saved: {state.machine_x}, {state.machine_y}")
+                return True
 
 
-        return True
+            # Retry after failed attempt with exponential backoff delay
+            if attempt < max_retries - 1:
+                backoff_delay = 1.0 * (2 ** attempt)  # 1s, 2s, 4s, 8s
+                logger.warning(f"Reset attempt {attempt + 1}/{max_retries} failed, retrying in {backoff_delay:.0f}s...")
+                time.sleep(backoff_delay)
+
+        # All attempts failed - DO NOT reset position to prevent drift
+        logger.error(
+            f"All {max_retries} reset attempts failed - no confirmation received. "
+            f"Position NOT reset (still: X={state.machine_x:.2f}, Y={state.machine_y:.2f}). "
+            "This may indicate communication issues or controller not responding."
+        )
+        return False
 
 
     except Exception as e:
     except Exception as e:
         logger.error(f"Error performing soft reset: {e}")
         logger.error(f"Error performing soft reset: {e}")

+ 26 - 3
modules/core/log_handler.py

@@ -75,13 +75,14 @@ class MemoryLogHandler(logging.Handler):
             "module": record.module,
             "module": record.module,
         }
         }
 
 
-    def get_logs(self, limit: int = None, level: str = None) -> List[Dict[str, Any]]:
+    def get_logs(self, limit: int = None, level: str = None, offset: int = 0) -> List[Dict[str, Any]]:
         """
         """
-        Retrieve stored log entries.
+        Retrieve stored log entries with pagination support.
 
 
         Args:
         Args:
             limit: Maximum number of entries to return (newest first).
             limit: Maximum number of entries to return (newest first).
             level: Filter by log level (DEBUG, INFO, WARNING, ERROR, CRITICAL).
             level: Filter by log level (DEBUG, INFO, WARNING, ERROR, CRITICAL).
+            offset: Number of entries to skip from the newest (for pagination).
 
 
         Returns:
         Returns:
             List of log entries as dictionaries.
             List of log entries as dictionaries.
@@ -94,13 +95,35 @@ class MemoryLogHandler(logging.Handler):
             level_upper = level.upper()
             level_upper = level.upper()
             logs = [log for log in logs if log["level"] == level_upper]
             logs = [log for log in logs if log["level"] == level_upper]
 
 
-        # Return newest first, with optional limit
+        # Return newest first
         logs.reverse()
         logs.reverse()
+
+        # Apply offset for pagination
+        if offset > 0:
+            logs = logs[offset:]
+
+        # Apply limit
         if limit:
         if limit:
             logs = logs[:limit]
             logs = logs[:limit]
 
 
         return logs
         return logs
 
 
+    def get_total_count(self, level: str = None) -> int:
+        """
+        Get total count of log entries (optionally filtered by level).
+
+        Args:
+            level: Filter by log level (DEBUG, INFO, WARNING, ERROR, CRITICAL).
+
+        Returns:
+            Total count of matching log entries.
+        """
+        with self._lock:
+            if not level:
+                return len(self._buffer)
+            level_upper = level.upper()
+            return sum(1 for log in self._buffer if log["level"] == level_upper)
+
     def clear(self) -> None:
     def clear(self) -> None:
         """Clear all stored log entries."""
         """Clear all stored log entries."""
         with self._lock:
         with self._lock:

+ 272 - 67
modules/core/pattern_manager.py

@@ -11,8 +11,6 @@ from modules.core.state import state
 from math import pi, isnan, isinf
 from math import pi, isnan, isinf
 import asyncio
 import asyncio
 import json
 import json
-# Import for legacy support, but we'll use LED interface through state
-from modules.led.led_controller import effect_playing, effect_idle
 from modules.led.idle_timeout_manager import idle_timeout_manager
 from modules.led.idle_timeout_manager import idle_timeout_manager
 import queue
 import queue
 from dataclasses import dataclass
 from dataclasses import dataclass
@@ -340,11 +338,34 @@ async def check_table_is_idle() -> bool:
     return await asyncio.to_thread(connection_manager.is_machine_idle)
     return await asyncio.to_thread(connection_manager.is_machine_idle)
 
 
 
 
-def start_idle_led_timeout():
+async def start_idle_led_timeout(check_still_sands: bool = True):
     """
     """
-    Start the idle LED timeout if enabled.
-    Should be called whenever the idle effect is activated.
+    Set LED to idle state and start timeout if enabled.
+    Handles Still Sands: if in scheduled pause period with LED control enabled,
+    turns off LEDs instead of showing idle effect.
+    Should be called whenever the table goes idle.
+
+    Args:
+        check_still_sands: If True, checks Still Sands period and turns off LEDs if applicable.
+                          Set to False when caller already handles Still Sands logic
+                          (e.g., during pause with "finish pattern first" mode).
     """
     """
+    if not state.led_controller:
+        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")
+        await state.led_controller.set_power_async(0)
+        return
+
+    # Normal flow: show idle effect (only if one is configured)
+    if not state.dw_led_idle_effect:
+        logger.debug("No idle effect configured, leaving LEDs unchanged")
+        return
+    await state.led_controller.effect_idle_async(state.dw_led_idle_effect)
+
+    # Start timeout if enabled
     if not state.dw_led_idle_timeout_enabled:
     if not state.dw_led_idle_timeout_enabled:
         logger.debug("Idle LED timeout not enabled")
         logger.debug("Idle LED timeout not enabled")
         return
         return
@@ -626,7 +647,7 @@ class MotionControlThread:
                                 if resp:
                                 if resp:
                                     responses_received.append(resp)
                                     responses_received.append(resp)
                                     logger.info(f"Motion thread: Recovery response [{i+1}/10]: '{resp}'")
                                     logger.info(f"Motion thread: Recovery response [{i+1}/10]: '{resp}'")
-                                    if '<' in resp or 'Idle' in resp or 'Run' in resp:
+                                    if '<' in resp or 'Idle' in resp or 'Run' in resp or 'Hold' in resp or 'Alarm' in resp:
                                         status_response = resp
                                         status_response = resp
                                         logger.info(f"Motion thread: Found valid status response: '{resp}'")
                                         logger.info(f"Motion thread: Found valid status response: '{resp}'")
                                         break
                                         break
@@ -655,8 +676,82 @@ class MotionControlThread:
                                     logger.info("Motion thread: Machine still running, extending wait time")
                                     logger.info("Motion thread: Machine still running, extending wait time")
                                     wait_start = time.time()  # Reset timeout
                                     wait_start = time.time()  # Reset timeout
                                     continue
                                     continue
+                                elif 'Hold' in status_response:
+                                    # Machine is in Hold state - attempt to resume
+                                    logger.warning(f"Motion thread: Machine in Hold state: '{status_response}'")
+                                    logger.info("Motion thread: Sending cycle start command '~' to resume from Hold...")
+
+                                    # Send cycle start command to resume
+                                    state.conn.send("~\n")
+                                    time.sleep(0.3)  # Give time for resume to process
+
+                                    # Re-check status after resume attempt
+                                    state.conn.send("?\n")
+                                    time.sleep(0.2)
+
+                                    # Read new status
+                                    resume_response = None
+                                    for _ in range(5):
+                                        resp = state.conn.readline()
+                                        if resp:
+                                            logger.info(f"Motion thread: Post-resume response: '{resp}'")
+                                            if '<' in resp:
+                                                resume_response = resp
+                                                break
+                                        time.sleep(0.05)
+
+                                    if resume_response:
+                                        if 'Idle' in resume_response:
+                                            logger.info("Motion thread: Machine resumed and is now Idle - SUCCESS")
+                                            return True
+                                        elif 'Run' in resume_response:
+                                            logger.info("Motion thread: Machine resumed and running, extending wait time")
+                                            wait_start = time.time()
+                                            continue
+                                        elif 'Hold' in resume_response:
+                                            # Still in Hold - may need user intervention
+                                            logger.warning(f"Motion thread: Still in Hold after resume: '{resume_response}'")
+                                    else:
+                                        logger.warning("Motion thread: No response after resume attempt")
+                                elif 'Alarm' in status_response:
+                                    # Machine is in Alarm state - attempt to unlock
+                                    logger.warning(f"Motion thread: Machine in ALARM state: '{status_response}'")
+                                    logger.info("Motion thread: Sending $X to unlock from Alarm...")
+
+                                    # Send unlock command
+                                    state.conn.send("$X\n")
+                                    time.sleep(0.5)  # Give time for unlock to process
+
+                                    # Re-check status after unlock attempt
+                                    state.conn.send("?\n")
+                                    time.sleep(0.2)
+
+                                    # Read new status
+                                    unlock_response = None
+                                    for _ in range(5):
+                                        resp = state.conn.readline()
+                                        if resp:
+                                            logger.info(f"Motion thread: Post-unlock response: '{resp}'")
+                                            if '<' in resp:
+                                                unlock_response = resp
+                                                break
+                                        time.sleep(0.05)
+
+                                    if unlock_response:
+                                        if 'Idle' in unlock_response:
+                                            logger.info("Motion thread: Machine unlocked and is now Idle - retrying command")
+                                            # Don't return True - we need to resend the failed command
+                                            break  # Break inner loop to retry the command
+                                        elif 'Alarm' in unlock_response:
+                                            # Still in Alarm - underlying issue persists (e.g., sensor triggered)
+                                            logger.error(f"Motion thread: Still in ALARM after unlock: '{unlock_response}'")
+                                            logger.error("Motion thread: Machine may need physical attention")
+                                            state.stop_requested = True
+                                            return False
+                                    else:
+                                        logger.warning("Motion thread: No response after unlock attempt")
                                 else:
                                 else:
-                                    logger.warning(f"Motion thread: Status response didn't contain Idle or Run: '{status_response}'")
+                                    logger.warning(f"Motion thread: Unrecognized status response: '{status_response}'")
                             else:
                             else:
                                 logger.warning("Motion thread: No valid status response found in any received data")
                                 logger.warning("Motion thread: No valid status response found in any received data")
 
 
@@ -812,9 +907,11 @@ async def cleanup_pattern_manager():
             logger.info("Pattern lock is held, waiting for release (max 5s)...")
             logger.info("Pattern lock is held, waiting for release (max 5s)...")
             try:
             try:
                 # Wait with timeout for the lock to become available
                 # Wait with timeout for the lock to become available
-                async with asyncio.timeout(5.0):
+                # Use wait_for for Python 3.9 compatibility (asyncio.timeout is 3.11+)
+                async def acquire_lock():
                     async with current_lock:
                     async with current_lock:
                         pass  # Lock acquired means previous holder released it
                         pass  # Lock acquired means previous holder released it
+                await asyncio.wait_for(acquire_lock(), timeout=5.0)
                 logger.info("Pattern lock released normally")
                 logger.info("Pattern lock released normally")
             except asyncio.TimeoutError:
             except asyncio.TimeoutError:
                 logger.warning("Timed out waiting for pattern lock - creating fresh lock")
                 logger.warning("Timed out waiting for pattern lock - creating fresh lock")
@@ -1085,6 +1182,17 @@ async def _execute_pattern_internal(file_path):
         logger.warning("Not enough coordinates for interpolation")
         logger.warning("Not enough coordinates for interpolation")
         return False
         return False
 
 
+    # 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
+    def calc_move_weight(rho):
+        # Weight inversely proportional to rho, with floor to avoid extreme values
+        # At rho=0: weight≈6.7, at rho=0.5: weight≈1.5, at rho=1.0: weight≈0.87
+        return 1.0 / (rho + 0.15)
+
+    coord_weights = [calc_move_weight(rho) for _, rho in coordinates]
+    total_weight = sum(coord_weights)
+
     # Determine if this is a clearing pattern
     # Determine if this is a clearing pattern
     is_clear_file = is_clear_pattern(file_path)
     is_clear_file = is_clear_pattern(file_path)
 
 
@@ -1115,7 +1223,9 @@ async def _execute_pattern_internal(file_path):
 
 
     start_time = time.time()
     start_time = time.time()
     total_pause_time = 0  # Track total time spent paused (manual + scheduled)
     total_pause_time = 0  # Track total time spent paused (manual + scheduled)
-    if state.led_controller:
+    completed_weight = 0.0  # Track rho-weighted progress
+    smoothed_rate = None  # For exponential smoothing of time-per-unit-weight rate
+    if state.led_controller and state.dw_led_playing_effect:
         logger.info(f"Setting LED to playing effect: {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)
         await state.led_controller.effect_playing_async(state.dw_led_playing_effect)
         # Cancel idle timeout when playing starts
         # Cancel idle timeout when playing starts
@@ -1133,17 +1243,13 @@ async def _execute_pattern_internal(file_path):
             theta, rho = coordinate
             theta, rho = coordinate
             if state.stop_requested:
             if state.stop_requested:
                 logger.info("Execution stopped by user")
                 logger.info("Execution stopped by user")
-                if state.led_controller:
-                    await state.led_controller.effect_idle_async(state.dw_led_idle_effect)
-                    start_idle_led_timeout()
+                await start_idle_led_timeout()
                 break
                 break
 
 
             if state.skip_requested:
             if state.skip_requested:
                 logger.info("Skipping pattern...")
                 logger.info("Skipping pattern...")
                 await connection_manager.check_idle_async()
                 await connection_manager.check_idle_async()
-                if state.led_controller:
-                    await state.led_controller.effect_idle_async(state.dw_led_idle_effect)
-                    start_idle_led_timeout()
+                await start_idle_led_timeout()
                 break
                 break
 
 
             # Wait for resume if paused (manual or scheduled)
             # Wait for resume if paused (manual or scheduled)
@@ -1164,11 +1270,10 @@ async def _execute_pattern_internal(file_path):
                         logger.info("Turning off LED lights during Still Sands period")
                         logger.info("Turning off LED lights during Still Sands period")
                         await state.led_controller.set_power_async(0)
                         await state.led_controller.set_power_async(0)
 
 
-                # Only show idle effect if NOT in scheduled pause with LED control
-                # (manual pause always shows idle effect)
-                if state.led_controller and not (scheduled_pause and state.scheduled_pause_control_wled):
-                    await state.led_controller.effect_idle_async(state.dw_led_idle_effect)
-                    start_idle_led_timeout()
+                # Show idle effect for manual pause or scheduled pause without LED control
+                # (skip Still Sands check since we handle it above with local scheduled_pause variable)
+                if not (scheduled_pause and state.scheduled_pause_control_wled):
+                    await start_idle_led_timeout(check_still_sands=False)
 
 
                 # Remember if we turned off LED controller for scheduled pause
                 # Remember if we turned off LED controller for scheduled pause
                 wled_was_off_for_scheduled = scheduled_pause and state.scheduled_pause_control_wled and not manual_pause
                 wled_was_off_for_scheduled = scheduled_pause and state.scheduled_pause_control_wled and not manual_pause
@@ -1232,13 +1337,18 @@ async def _execute_pattern_internal(file_path):
                 logger.info("Execution resumed...")
                 logger.info("Execution resumed...")
                 if state.led_controller:
                 if state.led_controller:
                     # Turn LED controller back on if it was turned off for scheduled pause
                     # Turn LED controller back on if it was turned off for scheduled pause
+                    # Only power on if a playing effect is configured, otherwise leave LEDs off
                     if wled_was_off_for_scheduled:
                     if wled_was_off_for_scheduled:
-                        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)
-                    await state.led_controller.effect_playing_async(state.dw_led_playing_effect)
+                        if state.dw_led_playing_effect:
+                            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")
+                    if state.dw_led_playing_effect:
+                        await state.led_controller.effect_playing_async(state.dw_led_playing_effect)
                     # Cancel idle timeout when resuming from pause
                     # Cancel idle timeout when resuming from pause
                     idle_timeout_manager.cancel_timeout()
                     idle_timeout_manager.cancel_timeout()
 
 
@@ -1254,8 +1364,33 @@ async def _execute_pattern_internal(file_path):
             # Update progress for all coordinates including the first one
             # Update progress for all coordinates including the first one
             pbar.update(1)
             pbar.update(1)
             elapsed_time = time.time() - start_time
             elapsed_time = time.time() - start_time
-            estimated_remaining_time = (total_coordinates - (i + 1)) / pbar.format_dict['rate'] if pbar.format_dict['rate'] and total_coordinates else 0
-            state.execution_progress = (i + 1, total_coordinates, estimated_remaining_time, elapsed_time)
+            coords_done = i + 1
+
+            # Track rho-weighted progress for accurate time estimation
+            completed_weight += coord_weights[i]
+            remaining_weight = total_weight - completed_weight
+
+            # Calculate actual execution time (excluding pauses)
+            active_time = elapsed_time - total_pause_time
+
+            # Need minimum samples for stable estimate (at least 100 coords and 10 seconds)
+            if coords_done >= 100 and active_time > 10:
+                # Rate is time per unit weight (accounts for slower moves near center)
+                current_rate = active_time / completed_weight
+
+                # Smooth the RATE for stability
+                if smoothed_rate is not None:
+                    alpha = 0.02  # Very smooth - 2% new, 98% old
+                    smoothed_rate = alpha * current_rate + (1 - alpha) * smoothed_rate
+                else:
+                    smoothed_rate = current_rate
+
+                # Remaining time based on weighted remaining work
+                estimated_remaining_time = smoothed_rate * remaining_weight
+            else:
+                estimated_remaining_time = None
+
+            state.execution_progress = (coords_done, total_coordinates, estimated_remaining_time, elapsed_time)
 
 
             # Add a small delay to allow other async operations
             # Add a small delay to allow other async operations
             await asyncio.sleep(0.001)
             await asyncio.sleep(0.001)
@@ -1287,11 +1422,9 @@ async def _execute_pattern_internal(file_path):
     await connection_manager.check_idle_async()
     await connection_manager.check_idle_async()
 
 
     # Set LED back to idle when pattern completes normally (not stopped early)
     # Set LED back to idle when pattern completes normally (not stopped early)
-    if state.led_controller and not state.stop_requested:
-        logger.info(f"Setting LED to idle effect: {state.dw_led_idle_effect}")
-        await state.led_controller.effect_idle_async(state.dw_led_idle_effect)
-        start_idle_led_timeout()
-        logger.debug("LED effect set to idle after pattern completion")
+    # This also handles Still Sands: turns off LEDs if in scheduled pause period with LED control
+    if not state.stop_requested:
+        await start_idle_led_timeout()
 
 
     return was_completed
     return was_completed
 
 
@@ -1340,7 +1473,7 @@ async def run_theta_rho_file(file_path, is_playlist=False, clear_pattern=None, c
             return
             return
 
 
         # Run the main pattern
         # Run the main pattern
-        completed = await _execute_pattern_internal(file_path)
+        await _execute_pattern_internal(file_path)
 
 
         # Only clear state if not part of a playlist
         # Only clear state if not part of a playlist
         if not is_playlist:
         if not is_playlist:
@@ -1371,9 +1504,12 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
     import time as time_module
     import time as time_module
     state.dw_led_last_activity_time = time_module.time()
     state.dw_led_last_activity_time = time_module.time()
 
 
-    # Set initial playlist state
-    state.playlist_mode = run_mode
-    state.current_playlist_index = 0
+    # Set initial playlist state only if not already set by caller (playlist_manager).
+    # This ensures backward compatibility when this function is called directly.
+    if state.playlist_mode is None:
+        state.playlist_mode = run_mode
+    if state.current_playlist_index is None:
+        state.current_playlist_index = 0
 
 
     # Start progress update task for the playlist
     # Start progress update task for the playlist
     global progress_update_task
     global progress_update_task
@@ -1385,8 +1521,10 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
         random.shuffle(file_paths)
         random.shuffle(file_paths)
         logger.info("Playlist shuffled")
         logger.info("Playlist shuffled")
 
 
-    # Store only main patterns in the playlist
-    state.current_playlist = file_paths
+    # Store patterns in state only if not already set by caller.
+    # The caller (playlist_manager.run_playlist) sets this before creating the task.
+    if state.current_playlist is None:
+        state.current_playlist = file_paths
 
 
     try:
     try:
         while True:
         while True:
@@ -1439,9 +1577,9 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
                         logger.info("Turning off LED lights during Still Sands period")
                         logger.info("Turning off LED lights during Still Sands period")
                         await state.led_controller.set_power_async(0)
                         await state.led_controller.set_power_async(0)
                         wled_was_off_for_scheduled = True
                         wled_was_off_for_scheduled = True
-                    elif state.led_controller:
-                        await state.led_controller.effect_idle_async(state.dw_led_idle_effect)
-                        start_idle_led_timeout()
+                    else:
+                        # Show idle effect (WLED control not enabled)
+                        await start_idle_led_timeout(check_still_sands=False)
 
 
                     # Wait for scheduled pause to end, but allow stop/skip to interrupt
                     # Wait for scheduled pause to end, but allow stop/skip to interrupt
                     result = await wait_with_interrupt(
                     result = await wait_with_interrupt(
@@ -1453,11 +1591,16 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
                     if result == 'completed':
                     if result == 'completed':
                         logger.info("Still Sands period ended. Resuming playlist...")
                         logger.info("Still Sands period ended. Resuming playlist...")
                         if state.led_controller:
                         if state.led_controller:
+                            # Only power on if a playing effect is configured, otherwise leave LEDs off
                             if wled_was_off_for_scheduled:
                             if wled_was_off_for_scheduled:
-                                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)
-                            await state.led_controller.effect_playing_async(state.dw_led_playing_effect)
+                                if state.dw_led_playing_effect:
+                                    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")
+                            if state.dw_led_playing_effect:
+                                await state.led_controller.effect_playing_async(state.dw_led_playing_effect)
                             idle_timeout_manager.cancel_timeout()
                             idle_timeout_manager.cancel_timeout()
 
 
                 # Handle pause between patterns
                 # Handle pause between patterns
@@ -1513,6 +1656,18 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
                 logger.info("Playlist completed")
                 logger.info("Playlist completed")
                 break
                 break
 
 
+    except asyncio.CancelledError:
+        # Task was cancelled externally (e.g., by TestClient cleanup, or explicit cancellation).
+        # Do NOT clear playlist state - preserve what the caller set.
+        logger.info("Playlist task was cancelled externally, preserving state")
+        if progress_update_task:
+            progress_update_task.cancel()
+            try:
+                await progress_update_task
+            except asyncio.CancelledError:
+                pass
+            progress_update_task = None
+        raise  # Re-raise to signal cancellation
     finally:
     finally:
         if progress_update_task:
         if progress_update_task:
             progress_update_task.cancel()
             progress_update_task.cancel()
@@ -1522,18 +1677,27 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
                 pass
                 pass
             progress_update_task = None
             progress_update_task = None
 
 
-        state.current_playing_file = None
-        state.execution_progress = None
-        state.current_playlist = None
-        state.current_playlist_index = None
-        state.playlist_mode = None
-        state.pause_time_remaining = 0
+        # Check if we're exiting due to CancelledError - if so, don't clear state.
+        # State should only be cleared when:
+        # 1. Task completed normally (all patterns executed)
+        # 2. Task was stopped by user request (stop_requested)
+        # NOT when task was cancelled externally (CancelledError)
+        import sys
+        exc_type = sys.exc_info()[0]
+        if exc_type is asyncio.CancelledError:
+            logger.info("Task exiting due to cancellation, state preserved for caller")
+        else:
+            # Normal completion or user-requested stop - clear state
+            state.current_playing_file = None
+            state.execution_progress = None
+            state.current_playlist = None
+            state.current_playlist_index = None
+            state.playlist_mode = None
+            state.pause_time_remaining = 0
 
 
-        if state.led_controller:
-            await state.led_controller.effect_idle_async(state.dw_led_idle_effect)
-            start_idle_led_timeout()
+            await start_idle_led_timeout()
 
 
-        logger.info("All requested patterns completed (or stopped) and state cleared")
+            logger.info("All requested patterns completed (or stopped) and state cleared")
 
 
 async def stop_actions(clear_playlist = True, wait_for_lock = True):
 async def stop_actions(clear_playlist = True, wait_for_lock = True):
     """Stop all current actions and wait for pattern to fully release.
     """Stop all current actions and wait for pattern to fully release.
@@ -1588,10 +1752,12 @@ async def stop_actions(clear_playlist = True, wait_for_lock = True):
         if wait_for_lock and lock.locked():
         if wait_for_lock and lock.locked():
             logger.info("Waiting for pattern to fully stop...")
             logger.info("Waiting for pattern to fully stop...")
             # Use a timeout to prevent hanging forever
             # Use a timeout to prevent hanging forever
+            # Use wait_for for Python 3.9 compatibility (asyncio.timeout is 3.11+)
             try:
             try:
-                async with asyncio.timeout(10.0):
+                async def acquire_stop_lock():
                     async with lock:
                     async with lock:
                         logger.info("Pattern lock acquired - pattern has fully stopped")
                         logger.info("Pattern lock acquired - pattern has fully stopped")
+                await asyncio.wait_for(acquire_stop_lock(), timeout=10.0)
             except asyncio.TimeoutError:
             except asyncio.TimeoutError:
                 logger.warning("Timeout waiting for pattern to stop - forcing cleanup")
                 logger.warning("Timeout waiting for pattern to stop - forcing cleanup")
                 timed_out = True
                 timed_out = True
@@ -1600,9 +1766,23 @@ async def stop_actions(clear_playlist = True, wait_for_lock = True):
                 state.execution_progress = None
                 state.execution_progress = None
                 state.is_running = False
                 state.is_running = False
 
 
-        # Always clear the current playing file after stop
-        state.current_playing_file = None
-        state.execution_progress = None
+        # Clear current playing file only when clearing the entire playlist.
+        # When clear_playlist=False (called from within pattern execution), the caller
+        # will set current_playing_file to the new pattern immediately after.
+        if clear_playlist:
+            state.current_playing_file = None
+            state.execution_progress = None
+
+        # Clear stop_requested now that the pattern has stopped - this allows
+        # check_idle_async to work (it exits early if stop_requested is True)
+        state.stop_requested = False
+
+        # Wait for hardware to reach idle state before returning
+        # This ensures the machine has physically stopped moving
+        if not timed_out:
+            idle = await connection_manager.check_idle_async(timeout=30.0)
+            if not idle:
+                logger.warning("Machine did not reach idle after stop")
 
 
         # Call async function directly since we're in async context
         # Call async function directly since we're in async context
         await connection_manager.update_machine_position()
         await connection_manager.update_machine_position()
@@ -1672,17 +1852,41 @@ def resume_execution():
     
     
 async def reset_theta():
 async def reset_theta():
     """
     """
-    Reset theta to [0, 2π) range and hard reset machine position using $Bye.
+    Reset theta to [0, 2π) range and optionally hard reset machine position using $Bye.
+
+    When state.hard_reset_theta is True:
+    - $Bye sends a soft reset to FluidNC which resets the controller and clears
+      all position counters to 0. This is more reliable than G92 which only sets
+      a work coordinate offset without changing the actual machine position (MPos).
+    - We wait for machine to be idle before sending $Bye to avoid error:25
 
 
-    $Bye sends a soft reset to FluidNC which resets the controller and clears
-    all position counters to 0. This is more reliable than G92 which only sets
-    a work coordinate offset without changing the actual machine position (MPos).
+    When state.hard_reset_theta is False (default):
+    - Only normalizes theta to [0, 2π) range without affecting machine position
+    - Faster and doesn't interrupt machine state
     """
     """
     logger.info('Resetting Theta')
     logger.info('Resetting Theta')
-    state.current_theta = state.current_theta % (2 * pi)
 
 
-    # Hard reset machine position using $Bye via connection_manager
-    await connection_manager.perform_soft_reset()
+    # Always normalize theta to [0, 2π) range
+    state.current_theta = state.current_theta % (2 * pi)
+    logger.info(f'Theta normalized to {state.current_theta:.4f} radians')
+
+    # Only perform hard reset if enabled
+    if state.hard_reset_theta:
+        logger.info('Hard reset enabled - performing machine soft reset')
+
+        # Wait for machine to be idle before reset to prevent error:25
+        if state.conn and state.conn.is_connected():
+            logger.info("Waiting for machine to be idle before reset...")
+            idle = await connection_manager.check_idle_async(timeout=30)
+            if not idle:
+                logger.warning("Machine not idle after 30s, proceeding with reset anyway")
+
+        # Hard reset machine position using $Bye via connection_manager
+        success = await connection_manager.perform_soft_reset()
+        if not success:
+            logger.error("Soft reset failed - theta reset may be unreliable")
+    else:
+        logger.info('Hard reset disabled - skipping machine soft reset')
 
 
 def set_speed(new_speed):
 def set_speed(new_speed):
     state.speed = new_speed
     state.speed = new_speed
@@ -1697,6 +1901,7 @@ def get_status():
         "scheduled_pause": is_in_scheduled_pause_period(),
         "scheduled_pause": is_in_scheduled_pause_period(),
         "is_running": bool(state.current_playing_file and not state.stop_requested),
         "is_running": bool(state.current_playing_file and not state.stop_requested),
         "is_homing": state.is_homing,
         "is_homing": state.is_homing,
+        "sensor_homing_failed": state.sensor_homing_failed,
         "is_clearing": state.is_clearing,
         "is_clearing": state.is_clearing,
         "progress": None,
         "progress": None,
         "playlist": None,
         "playlist": None,

+ 8 - 0
modules/core/playlist_manager.py

@@ -157,8 +157,16 @@ async def run_playlist(playlist_name, pause_time=0, clear_pattern=None, run_mode
 
 
     try:
     try:
         logger.info(f"Starting playlist '{playlist_name}' with mode={run_mode}, shuffle={shuffle}")
         logger.info(f"Starting playlist '{playlist_name}' with mode={run_mode}, shuffle={shuffle}")
+        # Set ALL playlist state variables BEFORE creating the async task.
+        # This ensures state is correct even if the task doesn't start immediately
+        # (important for TestClient which may cancel background tasks).
         state.current_playlist = file_paths
         state.current_playlist = file_paths
         state.current_playlist_name = playlist_name
         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(
         _current_playlist_task = asyncio.create_task(
             pattern_manager.run_theta_rho_files(
             pattern_manager.run_theta_rho_files(
                 file_paths,
                 file_paths,

+ 25 - 2
modules/core/state.py

@@ -63,6 +63,10 @@ class AppState:
         # Homing in progress flag - blocks other movement operations
         # Homing in progress flag - blocks other movement operations
         self.is_homing = False
         self.is_homing = False
 
 
+        # Sensor homing failure flag - set when sensor homing fails
+        # This indicates to the UI that sensor homing failed and user action is needed
+        self.sensor_homing_failed = False
+
         # Angular homing compass reference point
         # Angular homing compass reference point
         # This is the angular offset in degrees where the sensor is placed
         # This is the angular offset in degrees where the sensor is placed
         # After homing, theta will be set to this value
         # After homing, theta will be set to this value
@@ -74,6 +78,11 @@ class AppState:
         self.auto_home_after_patterns = 5  # Number of patterns after which to auto-home
         self.auto_home_after_patterns = 5  # Number of patterns after which to auto-home
         self.patterns_since_last_home = 0  # Counter for patterns played since last home
         self.patterns_since_last_home = 0  # Counter for patterns played since last home
 
 
+        # Hard reset on theta reset (sends $Bye to FluidNC to reset machine position)
+        # When False (default), only normalizes theta to [0, 2π) without machine reset
+        # When True, also performs soft reset which clears all position counters
+        self.hard_reset_theta = False
+
         self.STATE_FILE = "state.json"
         self.STATE_FILE = "state.json"
         self.mqtt_handler = None  # Will be set by the MQTT handler
         self.mqtt_handler = None  # Will be set by the MQTT handler
         self.conn = None
         self.conn = None
@@ -107,6 +116,7 @@ class AppState:
         self._pause_time = 0
         self._pause_time = 0
         self._clear_pattern = "none"
         self._clear_pattern = "none"
         self._clear_pattern_speed = None  # None means use state.speed as default
         self._clear_pattern_speed = None  # None means use state.speed as default
+        self._shuffle = False  # Shuffle playlist order
         self.custom_clear_from_in = None  # Custom clear from center pattern
         self.custom_clear_from_in = None  # Custom clear from center pattern
         self.custom_clear_from_out = None  # Custom clear from perimeter pattern
         self.custom_clear_from_out = None  # Custom clear from perimeter pattern
         
         
@@ -175,7 +185,7 @@ class AppState:
         self._current_playing_file = value
         self._current_playing_file = value
 
 
         # force an empty string (and not None) if we need to unset
         # force an empty string (and not None) if we need to unset
-        if value == None:
+        if value is None:
             value = ""
             value = ""
         if self.mqtt_handler:
         if self.mqtt_handler:
             is_running = bool(value and not self._pause_requested)
             is_running = bool(value and not self._pause_requested)
@@ -211,7 +221,7 @@ class AppState:
         self._current_playlist = value
         self._current_playlist = value
         
         
         # force an empty string (and not None) if we need to unset
         # force an empty string (and not None) if we need to unset
-        if value == None:
+        if value is None:
             value = ""
             value = ""
             # Also clear the playlist name when playlist is cleared
             # Also clear the playlist name when playlist is cleared
             self._current_playlist_name = None
             self._current_playlist_name = None
@@ -401,6 +411,7 @@ class AppState:
         timeout_task = asyncio.create_task(asyncio.sleep(timeout), name='timeout')
         timeout_task = asyncio.create_task(asyncio.sleep(timeout), name='timeout')
         tasks.append(timeout_task)
         tasks.append(timeout_task)
 
 
+        pending = set()  # Initialize to empty set to avoid UnboundLocalError
         try:
         try:
             done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
             done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
         finally:
         finally:
@@ -421,6 +432,14 @@ class AppState:
             return 'skipped'
             return 'skipped'
         return 'timeout'
         return 'timeout'
 
 
+    @property
+    def shuffle(self):
+        return self._shuffle
+
+    @shuffle.setter
+    def shuffle(self, value):
+        self._shuffle = value
+
     def to_dict(self):
     def to_dict(self):
         """Return a dictionary representation of the state."""
         """Return a dictionary representation of the state."""
         return {
         return {
@@ -442,6 +461,7 @@ class AppState:
             "angular_homing_offset_degrees": self.angular_homing_offset_degrees,
             "angular_homing_offset_degrees": self.angular_homing_offset_degrees,
             "auto_home_enabled": self.auto_home_enabled,
             "auto_home_enabled": self.auto_home_enabled,
             "auto_home_after_patterns": self.auto_home_after_patterns,
             "auto_home_after_patterns": self.auto_home_after_patterns,
+            "hard_reset_theta": self.hard_reset_theta,
             "current_playlist": self._current_playlist,
             "current_playlist": self._current_playlist,
             "current_playlist_name": self._current_playlist_name,
             "current_playlist_name": self._current_playlist_name,
             "current_playlist_index": self.current_playlist_index,
             "current_playlist_index": self.current_playlist_index,
@@ -449,6 +469,7 @@ class AppState:
             "pause_time": self._pause_time,
             "pause_time": self._pause_time,
             "clear_pattern": self._clear_pattern,
             "clear_pattern": self._clear_pattern,
             "clear_pattern_speed": self._clear_pattern_speed,
             "clear_pattern_speed": self._clear_pattern_speed,
+            "shuffle": self._shuffle,
             "custom_clear_from_in": self.custom_clear_from_in,
             "custom_clear_from_in": self.custom_clear_from_in,
             "custom_clear_from_out": self.custom_clear_from_out,
             "custom_clear_from_out": self.custom_clear_from_out,
             "port": self.port,
             "port": self.port,
@@ -514,6 +535,7 @@ class AppState:
         self.angular_homing_offset_degrees = data.get('angular_homing_offset_degrees', 0.0)
         self.angular_homing_offset_degrees = data.get('angular_homing_offset_degrees', 0.0)
         self.auto_home_enabled = data.get('auto_home_enabled', False)
         self.auto_home_enabled = data.get('auto_home_enabled', False)
         self.auto_home_after_patterns = data.get('auto_home_after_patterns', 5)
         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 = data.get("current_playlist", None)
         self._current_playlist_name = data.get("current_playlist_name", None)
         self._current_playlist_name = data.get("current_playlist_name", None)
         self.current_playlist_index = data.get("current_playlist_index", None)
         self.current_playlist_index = data.get("current_playlist_index", None)
@@ -521,6 +543,7 @@ class AppState:
         self._pause_time = data.get("pause_time", 0)
         self._pause_time = data.get("pause_time", 0)
         self._clear_pattern = data.get("clear_pattern", "none")
         self._clear_pattern = data.get("clear_pattern", "none")
         self._clear_pattern_speed = data.get("clear_pattern_speed", None)
         self._clear_pattern_speed = data.get("clear_pattern_speed", None)
+        self._shuffle = data.get("shuffle", False)
         self.custom_clear_from_in = data.get("custom_clear_from_in", None)
         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.custom_clear_from_out = data.get("custom_clear_from_out", None)
         self.port = data.get("port", None)
         self.port = data.get("port", None)

+ 35 - 6
modules/mqtt/handler.py

@@ -3,11 +3,10 @@ import os
 import threading
 import threading
 import time
 import time
 import json
 import json
-from typing import Dict, Callable, List, Optional, Any
+from typing import Dict, Callable
 import paho.mqtt.client as mqtt
 import paho.mqtt.client as mqtt
 import logging
 import logging
 import asyncio
 import asyncio
-from functools import partial
 
 
 from .base import BaseMQTTHandler
 from .base import BaseMQTTHandler
 from modules.core.state import state
 from modules.core.state import state
@@ -122,7 +121,7 @@ class MQTTHandler(BaseMQTTHandler):
 
 
         # Stop Button
         # Stop Button
         stop_config = {
         stop_config = {
-            "name": f"Stop pattern execution",
+            "name": "Stop pattern execution",
             "unique_id": f"{self.device_id}_stop",
             "unique_id": f"{self.device_id}_stop",
             "command_topic": f"{self.device_id}/command/stop",
             "command_topic": f"{self.device_id}/command/stop",
             "device": base_device,
             "device": base_device,
@@ -133,7 +132,7 @@ class MQTTHandler(BaseMQTTHandler):
 
 
         # Pause Button
         # Pause Button
         pause_config = {
         pause_config = {
-            "name": f"Pause pattern execution",
+            "name": "Pause pattern execution",
             "unique_id": f"{self.device_id}_pause",
             "unique_id": f"{self.device_id}_pause",
             "command_topic": f"{self.device_id}/command/pause",
             "command_topic": f"{self.device_id}/command/pause",
             "state_topic": f"{self.device_id}/command/pause/state",
             "state_topic": f"{self.device_id}/command/pause/state",
@@ -151,7 +150,7 @@ class MQTTHandler(BaseMQTTHandler):
 
 
         # Play Button
         # Play Button
         play_config = {
         play_config = {
-            "name": f"Resume pattern execution",
+            "name": "Resume pattern execution",
             "unique_id": f"{self.device_id}_play",
             "unique_id": f"{self.device_id}_play",
             "command_topic": f"{self.device_id}/command/play",
             "command_topic": f"{self.device_id}/command/play",
             "state_topic": f"{self.device_id}/command/play/state",
             "state_topic": f"{self.device_id}/command/play/state",
@@ -248,6 +247,20 @@ class MQTTHandler(BaseMQTTHandler):
         }
         }
         self._publish_discovery("select", "clear_pattern", clear_pattern_config)
         self._publish_discovery("select", "clear_pattern", clear_pattern_config)
 
 
+        # Shuffle Switch
+        shuffle_config = {
+            "name": f"{self.device_name} Shuffle",
+            "unique_id": f"{self.device_id}_shuffle",
+            "command_topic": f"{self.device_id}/playlist/shuffle/set",
+            "state_topic": f"{self.device_id}/playlist/shuffle/state",
+            "payload_on": "ON",
+            "payload_off": "OFF",
+            "device": base_device,
+            "icon": "mdi:shuffle-variant",
+            "entity_category": "config"
+        }
+        self._publish_discovery("switch", "shuffle", shuffle_config)
+
         # Completion Percentage Sensor
         # Completion Percentage Sensor
         completion_config = {
         completion_config = {
             "name": f"{self.device_name} Completion",
             "name": f"{self.device_name} Completion",
@@ -448,6 +461,14 @@ class MQTTHandler(BaseMQTTHandler):
             self.client.publish(self.completion_topic, 0, retain=True)
             self.client.publish(self.completion_topic, 0, retain=True)
             self.client.publish(self.time_remaining_topic, 0, retain=True)
             self.client.publish(self.time_remaining_topic, 0, retain=True)
 
 
+    def _publish_playlist_settings_state(self):
+        """Helper to publish playlist settings state (mode, pause_time, clear_pattern, shuffle)."""
+        self.client.publish(f"{self.device_id}/playlist/mode/state", state.playlist_mode, retain=True)
+        self.client.publish(f"{self.device_id}/playlist/pause_time/state", state.pause_time, retain=True)
+        self.client.publish(f"{self.device_id}/playlist/clear_pattern/state", state.clear_pattern, retain=True)
+        shuffle_state = "ON" if state.shuffle else "OFF"
+        self.client.publish(f"{self.device_id}/playlist/shuffle/state", shuffle_state, retain=True)
+
     def _publish_led_state(self):
     def _publish_led_state(self):
         """Helper to publish LED state to MQTT (DW LEDs only - WLED has its own MQTT)."""
         """Helper to publish LED state to MQTT (DW LEDs only - WLED has its own MQTT)."""
         if not state.led_controller or state.led_provider != "dw_leds":
         if not state.led_controller or state.led_provider != "dw_leds":
@@ -544,6 +565,7 @@ class MQTTHandler(BaseMQTTHandler):
                 (f"{self.device_id}/playlist/mode/set", 0),
                 (f"{self.device_id}/playlist/mode/set", 0),
                 (f"{self.device_id}/playlist/pause_time/set", 0),
                 (f"{self.device_id}/playlist/pause_time/set", 0),
                 (f"{self.device_id}/playlist/clear_pattern/set", 0),
                 (f"{self.device_id}/playlist/clear_pattern/set", 0),
+                (f"{self.device_id}/playlist/shuffle/set", 0),
                 (self.led_power_topic, 0),
                 (self.led_power_topic, 0),
                 (self.led_brightness_topic, 0),
                 (self.led_brightness_topic, 0),
                 (self.led_effect_topic, 0),
                 (self.led_effect_topic, 0),
@@ -599,7 +621,8 @@ class MQTTHandler(BaseMQTTHandler):
                             playlist_name=playlist_name,
                             playlist_name=playlist_name,
                             run_mode=self.state.playlist_mode,
                             run_mode=self.state.playlist_mode,
                             pause_time=self.state.pause_time,
                             pause_time=self.state.pause_time,
-                            clear_pattern=self.state.clear_pattern
+                            clear_pattern=self.state.clear_pattern,
+                            shuffle=self.state.shuffle
                         ),
                         ),
                         self.main_loop
                         self.main_loop
                     ).add_done_callback(
                     ).add_done_callback(
@@ -652,6 +675,11 @@ class MQTTHandler(BaseMQTTHandler):
                 if clear_pattern in ["none", "random", "adaptive", "clear_from_in", "clear_from_out", "clear_sideway"]:
                 if clear_pattern in ["none", "random", "adaptive", "clear_from_in", "clear_from_out", "clear_sideway"]:
                     state.clear_pattern = clear_pattern
                     state.clear_pattern = clear_pattern
                     self.client.publish(f"{self.device_id}/playlist/clear_pattern/state", clear_pattern, retain=True)
                     self.client.publish(f"{self.device_id}/playlist/clear_pattern/state", clear_pattern, retain=True)
+            elif msg.topic == f"{self.device_id}/playlist/shuffle/set":
+                payload = msg.payload.decode()
+                shuffle_value = payload == "ON"
+                state.shuffle = shuffle_value
+                self.client.publish(f"{self.device_id}/playlist/shuffle/state", payload, retain=True)
             elif msg.topic == self.led_power_topic:
             elif msg.topic == self.led_power_topic:
                 # Handle LED power command (DW LEDs only)
                 # Handle LED power command (DW LEDs only)
                 payload = msg.payload.decode()
                 payload = msg.payload.decode()
@@ -798,6 +826,7 @@ class MQTTHandler(BaseMQTTHandler):
             self._publish_playlist_state()
             self._publish_playlist_state()
             self._publish_serial_state()
             self._publish_serial_state()
             self._publish_progress_state()
             self._publish_progress_state()
+            self._publish_playlist_settings_state()
             self._publish_led_state()
             self._publish_led_state()
 
 
             # Setup Home Assistant discovery
             # Setup Home Assistant discovery

+ 1 - 1
nginx.conf

@@ -42,7 +42,7 @@ server {
     }
     }
 
 
     # All backend API endpoints (legacy non-/api/ routes)
     # 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) {
+    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_pass http://127.0.0.1:8080;
         proxy_set_header Host $host;
         proxy_set_header Host $host;
         proxy_set_header X-Real-IP $remote_addr;
         proxy_set_header X-Real-IP $remote_addr;

+ 2 - 1
package.json

@@ -8,7 +8,8 @@
     "dev:frontend": "cd frontend && npm run dev",
     "dev:frontend": "cd frontend && npm run dev",
     "dev:backend": "python main.py",
     "dev:backend": "python main.py",
     "build": "cd frontend && npm run build",
     "build": "cd frontend && npm run build",
-    "start": "npm run build && python main.py"
+    "start": "npm run build && python main.py",
+    "prepare": "cp scripts/pre-commit .git/hooks/pre-commit 2>/dev/null && chmod +x .git/hooks/pre-commit 2>/dev/null || true"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "concurrently": "^9.0.0"
     "concurrently": "^9.0.0"

+ 39 - 0
pyproject.toml

@@ -0,0 +1,39 @@
+[project]
+name = "dune-weaver"
+version = "1.0.0"
+description = "Web-controlled kinetic sand table system"
+requires-python = ">=3.9"
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+asyncio_mode = "auto"
+asyncio_default_fixture_loop_scope = "function"
+markers = [
+    "hardware: marks tests requiring real hardware (skip in CI)",
+    "slow: marks slow tests",
+]
+addopts = [
+    "-v",
+    "--tb=short",
+    "--strict-markers",
+]
+filterwarnings = [
+    "ignore::DeprecationWarning",
+]
+
+[tool.coverage.run]
+relative_files = true
+source = ["modules", "main.py"]
+omit = [
+    "tests/*",
+    "*/__pycache__/*",
+]
+
+[tool.coverage.report]
+exclude_lines = [
+    "pragma: no cover",
+    "if TYPE_CHECKING:",
+    "raise NotImplementedError",
+    "if __name__ == .__main__.:",
+]
+show_missing = true

+ 18 - 0
requirements-dev.txt

@@ -0,0 +1,18 @@
+# Development and testing dependencies
+# Install: pip install -r requirements-dev.txt
+
+# Core testing
+pytest>=7.0
+pytest-asyncio>=0.23
+pytest-cov>=4.0
+pytest-timeout>=2.0
+
+# HTTP client for API testing
+httpx>=0.25
+
+# Serial port mocking (Linux/macOS only, not Windows)
+mock-serial>=0.0.1
+
+# Test data generation
+factory-boy>=3.3
+faker>=18.0

+ 44 - 0
scripts/pre-commit

@@ -0,0 +1,44 @@
+#!/bin/sh
+# Pre-commit hook: runs Ruff on staged Python files and frontend tests on staged TS/TSX files
+#
+# Install: cp scripts/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit
+
+set -e
+
+# Colors
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[0;33m'
+NC='\033[0m'
+
+# --- Ruff lint on staged Python files ---
+STAGED_PY=$(git diff --cached --name-only --diff-filter=ACM -- '*.py')
+
+if [ -n "$STAGED_PY" ]; then
+    printf "${YELLOW}Running Ruff on staged Python files...${NC}\n"
+    if command -v ruff >/dev/null 2>&1; then
+        if ! echo "$STAGED_PY" | xargs ruff check; then
+            printf "${RED}Ruff found issues. Fix them or run 'ruff check --fix' then re-stage.${NC}\n"
+            exit 1
+        fi
+        printf "${GREEN}Ruff passed.${NC}\n"
+    else
+        printf "${YELLOW}Ruff not installed, skipping Python lint. Install with: pip install ruff${NC}\n"
+    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')
+
+if [ -n "$STAGED_FE" ]; then
+    printf "${YELLOW}Running frontend tests...${NC}\n"
+    if [ -d "frontend/node_modules" ]; then
+        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"
+    else
+        printf "${YELLOW}frontend/node_modules not found, skipping tests. Run: cd frontend && npm install${NC}\n"
+    fi
+fi

BIN
static/IMG_7404.gif


BIN
static/og-image.jpg


+ 12 - 0
static/site.webmanifest

@@ -14,6 +14,18 @@
       "sizes": "512x512",
       "sizes": "512x512",
       "type": "image/png",
       "type": "image/png",
       "purpose": "any"
       "purpose": "any"
+    },
+    {
+      "src": "/static/android-chrome-192x192.png",
+      "sizes": "192x192",
+      "type": "image/png",
+      "purpose": "maskable"
+    },
+    {
+      "src": "/static/android-chrome-512x512.png",
+      "sizes": "512x512",
+      "type": "image/png",
+      "purpose": "maskable"
     }
     }
   ],
   ],
   "start_url": "/",
   "start_url": "/",

+ 190 - 0
tests/README.md

@@ -0,0 +1,190 @@
+# Testing Guide
+
+This document explains how to run tests for the Dune Weaver backend.
+
+## Quick Start
+
+```bash
+# Install test dependencies
+pip install -r requirements-dev.txt
+
+# Run all unit tests (no hardware needed)
+pytest tests/unit/ -v
+
+# Run with coverage report
+pytest tests/ --cov=modules --cov-report=term-missing
+```
+
+## Test Structure
+
+```
+tests/
+├── conftest.py              # Shared fixtures
+├── unit/                    # Unit tests (run in CI, no hardware)
+│   ├── conftest.py
+│   ├── test_api_patterns.py
+│   ├── test_api_playlists.py
+│   ├── test_api_status.py
+│   ├── test_connection_manager.py
+│   ├── test_pattern_manager.py
+│   └── test_playlist_manager.py
+├── integration/             # Integration tests (require hardware)
+│   ├── conftest.py
+│   ├── test_hardware.py
+│   ├── test_playback_controls.py
+│   └── test_playlist.py
+└── fixtures/                # Test data files
+```
+
+## Unit Tests
+
+Unit tests mock all hardware dependencies and run quickly. They're safe to run anywhere.
+
+```bash
+# Run all unit tests
+pytest tests/unit/ -v
+
+# Run specific test file
+pytest tests/unit/test_pattern_manager.py -v
+
+# Run specific test
+pytest tests/unit/test_api_status.py::test_get_status -v
+
+# Run tests matching a pattern
+pytest tests/unit/ -v -k "playlist"
+```
+
+## Integration Tests
+
+Integration tests require the sand table hardware to be connected. They are **skipped by default**.
+
+```bash
+# Run integration tests (with hardware connected)
+pytest tests/integration/ --run-hardware -v
+
+# Run specific integration test file
+pytest tests/integration/test_playback_controls.py --run-hardware -v
+
+# Run with output visible (helpful for debugging)
+pytest tests/integration/ --run-hardware -v -s
+```
+
+### Integration Test Categories
+
+| File | What it tests | Duration |
+|------|---------------|----------|
+| `test_hardware.py` | Serial connection, homing, movement, pattern execution | ~5-10 min |
+| `test_playback_controls.py` | Pause, resume, stop, skip, speed control | ~5 min |
+| `test_playlist.py` | Playlist modes, clear patterns, state updates | ~5 min |
+
+### Safety Notes
+
+- **Movement tests physically move the table** — ensure the ball path is clear
+- **Homing tests run the homing sequence** — table will move to home position
+- **Pattern tests execute real patterns** — star.thr runs end-to-end
+
+## Coverage Reports
+
+```bash
+# Terminal report
+pytest tests/ --cov=modules --cov-report=term-missing
+
+# HTML report (creates htmlcov/ directory)
+pytest tests/ --cov=modules --cov-report=html
+open htmlcov/index.html
+
+# XML report (for CI tools)
+pytest tests/ --cov=modules --cov-report=xml
+```
+
+## CI Behavior
+
+When `CI=true` environment variable is set:
+- All `@pytest.mark.hardware` tests are automatically skipped
+- Unit tests run normally
+
+```bash
+# Simulate CI environment locally
+CI=true pytest tests/ -v
+```
+
+## Common Commands
+
+| Command | Description |
+|---------|-------------|
+| `pytest tests/unit/ -v` | Run unit tests |
+| `pytest tests/integration/ --run-hardware -v` | Run integration tests |
+| `pytest tests/ --cov=modules` | Run with coverage |
+| `pytest tests/ -v -k "pattern"` | Run tests matching "pattern" |
+| `pytest tests/ -x` | Stop on first failure |
+| `pytest tests/ --lf` | Run only last failed tests |
+| `pytest tests/ -v -s` | Show print statements |
+
+## Adding New Tests
+
+### Unit Test Example
+
+```python
+# tests/unit/test_my_feature.py
+import pytest
+from unittest.mock import MagicMock, patch
+
+def test_my_function():
+    """Test description here."""
+    # Arrange
+    expected = "result"
+
+    # Act
+    result = my_function()
+
+    # Assert
+    assert result == expected
+
+@pytest.mark.asyncio
+async def test_async_function(async_client):
+    """Test async endpoint."""
+    response = await async_client.get("/my_endpoint")
+    assert response.status_code == 200
+```
+
+### Integration Test Example
+
+```python
+# tests/integration/test_my_hardware.py
+import pytest
+
+@pytest.mark.hardware
+@pytest.mark.slow
+def test_hardware_operation(hardware_port, run_hardware):
+    """Test that requires real hardware."""
+    if not run_hardware:
+        pytest.skip("Hardware tests disabled")
+
+    from modules.connection import connection_manager
+
+    conn = connection_manager.SerialConnection(hardware_port)
+    try:
+        assert conn.is_connected()
+        # ... test hardware operation
+    finally:
+        conn.close()
+```
+
+## Troubleshooting
+
+### Tests hang or timeout
+- Check if hardware is connected and powered on
+- Verify serial port is not in use by another application
+- Try running with `-s` flag to see output
+
+### Import errors
+- Ensure you're in the project root directory
+- Install dependencies: `pip install -r requirements-dev.txt`
+
+### Hardware tests skip unexpectedly
+- Make sure to pass `--run-hardware` flag
+- Check that `CI` environment variable is not set
+
+### Coverage shows 0%
+- Ensure `relative_files = true` is in pyproject.toml
+- Run from project root directory

+ 1 - 0
tests/__init__.py

@@ -0,0 +1 @@
+# Dune Weaver Backend Tests

+ 165 - 0
tests/conftest.py

@@ -0,0 +1,165 @@
+"""
+Root conftest.py - Shared fixtures for all tests.
+
+This file provides:
+- CI environment detection for auto-skipping hardware tests
+- AsyncClient fixture for API testing
+- Mock state fixture for isolated testing
+"""
+import os
+import pytest
+from unittest.mock import MagicMock, AsyncMock
+
+
+def pytest_configure(config):
+    """Configure pytest with custom markers and CI detection."""
+    # Register custom markers
+    config.addinivalue_line(
+        "markers", "hardware: marks tests requiring real hardware (skip in CI)"
+    )
+    config.addinivalue_line(
+        "markers", "slow: marks slow tests"
+    )
+
+
+def pytest_collection_modifyitems(config, items):
+    """Auto-skip hardware tests when CI=true environment variable is set."""
+    if os.environ.get("CI"):
+        skip_hardware = pytest.mark.skip(reason="Hardware not available in CI")
+        for item in items:
+            if "hardware" in item.keywords:
+                item.add_marker(skip_hardware)
+
+
+@pytest.fixture
+async def async_client():
+    """Async HTTP client for testing API endpoints.
+
+    Uses httpx AsyncClient with ASGITransport to test FastAPI app directly
+    without starting a server.
+    """
+    from httpx import ASGITransport, AsyncClient
+    from main import app
+
+    async with AsyncClient(
+        transport=ASGITransport(app=app),
+        base_url="http://test"
+    ) as client:
+        yield client
+
+
+@pytest.fixture
+def mock_state():
+    """Mock global state object for isolated testing.
+
+    Returns a MagicMock configured with common defaults to simulate
+    the application state without affecting real state.
+    """
+    mock = MagicMock()
+
+    # Connection mock
+    mock.conn = MagicMock()
+    mock.conn.is_connected.return_value = False
+    mock.port = None
+    mock.is_connected = False
+
+    # Pattern execution state
+    mock.current_playing_file = None
+    mock.is_running = False
+    mock.pause_requested = False
+    mock.stop_requested = False
+    mock.skip_requested = False
+    mock.execution_progress = None
+    mock.is_homing = False
+    mock.is_clearing = False
+
+    # Position state
+    mock.current_theta = 0.0
+    mock.current_rho = 0.0
+    mock.machine_x = 0.0
+    mock.machine_y = 0.0
+
+    # Speed and settings
+    mock.speed = 100
+    mock.clear_pattern_speed = None
+    mock.table_type = "dune_weaver"
+    mock.table_type_override = None
+    mock.homing = 0
+
+    # Playlist state
+    mock.current_playlist = None
+    mock.current_playlist_name = None
+    mock.current_playlist_index = None
+    mock.playlist_mode = None
+    mock.pause_time_remaining = 0
+    mock.original_pause_time = None
+
+    # LED state
+    mock.led_controller = None
+    mock.led_provider = "none"
+    mock.wled_ip = None
+    mock.dw_led_idle_effect = "solid"
+    mock.dw_led_playing_effect = "rainbow"
+    mock.dw_led_idle_timeout_enabled = False
+    mock.dw_led_idle_timeout_minutes = 30
+
+    # Scheduled pause
+    mock.scheduled_pause_enabled = False
+    mock.scheduled_pause_time_slots = []
+    mock.scheduled_pause_control_wled = False
+    mock.scheduled_pause_finish_pattern = False
+    mock.scheduled_pause_timezone = None
+
+    # Steps and gear ratio
+    mock.x_steps_per_mm = 200.0
+    mock.y_steps_per_mm = 287.0
+    mock.gear_ratio = 10.0
+
+    # Auto-home settings
+    mock.auto_home_enabled = False
+    mock.auto_home_after_patterns = 10
+    mock.patterns_since_last_home = 0
+
+    # Custom clear patterns
+    mock.custom_clear_from_out = None
+    mock.custom_clear_from_in = None
+
+    # Homing offset
+    mock.angular_homing_offset_degrees = 0.0
+
+    # Methods
+    mock.save = MagicMock()
+    mock.get_stop_event = MagicMock(return_value=None)
+    mock.get_skip_event = MagicMock(return_value=None)
+    mock.wait_for_interrupt = AsyncMock(return_value='timeout')
+
+    return mock
+
+
+@pytest.fixture
+def mock_connection():
+    """Mock connection object for testing hardware communication.
+
+    Returns a MagicMock configured to simulate serial/websocket connection.
+    """
+    mock = MagicMock()
+    mock.is_connected.return_value = True
+    mock.send = MagicMock()
+    mock.readline = MagicMock(return_value="ok")
+    mock.in_waiting = MagicMock(return_value=0)
+    mock.flush = MagicMock()
+    mock.close = MagicMock()
+    mock.reset_input_buffer = MagicMock()
+    return mock
+
+
+@pytest.fixture
+def patterns_dir(tmp_path):
+    """Create a temporary patterns directory for testing.
+
+    Returns the path to a temporary directory that can be used
+    for pattern file operations during tests.
+    """
+    patterns = tmp_path / "patterns"
+    patterns.mkdir()
+    return patterns

+ 0 - 0
tests/fixtures/.gitkeep


+ 1 - 0
tests/integration/__init__.py

@@ -0,0 +1 @@
+# Integration tests - hardware tests, local only

+ 144 - 0
tests/integration/conftest.py

@@ -0,0 +1,144 @@
+"""
+Integration test fixtures - Hardware detection and setup.
+
+These fixtures help determine if real hardware is available
+and provide setup/teardown for hardware-dependent tests.
+"""
+import pytest
+import serial.tools.list_ports
+
+
+def pytest_addoption(parser):
+    """Add --run-hardware option to pytest CLI."""
+    parser.addoption(
+        "--run-hardware",
+        action="store_true",
+        default=False,
+        help="Run tests that require real hardware connection"
+    )
+
+
+@pytest.fixture
+def run_hardware(request):
+    """Check if hardware tests should run."""
+    return request.config.getoption("--run-hardware")
+
+
+@pytest.fixture
+def available_serial_ports():
+    """Return list of available serial ports on this machine.
+
+    Filters out known non-hardware ports like debug consoles.
+    """
+    IGNORE_PORTS = ['/dev/cu.debug-console', '/dev/cu.Bluetooth-Incoming-Port']
+    ports = serial.tools.list_ports.comports()
+    return [port.device for port in ports if port.device not in IGNORE_PORTS]
+
+
+@pytest.fixture
+def hardware_port(available_serial_ports, run_hardware):
+    """Get a hardware port for testing, or skip if not available.
+
+    This fixture:
+    1. Checks if --run-hardware flag was passed
+    2. Checks if any serial ports are available
+    3. Returns the first available port or skips the test
+    """
+    if not run_hardware:
+        pytest.skip("Hardware tests disabled (use --run-hardware to enable)")
+
+    if not available_serial_ports:
+        pytest.skip("No serial ports available for hardware testing")
+
+    # Prefer USB ports over built-in ports
+    usb_ports = [p for p in available_serial_ports if 'usb' in p.lower() or 'USB' in p]
+    if usb_ports:
+        return usb_ports[0]
+
+    return available_serial_ports[0]
+
+
+@pytest.fixture
+def serial_connection(hardware_port):
+    """Create a real serial connection for testing.
+
+    This fixture establishes an actual serial connection to the hardware.
+    The connection is automatically closed after the test.
+    """
+    import serial
+
+    conn = serial.Serial(hardware_port, baudrate=115200, timeout=2)
+    yield conn
+    conn.close()
+
+
+@pytest.fixture(autouse=True)
+def fast_test_speed(run_hardware):
+    """Set speed to 500 for faster integration tests.
+
+    This fixture runs automatically for all integration tests.
+    Restores original speed after the test.
+    """
+    if not run_hardware:
+        yield
+        return
+
+    from modules.core.state import state
+
+    original_speed = state.speed
+    state.speed = 500  # Fast speed for tests
+
+    yield
+
+    state.speed = original_speed  # Restore original speed
+
+
+@pytest.fixture(autouse=True)
+def reset_asyncio_events(run_hardware):
+    """Reset global asyncio primitives before each test.
+
+    The pattern_manager uses global asyncio objects (Lock, Event) that are
+    bound to the event loop where they were created. When TestClient creates
+    its own event loop, these become incompatible.
+
+    This fixture resets them to None so they get recreated in the current loop.
+    Also ensures pause/stop state is cleared so tests start fresh.
+    """
+    if not run_hardware:
+        yield
+        return
+
+    import modules.core.pattern_manager as pm
+    from modules.core.state import state
+
+    # Reset pattern_manager's global async primitives
+    pm.pause_event = None
+    pm.pattern_lock = None  # Will be recreated via get_pattern_lock()
+
+    # Reset state's event loop tracking so events get recreated in new loop
+    state._event_loop = None
+    state._stop_event = None
+    state._skip_event = None
+
+    # Clear any lingering pause/stop state from previous tests
+    state._pause_requested = False
+    state._stop_requested = False
+    state._skip_requested = False
+
+    # Clear playback state
+    state.current_playing_file = None
+    state.current_playlist = None
+    state.playlist_mode = None
+    state.current_playlist_index = None
+
+    yield
+
+    # Clean up after test
+    pm.pause_event = None
+    pm.pattern_lock = None
+    state._event_loop = None
+    state._stop_event = None
+    state._skip_event = None
+    state._pause_requested = False
+    state._stop_requested = False
+    state._skip_requested = False

+ 475 - 0
tests/integration/test_hardware.py

@@ -0,0 +1,475 @@
+"""
+Integration tests for hardware communication.
+
+These tests require real hardware to be connected and are skipped by default.
+Run with: pytest tests/integration/ --run-hardware
+
+All tests in this file are marked with @pytest.mark.hardware and will
+be automatically skipped in CI environments (when CI=true).
+
+Test order matters for some tests - they build on each other:
+1. test_homing_sequence - Homes the table (required first)
+2. test_move_to_perimeter - Moves ball to edge
+3. test_move_to_center - Moves ball to center
+4. test_execute_star_pattern - Runs a full pattern
+"""
+import pytest
+import time
+import os
+import json
+import asyncio
+
+
+@pytest.mark.hardware
+class TestSerialConnection:
+    """Tests for real serial connection to sand table hardware."""
+
+    def test_serial_port_opens(self, serial_connection):
+        """Test that we can open a serial connection to the hardware."""
+        assert serial_connection.is_open
+        assert serial_connection.baudrate == 115200
+
+    def test_grbl_status_query(self, serial_connection):
+        """Test querying GRBL status with '?' command.
+
+        GRBL responds with a status string like:
+        <Idle|MPos:0.000,0.000,0.000|Bf:15,128>
+        <Run|MPos:10.000,5.000,0.000|Bf:15,128>
+        <Hold|WPos:0.000,0.000,0.000|Bf:15,128>
+
+        Note: Table may be in any state (Idle, Run, Hold, Alarm, etc.)
+        """
+        # Clear any stale data
+        serial_connection.reset_input_buffer()
+
+        # Send status query
+        serial_connection.write(b'?')
+        serial_connection.flush()
+
+        # Wait for response
+        time.sleep(0.1)
+        response = serial_connection.readline().decode().strip()
+
+        # GRBL status starts with '<' and contains position info
+        # Don't assume Idle - table could be in Run, Hold, Alarm, etc.
+        assert response.startswith('<'), f"Expected GRBL status starting with '<', got: {response}"
+        assert 'Pos:' in response, f"Expected position data (MPos or WPos) in: {response}"
+        assert '>' in response, f"Expected closing '>' in status: {response}"
+
+    def test_grbl_settings_query(self, serial_connection):
+        """Test querying GRBL settings with '$$' command.
+
+        GRBL should respond with settings like:
+        $0=10
+        $1=25
+        ...
+        ok
+        """
+        # Clear any stale data
+        serial_connection.reset_input_buffer()
+
+        # Send settings query
+        serial_connection.write(b'$$\n')
+        serial_connection.flush()
+
+        # Collect all response lines
+        responses = []
+        timeout = time.time() + 2  # 2 second timeout
+
+        while time.time() < timeout:
+            if serial_connection.in_waiting:
+                line = serial_connection.readline().decode().strip()
+                responses.append(line)
+                if line == 'ok':
+                    break
+            time.sleep(0.01)
+
+        # Should have received settings
+        assert len(responses) > 1, "Expected GRBL settings response"
+        assert responses[-1] == 'ok', f"Expected 'ok' at end, got: {responses[-1]}"
+
+        # At least some settings should start with '$'
+        settings = [r for r in responses if r.startswith('$')]
+        assert len(settings) > 0, "Expected at least one setting line"
+
+
+@pytest.mark.hardware
+class TestConnectionManager:
+    """Integration tests for the connection_manager module with real hardware."""
+
+    def test_list_serial_ports_finds_hardware(self, available_serial_ports, run_hardware):
+        """Test that list_serial_ports finds the connected hardware."""
+        if not run_hardware:
+            pytest.skip("Hardware tests disabled")
+
+        from modules.connection import connection_manager
+
+        ports = connection_manager.list_serial_ports()
+
+        # Should find at least one port
+        assert len(ports) > 0, "Expected to find at least one serial port"
+
+        # Should match what we found independently
+        for port in available_serial_ports:
+            if 'usb' in port.lower() or 'tty' in port.lower():
+                assert port in ports or any(port in p for p in ports)
+
+    def test_serial_connection_class(self, hardware_port, run_hardware):
+        """Test SerialConnection class with real hardware.
+
+        This tests the actual SerialConnection wrapper from connection_manager.
+        """
+        if not run_hardware:
+            pytest.skip("Hardware tests disabled")
+
+        from modules.connection.connection_manager import SerialConnection
+
+        conn = SerialConnection(hardware_port)
+        try:
+            assert conn.is_connected()
+
+            # Send status query
+            conn.send('?')
+            time.sleep(0.1)
+
+            response = conn.readline()
+            assert '<' in response, f"Expected GRBL status, got: {response}"
+        finally:
+            conn.close()
+
+    def test_firmware_detection(self, hardware_port, run_hardware):
+        """Test firmware type detection (FluidNC vs GRBL)."""
+        if not run_hardware:
+            pytest.skip("Hardware tests disabled")
+
+        from modules.connection.connection_manager import SerialConnection, _detect_firmware
+        from modules.core.state import state
+
+        conn = SerialConnection(hardware_port)
+        state.conn = conn
+        try:
+            firmware_type, version = _detect_firmware()
+
+            # Should detect one of the known firmware types
+            assert firmware_type in ['fluidnc', 'grbl', 'unknown'], \
+                f"Unexpected firmware type: {firmware_type}"
+
+            print(f"Detected firmware: {firmware_type} {version or ''}")
+        finally:
+            conn.close()
+            state.conn = None
+
+
+@pytest.mark.hardware
+@pytest.mark.slow
+class TestSoftReset:
+    """Tests for soft reset functionality."""
+
+    def test_soft_reset(self, hardware_port, run_hardware):
+        """Test soft reset using firmware-appropriate command.
+
+        FluidNC uses $Bye, GRBL uses Ctrl+X (0x18).
+        The test auto-detects firmware type and sends the correct command.
+        """
+        if not run_hardware:
+            pytest.skip("Hardware tests disabled")
+
+        from modules.connection.connection_manager import SerialConnection, _detect_firmware
+        from modules.core.state import state
+
+        conn = SerialConnection(hardware_port)
+        state.conn = conn
+        try:
+            # Detect firmware to determine reset command
+            firmware_type, _ = _detect_firmware()
+
+            # Clear buffer
+            conn.ser.reset_input_buffer()
+
+            # Send appropriate reset command
+            if firmware_type == 'fluidnc':
+                conn.ser.write(b'$Bye\n')
+                reset_cmd = '$Bye'
+            else:
+                conn.ser.write(b'\x18')
+                reset_cmd = 'Ctrl+X'
+
+            conn.flush()
+            print(f"Sent {reset_cmd} reset command")
+
+            # Wait for reset and startup message
+            time.sleep(1.5)
+
+            # Collect responses
+            responses = []
+            timeout = time.time() + 3
+
+            while time.time() < timeout:
+                if conn.ser.in_waiting:
+                    line = conn.ser.readline().decode().strip()
+                    if line:
+                        responses.append(line)
+                        print(f"  Response: {line}")
+                time.sleep(0.01)
+
+            # Should see GRBL/FluidNC startup message
+            all_responses = ' '.join(responses)
+            assert 'Grbl' in all_responses or 'grbl' in all_responses.lower() or 'FluidNC' in all_responses, \
+                f"Expected GRBL/FluidNC startup message, got: {responses}"
+
+        finally:
+            conn.close()
+            state.conn = None
+
+
+@pytest.mark.hardware
+@pytest.mark.slow
+class TestTableMovement:
+    """Tests for table movement operations.
+
+    IMPORTANT: These tests physically move the table!
+    Run in order: homing -> perimeter -> center -> pattern
+    """
+
+    def test_homing_sequence(self, hardware_port, run_hardware):
+        """Test full homing sequence.
+
+        This test:
+        1. Connects to hardware
+        2. Runs the homing procedure
+        3. Verifies position matches the configured homing offset
+        """
+        if not run_hardware:
+            pytest.skip("Hardware tests disabled")
+
+        import math
+        from modules.connection import connection_manager
+        from modules.core.state import state
+
+        # Connect and initialize
+        conn = connection_manager.SerialConnection(hardware_port)
+        state.conn = conn
+
+        try:
+            # Run homing (timeout 120 seconds for crash homing)
+            print("Starting homing sequence...")
+            success = connection_manager.home(timeout=120)
+
+            assert success, "Homing sequence failed"
+
+            # After homing, theta should match the configured angular_homing_offset_degrees
+            # (converted to radians), and rho should be near 0
+            expected_theta = math.radians(state.angular_homing_offset_degrees)
+            theta_diff = abs(state.current_theta - expected_theta)
+
+            assert theta_diff < 0.1, \
+                f"Expected theta near {expected_theta:.3f} rad ({state.angular_homing_offset_degrees}°), got: {state.current_theta:.3f}"
+            assert abs(state.current_rho) < 0.1, \
+                f"Expected rho near 0 after homing, got: {state.current_rho}"
+
+            print(f"Homing complete: theta={state.current_theta:.3f} rad (offset={state.angular_homing_offset_degrees}°), rho={state.current_rho:.3f}")
+
+        finally:
+            conn.close()
+            state.conn = None
+
+    def test_move_to_perimeter(self, hardware_port, run_hardware):
+        """Test moving ball to perimeter (rho=1.0) via API endpoint.
+
+        Uses the /move_to_perimeter endpoint which waits for idle before returning.
+        """
+        if not run_hardware:
+            pytest.skip("Hardware tests disabled")
+
+        from httpx import Client
+        from modules.connection import connection_manager
+        from modules.core.state import state
+
+        # Connect
+        conn = connection_manager.SerialConnection(hardware_port)
+        state.conn = conn
+
+        try:
+            # Use the API endpoint which waits for idle
+            print("Moving to perimeter via API...")
+
+            from main import app
+            from fastapi.testclient import TestClient
+
+            client = TestClient(app)
+            response = client.post("/move_to_perimeter")
+
+            assert response.status_code == 200, f"API returned {response.status_code}: {response.text}"
+            assert response.json()["success"] is True
+
+            # Verify we're near the perimeter
+            assert state.current_rho > 0.9, \
+                f"Expected rho near 1.0, got: {state.current_rho}"
+
+            print(f"At perimeter: theta={state.current_theta:.3f}, rho={state.current_rho:.3f}")
+
+        finally:
+            conn.close()
+            state.conn = None
+
+    def test_move_to_center(self, hardware_port, run_hardware):
+        """Test moving ball to center (rho=0.0) via API endpoint.
+
+        Uses the /move_to_center endpoint which waits for idle before returning.
+        """
+        if not run_hardware:
+            pytest.skip("Hardware tests disabled")
+
+        from modules.connection import connection_manager
+        from modules.core.state import state
+
+        # Connect
+        conn = connection_manager.SerialConnection(hardware_port)
+        state.conn = conn
+
+        try:
+            # Use the API endpoint which waits for idle
+            print("Moving to center via API...")
+
+            from main import app
+            from fastapi.testclient import TestClient
+
+            client = TestClient(app)
+            response = client.post("/move_to_center")
+
+            assert response.status_code == 200, f"API returned {response.status_code}: {response.text}"
+            assert response.json()["success"] is True
+
+            # Verify we're near the center
+            assert state.current_rho < 0.1, \
+                f"Expected rho near 0.0, got: {state.current_rho}"
+
+            print(f"At center: theta={state.current_theta:.3f}, rho={state.current_rho:.3f}")
+
+        finally:
+            conn.close()
+            state.conn = None
+
+    def test_execute_star_pattern(self, hardware_port, run_hardware):
+        """Test executing the star.thr pattern.
+
+        This runs a full pattern execution and verifies it completes successfully.
+        The star pattern is relatively quick and good for testing.
+        """
+        if not run_hardware:
+            pytest.skip("Hardware tests disabled")
+
+        from modules.connection import connection_manager
+        from modules.core import pattern_manager
+        from modules.core.state import state
+
+        # Connect
+        conn = connection_manager.SerialConnection(hardware_port)
+        state.conn = conn
+
+        try:
+            pattern_path = './patterns/star.thr'
+            assert os.path.exists(pattern_path), f"Pattern file not found: {pattern_path}"
+
+            print(f"Executing pattern: {pattern_path}")
+
+            async def run_pattern():
+                await pattern_manager.run_theta_rho_file(pattern_path)
+
+            asyncio.get_event_loop().run_until_complete(run_pattern())
+
+            # Pattern should have completed
+            assert state.current_playing_file is None, \
+                "Pattern should have completed (current_playing_file should be None)"
+
+            print("Pattern execution completed successfully")
+
+        finally:
+            conn.close()
+            state.conn = None
+
+
+@pytest.mark.hardware
+class TestWebSocketConnection:
+    """Tests for WebSocket connection to FluidNC."""
+
+    def test_websocket_status_endpoint(self, run_hardware):
+        """Test the /ws/status WebSocket endpoint.
+
+        This tests the FastAPI WebSocket endpoint, not direct FluidNC WebSocket.
+        """
+        if not run_hardware:
+            pytest.skip("Hardware tests disabled")
+
+        from fastapi.testclient import TestClient
+        from main import app
+
+        client = TestClient(app)
+
+        # Connect to WebSocket
+        with client.websocket_connect("/ws/status") as websocket:
+            # Should receive initial status
+            message = websocket.receive_json()
+
+            # Status format is {'type': 'status_update', 'data': {...}}
+            assert message.get("type") == "status_update", \
+                f"Expected type='status_update', got: {message}"
+
+            data = message.get("data", {})
+            assert "is_running" in data, \
+                f"Expected 'is_running' in data, got: {data.keys()}"
+
+            print(f"Received WebSocket status: {data}")
+
+
+@pytest.mark.hardware
+class TestStatePersistence:
+    """Tests for state persistence across connections."""
+
+    def test_position_saved_on_disconnect(self, hardware_port, run_hardware, tmp_path):
+        """Test that position is saved to state.json on disconnect.
+
+        This verifies the state persistence mechanism works correctly.
+        """
+        if not run_hardware:
+            pytest.skip("Hardware tests disabled")
+
+        from modules.connection import connection_manager
+        from modules.core.state import state
+
+        # Connect
+        conn = connection_manager.SerialConnection(hardware_port)
+        state.conn = conn
+
+        try:
+            # Record current position
+            initial_theta = state.current_theta
+            initial_rho = state.current_rho
+
+            # The state file path
+            state_file = './state.json'
+
+            # Disconnect (this should trigger state save)
+            conn.close()
+            state.conn = None
+
+            # Give it a moment to save
+            time.sleep(0.5)
+
+            # Verify state was saved
+            assert os.path.exists(state_file), "state.json should exist"
+
+            with open(state_file, 'r') as f:
+                saved_state = json.load(f)
+
+            # Check that position-related fields exist
+            # The exact field names depend on your state implementation
+            assert 'current_theta' in saved_state or 'theta' in saved_state or 'machine_x' in saved_state, \
+                f"Expected position data in state.json, got keys: {list(saved_state.keys())}"
+
+            print(f"State saved successfully. Position before disconnect: theta={initial_theta}, rho={initial_rho}")
+
+        finally:
+            if state.conn:
+                state.conn.close()
+                state.conn = None

+ 576 - 0
tests/integration/test_playback_controls.py

@@ -0,0 +1,576 @@
+"""
+Integration tests for playback controls.
+
+These tests verify pause, resume, stop, skip, and speed control functionality
+with real hardware connected.
+
+Run with: pytest tests/integration/test_playback_controls.py --run-hardware -v
+"""
+import pytest
+import time
+import threading
+import os
+
+
+def start_pattern_async(client, file_name="star.thr"):
+    """Helper to start a pattern in a background thread.
+
+    Returns the thread so caller can join() it after stopping.
+    """
+    def run():
+        client.post("/run_theta_rho", json={"file_name": file_name})
+
+    thread = threading.Thread(target=run)
+    thread.start()
+    return thread
+
+
+def stop_pattern(client):
+    """Helper to stop pattern execution.
+
+    Uses force_stop which doesn't wait for locks (avoids event loop issues in tests).
+    """
+    response = client.post("/force_stop")
+    return response
+
+
+@pytest.mark.hardware
+@pytest.mark.slow
+class TestPauseResume:
+    """Tests for pause and resume functionality."""
+
+    def test_pause_during_pattern(self, hardware_port, run_hardware):
+        """Test pausing execution mid-pattern.
+
+        Verifies:
+        1. Pattern starts executing
+        2. Pause request is acknowledged
+        3. Ball actually stops moving
+        """
+        if not run_hardware:
+            pytest.skip("Hardware tests disabled")
+
+        from modules.connection import connection_manager
+        from modules.core.state import state
+        from fastapi.testclient import TestClient
+        from main import app
+
+        conn = connection_manager.SerialConnection(hardware_port)
+        state.conn = conn
+
+        try:
+            client = TestClient(app)
+
+            # Start pattern in background
+            pattern_thread = start_pattern_async(client, "star.thr")
+
+            # Wait for pattern to start
+            time.sleep(3)
+            assert state.current_playing_file is not None, "Pattern should be running"
+            print(f"Pattern running: {state.current_playing_file}")
+
+            # Record position before pause
+            pos_before = (state.current_theta, state.current_rho)
+
+            # Pause execution
+            response = client.post("/pause_execution")
+            assert response.status_code == 200, f"Pause failed: {response.text}"
+            assert state.pause_requested, "pause_requested should be True"
+
+            # Wait and check ball stopped
+            time.sleep(1)
+            pos_after = (state.current_theta, state.current_rho)
+
+            theta_diff = abs(pos_after[0] - pos_before[0])
+            rho_diff = abs(pos_after[1] - pos_before[1])
+
+            print(f"Position change during pause: theta={theta_diff:.4f}, rho={rho_diff:.4f}")
+
+            # Allow small tolerance for deceleration
+            assert theta_diff < 0.5, f"Theta changed too much while paused: {theta_diff}"
+            assert rho_diff < 0.1, f"Rho changed too much while paused: {rho_diff}"
+
+            # Clean up
+            stop_pattern(client)
+            pattern_thread.join(timeout=5)
+
+        finally:
+            conn.close()
+            state.conn = None
+
+    def test_resume_after_pause(self, hardware_port, run_hardware):
+        """Test resuming execution after pause.
+
+        Verifies:
+        1. Pattern can be paused
+        2. Resume causes movement to continue
+        3. Position changes after resume
+        """
+        if not run_hardware:
+            pytest.skip("Hardware tests disabled")
+
+        from modules.connection import connection_manager
+        from modules.core.state import state
+        from fastapi.testclient import TestClient
+        from main import app
+
+        conn = connection_manager.SerialConnection(hardware_port)
+        state.conn = conn
+
+        try:
+            client = TestClient(app)
+
+            # Start pattern
+            pattern_thread = start_pattern_async(client, "star.thr")
+
+            # Wait for pattern to actually start executing (not just queued)
+            # Check that position has changed from initial, indicating movement
+            initial_pos = (state.current_theta, state.current_rho)
+            max_wait = 10  # seconds
+            started = False
+            for _ in range(max_wait * 2):  # Check every 0.5s
+                time.sleep(0.5)
+                if state.current_playing_file is not None:
+                    current_pos = (state.current_theta, state.current_rho)
+                    # Check if position changed (pattern actually moving)
+                    if (abs(current_pos[0] - initial_pos[0]) > 0.01 or
+                            abs(current_pos[1] - initial_pos[1]) > 0.01):
+                        started = True
+                        print(f"Pattern started moving: theta={current_pos[0]:.3f}, rho={current_pos[1]:.3f}")
+                        break
+
+            assert started, "Pattern should start moving within timeout"
+
+            # Pause
+            client.post("/pause_execution")
+            time.sleep(1)  # Wait for pause to take effect
+
+            pos_paused = (state.current_theta, state.current_rho)
+            print(f"Position when paused: theta={pos_paused[0]:.4f}, rho={pos_paused[1]:.4f}")
+
+            # Resume
+            response = client.post("/resume_execution")
+            assert response.status_code == 200, f"Resume failed: {response.text}"
+            assert not state.pause_requested, "pause_requested should be False after resume"
+
+            # Wait for movement after resume
+            time.sleep(3)
+
+            pos_resumed = (state.current_theta, state.current_rho)
+
+            theta_diff = abs(pos_resumed[0] - pos_paused[0])
+            rho_diff = abs(pos_resumed[1] - pos_paused[1])
+
+            print(f"Position after resume: theta={pos_resumed[0]:.4f}, rho={pos_resumed[1]:.4f}")
+            print(f"Position change after resume: theta={theta_diff:.4f}, rho={rho_diff:.4f}")
+            assert theta_diff > 0.1 or rho_diff > 0.05, "Position should change after resume"
+
+            # Clean up
+            stop_pattern(client)
+            pattern_thread.join(timeout=5)
+
+        finally:
+            conn.close()
+            state.conn = None
+
+
+@pytest.mark.hardware
+@pytest.mark.slow
+class TestStop:
+    """Tests for stop functionality."""
+
+    def test_stop_during_pattern(self, hardware_port, run_hardware):
+        """Test stopping execution mid-pattern.
+
+        Verifies:
+        1. Stop clears current_playing_file
+        2. Pattern execution actually stops
+
+        Note: Uses force_stop in test environment because regular stop_execution
+        has asyncio lock issues with TestClient's event loop handling.
+        """
+        if not run_hardware:
+            pytest.skip("Hardware tests disabled")
+
+        from modules.connection import connection_manager
+        from modules.core.state import state
+        from fastapi.testclient import TestClient
+        from main import app
+
+        conn = connection_manager.SerialConnection(hardware_port)
+        state.conn = conn
+
+        try:
+            client = TestClient(app)
+
+            # Start pattern
+            pattern_thread = start_pattern_async(client, "star.thr")
+            time.sleep(3)
+            assert state.current_playing_file is not None, "Pattern should be running"
+
+            # Stop execution (use force_stop for test reliability)
+            response = stop_pattern(client)
+            assert response.status_code == 200, f"Stop failed: {response.text}"
+
+            # Verify stopped
+            time.sleep(0.5)
+            assert state.current_playing_file is None, "current_playing_file should be None"
+
+            print("Stop completed successfully")
+            pattern_thread.join(timeout=5)
+
+        finally:
+            conn.close()
+            state.conn = None
+
+    def test_force_stop(self, hardware_port, run_hardware):
+        """Test force stop clears all state."""
+        if not run_hardware:
+            pytest.skip("Hardware tests disabled")
+
+        from modules.connection import connection_manager
+        from modules.core.state import state
+        from fastapi.testclient import TestClient
+        from main import app
+
+        conn = connection_manager.SerialConnection(hardware_port)
+        state.conn = conn
+
+        try:
+            client = TestClient(app)
+
+            # Start pattern
+            pattern_thread = start_pattern_async(client, "star.thr")
+            time.sleep(3)
+
+            # Force stop via API
+            response = client.post("/force_stop")
+            assert response.status_code == 200, f"Force stop failed: {response.text}"
+
+            time.sleep(0.5)
+
+            # Verify all state cleared
+            assert state.current_playing_file is None
+            assert state.current_playlist is None
+
+            print("Force stop completed successfully")
+            pattern_thread.join(timeout=5)
+
+        finally:
+            conn.close()
+            state.conn = None
+
+    def test_pause_then_stop(self, hardware_port, run_hardware):
+        """Test that stop works while paused."""
+        if not run_hardware:
+            pytest.skip("Hardware tests disabled")
+
+        from modules.connection import connection_manager
+        from modules.core.state import state
+        from fastapi.testclient import TestClient
+        from main import app
+
+        conn = connection_manager.SerialConnection(hardware_port)
+        state.conn = conn
+
+        try:
+            client = TestClient(app)
+
+            # Start pattern
+            pattern_thread = start_pattern_async(client, "star.thr")
+            time.sleep(3)
+
+            # Pause first
+            client.post("/pause_execution")
+            time.sleep(0.5)
+            assert state.pause_requested, "Should be paused"
+
+            # Now stop while paused
+            response = stop_pattern(client)
+            assert response.status_code == 200, f"Stop while paused failed: {response.text}"
+            assert state.current_playing_file is None, "Pattern should be stopped"
+
+            print("Stop while paused completed successfully")
+            pattern_thread.join(timeout=5)
+
+        finally:
+            conn.close()
+            state.conn = None
+
+
+@pytest.mark.hardware
+@pytest.mark.slow
+class TestSpeedControl:
+    """Tests for speed control functionality."""
+
+    def test_set_speed_during_playback(self, hardware_port, run_hardware):
+        """Test changing speed during pattern execution."""
+        if not run_hardware:
+            pytest.skip("Hardware tests disabled")
+
+        from modules.connection import connection_manager
+        from modules.core.state import state
+        from fastapi.testclient import TestClient
+        from main import app
+
+        conn = connection_manager.SerialConnection(hardware_port)
+        state.conn = conn
+
+        try:
+            client = TestClient(app)
+            original_speed = state.speed
+
+            # Start pattern
+            pattern_thread = start_pattern_async(client, "star.thr")
+            time.sleep(3)
+
+            # Change speed via API
+            new_speed = 150
+            response = client.post("/set_speed", json={"speed": new_speed})
+            assert response.status_code == 200, f"Set speed failed: {response.text}"
+            assert state.speed == new_speed, "Speed should be updated"
+
+            print(f"Speed changed from {original_speed} to {new_speed}")
+
+            # Let it run at new speed briefly
+            time.sleep(2)
+
+            # Clean up
+            stop_pattern(client)
+            pattern_thread.join(timeout=5)
+
+        finally:
+            conn.close()
+            state.conn = None
+
+    def test_speed_bounds(self, hardware_port, run_hardware):
+        """Test that invalid speed values are rejected."""
+        if not run_hardware:
+            pytest.skip("Hardware tests disabled")
+
+        from modules.connection import connection_manager
+        from modules.core.state import state
+        from fastapi.testclient import TestClient
+        from main import app
+
+        conn = connection_manager.SerialConnection(hardware_port)
+        state.conn = conn
+
+        try:
+            client = TestClient(app)
+            original_speed = state.speed
+
+            # Valid speeds should work
+            response = client.post("/set_speed", json={"speed": 50})
+            assert response.status_code == 200
+
+            response = client.post("/set_speed", json={"speed": 200})
+            assert response.status_code == 200
+
+            # Invalid speed (0 or negative) should fail
+            response = client.post("/set_speed", json={"speed": 0})
+            assert response.status_code == 400, "Speed 0 should be rejected"
+
+            response = client.post("/set_speed", json={"speed": -10})
+            assert response.status_code == 400, "Negative speed should be rejected"
+
+            # Restore
+            client.post("/set_speed", json={"speed": original_speed})
+
+        finally:
+            conn.close()
+            state.conn = None
+
+    def test_change_speed_while_paused(self, hardware_port, run_hardware):
+        """Test changing speed while paused, then resuming."""
+        if not run_hardware:
+            pytest.skip("Hardware tests disabled")
+
+        from modules.connection import connection_manager
+        from modules.core.state import state
+        from fastapi.testclient import TestClient
+        from main import app
+
+        conn = connection_manager.SerialConnection(hardware_port)
+        state.conn = conn
+
+        try:
+            client = TestClient(app)
+            original_speed = state.speed
+
+            # Start pattern
+            pattern_thread = start_pattern_async(client, "star.thr")
+            time.sleep(3)
+
+            # Pause
+            client.post("/pause_execution")
+            time.sleep(0.5)
+
+            # Change speed while paused
+            new_speed = 180
+            response = client.post("/set_speed", json={"speed": new_speed})
+            assert response.status_code == 200
+            print(f"Speed changed to {new_speed} while paused")
+
+            # Resume
+            client.post("/resume_execution")
+            time.sleep(2)
+
+            # Verify speed persisted
+            assert state.speed == new_speed, "Speed should persist after resume"
+
+            # Clean up
+            stop_pattern(client)
+            pattern_thread.join(timeout=5)
+
+            # Restore original speed
+            state.speed = original_speed
+
+        finally:
+            conn.close()
+            state.conn = None
+
+
+@pytest.mark.hardware
+@pytest.mark.slow
+class TestSkip:
+    """Tests for skip pattern functionality."""
+
+    def test_skip_pattern_in_playlist(self, hardware_port, run_hardware):
+        """Test skipping to next pattern in playlist."""
+        if not run_hardware:
+            pytest.skip("Hardware tests disabled")
+
+        from modules.connection import connection_manager
+        from modules.core import playlist_manager
+        from modules.core.state import state
+        from fastapi.testclient import TestClient
+        from main import app
+
+        conn = connection_manager.SerialConnection(hardware_port)
+        state.conn = conn
+
+        try:
+            client = TestClient(app)
+
+            # Create test playlist with 2 patterns
+            test_playlist_name = "_test_skip_playlist"
+            patterns = ["star.thr", "circle_normalized.thr"]
+
+            existing_patterns = [p for p in patterns if os.path.exists(f"./patterns/{p}")]
+            if len(existing_patterns) < 2:
+                pytest.skip("Need at least 2 patterns for skip test")
+
+            playlist_manager.create_playlist(test_playlist_name, existing_patterns)
+
+            try:
+                # Run playlist in background
+                def run_playlist():
+                    client.post("/run_playlist", json={
+                        "playlist_name": test_playlist_name,
+                        "pause_time": 0,
+                        "run_mode": "single"
+                    })
+
+                playlist_thread = threading.Thread(target=run_playlist)
+                playlist_thread.start()
+
+                # Wait for first pattern to start
+                time.sleep(3)
+
+                first_pattern = state.current_playing_file
+                print(f"First pattern: {first_pattern}")
+                assert first_pattern is not None
+
+                # Skip to next pattern
+                response = client.post("/skip_pattern")
+                assert response.status_code == 200, f"Skip failed: {response.text}"
+
+                # Wait for skip to process
+                time.sleep(3)
+
+                second_pattern = state.current_playing_file
+                print(f"After skip: {second_pattern}")
+
+                # Pattern should have changed (or playlist ended)
+                if second_pattern is not None:
+                    assert second_pattern != first_pattern or state.current_playlist_index > 0
+
+                # Clean up
+                stop_pattern(client)
+                playlist_thread.join(timeout=5)
+
+            finally:
+                playlist_manager.delete_playlist(test_playlist_name)
+
+        finally:
+            conn.close()
+            state.conn = None
+
+    def test_skip_while_paused(self, hardware_port, run_hardware):
+        """Test that skip works while paused."""
+        if not run_hardware:
+            pytest.skip("Hardware tests disabled")
+
+        from modules.connection import connection_manager
+        from modules.core import playlist_manager
+        from modules.core.state import state
+        from fastapi.testclient import TestClient
+        from main import app
+
+        conn = connection_manager.SerialConnection(hardware_port)
+        state.conn = conn
+
+        try:
+            client = TestClient(app)
+
+            # Create test playlist
+            test_playlist_name = "_test_skip_paused"
+            patterns = ["star.thr", "circle_normalized.thr"]
+
+            existing_patterns = [p for p in patterns if os.path.exists(f"./patterns/{p}")]
+            if len(existing_patterns) < 2:
+                pytest.skip("Need at least 2 patterns")
+
+            playlist_manager.create_playlist(test_playlist_name, existing_patterns)
+
+            try:
+                # Run playlist
+                def run_playlist():
+                    client.post("/run_playlist", json={
+                        "playlist_name": test_playlist_name,
+                        "run_mode": "single"
+                    })
+
+                playlist_thread = threading.Thread(target=run_playlist)
+                playlist_thread.start()
+
+                time.sleep(3)
+
+                # Pause
+                client.post("/pause_execution")
+                time.sleep(0.5)
+                assert state.pause_requested
+
+                first_pattern = state.current_playing_file
+
+                # Skip while paused
+                response = client.post("/skip_pattern")
+                assert response.status_code == 200
+
+                # Resume to allow skip to process
+                client.post("/resume_execution")
+                time.sleep(3)
+
+                print(f"Skipped from {first_pattern} while paused")
+
+                # Clean up
+                stop_pattern(client)
+                playlist_thread.join(timeout=5)
+
+            finally:
+                playlist_manager.delete_playlist(test_playlist_name)
+
+        finally:
+            conn.close()
+            state.conn = None

+ 529 - 0
tests/integration/test_playlist.py

@@ -0,0 +1,529 @@
+"""
+Integration tests for playlist functionality.
+
+These tests verify playlist playback modes, clear patterns,
+pause between patterns, and state updates.
+
+Run with: pytest tests/integration/test_playlist.py --run-hardware -v
+"""
+import pytest
+import time
+import threading
+import os
+
+
+def start_playlist_async(client, playlist_name, pause_time=1, run_mode="single",
+                          clear_pattern=None, shuffle=False):
+    """Helper to start a playlist in a background thread.
+
+    Returns the thread so caller can join() it after stopping.
+    """
+    def run():
+        payload = {
+            "playlist_name": playlist_name,
+            "pause_time": pause_time,
+            "run_mode": run_mode
+        }
+        if clear_pattern:
+            payload["clear_pattern"] = clear_pattern
+        if shuffle:
+            payload["shuffle"] = shuffle
+        client.post("/run_playlist", json=payload)
+
+    thread = threading.Thread(target=run)
+    thread.start()
+    return thread
+
+
+def start_pattern_async(client, file_name="star.thr"):
+    """Helper to start a pattern in a background thread.
+
+    Returns the thread so caller can join() it after stopping.
+    """
+    def run():
+        client.post("/run_theta_rho", json={"file_name": file_name})
+
+    thread = threading.Thread(target=run)
+    thread.start()
+    return thread
+
+
+def stop_pattern(client):
+    """Helper to stop pattern execution.
+
+    Uses force_stop which doesn't wait for locks (avoids event loop issues in tests).
+    """
+    response = client.post("/force_stop")
+    return response
+
+
+@pytest.fixture
+def test_playlist(run_hardware):
+    """Create a test playlist and clean it up after the test."""
+    if not run_hardware:
+        pytest.skip("Hardware tests disabled")
+
+    from modules.core import playlist_manager
+
+    playlist_name = "_integration_test_playlist"
+
+    # Use specific simple patterns for testing
+    test_patterns = [
+        "star.thr",
+        "circle_normalized.thr",
+        "square.thr"
+    ]
+
+    # Verify patterns exist
+    available_patterns = []
+    for pattern in test_patterns:
+        if os.path.exists(f"./patterns/{pattern}"):
+            available_patterns.append(pattern)
+
+    if len(available_patterns) < 2:
+        pytest.skip(f"Need at least 2 of these patterns: {test_patterns}")
+
+    # Create the playlist
+    playlist_manager.create_playlist(playlist_name, available_patterns)
+
+    yield {
+        "name": playlist_name,
+        "patterns": available_patterns,
+        "count": len(available_patterns)
+    }
+
+    # Cleanup
+    playlist_manager.delete_playlist(playlist_name)
+
+
+@pytest.mark.hardware
+@pytest.mark.slow
+class TestPlaylistModes:
+    """Tests for different playlist run modes."""
+
+    def test_run_playlist_single_mode(self, hardware_port, run_hardware, test_playlist):
+        """Test playlist in single mode - plays all patterns once then stops."""
+        from modules.connection import connection_manager
+        from modules.core.state import state
+        from fastapi.testclient import TestClient
+        from main import app
+
+        conn = connection_manager.SerialConnection(hardware_port)
+        state.conn = conn
+
+        try:
+            client = TestClient(app)
+
+            print(f"Test playlist: {test_playlist}")
+
+            # Try direct API call first to see response
+            response = client.post("/run_playlist", json={
+                "playlist_name": test_playlist["name"],
+                "pause_time": 1,
+                "run_mode": "single"
+            })
+            print(f"API response: {response.status_code} - {response.text}")
+
+            # Wait for it to start
+            time.sleep(3)
+
+            print(f"state.current_playlist = {state.current_playlist}")
+            print(f"state.playlist_mode = {state.playlist_mode}")
+            print(f"state.current_playing_file = {state.current_playing_file}")
+
+            assert state.current_playlist is not None, "Playlist should be running"
+            assert state.playlist_mode == "single", f"Mode should be 'single', got: {state.playlist_mode}"
+
+            print(f"Playlist running in single mode with {test_playlist['count']} patterns")
+
+            # Clean up
+            stop_pattern(client)
+
+        finally:
+            conn.close()
+            state.conn = None
+
+    def test_run_playlist_loop_mode(self, hardware_port, run_hardware, test_playlist):
+        """Test playlist in loop mode - continues from start after last pattern."""
+        from modules.connection import connection_manager
+        from modules.core.state import state
+        from fastapi.testclient import TestClient
+        from main import app
+
+        conn = connection_manager.SerialConnection(hardware_port)
+        state.conn = conn
+
+        try:
+            client = TestClient(app)
+
+            # Start playlist in background
+            playlist_thread = start_playlist_async(
+                client,
+                test_playlist["name"],
+                pause_time=1,
+                run_mode="loop"
+            )
+
+            time.sleep(3)
+
+            assert state.playlist_mode == "loop", f"Mode should be 'loop', got: {state.playlist_mode}"
+
+            print("Playlist running in loop mode")
+
+            # Clean up
+            stop_pattern(client)
+            playlist_thread.join(timeout=5)
+
+        finally:
+            conn.close()
+            state.conn = None
+
+    def test_run_playlist_shuffle(self, hardware_port, run_hardware, test_playlist):
+        """Test playlist shuffle mode randomizes order."""
+        from modules.connection import connection_manager
+        from modules.core.state import state
+        from fastapi.testclient import TestClient
+        from main import app
+
+        conn = connection_manager.SerialConnection(hardware_port)
+        state.conn = conn
+
+        try:
+            client = TestClient(app)
+
+            # Start playlist in background with shuffle
+            playlist_thread = start_playlist_async(
+                client,
+                test_playlist["name"],
+                pause_time=1,
+                run_mode="single",
+                shuffle=True
+            )
+
+            time.sleep(3)
+
+            # Playlist should be running
+            assert state.current_playlist is not None
+
+            print(f"Playlist running with shuffle enabled")
+            print(f"Current pattern: {state.current_playing_file}")
+            print(f"Playlist order: {state.current_playlist}")
+
+            # Clean up
+            stop_pattern(client)
+            playlist_thread.join(timeout=5)
+
+        finally:
+            conn.close()
+            state.conn = None
+
+
+@pytest.mark.hardware
+@pytest.mark.slow
+class TestPlaylistPause:
+    """Tests for pause time between patterns."""
+
+    def test_playlist_pause_between_patterns(self, hardware_port, run_hardware, test_playlist):
+        """Test that pause_time is respected between patterns."""
+        from modules.connection import connection_manager
+        from modules.core.state import state
+        from fastapi.testclient import TestClient
+        from main import app
+
+        conn = connection_manager.SerialConnection(hardware_port)
+        state.conn = conn
+
+        try:
+            client = TestClient(app)
+            pause_time = 5  # 5 seconds between patterns
+
+            # Start playlist in background
+            playlist_thread = start_playlist_async(
+                client,
+                test_playlist["name"],
+                pause_time=pause_time,
+                run_mode="single"
+            )
+
+            # Wait for first pattern to complete (this may take a while)
+            # For testing, we'll just verify the pause_time setting is stored
+            time.sleep(3)
+
+            # Check that pause_time_remaining is used during transitions
+            # (We can't easily wait for pattern completion in a test)
+            print(f"Playlist started with pause_time={pause_time}s")
+            print(f"Current pause_time_remaining: {state.pause_time_remaining}")
+
+            # Clean up
+            stop_pattern(client)
+            playlist_thread.join(timeout=5)
+
+        finally:
+            conn.close()
+            state.conn = None
+
+    def test_stop_during_playlist_pause(self, hardware_port, run_hardware, test_playlist):
+        """Test that stop works during the pause between patterns."""
+        from modules.connection import connection_manager
+        from modules.core.state import state
+        from fastapi.testclient import TestClient
+        from main import app
+
+        conn = connection_manager.SerialConnection(hardware_port)
+        state.conn = conn
+
+        try:
+            client = TestClient(app)
+
+            # Start playlist with long pause
+            playlist_thread = start_playlist_async(
+                client,
+                test_playlist["name"],
+                pause_time=30,  # Long pause
+                run_mode="single"
+            )
+
+            time.sleep(3)
+
+            # Stop (whether during pattern or pause)
+            response = stop_pattern(client)
+            assert response.status_code == 200, f"Stop failed: {response.text}"
+
+            time.sleep(0.5)
+            assert state.current_playlist is None, "Playlist should be stopped"
+
+            print("Successfully stopped during playlist")
+            playlist_thread.join(timeout=5)
+
+        finally:
+            conn.close()
+            state.conn = None
+
+
+@pytest.mark.hardware
+@pytest.mark.slow
+class TestPlaylistClearPattern:
+    """Tests for clear pattern functionality between patterns."""
+
+    def test_playlist_with_clear_pattern(self, hardware_port, run_hardware, test_playlist):
+        """Test that clear pattern runs between main patterns."""
+        from modules.connection import connection_manager
+        from modules.core.state import state
+        from fastapi.testclient import TestClient
+        from main import app
+
+        conn = connection_manager.SerialConnection(hardware_port)
+        state.conn = conn
+
+        try:
+            client = TestClient(app)
+
+            # Start playlist with clear pattern
+            playlist_thread = start_playlist_async(
+                client,
+                test_playlist["name"],
+                pause_time=1,
+                clear_pattern="clear_from_in",
+                run_mode="single"
+            )
+
+            time.sleep(3)
+
+            assert state.current_playlist is not None
+
+            print("Playlist running with clear_pattern='clear_from_in'")
+
+            # Clean up
+            stop_pattern(client)
+            playlist_thread.join(timeout=5)
+
+        finally:
+            conn.close()
+            state.conn = None
+
+
+@pytest.mark.hardware
+@pytest.mark.slow
+class TestPlaylistStateUpdates:
+    """Tests for state updates during playlist playback."""
+
+    def test_current_file_updates(self, hardware_port, run_hardware, test_playlist):
+        """Test that current_playing_file reflects the active pattern."""
+        from modules.connection import connection_manager
+        from modules.core.state import state
+        from fastapi.testclient import TestClient
+        from main import app
+
+        conn = connection_manager.SerialConnection(hardware_port)
+        state.conn = conn
+
+        try:
+            client = TestClient(app)
+
+            # Start playlist in background
+            playlist_thread = start_playlist_async(
+                client,
+                test_playlist["name"],
+                pause_time=1,
+                run_mode="single"
+            )
+
+            time.sleep(3)
+
+            # current_playing_file should be set
+            assert state.current_playing_file is not None, \
+                "current_playing_file should be set during playback"
+
+            # Should be one of the playlist patterns
+            current = state.current_playing_file
+            print(f"Current playing file: {current}")
+
+            # Normalize paths for comparison
+            playlist_patterns = [os.path.normpath(p) for p in test_playlist["patterns"]]
+            current_normalized = os.path.normpath(current) if current else None
+
+            # The current file should be related to one of the playlist patterns
+            # (path may differ slightly based on how it's resolved)
+            assert current is not None, "Should have a current playing file"
+
+            # Clean up
+            stop_pattern(client)
+            playlist_thread.join(timeout=5)
+
+        finally:
+            conn.close()
+            state.conn = None
+
+    def test_playlist_index_updates(self, hardware_port, run_hardware, test_playlist):
+        """Test that current_playlist_index updates correctly."""
+        from modules.connection import connection_manager
+        from modules.core.state import state
+        from fastapi.testclient import TestClient
+        from main import app
+
+        conn = connection_manager.SerialConnection(hardware_port)
+        state.conn = conn
+
+        try:
+            client = TestClient(app)
+
+            # Start playlist in background
+            playlist_thread = start_playlist_async(
+                client,
+                test_playlist["name"],
+                pause_time=1,
+                run_mode="single"
+            )
+
+            time.sleep(3)
+
+            # Index should be set
+            assert state.current_playlist_index is not None, \
+                "current_playlist_index should be set"
+            assert state.current_playlist_index >= 0, \
+                "Index should be non-negative"
+
+            print(f"Current playlist index: {state.current_playlist_index}")
+            print(f"Playlist length: {len(state.current_playlist) if state.current_playlist else 0}")
+
+            # Clean up
+            stop_pattern(client)
+            playlist_thread.join(timeout=5)
+
+        finally:
+            conn.close()
+            state.conn = None
+
+    def test_progress_updates(self, hardware_port, run_hardware):
+        """Test that execution_progress updates during pattern execution."""
+        from modules.connection import connection_manager
+        from modules.core.state import state
+        from fastapi.testclient import TestClient
+        from main import app
+
+        conn = connection_manager.SerialConnection(hardware_port)
+        state.conn = conn
+
+        try:
+            client = TestClient(app)
+
+            # Start pattern in background
+            pattern_thread = start_pattern_async(client, "star.thr")
+
+            # Wait for pattern to start
+            time.sleep(2)
+
+            # Check progress
+            progress_samples = []
+            for _ in range(5):
+                if state.execution_progress:
+                    progress_samples.append(state.execution_progress)
+                    print(f"Progress: {state.execution_progress}")
+                time.sleep(1)
+
+            # Should have captured some progress
+            assert len(progress_samples) > 0, "Should have recorded some progress updates"
+
+            # Progress should be changing (pattern executing)
+            if len(progress_samples) > 1:
+                first = progress_samples[0]
+                last = progress_samples[-1]
+                # Progress is typically a dict with 'current' and 'total'
+                if isinstance(first, dict) and isinstance(last, dict):
+                    print(f"Progress went from {first} to {last}")
+
+            # Clean up
+            stop_pattern(client)
+            pattern_thread.join(timeout=5)
+
+        finally:
+            conn.close()
+            state.conn = None
+
+
+@pytest.mark.hardware
+class TestWebSocketStatus:
+    """Tests for WebSocket status updates during playback."""
+
+    def test_status_updates_during_playback(self, hardware_port, run_hardware):
+        """Test that WebSocket broadcasts correct state during playback."""
+        if not run_hardware:
+            pytest.skip("Hardware tests disabled")
+
+        from fastapi.testclient import TestClient
+        from modules.connection import connection_manager
+        from modules.core.state import state
+        from main import app
+
+        conn = connection_manager.SerialConnection(hardware_port)
+        state.conn = conn
+
+        try:
+            client = TestClient(app)
+
+            # Start pattern in background
+            pattern_thread = start_pattern_async(client, "star.thr")
+
+            time.sleep(2)
+
+            # Check WebSocket status
+            with client.websocket_connect("/ws/status") as websocket:
+                message = websocket.receive_json()
+
+                # Status format is {'type': 'status_update', 'data': {...}}
+                assert message.get("type") == "status_update", \
+                    f"Expected type='status_update', got: {message}"
+
+                data = message.get("data", {})
+                print(f"WebSocket status: {data}")
+
+                # Should have expected status fields
+                assert "is_running" in data, f"Expected 'is_running' in data"
+
+            # Clean up
+            stop_pattern(client)
+            pattern_thread.join(timeout=5)
+
+        finally:
+            conn.close()
+            state.conn = None

+ 1 - 0
tests/unit/__init__.py

@@ -0,0 +1 @@
+# Unit tests - mocked dependencies, runs in CI

+ 189 - 0
tests/unit/conftest.py

@@ -0,0 +1,189 @@
+"""
+Unit test conftest.py - Fixtures specific to unit tests.
+
+Provides fixtures for mocking FastAPI dependencies and isolating tests
+from real state and hardware connections.
+"""
+import pytest
+from unittest.mock import MagicMock, AsyncMock, patch
+
+
+@pytest.fixture
+def mock_state_unit():
+    """Mock state for unit tests with common defaults.
+
+    This is a more comprehensive mock than the root conftest version,
+    specifically designed for API endpoint testing where we need to
+    control the application state precisely.
+    """
+    mock = MagicMock()
+
+    # Connection mock
+    mock.conn = MagicMock()
+    mock.conn.is_connected.return_value = False
+    mock.port = None
+    mock.is_connected = False
+    mock.preferred_port = None
+
+    # Pattern execution state
+    mock.current_playing_file = None
+    mock.is_running = False
+    mock.pause_requested = False
+    mock.stop_requested = False
+    mock.skip_requested = False
+    mock.execution_progress = None
+    mock.is_homing = False
+    mock.is_clearing = False
+
+    # Position state
+    mock.current_theta = 0.0
+    mock.current_rho = 0.0
+    mock.machine_x = 0.0
+    mock.machine_y = 0.0
+
+    # Speed and settings
+    mock.speed = 100
+    mock.clear_pattern_speed = None
+    mock.table_type = "dune_weaver"
+    mock.table_type_override = None
+    mock.homing = 0
+
+    # Playlist state
+    mock.current_playlist = None
+    mock.current_playlist_name = None
+    mock.current_playlist_index = None
+    mock.playlist_mode = None
+    mock.pause_time_remaining = 0
+    mock.original_pause_time = None
+
+    # LED state
+    mock.led_controller = None
+    mock.led_provider = "none"
+    mock.wled_ip = None
+    mock.hyperion_ip = None
+    mock.hyperion_port = 19444
+    mock.dw_led_num_leds = 60
+    mock.dw_led_gpio_pin = 18
+    mock.dw_led_pixel_order = "GRB"
+    mock.dw_led_brightness = 50
+    mock.dw_led_speed = 128
+    mock.dw_led_intensity = 128
+    mock.dw_led_idle_effect = "solid"
+    mock.dw_led_playing_effect = "rainbow"
+    mock.dw_led_idle_timeout_enabled = False
+    mock.dw_led_idle_timeout_minutes = 30
+    mock.dw_led_last_activity_time = 0
+
+    # Scheduled pause
+    mock.scheduled_pause_enabled = False
+    mock.scheduled_pause_time_slots = []
+    mock.scheduled_pause_control_wled = False
+    mock.scheduled_pause_finish_pattern = False
+    mock.scheduled_pause_timezone = None
+
+    # Steps and gear ratio
+    mock.x_steps_per_mm = 200.0
+    mock.y_steps_per_mm = 287.0
+    mock.gear_ratio = 10.0
+
+    # Auto-home settings
+    mock.auto_home_enabled = False
+    mock.auto_home_after_patterns = 10
+    mock.patterns_since_last_home = 0
+
+    # Custom clear patterns
+    mock.custom_clear_from_out = None
+    mock.custom_clear_from_in = None
+
+    # Homing offset
+    mock.angular_homing_offset_degrees = 0.0
+
+    # App settings
+    mock.app_name = "Dune Weaver"
+    mock.custom_logo_path = None
+
+    # MQTT settings
+    mock.mqtt_enabled = False
+    mock.mqtt_broker = None
+    mock.mqtt_port = 1883
+    mock.mqtt_username = None
+    mock.mqtt_password = None
+    mock.mqtt_topic_prefix = "dune_weaver"
+
+    # Table info
+    mock.table_id = None
+    mock.table_name = None
+    mock.known_tables = []
+
+    # Methods
+    mock.save = MagicMock()
+    mock.get_stop_event = MagicMock(return_value=None)
+    mock.get_skip_event = MagicMock(return_value=None)
+    mock.wait_for_interrupt = AsyncMock(return_value='timeout')
+    mock.pause_condition = MagicMock()
+    mock.pause_condition.__enter__ = MagicMock()
+    mock.pause_condition.__exit__ = MagicMock()
+    mock.pause_condition.notify_all = MagicMock()
+
+    return mock
+
+
+@pytest.fixture
+def mock_connection_unit():
+    """Mock connection for unit tests.
+
+    Provides a connection mock that simulates a connected device
+    without requiring actual hardware.
+    """
+    mock = MagicMock()
+    mock.is_connected.return_value = True
+    mock.send = MagicMock()
+    mock.readline = MagicMock(return_value="ok")
+    mock.in_waiting = MagicMock(return_value=0)
+    mock.flush = MagicMock()
+    mock.close = MagicMock()
+    mock.reset_input_buffer = MagicMock()
+    return mock
+
+
+@pytest.fixture
+def app_with_mocked_state(mock_state_unit):
+    """Fixture that patches state module before importing app.
+
+    This ensures the app uses mocked state for all operations.
+    Must be used before creating async_client.
+    """
+    with patch("modules.core.state.state", mock_state_unit):
+        with patch("modules.core.pattern_manager.state", mock_state_unit):
+            with patch("modules.core.playlist_manager.state", mock_state_unit):
+                with patch("modules.connection.connection_manager.state", mock_state_unit):
+                    from main import app
+                    yield app, mock_state_unit
+
+
+@pytest.fixture
+async def async_client_with_mocked_state(app_with_mocked_state):
+    """AsyncClient with mocked state for isolated API testing.
+
+    This fixture combines the app patching with the async client creation.
+    """
+    from httpx import ASGITransport, AsyncClient
+
+    app, mock_state = app_with_mocked_state
+
+    async with AsyncClient(
+        transport=ASGITransport(app=app),
+        base_url="http://test"
+    ) as client:
+        yield client, mock_state
+
+
+@pytest.fixture
+def cleanup_app_overrides():
+    """Fixture to ensure app.dependency_overrides is cleaned up after tests."""
+    from main import app
+
+    yield
+
+    # Cleanup after test
+    app.dependency_overrides.clear()

+ 304 - 0
tests/unit/test_api_patterns.py

@@ -0,0 +1,304 @@
+"""
+Unit tests for pattern API endpoints.
+
+Tests the following endpoints:
+- GET /list_theta_rho_files
+- GET /list_theta_rho_files_with_metadata
+- POST /get_theta_rho_coordinates
+- POST /run_theta_rho (when disconnected)
+"""
+import pytest
+from unittest.mock import patch, MagicMock, AsyncMock
+import os
+
+
+class TestListThetaRhoFiles:
+    """Tests for /list_theta_rho_files endpoint."""
+
+    @pytest.mark.asyncio
+    async def test_list_theta_rho_files(self, async_client):
+        """Test list_theta_rho_files returns list of pattern files."""
+        mock_files = ["circle.thr", "spiral.thr", "custom/pattern.thr"]
+
+        with patch("main.pattern_manager.list_theta_rho_files", return_value=mock_files):
+            response = await async_client.get("/list_theta_rho_files")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert isinstance(data, list)
+        assert len(data) == 3
+        assert "circle.thr" in data
+        assert "spiral.thr" in data
+        assert "custom/pattern.thr" in data
+
+    @pytest.mark.asyncio
+    async def test_list_theta_rho_files_empty(self, async_client):
+        """Test list_theta_rho_files returns empty list when no patterns."""
+        with patch("main.pattern_manager.list_theta_rho_files", return_value=[]):
+            response = await async_client.get("/list_theta_rho_files")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data == []
+
+
+class TestListThetaRhoFilesWithMetadata:
+    """Tests for /list_theta_rho_files_with_metadata endpoint."""
+
+    @pytest.mark.asyncio
+    async def test_list_theta_rho_files_with_metadata(self, async_client, tmp_path):
+        """Test list_theta_rho_files_with_metadata returns files with metadata."""
+        mock_files = ["circle.thr"]
+
+        # The endpoint has two paths:
+        # 1. If metadata_cache.json exists, use it
+        # 2. Otherwise, use ThreadPoolExecutor with process_file
+        # We'll test the fallback path by having the cache file not exist
+
+        patterns_dir = tmp_path / "patterns"
+        patterns_dir.mkdir()
+        (patterns_dir / "circle.thr").write_text("0 0.5\n1 0.6")
+
+        with patch("main.pattern_manager.list_theta_rho_files", return_value=mock_files):
+            with patch("main.pattern_manager.THETA_RHO_DIR", str(patterns_dir)):
+                # Simulate cache file not existing
+                with patch("builtins.open", side_effect=FileNotFoundError):
+                    response = await async_client.get("/list_theta_rho_files_with_metadata")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert isinstance(data, list)
+        assert len(data) == 1
+
+        # The response structure has 'path', 'name', 'category', 'date_modified', 'coordinates_count'
+        item = data[0]
+        assert item["path"] == "circle.thr"
+        assert item["name"] == "circle"
+        assert "category" in item
+        assert "date_modified" in item
+        assert "coordinates_count" in item
+
+
+class TestGetThetaRhoCoordinates:
+    """Tests for /get_theta_rho_coordinates endpoint."""
+
+    @pytest.mark.asyncio
+    async def test_get_theta_rho_coordinates_valid_file(self, async_client, tmp_path):
+        """Test getting coordinates from a valid file."""
+        # Create test pattern file
+        patterns_dir = tmp_path / "patterns"
+        patterns_dir.mkdir()
+        test_file = patterns_dir / "test.thr"
+        test_file.write_text("0.0 0.5\n1.57 0.8\n3.14 0.3\n")
+
+        mock_coordinates = [(0.0, 0.5), (1.57, 0.8), (3.14, 0.3)]
+
+        with patch("main.THETA_RHO_DIR", str(patterns_dir)):
+            with patch("main.parse_theta_rho_file", return_value=mock_coordinates):
+                response = await async_client.post(
+                    "/get_theta_rho_coordinates",
+                    json={"file_name": "test.thr"}
+                )
+
+        assert response.status_code == 200
+        data = response.json()
+        assert "coordinates" in data
+        assert len(data["coordinates"]) == 3
+        assert data["coordinates"][0] == [0.0, 0.5]
+        assert data["coordinates"][1] == [1.57, 0.8]
+        assert data["coordinates"][2] == [3.14, 0.3]
+
+    @pytest.mark.asyncio
+    async def test_get_theta_rho_coordinates_file_not_found(self, async_client, tmp_path):
+        """Test getting coordinates from non-existent file returns error.
+
+        Note: The endpoint returns 500 because it catches the HTTPException and re-raises it.
+        """
+        patterns_dir = tmp_path / "patterns"
+        patterns_dir.mkdir()
+
+        with patch("main.THETA_RHO_DIR", str(patterns_dir)):
+            response = await async_client.post(
+                "/get_theta_rho_coordinates",
+                json={"file_name": "nonexistent.thr"}
+            )
+
+        # The endpoint wraps the 404 in a 500 due to exception handling
+        assert response.status_code in [404, 500]
+        data = response.json()
+        assert "not found" in data["detail"].lower()
+
+
+class TestRunThetaRho:
+    """Tests for /run_theta_rho endpoint."""
+
+    @pytest.mark.asyncio
+    async def test_run_theta_rho_when_disconnected(self, async_client, mock_state, tmp_path):
+        """Test run_theta_rho fails gracefully when disconnected."""
+        # The endpoint checks file existence first, then connection
+        patterns_dir = tmp_path / "patterns"
+        patterns_dir.mkdir()
+        test_file = patterns_dir / "circle.thr"
+        test_file.write_text("0 0.5")
+
+        mock_state.conn = None
+        mock_state.is_homing = False
+
+        with patch("main.state", mock_state):
+            with patch("main.pattern_manager.THETA_RHO_DIR", str(patterns_dir)):
+                response = await async_client.post(
+                    "/run_theta_rho",
+                    json={
+                        "file_name": "circle.thr",
+                        "pre_execution": "none"
+                    }
+                )
+
+        assert response.status_code == 400
+        data = response.json()
+        assert "not established" in data["detail"].lower() or "not connected" in data["detail"].lower()
+
+    @pytest.mark.asyncio
+    async def test_run_theta_rho_during_homing(self, async_client, mock_state, tmp_path):
+        """Test run_theta_rho fails when homing is in progress."""
+        patterns_dir = tmp_path / "patterns"
+        patterns_dir.mkdir()
+        test_file = patterns_dir / "circle.thr"
+        test_file.write_text("0 0.5")
+
+        mock_state.is_homing = True
+        mock_state.conn = MagicMock()
+        mock_state.conn.is_connected.return_value = True
+
+        with patch("main.state", mock_state):
+            with patch("main.pattern_manager.THETA_RHO_DIR", str(patterns_dir)):
+                response = await async_client.post(
+                    "/run_theta_rho",
+                    json={
+                        "file_name": "circle.thr",
+                        "pre_execution": "none"
+                    }
+                )
+
+        assert response.status_code == 409
+        data = response.json()
+        assert "homing" in data["detail"].lower()
+
+    @pytest.mark.asyncio
+    async def test_run_theta_rho_file_not_found(self, async_client, mock_state, tmp_path):
+        """Test run_theta_rho returns 404 for non-existent file."""
+        patterns_dir = tmp_path / "patterns"
+        patterns_dir.mkdir()
+
+        mock_state.conn = MagicMock()
+        mock_state.conn.is_connected.return_value = True
+        mock_state.is_homing = False
+
+        with patch("main.state", mock_state):
+            with patch("main.pattern_manager.THETA_RHO_DIR", str(patterns_dir)):
+                response = await async_client.post(
+                    "/run_theta_rho",
+                    json={
+                        "file_name": "nonexistent.thr",
+                        "pre_execution": "none"
+                    }
+                )
+
+        assert response.status_code == 404
+        data = response.json()
+        assert "not found" in data["detail"].lower()
+
+
+class TestStopExecution:
+    """Tests for /stop_execution endpoint."""
+
+    @pytest.mark.asyncio
+    async def test_stop_execution(self, async_client, mock_state):
+        """Test stop_execution endpoint."""
+        mock_state.is_homing = False
+        mock_state.conn = MagicMock()
+        mock_state.conn.is_connected.return_value = True
+
+        with patch("main.state", mock_state):
+            with patch("main.pattern_manager.stop_actions", new_callable=AsyncMock, return_value=True):
+                response = await async_client.post("/stop_execution")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["success"] is True
+
+    @pytest.mark.asyncio
+    async def test_stop_execution_when_disconnected(self, async_client, mock_state):
+        """Test stop_execution fails when not connected."""
+        mock_state.conn = None
+
+        with patch("main.state", mock_state):
+            response = await async_client.post("/stop_execution")
+
+        assert response.status_code == 400
+        data = response.json()
+        assert "not established" in data["detail"].lower()
+
+
+class TestPauseResumeExecution:
+    """Tests for /pause_execution and /resume_execution endpoints."""
+
+    @pytest.mark.asyncio
+    async def test_pause_execution(self, async_client):
+        """Test pause_execution endpoint."""
+        with patch("main.pattern_manager.pause_execution", return_value=True):
+            response = await async_client.post("/pause_execution")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["success"] is True
+
+    @pytest.mark.asyncio
+    async def test_resume_execution(self, async_client):
+        """Test resume_execution endpoint."""
+        with patch("main.pattern_manager.resume_execution", return_value=True):
+            response = await async_client.post("/resume_execution")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["success"] is True
+
+
+class TestDeleteThetaRhoFile:
+    """Tests for /delete_theta_rho_file endpoint."""
+
+    @pytest.mark.asyncio
+    async def test_delete_theta_rho_file_success(self, async_client, tmp_path):
+        """Test deleting an existing pattern file."""
+        patterns_dir = tmp_path / "patterns"
+        patterns_dir.mkdir()
+        test_file = patterns_dir / "test.thr"
+        test_file.write_text("0 0.5")
+
+        # Must patch pattern_manager.THETA_RHO_DIR which is what the endpoint uses
+        with patch("main.pattern_manager.THETA_RHO_DIR", str(patterns_dir)):
+            with patch("modules.core.cache_manager.delete_pattern_cache", return_value=True):
+                response = await async_client.post(
+                    "/delete_theta_rho_file",
+                    json={"file_name": "test.thr"}
+                )
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["success"] is True
+        # Verify file was actually deleted
+        assert not test_file.exists()
+
+    @pytest.mark.asyncio
+    async def test_delete_theta_rho_file_not_found(self, async_client, tmp_path):
+        """Test deleting a non-existent file returns error."""
+        patterns_dir = tmp_path / "patterns"
+        patterns_dir.mkdir()
+
+        with patch("main.pattern_manager.THETA_RHO_DIR", str(patterns_dir)):
+            response = await async_client.post(
+                "/delete_theta_rho_file",
+                json={"file_name": "nonexistent.thr"}
+            )
+
+        assert response.status_code == 404

+ 304 - 0
tests/unit/test_api_playlists.py

@@ -0,0 +1,304 @@
+"""
+Unit tests for playlist API endpoints.
+
+Tests the following endpoints:
+- GET /list_all_playlists
+- GET /get_playlist
+- POST /create_playlist
+- POST /modify_playlist
+- DELETE /delete_playlist
+- POST /rename_playlist
+- POST /add_to_playlist
+- POST /run_playlist (when disconnected)
+"""
+import pytest
+from unittest.mock import patch, MagicMock, AsyncMock
+
+
+class TestListAllPlaylists:
+    """Tests for /list_all_playlists endpoint."""
+
+    @pytest.mark.asyncio
+    async def test_list_all_playlists(self, async_client):
+        """Test list_all_playlists returns list of playlist names."""
+        mock_playlists = ["favorites", "evening", "morning"]
+
+        with patch("main.playlist_manager.list_all_playlists", return_value=mock_playlists):
+            response = await async_client.get("/list_all_playlists")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert isinstance(data, list)
+        assert len(data) == 3
+        assert "favorites" in data
+
+    @pytest.mark.asyncio
+    async def test_list_all_playlists_empty(self, async_client):
+        """Test list_all_playlists returns empty list when no playlists."""
+        with patch("main.playlist_manager.list_all_playlists", return_value=[]):
+            response = await async_client.get("/list_all_playlists")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data == []
+
+
+class TestGetPlaylist:
+    """Tests for /get_playlist endpoint."""
+
+    @pytest.mark.asyncio
+    async def test_get_playlist_exists(self, async_client):
+        """Test get_playlist returns playlist data."""
+        mock_playlist = {
+            "name": "favorites",
+            "files": ["circle.thr", "spiral.thr"]
+        }
+
+        with patch("main.playlist_manager.get_playlist", return_value=mock_playlist):
+            response = await async_client.get("/get_playlist", params={"name": "favorites"})
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["name"] == "favorites"
+        assert data["files"] == ["circle.thr", "spiral.thr"]
+
+    @pytest.mark.asyncio
+    async def test_get_playlist_creates_empty_if_not_found(self, async_client):
+        """Test get_playlist auto-creates empty playlist if not found.
+
+        Note: This is the actual behavior - the endpoint auto-creates empty playlists.
+        """
+        with patch("main.playlist_manager.get_playlist", return_value=None):
+            with patch("main.playlist_manager.create_playlist", return_value=True):
+                response = await async_client.get("/get_playlist", params={"name": "nonexistent"})
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["name"] == "nonexistent"
+        assert data["files"] == []
+
+
+class TestCreatePlaylist:
+    """Tests for /create_playlist endpoint."""
+
+    @pytest.mark.asyncio
+    async def test_create_playlist(self, async_client):
+        """Test creating a new playlist."""
+        with patch("main.playlist_manager.create_playlist", return_value=True):
+            response = await async_client.post(
+                "/create_playlist",
+                json={
+                    "playlist_name": "new_playlist",  # API uses playlist_name, not name
+                    "files": ["circle.thr", "spiral.thr"]
+                }
+            )
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["success"] is True
+
+
+class TestModifyPlaylist:
+    """Tests for /modify_playlist endpoint."""
+
+    @pytest.mark.asyncio
+    async def test_modify_playlist(self, async_client):
+        """Test modifying an existing playlist."""
+        with patch("main.playlist_manager.modify_playlist", return_value=True):
+            response = await async_client.post(
+                "/modify_playlist",
+                json={
+                    "playlist_name": "favorites",  # API uses playlist_name
+                    "files": ["new_pattern.thr"]
+                }
+            )
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["success"] is True
+
+
+class TestDeletePlaylist:
+    """Tests for /delete_playlist endpoint."""
+
+    @pytest.mark.asyncio
+    async def test_delete_playlist(self, async_client):
+        """Test deleting a playlist."""
+        with patch("main.playlist_manager.delete_playlist", return_value=True):
+            response = await async_client.request(
+                "DELETE",
+                "/delete_playlist",
+                json={"playlist_name": "to_delete"}  # DELETE with body
+            )
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["success"] is True
+
+    @pytest.mark.asyncio
+    async def test_delete_playlist_not_found(self, async_client):
+        """Test deleting a non-existent playlist returns 404."""
+        with patch("main.playlist_manager.delete_playlist", return_value=False):
+            response = await async_client.request(
+                "DELETE",
+                "/delete_playlist",
+                json={"playlist_name": "nonexistent"}
+            )
+
+        assert response.status_code == 404
+
+
+class TestRenamePlaylist:
+    """Tests for /rename_playlist endpoint."""
+
+    @pytest.mark.asyncio
+    async def test_rename_playlist_success(self, async_client):
+        """Test renaming a playlist."""
+        with patch("main.playlist_manager.rename_playlist", return_value=(True, "Renamed")):
+            response = await async_client.post(
+                "/rename_playlist",
+                json={
+                    "old_name": "old_playlist",
+                    "new_name": "new_playlist"
+                }
+            )
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["success"] is True
+
+    @pytest.mark.asyncio
+    async def test_rename_playlist_not_found(self, async_client):
+        """Test renaming a non-existent playlist fails."""
+        with patch("main.playlist_manager.rename_playlist", return_value=(False, "Playlist not found")):
+            response = await async_client.post(
+                "/rename_playlist",
+                json={
+                    "old_name": "nonexistent",
+                    "new_name": "new_name"
+                }
+            )
+
+        # Returns 400 with message (not 404)
+        assert response.status_code == 400
+
+
+class TestAddToPlaylist:
+    """Tests for /add_to_playlist endpoint."""
+
+    @pytest.mark.asyncio
+    async def test_add_to_playlist(self, async_client):
+        """Test adding a pattern to a playlist."""
+        with patch("main.playlist_manager.add_to_playlist", return_value=True):
+            response = await async_client.post(
+                "/add_to_playlist",
+                json={
+                    "playlist_name": "favorites",  # API uses playlist_name
+                    "pattern": "new_pattern.thr"   # API uses pattern, not file
+                }
+            )
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["success"] is True
+
+    @pytest.mark.asyncio
+    async def test_add_to_playlist_not_found(self, async_client):
+        """Test adding to a non-existent playlist fails."""
+        with patch("main.playlist_manager.add_to_playlist", return_value=False):
+            response = await async_client.post(
+                "/add_to_playlist",
+                json={
+                    "playlist_name": "nonexistent",
+                    "pattern": "pattern.thr"
+                }
+            )
+
+        assert response.status_code == 404
+
+
+class TestRunPlaylist:
+    """Tests for /run_playlist endpoint."""
+
+    @pytest.mark.asyncio
+    async def test_run_playlist_when_disconnected(self, async_client, mock_state):
+        """Test run_playlist fails when not connected.
+
+        Note: The endpoint catches HTTPException and re-raises as 500.
+        """
+        mock_state.conn = None
+        mock_state.is_homing = False
+
+        with patch("main.state", mock_state):
+            response = await async_client.post(
+                "/run_playlist",
+                json={
+                    "playlist_name": "test",
+                    "pause_time": 5,
+                    "clear_pattern": None,
+                    "run_mode": "single"
+                }
+            )
+
+        # Endpoint wraps in try/except and returns 500
+        assert response.status_code == 500
+        data = response.json()
+        assert "not established" in data["detail"].lower()
+
+    @pytest.mark.asyncio
+    async def test_run_playlist_during_homing(self, async_client, mock_state):
+        """Test run_playlist fails during homing.
+
+        Note: The endpoint catches HTTPException and re-raises as 500.
+        """
+        mock_state.is_homing = True
+        mock_state.conn = MagicMock()
+        mock_state.conn.is_connected.return_value = True
+
+        with patch("main.state", mock_state):
+            response = await async_client.post(
+                "/run_playlist",
+                json={
+                    "playlist_name": "test",
+                    "pause_time": 5,
+                    "clear_pattern": None,
+                    "run_mode": "single"
+                }
+            )
+
+        # Endpoint wraps in try/except and returns 500
+        assert response.status_code == 500
+        data = response.json()
+        assert "homing" in data["detail"].lower()
+
+
+class TestSkipPattern:
+    """Tests for /skip_pattern endpoint."""
+
+    @pytest.mark.asyncio
+    async def test_skip_pattern(self, async_client, mock_state):
+        """Test skip_pattern during playlist execution."""
+        mock_state.current_playlist = ["a.thr", "b.thr"]
+        mock_state.current_playlist_index = 0
+        mock_state.skip_requested = False
+
+        with patch("main.state", mock_state):
+            response = await async_client.post("/skip_pattern")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["success"] is True
+        # Endpoint sets skip_requested directly
+        assert mock_state.skip_requested is True
+
+    @pytest.mark.asyncio
+    async def test_skip_pattern_no_playlist(self, async_client, mock_state):
+        """Test skip_pattern fails when no playlist is running."""
+        mock_state.current_playlist = None
+
+        with patch("main.state", mock_state):
+            response = await async_client.post("/skip_pattern")
+
+        assert response.status_code == 400
+        data = response.json()
+        assert "no playlist" in data["detail"].lower()

+ 270 - 0
tests/unit/test_api_status.py

@@ -0,0 +1,270 @@
+"""
+Unit tests for status and info API endpoints.
+
+Tests the following endpoints:
+- GET /serial_status
+- GET /list_serial_ports
+- GET /api/settings
+- GET /api/table-info
+"""
+import pytest
+from unittest.mock import patch, MagicMock, AsyncMock
+
+
+class TestSerialStatus:
+    """Tests for /serial_status endpoint."""
+
+    @pytest.mark.asyncio
+    async def test_serial_status_when_connected(self, async_client, mock_state):
+        """Test serial_status returns connected state."""
+        mock_state.conn = MagicMock()
+        mock_state.conn.is_connected.return_value = True
+        mock_state.port = "/dev/ttyUSB0"
+        mock_state.preferred_port = "__auto__"
+
+        with patch("main.state", mock_state):
+            response = await async_client.get("/serial_status")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["connected"] is True
+        assert data["port"] == "/dev/ttyUSB0"
+        assert data["preferred_port"] == "__auto__"
+
+    @pytest.mark.asyncio
+    async def test_serial_status_when_disconnected(self, async_client, mock_state):
+        """Test serial_status returns disconnected state."""
+        mock_state.conn = None
+        mock_state.port = None
+        mock_state.preferred_port = "__none__"
+
+        with patch("main.state", mock_state):
+            response = await async_client.get("/serial_status")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["connected"] is False
+        assert data["port"] is None
+        assert data["preferred_port"] == "__none__"
+
+    @pytest.mark.asyncio
+    async def test_serial_status_with_disconnected_conn(self, async_client, mock_state):
+        """Test serial_status when conn exists but is disconnected."""
+        mock_state.conn = MagicMock()
+        mock_state.conn.is_connected.return_value = False
+        mock_state.port = "/dev/ttyUSB0"
+        mock_state.preferred_port = "/dev/ttyUSB0"
+
+        with patch("main.state", mock_state):
+            response = await async_client.get("/serial_status")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["connected"] is False
+
+
+class TestListSerialPorts:
+    """Tests for /list_serial_ports endpoint."""
+
+    @pytest.mark.asyncio
+    async def test_list_serial_ports_returns_list(self, async_client):
+        """Test list_serial_ports returns a list of available ports."""
+        mock_ports = ["/dev/ttyUSB0", "/dev/ttyACM0"]
+
+        with patch("main.connection_manager.list_serial_ports", return_value=mock_ports):
+            response = await async_client.get("/list_serial_ports")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert isinstance(data, list)
+        assert "/dev/ttyUSB0" in data
+        assert "/dev/ttyACM0" in data
+
+    @pytest.mark.asyncio
+    async def test_list_serial_ports_empty(self, async_client):
+        """Test list_serial_ports returns empty list when no ports."""
+        with patch("main.connection_manager.list_serial_ports", return_value=[]):
+            response = await async_client.get("/list_serial_ports")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data == []
+
+
+class TestGetAllSettings:
+    """Tests for /api/settings endpoint."""
+
+    @pytest.mark.asyncio
+    async def test_get_all_settings_returns_expected_structure(self, async_client, mock_state):
+        """Test get_all_settings returns complete settings structure."""
+        mock_state.app_name = "Test Table"
+        mock_state.custom_logo = None
+        mock_state.preferred_port = "__auto__"
+        mock_state.clear_pattern_speed = 150
+        mock_state.custom_clear_from_in = None
+        mock_state.custom_clear_from_out = None
+        mock_state.auto_play_enabled = False
+        mock_state.auto_play_playlist = None
+        mock_state.auto_play_run_mode = "single"
+        mock_state.auto_play_pause_time = 0
+        mock_state.auto_play_clear_pattern = None
+        mock_state.auto_play_shuffle = False
+        mock_state.scheduled_pause_enabled = False
+        mock_state.scheduled_pause_control_wled = False
+        mock_state.scheduled_pause_finish_pattern = False
+        mock_state.scheduled_pause_timezone = None
+        mock_state.scheduled_pause_time_slots = []
+        mock_state.homing = 0
+        mock_state.homing_user_override = False
+        mock_state.angular_homing_offset_degrees = 0.0
+        mock_state.auto_home_enabled = False
+        mock_state.auto_home_after_patterns = 10
+        mock_state.led_provider = "none"
+        mock_state.wled_ip = None
+        mock_state.dw_led_num_leds = 60
+        mock_state.dw_led_gpio_pin = 18
+        mock_state.dw_led_pixel_order = "GRB"
+        mock_state.dw_led_brightness = 50
+        mock_state.dw_led_speed = 128
+        mock_state.dw_led_intensity = 128
+        mock_state.dw_led_idle_effect = "solid"
+        mock_state.dw_led_playing_effect = "rainbow"
+        mock_state.dw_led_idle_timeout_enabled = False
+        mock_state.dw_led_idle_timeout_minutes = 30
+        mock_state.mqtt_enabled = False
+        mock_state.mqtt_broker = None
+        mock_state.mqtt_port = 1883
+        mock_state.mqtt_username = None
+        mock_state.mqtt_password = None
+        mock_state.mqtt_client_id = "dune_weaver"
+        mock_state.mqtt_discovery_prefix = "homeassistant"
+        mock_state.mqtt_device_id = "dune_weaver_01"
+        mock_state.mqtt_device_name = "Dune Weaver"
+        mock_state.table_type = "dune_weaver"
+        mock_state.table_type_override = None
+        mock_state.gear_ratio = 10.0
+        mock_state.x_steps_per_mm = 200.0
+        mock_state.y_steps_per_mm = 287.0
+        mock_state.timezone = "UTC"
+
+        with patch("main.state", mock_state):
+            response = await async_client.get("/api/settings")
+
+        assert response.status_code == 200
+        data = response.json()
+
+        # Check top-level structure
+        assert "app" in data
+        assert "connection" in data
+        assert "patterns" in data
+        assert "auto_play" in data
+        assert "scheduled_pause" in data
+        assert "homing" in data
+        assert "led" in data
+        assert "mqtt" in data
+        assert "machine" in data
+
+        # Verify specific values
+        assert data["app"]["name"] == "Test Table"
+        assert data["connection"]["preferred_port"] == "__auto__"
+        assert data["patterns"]["clear_pattern_speed"] == 150
+        assert data["machine"]["detected_table_type"] == "dune_weaver"
+
+    @pytest.mark.asyncio
+    async def test_get_all_settings_effective_table_type(self, async_client, mock_state):
+        """Test that effective_table_type uses override when set."""
+        mock_state.app_name = "Test"
+        mock_state.custom_logo = None
+        mock_state.preferred_port = None
+        mock_state.clear_pattern_speed = None
+        mock_state.custom_clear_from_in = None
+        mock_state.custom_clear_from_out = None
+        mock_state.auto_play_enabled = False
+        mock_state.auto_play_playlist = None
+        mock_state.auto_play_run_mode = "single"
+        mock_state.auto_play_pause_time = 0
+        mock_state.auto_play_clear_pattern = None
+        mock_state.auto_play_shuffle = False
+        mock_state.scheduled_pause_enabled = False
+        mock_state.scheduled_pause_control_wled = False
+        mock_state.scheduled_pause_finish_pattern = False
+        mock_state.scheduled_pause_timezone = None
+        mock_state.scheduled_pause_time_slots = []
+        mock_state.homing = 0
+        mock_state.homing_user_override = False
+        mock_state.angular_homing_offset_degrees = 0.0
+        mock_state.auto_home_enabled = False
+        mock_state.auto_home_after_patterns = 10
+        mock_state.led_provider = "none"
+        mock_state.wled_ip = None
+        mock_state.dw_led_num_leds = 60
+        mock_state.dw_led_gpio_pin = 18
+        mock_state.dw_led_pixel_order = "GRB"
+        mock_state.dw_led_brightness = 50
+        mock_state.dw_led_speed = 128
+        mock_state.dw_led_intensity = 128
+        mock_state.dw_led_idle_effect = "solid"
+        mock_state.dw_led_playing_effect = "rainbow"
+        mock_state.dw_led_idle_timeout_enabled = False
+        mock_state.dw_led_idle_timeout_minutes = 30
+        mock_state.mqtt_enabled = False
+        mock_state.mqtt_broker = None
+        mock_state.mqtt_port = 1883
+        mock_state.mqtt_username = None
+        mock_state.mqtt_password = None
+        mock_state.mqtt_client_id = "dune_weaver"
+        mock_state.mqtt_discovery_prefix = "homeassistant"
+        mock_state.mqtt_device_id = "dune_weaver_01"
+        mock_state.mqtt_device_name = "Dune Weaver"
+        mock_state.table_type = "dune_weaver"
+        mock_state.table_type_override = "dune_weaver_mini"  # Override set
+        mock_state.gear_ratio = 6.25
+        mock_state.x_steps_per_mm = 256.0
+        mock_state.y_steps_per_mm = 180.0
+        mock_state.timezone = "UTC"
+
+        with patch("main.state", mock_state):
+            response = await async_client.get("/api/settings")
+
+        assert response.status_code == 200
+        data = response.json()
+
+        assert data["machine"]["detected_table_type"] == "dune_weaver"
+        assert data["machine"]["table_type_override"] == "dune_weaver_mini"
+        assert data["machine"]["effective_table_type"] == "dune_weaver_mini"
+
+
+class TestGetTableInfo:
+    """Tests for /api/table-info endpoint."""
+
+    @pytest.mark.asyncio
+    async def test_get_table_info(self, async_client, mock_state):
+        """Test get_table_info returns table identification info."""
+        mock_state.table_id = "table-123"
+        mock_state.table_name = "Living Room Table"
+
+        with patch("main.state", mock_state):
+            with patch("main.version_manager.get_current_version", return_value="1.0.0"):
+                response = await async_client.get("/api/table-info")
+
+        assert response.status_code == 200
+        data = response.json()
+        # API returns "id" and "name", not "table_id" and "table_name"
+        assert data["id"] == "table-123"
+        assert data["name"] == "Living Room Table"
+        assert data["version"] == "1.0.0"
+
+    @pytest.mark.asyncio
+    async def test_get_table_info_not_set(self, async_client, mock_state):
+        """Test get_table_info when not configured."""
+        mock_state.table_id = None
+        mock_state.table_name = None
+
+        with patch("main.state", mock_state):
+            with patch("main.version_manager.get_current_version", return_value="1.0.0"):
+                response = await async_client.get("/api/table-info")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["id"] is None
+        assert data["name"] is None

+ 282 - 0
tests/unit/test_connection_manager.py

@@ -0,0 +1,282 @@
+"""
+Unit tests for connection_manager parsing functions.
+
+Tests the pure functions that parse GRBL responses:
+- Machine position parsing (MPos and WPos formats)
+- Serial port listing/filtering
+"""
+import pytest
+from unittest.mock import patch, MagicMock
+
+
+class TestParseMachinePosition:
+    """Tests for parse_machine_position function."""
+
+    def test_parse_machine_position_mpos_format(self):
+        """Test parsing MPos format from GRBL status response."""
+        from modules.connection.connection_manager import parse_machine_position
+
+        response = "<Idle|MPos:100.500,-50.250,0.000|Bf:15,128>"
+        result = parse_machine_position(response)
+
+        assert result is not None
+        assert result == (100.5, -50.25)
+
+    def test_parse_machine_position_wpos_format(self):
+        """Test parsing WPos format from GRBL status response."""
+        from modules.connection.connection_manager import parse_machine_position
+
+        response = "<Idle|WPos:0.000,19.000,0.000|Bf:15,128>"
+        result = parse_machine_position(response)
+
+        assert result is not None
+        assert result == (0.0, 19.0)
+
+    def test_parse_machine_position_prefers_mpos(self):
+        """Test that MPos is preferred when both are present (rare but possible)."""
+        from modules.connection.connection_manager import parse_machine_position
+
+        # This response has both MPos and WPos - MPos should be used first
+        response = "<Idle|MPos:10.0,20.0,0.0|WPos:5.0,10.0,0.0|Bf:15,128>"
+        result = parse_machine_position(response)
+
+        assert result is not None
+        assert result == (10.0, 20.0)
+
+    def test_parse_machine_position_invalid(self):
+        """Test parsing returns None for invalid response."""
+        from modules.connection.connection_manager import parse_machine_position
+
+        # No position info
+        result = parse_machine_position("ok")
+        assert result is None
+
+        # Empty string
+        result = parse_machine_position("")
+        assert result is None
+
+        # Malformed response
+        result = parse_machine_position("<Idle|Bf:15,128>")
+        assert result is None
+
+    def test_parse_machine_position_run_state(self):
+        """Test parsing position during run state."""
+        from modules.connection.connection_manager import parse_machine_position
+
+        response = "<Run|MPos:-994.869,-321.861,0.000|Bf:15,127>"
+        result = parse_machine_position(response)
+
+        assert result is not None
+        assert result[0] == pytest.approx(-994.869)
+        assert result[1] == pytest.approx(-321.861)
+
+    def test_parse_machine_position_alarm_state(self):
+        """Test parsing position during alarm state."""
+        from modules.connection.connection_manager import parse_machine_position
+
+        response = "<Alarm|MPos:0.000,0.000,0.000|Bf:15,128|Pn:XY>"
+        result = parse_machine_position(response)
+
+        assert result is not None
+        assert result == (0.0, 0.0)
+
+    def test_parse_machine_position_with_extra_info(self):
+        """Test parsing position with extra fields in response."""
+        from modules.connection.connection_manager import parse_machine_position
+
+        # Response with WCO (Work Coordinate Offset)
+        response = "<Idle|MPos:5.0,10.0,0.0|FS:0,0|WCO:0,0,0>"
+        result = parse_machine_position(response)
+
+        assert result is not None
+        assert result == (5.0, 10.0)
+
+    def test_parse_machine_position_negative_coords(self):
+        """Test parsing negative coordinates."""
+        from modules.connection.connection_manager import parse_machine_position
+
+        response = "<Idle|MPos:-100.123,-200.456,0.000|Bf:15,128>"
+        result = parse_machine_position(response)
+
+        assert result is not None
+        assert result[0] == pytest.approx(-100.123)
+        assert result[1] == pytest.approx(-200.456)
+
+    def test_parse_machine_position_high_precision(self):
+        """Test parsing high precision coordinates."""
+        from modules.connection.connection_manager import parse_machine_position
+
+        response = "<Idle|MPos:123.456789,987.654321,0.000000|Bf:15,128>"
+        result = parse_machine_position(response)
+
+        assert result is not None
+        assert result[0] == pytest.approx(123.456789)
+        assert result[1] == pytest.approx(987.654321)
+
+
+class TestListSerialPorts:
+    """Tests for list_serial_ports function."""
+
+    def test_list_serial_ports_filters_ignored(self):
+        """Test that ignored ports are filtered out."""
+        # Create mock port objects
+        mock_port1 = MagicMock()
+        mock_port1.device = "/dev/ttyUSB0"
+
+        mock_port2 = MagicMock()
+        mock_port2.device = "/dev/cu.debug-console"  # Should be filtered
+
+        mock_port3 = MagicMock()
+        mock_port3.device = "/dev/cu.Bluetooth-Incoming-Port"  # Should be filtered
+
+        mock_port4 = MagicMock()
+        mock_port4.device = "/dev/ttyACM0"
+
+        with patch("serial.tools.list_ports.comports", return_value=[mock_port1, mock_port2, mock_port3, mock_port4]):
+            from modules.connection.connection_manager import list_serial_ports
+
+            ports = list_serial_ports()
+
+        assert "/dev/ttyUSB0" in ports
+        assert "/dev/ttyACM0" in ports
+        assert "/dev/cu.debug-console" not in ports
+        assert "/dev/cu.Bluetooth-Incoming-Port" not in ports
+        assert len(ports) == 2
+
+    def test_list_serial_ports_empty(self):
+        """Test list_serial_ports returns empty when no ports available."""
+        with patch("serial.tools.list_ports.comports", return_value=[]):
+            from modules.connection.connection_manager import list_serial_ports
+
+            ports = list_serial_ports()
+
+        assert ports == []
+
+    def test_list_serial_ports_all_ignored(self):
+        """Test list_serial_ports when all ports are ignored."""
+        mock_port1 = MagicMock()
+        mock_port1.device = "/dev/cu.debug-console"
+
+        mock_port2 = MagicMock()
+        mock_port2.device = "/dev/cu.Bluetooth-Incoming-Port"
+
+        with patch("serial.tools.list_ports.comports", return_value=[mock_port1, mock_port2]):
+            from modules.connection.connection_manager import list_serial_ports
+
+            ports = list_serial_ports()
+
+        assert ports == []
+
+
+class TestConnectionClasses:
+    """Tests for connection class structure (no hardware required)."""
+
+    def test_base_connection_interface(self):
+        """Test that BaseConnection defines required interface."""
+        from modules.connection.connection_manager import BaseConnection
+
+        # BaseConnection should have these abstract methods
+        base = BaseConnection()
+
+        with pytest.raises(NotImplementedError):
+            base.send("test")
+
+        with pytest.raises(NotImplementedError):
+            base.flush()
+
+        with pytest.raises(NotImplementedError):
+            base.readline()
+
+        with pytest.raises(NotImplementedError):
+            base.in_waiting()
+
+        with pytest.raises(NotImplementedError):
+            base.is_connected()
+
+        with pytest.raises(NotImplementedError):
+            base.close()
+
+    def test_serial_connection_inherits_base(self):
+        """Test SerialConnection inherits from BaseConnection."""
+        from modules.connection.connection_manager import SerialConnection, BaseConnection
+
+        assert issubclass(SerialConnection, BaseConnection)
+
+    def test_websocket_connection_inherits_base(self):
+        """Test WebSocketConnection inherits from BaseConnection."""
+        from modules.connection.connection_manager import WebSocketConnection, BaseConnection
+
+        assert issubclass(WebSocketConnection, BaseConnection)
+
+
+class TestIgnorePorts:
+    """Tests for IGNORE_PORTS and DEPRIORITIZED_PORTS constants."""
+
+    def test_ignore_ports_defined(self):
+        """Test that IGNORE_PORTS constant is defined."""
+        from modules.connection.connection_manager import IGNORE_PORTS
+
+        assert isinstance(IGNORE_PORTS, list)
+        assert "/dev/cu.debug-console" in IGNORE_PORTS
+        assert "/dev/cu.Bluetooth-Incoming-Port" in IGNORE_PORTS
+
+    def test_deprioritized_ports_defined(self):
+        """Test that DEPRIORITIZED_PORTS constant is defined."""
+        from modules.connection.connection_manager import DEPRIORITIZED_PORTS
+
+        assert isinstance(DEPRIORITIZED_PORTS, list)
+        # ttyS0 is typically the Pi hardware UART - should be deprioritized
+        assert "/dev/ttyS0" in DEPRIORITIZED_PORTS
+
+
+class TestIsMachineIdle:
+    """Tests for is_machine_idle function."""
+
+    def test_is_machine_idle_no_connection(self, mock_state):
+        """Test is_machine_idle returns False when no connection."""
+        mock_state.conn = None
+
+        with patch("modules.connection.connection_manager.state", mock_state):
+            from modules.connection.connection_manager import is_machine_idle
+
+            result = is_machine_idle()
+
+        assert result is False
+
+    def test_is_machine_idle_disconnected(self, mock_state):
+        """Test is_machine_idle returns False when disconnected."""
+        mock_state.conn.is_connected.return_value = False
+
+        with patch("modules.connection.connection_manager.state", mock_state):
+            from modules.connection.connection_manager import is_machine_idle
+
+            result = is_machine_idle()
+
+        assert result is False
+
+    def test_is_machine_idle_when_idle(self, mock_state):
+        """Test is_machine_idle returns True when machine is idle."""
+        mock_state.conn.is_connected.return_value = True
+        mock_state.conn.send = MagicMock()
+        mock_state.conn.readline.return_value = "<Idle|MPos:0,0,0|Bf:15,128>"
+
+        with patch("modules.connection.connection_manager.state", mock_state):
+            from modules.connection.connection_manager import is_machine_idle
+
+            result = is_machine_idle()
+
+        assert result is True
+        mock_state.conn.send.assert_called_with('?')
+
+    def test_is_machine_idle_when_running(self, mock_state):
+        """Test is_machine_idle returns False when machine is running."""
+        mock_state.conn.is_connected.return_value = True
+        mock_state.conn.send = MagicMock()
+        mock_state.conn.readline.return_value = "<Run|MPos:0,0,0|Bf:15,128>"
+
+        with patch("modules.connection.connection_manager.state", mock_state):
+            from modules.connection.connection_manager import is_machine_idle
+
+            result = is_machine_idle()
+
+        assert result is False

+ 352 - 0
tests/unit/test_pattern_manager.py

@@ -0,0 +1,352 @@
+"""
+Unit tests for pattern_manager parsing logic.
+
+Tests the core pattern file operations:
+- Parsing theta-rho files
+- Handling comments and empty lines
+- Error handling for invalid files
+- Listing pattern files
+"""
+import os
+import pytest
+from unittest.mock import patch, MagicMock
+
+
+class TestParseTheTaRhoFile:
+    """Tests for parse_theta_rho_file function."""
+
+    def test_parse_theta_rho_file_valid(self, tmp_path):
+        """Test parsing a valid theta-rho file."""
+        # Create test file
+        test_file = tmp_path / "valid.thr"
+        test_file.write_text("0.0 0.5\n1.57 0.8\n3.14 0.3\n")
+
+        from modules.core.pattern_manager import parse_theta_rho_file
+
+        coordinates = parse_theta_rho_file(str(test_file))
+
+        assert len(coordinates) == 3
+        assert coordinates[0] == (0.0, 0.5)
+        assert coordinates[1] == (1.57, 0.8)
+        assert coordinates[2] == (3.14, 0.3)
+
+    def test_parse_theta_rho_file_with_comments(self, tmp_path):
+        """Test parsing handles # comments correctly."""
+        test_file = tmp_path / "commented.thr"
+        test_file.write_text("""# This is a header comment
+0.0 0.5
+# Another comment in the middle
+1.0 0.6
+# Trailing comment
+""")
+
+        from modules.core.pattern_manager import parse_theta_rho_file
+
+        coordinates = parse_theta_rho_file(str(test_file))
+
+        assert len(coordinates) == 2
+        assert coordinates[0] == (0.0, 0.5)
+        assert coordinates[1] == (1.0, 0.6)
+
+    def test_parse_theta_rho_file_empty_lines(self, tmp_path):
+        """Test parsing handles empty lines correctly."""
+        test_file = tmp_path / "spaced.thr"
+        test_file.write_text("""0.0 0.5
+
+1.0 0.6
+
+2.0 0.7
+
+""")
+
+        from modules.core.pattern_manager import parse_theta_rho_file
+
+        coordinates = parse_theta_rho_file(str(test_file))
+
+        assert len(coordinates) == 3
+        assert coordinates[0] == (0.0, 0.5)
+        assert coordinates[1] == (1.0, 0.6)
+        assert coordinates[2] == (2.0, 0.7)
+
+    def test_parse_theta_rho_file_not_found(self, tmp_path):
+        """Test parsing a non-existent file returns empty list."""
+        from modules.core.pattern_manager import parse_theta_rho_file
+
+        coordinates = parse_theta_rho_file(str(tmp_path / "nonexistent.thr"))
+
+        assert coordinates == []
+
+    def test_parse_theta_rho_file_invalid_lines(self, tmp_path):
+        """Test parsing skips invalid lines (non-numeric values)."""
+        test_file = tmp_path / "invalid.thr"
+        test_file.write_text("""0.0 0.5
+invalid line
+1.0 0.6
+not a number here
+2.0 0.7
+""")
+
+        from modules.core.pattern_manager import parse_theta_rho_file
+
+        coordinates = parse_theta_rho_file(str(test_file))
+
+        # Should only get the valid lines
+        assert len(coordinates) == 3
+        assert coordinates[0] == (0.0, 0.5)
+        assert coordinates[1] == (1.0, 0.6)
+        assert coordinates[2] == (2.0, 0.7)
+
+    def test_parse_theta_rho_file_whitespace_handling(self, tmp_path):
+        """Test parsing handles various whitespace correctly."""
+        test_file = tmp_path / "whitespace.thr"
+        test_file.write_text("""  0.0 0.5
+	1.0 0.6
+0.0    0.5
+""")
+
+        from modules.core.pattern_manager import parse_theta_rho_file
+
+        coordinates = parse_theta_rho_file(str(test_file))
+
+        assert len(coordinates) == 3
+
+    def test_parse_theta_rho_file_scientific_notation(self, tmp_path):
+        """Test parsing handles scientific notation."""
+        test_file = tmp_path / "scientific.thr"
+        test_file.write_text("""1.5e-3 0.5
+3.14159 1.0e0
+""")
+
+        from modules.core.pattern_manager import parse_theta_rho_file
+
+        coordinates = parse_theta_rho_file(str(test_file))
+
+        assert len(coordinates) == 2
+        assert coordinates[0][0] == pytest.approx(0.0015)
+        assert coordinates[1][1] == pytest.approx(1.0)
+
+    def test_parse_theta_rho_file_negative_values(self, tmp_path):
+        """Test parsing handles negative values."""
+        test_file = tmp_path / "negative.thr"
+        test_file.write_text("""-3.14 0.5
+0.0 -0.5
+-1.0 -0.3
+""")
+
+        from modules.core.pattern_manager import parse_theta_rho_file
+
+        coordinates = parse_theta_rho_file(str(test_file))
+
+        assert len(coordinates) == 3
+        assert coordinates[0] == (-3.14, 0.5)
+        assert coordinates[1] == (0.0, -0.5)
+        assert coordinates[2] == (-1.0, -0.3)
+
+    def test_parse_theta_rho_file_only_comments(self, tmp_path):
+        """Test parsing a file with only comments returns empty list."""
+        test_file = tmp_path / "comments_only.thr"
+        test_file.write_text("""# This file only has comments
+# No actual coordinates
+# Just documentation
+""")
+
+        from modules.core.pattern_manager import parse_theta_rho_file
+
+        coordinates = parse_theta_rho_file(str(test_file))
+
+        assert coordinates == []
+
+    def test_parse_theta_rho_file_empty_file(self, tmp_path):
+        """Test parsing an empty file returns empty list."""
+        test_file = tmp_path / "empty.thr"
+        test_file.write_text("")
+
+        from modules.core.pattern_manager import parse_theta_rho_file
+
+        coordinates = parse_theta_rho_file(str(test_file))
+
+        assert coordinates == []
+
+
+class TestListThetaRhoFiles:
+    """Tests for list_theta_rho_files function."""
+
+    def test_list_theta_rho_files_basic(self, tmp_path):
+        """Test listing pattern files in directory."""
+        # Create test pattern files
+        patterns_dir = tmp_path / "patterns"
+        patterns_dir.mkdir()
+        (patterns_dir / "circle.thr").write_text("0 0.5")
+        (patterns_dir / "spiral.thr").write_text("0 0.5")
+        (patterns_dir / "readme.txt").write_text("not a pattern")
+
+        with patch("modules.core.pattern_manager.THETA_RHO_DIR", str(patterns_dir)):
+            from modules.core.pattern_manager import list_theta_rho_files
+
+            files = list_theta_rho_files()
+
+        # Should only list .thr files
+        assert len(files) == 2
+        assert "circle.thr" in files
+        assert "spiral.thr" in files
+
+    def test_list_theta_rho_files_subdirectories(self, tmp_path):
+        """Test listing pattern files in subdirectories."""
+        patterns_dir = tmp_path / "patterns"
+        patterns_dir.mkdir()
+
+        # Create subdirectory with patterns
+        subdir = patterns_dir / "custom"
+        subdir.mkdir()
+        (subdir / "custom_pattern.thr").write_text("0 0.5")
+        (patterns_dir / "root_pattern.thr").write_text("0 0.5")
+
+        with patch("modules.core.pattern_manager.THETA_RHO_DIR", str(patterns_dir)):
+            from modules.core.pattern_manager import list_theta_rho_files
+
+            files = list_theta_rho_files()
+
+        assert len(files) == 2
+        assert "root_pattern.thr" in files
+        # Subdirectory patterns should include relative path
+        assert "custom/custom_pattern.thr" in files
+
+    def test_list_theta_rho_files_skips_cached_images(self, tmp_path):
+        """Test that cached_images directories are skipped."""
+        patterns_dir = tmp_path / "patterns"
+        patterns_dir.mkdir()
+
+        # Create cached_images directory with files
+        cache_dir = patterns_dir / "cached_images"
+        cache_dir.mkdir()
+        (cache_dir / "preview.thr").write_text("should be skipped")
+
+        (patterns_dir / "real_pattern.thr").write_text("0 0.5")
+
+        with patch("modules.core.pattern_manager.THETA_RHO_DIR", str(patterns_dir)):
+            from modules.core.pattern_manager import list_theta_rho_files
+
+            files = list_theta_rho_files()
+
+        # Should only list the real pattern, not cached files
+        assert len(files) == 1
+        assert "real_pattern.thr" in files
+
+    def test_list_theta_rho_files_empty_directory(self, tmp_path):
+        """Test listing from empty directory returns empty list."""
+        patterns_dir = tmp_path / "patterns"
+        patterns_dir.mkdir()
+
+        with patch("modules.core.pattern_manager.THETA_RHO_DIR", str(patterns_dir)):
+            from modules.core.pattern_manager import list_theta_rho_files
+
+            files = list_theta_rho_files()
+
+        assert files == []
+
+
+class TestGetStatus:
+    """Tests for get_status function."""
+
+    def test_get_status_idle(self, mock_state):
+        """Test get_status returns expected fields when idle."""
+        with patch("modules.core.pattern_manager.state", mock_state):
+            from modules.core.pattern_manager import get_status
+
+            status = get_status()
+
+        assert "current_file" in status
+        assert "is_paused" in status
+        assert "is_running" in status
+        assert "is_homing" in status
+        assert "progress" in status
+        assert "playlist" in status
+        assert "speed" in status
+        assert "connection_status" in status
+        assert status["is_running"] is False
+        assert status["current_file"] is None
+
+    def test_get_status_running_pattern(self, mock_state):
+        """Test get_status reflects running pattern."""
+        mock_state.current_playing_file = "test_pattern.thr"
+        mock_state.stop_requested = False
+        mock_state.execution_progress = (50, 100, 30.5, 60.0)
+
+        with patch("modules.core.pattern_manager.state", mock_state):
+            from modules.core.pattern_manager import get_status
+
+            status = get_status()
+
+        assert status["is_running"] is True
+        assert status["current_file"] == "test_pattern.thr"
+        assert status["progress"] is not None
+        assert status["progress"]["current"] == 50
+        assert status["progress"]["total"] == 100
+        assert status["progress"]["percentage"] == 50.0
+
+    def test_get_status_paused(self, mock_state):
+        """Test get_status reflects paused state."""
+        mock_state.pause_requested = True
+
+        with patch("modules.core.pattern_manager.state", mock_state):
+            with patch("modules.core.pattern_manager.is_in_scheduled_pause_period", return_value=False):
+                from modules.core.pattern_manager import get_status
+
+                status = get_status()
+
+        assert status["is_paused"] is True
+        assert status["manual_pause"] is True
+
+    def test_get_status_with_playlist(self, mock_state):
+        """Test get_status includes playlist info when running."""
+        mock_state.current_playlist = ["a.thr", "b.thr", "c.thr"]
+        mock_state.current_playlist_name = "test_playlist"
+        mock_state.current_playlist_index = 1
+        mock_state.playlist_mode = "indefinite"
+
+        with patch("modules.core.pattern_manager.state", mock_state):
+            from modules.core.pattern_manager import get_status
+
+            status = get_status()
+
+        assert status["playlist"] is not None
+        assert status["playlist"]["current_index"] == 1
+        assert status["playlist"]["total_files"] == 3
+        assert status["playlist"]["mode"] == "indefinite"
+        assert status["playlist"]["name"] == "test_playlist"
+
+
+class TestIsClearPattern:
+    """Tests for is_clear_pattern function."""
+
+    def test_is_clear_pattern_matches_standard(self):
+        """Test identifying standard clear patterns."""
+        from modules.core.pattern_manager import is_clear_pattern
+
+        assert is_clear_pattern("./patterns/clear_from_out.thr") is True
+        assert is_clear_pattern("./patterns/clear_from_in.thr") is True
+        assert is_clear_pattern("./patterns/clear_sideway.thr") is True
+
+    def test_is_clear_pattern_matches_mini(self):
+        """Test identifying mini table clear patterns."""
+        from modules.core.pattern_manager import is_clear_pattern
+
+        assert is_clear_pattern("./patterns/clear_from_out_mini.thr") is True
+        assert is_clear_pattern("./patterns/clear_from_in_mini.thr") is True
+        assert is_clear_pattern("./patterns/clear_sideway_mini.thr") is True
+
+    def test_is_clear_pattern_matches_pro(self):
+        """Test identifying pro table clear patterns."""
+        from modules.core.pattern_manager import is_clear_pattern
+
+        assert is_clear_pattern("./patterns/clear_from_out_pro.thr") is True
+        assert is_clear_pattern("./patterns/clear_from_in_pro.thr") is True
+        assert is_clear_pattern("./patterns/clear_sideway_pro.thr") is True
+
+    def test_is_clear_pattern_rejects_regular_patterns(self):
+        """Test that regular patterns are not identified as clear patterns."""
+        from modules.core.pattern_manager import is_clear_pattern
+
+        assert is_clear_pattern("./patterns/circle.thr") is False
+        assert is_clear_pattern("./patterns/spiral.thr") is False
+        assert is_clear_pattern("./patterns/custom/my_pattern.thr") is False

+ 259 - 0
tests/unit/test_playlist_manager.py

@@ -0,0 +1,259 @@
+"""
+Unit tests for playlist_manager CRUD operations.
+
+Tests the core playlist management functions:
+- Loading playlists from file
+- Creating playlists
+- Getting playlists
+- Modifying playlists
+- Deleting playlists
+- Listing playlists
+- Renaming playlists
+"""
+import json
+import pytest
+from unittest.mock import patch, MagicMock
+
+
+class TestPlaylistManagerCRUD:
+    """Tests for playlist CRUD operations."""
+
+    @pytest.fixture
+    def playlists_file(self, tmp_path):
+        """Create a temporary playlists.json file."""
+        file_path = tmp_path / "playlists.json"
+        file_path.write_text("{}")
+        return str(file_path)
+
+    @pytest.fixture
+    def playlist_manager_patched(self, playlists_file):
+        """Patch PLAYLISTS_FILE to use temporary file."""
+        with patch("modules.core.playlist_manager.PLAYLISTS_FILE", playlists_file):
+            # Need to re-import to get patched version
+            from modules.core import playlist_manager
+            yield playlist_manager
+
+    def test_load_playlists_empty_file(self, playlists_file, playlist_manager_patched):
+        """Test loading playlists from an empty file returns empty dict."""
+        result = playlist_manager_patched.load_playlists()
+        assert result == {}
+
+    def test_load_playlists_with_data(self, playlists_file, playlist_manager_patched):
+        """Test loading playlists with existing data."""
+        # Write some data to the file
+        with open(playlists_file, "w") as f:
+            json.dump({"my_playlist": ["pattern1.thr", "pattern2.thr"]}, f)
+
+        result = playlist_manager_patched.load_playlists()
+
+        assert "my_playlist" in result
+        assert result["my_playlist"] == ["pattern1.thr", "pattern2.thr"]
+
+    def test_create_playlist(self, playlists_file, playlist_manager_patched):
+        """Test creating a new playlist."""
+        files = ["circle.thr", "spiral.thr"]
+
+        result = playlist_manager_patched.create_playlist("test_playlist", files)
+
+        assert result is True
+
+        # Verify it was saved
+        playlists = playlist_manager_patched.load_playlists()
+        assert "test_playlist" in playlists
+        assert playlists["test_playlist"] == files
+
+    def test_create_playlist_overwrites_existing(self, playlists_file, playlist_manager_patched):
+        """Test creating a playlist with existing name overwrites it."""
+        # Create initial playlist
+        playlist_manager_patched.create_playlist("test_playlist", ["old.thr"])
+
+        # Create again with same name
+        playlist_manager_patched.create_playlist("test_playlist", ["new.thr"])
+
+        playlists = playlist_manager_patched.load_playlists()
+        assert playlists["test_playlist"] == ["new.thr"]
+
+    def test_get_playlist_exists(self, playlists_file, playlist_manager_patched):
+        """Test getting an existing playlist."""
+        playlist_manager_patched.create_playlist("my_playlist", ["a.thr", "b.thr"])
+
+        result = playlist_manager_patched.get_playlist("my_playlist")
+
+        assert result is not None
+        assert result["name"] == "my_playlist"
+        assert result["files"] == ["a.thr", "b.thr"]
+
+    def test_get_playlist_not_found(self, playlists_file, playlist_manager_patched):
+        """Test getting a non-existent playlist returns None."""
+        result = playlist_manager_patched.get_playlist("nonexistent")
+
+        assert result is None
+
+    def test_modify_playlist(self, playlists_file, playlist_manager_patched):
+        """Test modifying an existing playlist."""
+        # Create initial playlist
+        playlist_manager_patched.create_playlist("my_playlist", ["old.thr"])
+
+        # Modify it
+        new_files = ["new1.thr", "new2.thr", "new3.thr"]
+        result = playlist_manager_patched.modify_playlist("my_playlist", new_files)
+
+        assert result is True
+
+        # Verify changes
+        playlist = playlist_manager_patched.get_playlist("my_playlist")
+        assert playlist["files"] == new_files
+
+    def test_delete_playlist(self, playlists_file, playlist_manager_patched):
+        """Test deleting a playlist."""
+        # Create a playlist
+        playlist_manager_patched.create_playlist("to_delete", ["pattern.thr"])
+
+        # Delete it
+        result = playlist_manager_patched.delete_playlist("to_delete")
+
+        assert result is True
+
+        # Verify it's gone
+        playlist = playlist_manager_patched.get_playlist("to_delete")
+        assert playlist is None
+
+    def test_delete_playlist_not_found(self, playlists_file, playlist_manager_patched):
+        """Test deleting a non-existent playlist returns False."""
+        result = playlist_manager_patched.delete_playlist("nonexistent")
+
+        assert result is False
+
+    def test_list_all_playlists(self, playlists_file, playlist_manager_patched):
+        """Test listing all playlist names."""
+        # Create multiple playlists
+        playlist_manager_patched.create_playlist("playlist1", ["a.thr"])
+        playlist_manager_patched.create_playlist("playlist2", ["b.thr"])
+        playlist_manager_patched.create_playlist("playlist3", ["c.thr"])
+
+        result = playlist_manager_patched.list_all_playlists()
+
+        assert len(result) == 3
+        assert "playlist1" in result
+        assert "playlist2" in result
+        assert "playlist3" in result
+
+    def test_list_all_playlists_empty(self, playlists_file, playlist_manager_patched):
+        """Test listing playlists when none exist."""
+        result = playlist_manager_patched.list_all_playlists()
+
+        assert result == []
+
+    def test_add_to_playlist(self, playlists_file, playlist_manager_patched):
+        """Test adding a pattern to an existing playlist."""
+        # Create playlist
+        playlist_manager_patched.create_playlist("my_playlist", ["existing.thr"])
+
+        # Add pattern
+        result = playlist_manager_patched.add_to_playlist("my_playlist", "new_pattern.thr")
+
+        assert result is True
+
+        # Verify
+        playlist = playlist_manager_patched.get_playlist("my_playlist")
+        assert "new_pattern.thr" in playlist["files"]
+        assert len(playlist["files"]) == 2
+
+    def test_add_to_playlist_not_found(self, playlists_file, playlist_manager_patched):
+        """Test adding to a non-existent playlist returns False."""
+        result = playlist_manager_patched.add_to_playlist("nonexistent", "pattern.thr")
+
+        assert result is False
+
+
+class TestPlaylistRename:
+    """Tests for playlist rename functionality."""
+
+    @pytest.fixture
+    def playlists_file(self, tmp_path):
+        """Create a temporary playlists.json file."""
+        file_path = tmp_path / "playlists.json"
+        file_path.write_text("{}")
+        return str(file_path)
+
+    @pytest.fixture
+    def playlist_manager_patched(self, playlists_file):
+        """Patch PLAYLISTS_FILE to use temporary file."""
+        with patch("modules.core.playlist_manager.PLAYLISTS_FILE", playlists_file):
+            from modules.core import playlist_manager
+            yield playlist_manager
+
+    def test_rename_playlist_success(self, playlists_file, playlist_manager_patched):
+        """Test successfully renaming a playlist."""
+        # Create initial playlist
+        playlist_manager_patched.create_playlist("old_name", ["a.thr", "b.thr"])
+
+        # Rename it
+        success, message = playlist_manager_patched.rename_playlist("old_name", "new_name")
+
+        assert success is True
+        assert "new_name" in message
+
+        # Verify old name is gone
+        assert playlist_manager_patched.get_playlist("old_name") is None
+
+        # Verify new name exists with same files
+        new_playlist = playlist_manager_patched.get_playlist("new_name")
+        assert new_playlist is not None
+        assert new_playlist["files"] == ["a.thr", "b.thr"]
+
+    def test_rename_playlist_not_found(self, playlists_file, playlist_manager_patched):
+        """Test renaming a non-existent playlist."""
+        success, message = playlist_manager_patched.rename_playlist("nonexistent", "new_name")
+
+        assert success is False
+        assert "not found" in message.lower()
+
+    def test_rename_playlist_empty_name(self, playlists_file, playlist_manager_patched):
+        """Test renaming with empty name fails."""
+        playlist_manager_patched.create_playlist("my_playlist", ["a.thr"])
+
+        success, message = playlist_manager_patched.rename_playlist("my_playlist", "")
+
+        assert success is False
+        assert "empty" in message.lower()
+
+    def test_rename_playlist_whitespace_name(self, playlists_file, playlist_manager_patched):
+        """Test renaming with whitespace-only name fails."""
+        playlist_manager_patched.create_playlist("my_playlist", ["a.thr"])
+
+        success, message = playlist_manager_patched.rename_playlist("my_playlist", "   ")
+
+        assert success is False
+        assert "empty" in message.lower()
+
+    def test_rename_playlist_same_name(self, playlists_file, playlist_manager_patched):
+        """Test renaming to the same name succeeds with unchanged message."""
+        playlist_manager_patched.create_playlist("my_playlist", ["a.thr"])
+
+        success, message = playlist_manager_patched.rename_playlist("my_playlist", "my_playlist")
+
+        assert success is True
+        assert "unchanged" in message.lower()
+
+    def test_rename_playlist_name_exists(self, playlists_file, playlist_manager_patched):
+        """Test renaming to an existing playlist name fails."""
+        playlist_manager_patched.create_playlist("playlist1", ["a.thr"])
+        playlist_manager_patched.create_playlist("playlist2", ["b.thr"])
+
+        success, message = playlist_manager_patched.rename_playlist("playlist1", "playlist2")
+
+        assert success is False
+        assert "already exists" in message.lower()
+
+    def test_rename_playlist_trims_whitespace(self, playlists_file, playlist_manager_patched):
+        """Test renaming trims whitespace from new name."""
+        playlist_manager_patched.create_playlist("old_name", ["a.thr"])
+
+        success, message = playlist_manager_patched.rename_playlist("old_name", "  new_name  ")
+
+        assert success is True
+
+        # Verify trimmed name is used
+        assert playlist_manager_patched.get_playlist("new_name") is not None
+        assert playlist_manager_patched.get_playlist("  new_name  ") is None

Деякі файли не було показано, через те що забагато файлів було змінено