91 Commits

Author SHA1 Message Date
pi
09a87b79d2 docs(ui): update help assets and regenerate help pdf 2026-03-26 00:40:40 +13:00
pi
ec39df00fc feat(settings/espnow): validate wifi_channel and wire into firmware 2026-03-26 00:40:21 +13:00
pi
43d494bcb9 fix(api): prevent circular reference in pattern create 2026-03-26 00:40:08 +13:00
pi
fed312a397 fix(test/endpoints): add pytest coverage for all Microdot routes 2026-03-26 00:39:41 +13:00
63235c7822 fix(ui): enforce save semantics for default and preset chunks 2026-03-22 02:53:34 +13:00
5badf17719 refactor(ui): simplify modal interactions and refresh fixtures 2026-03-22 02:00:28 +13:00
4597573ac5 fix(ui): update preset send/default behavior in edit mode 2026-03-22 01:47:32 +13:00
1550122ced fix(ui): populate preset patterns when definitions are empty
Made-with: Cursor
2026-03-22 00:08:12 +13:00
b7c45fd72c docs(ui): switch user-facing spelling to colour
Made-with: Cursor
2026-03-22 00:00:12 +13:00
9479d0d292 chore(cursor): add commit and spelling rules
Made-with: Cursor
2026-03-21 23:54:33 +13:00
3698385af4 feat(ui): help sections, menu order, remove settings, send presets edit-only
Made-with: Cursor
2026-03-21 23:51:02 +13:00
ef968ebe39 docs: run/edit mode, profiles behavior, send presets
Made-with: Cursor
2026-03-21 23:51:00 +13:00
a5432db99a feat(ui): gate profile create/clone/delete to edit mode
Made-with: Cursor
2026-03-21 23:50:59 +13:00
764d918d5b data: update local db fixtures and browser test expectations
Made-with: Cursor
2026-03-21 23:15:55 +13:00
edadb40cb6 docs: rewrite API reference for current HTTP and driver flows
Made-with: Cursor
2026-03-21 23:15:44 +13:00
9323719a85 feat(ui): add run/edit workflow and improve preset color editing
Made-with: Cursor
2026-03-21 23:15:31 +13:00
91de705647 feat(profiles): seed new profiles and refresh tabs on apply
Made-with: Cursor
2026-03-21 23:15:19 +13:00
3ee7b74152 fix(api): stabilize palette and preset endpoints
Made-with: Cursor
2026-03-21 23:15:08 +13:00
98bbdcbb3d chore: add dev watch command to Pipfile scripts
Made-with: Cursor
2026-03-21 23:15:00 +13:00
a2abd3e833 data: refresh db JSON fixtures
Made-with: Cursor
2026-03-21 20:17:33 +13:00
550217c443 ui: data-bwignore on AP password fields for password managers
Made-with: Cursor
2026-03-21 20:17:33 +13:00
2d2032e8b9 esp32: log startup and UART receive for debugging
Made-with: Cursor
2026-03-21 20:17:33 +13:00
81bf4dded5 docs: update msg.json example payload
Made-with: Cursor
2026-03-21 20:17:33 +13:00
a75e27e3d2 feat: device model, API, static UI, and endpoint tests
Made-with: Cursor
2026-03-21 20:17:33 +13:00
13538c39a6 tests: skip browser tests when no driver; try Firefox after Chrome
Made-with: Cursor
2026-03-21 20:17:33 +13:00
7b724e9ce1 tests: point model tests at db/ and align palette assertions
Made-with: Cursor
2026-03-21 20:17:33 +13:00
aaca5435e9 chore: gitignore local settings.json (session secret)
Made-with: Cursor
2026-03-21 20:17:33 +13:00
b64dacc1c3 Stop ignoring esp32; drop esp32 rules from .gitignore
Made-with: Cursor
2026-03-21 20:08:24 +13:00
8689bdb6ef Restore esp32 MicroPython sources (main, benchmark_peers)
Adjust .gitignore to ignore esp32/* except *.py so firmware .bin stays untracked.

Made-with: Cursor
2026-03-21 19:59:52 +13:00
c178e87966 Ignore esp32 folder 2026-03-21 19:53:19 +13:00
dfe7ae50d2 Add led-tool and led-driver submodules 2026-03-21 19:52:59 +13:00
8e87559af6 Add led-tool and led-driver as submodules 2026-03-21 19:52:14 +13:00
aa3546e9ac Remove obsolete scripts and root config files
Drop clear-debug-log, install, run_web, send_empty_json, esp32 helpers,
and root msg.json/settings.json in favor of current layout.

Made-with: Cursor
2026-03-21 19:47:29 +13:00
b56af23cbf Add scripts: start, copy ESP32 main, install boot service
Made-with: Cursor
2026-03-15 23:43:27 +13:00
ac9fca8d4b Pi port: serial transport, addressed ESP-NOW bridge, port 80
- Run app on Raspberry Pi: serial to ESP32 bridge at 912000 baud, /dev/ttyS0
- Remove ESP-NOW/MicroPython-only code from src (espnow, p2p, wifi, machine/Pin)
- Transport: always send 6-byte MAC + payload; optional to/destination_mac in API and WebSocket
- Settings and model DB use project paths (no root); fix sys.print_exception for CPython
- Preset/settings controllers use get_current_sender(); template paths for cwd=src
- Pipfile: run from src, PORT from env; scripts for port 80 (setcap) and test
- ESP32 bridge: receive 6-byte addr + payload, LRU peer management (20 max), handle ESP_ERR_ESPNOW_EXIST
- Add esp32/main.py, esp32/benchmark_peers.py, scripts/setup-port80.sh, scripts/test-port80.sh

Made-with: Cursor
2026-03-15 17:16:07 +13:00
0fdc11c0b0 ESP-NOW: STA interface, notify browser on send failure
- Activate STA interface before ESP-NOW to fix ESP_ERR_ESPNOW_IF
- Notify browser on send failure: WebSocket sends error JSON; preset API returns 503
- Use exceptions for failure (not return value) to avoid false errors when send succeeds
- presets.js: handle server error messages in WebSocket onmessage

Made-with: Cursor
2026-03-08 23:47:55 +13:00
91bd78ab31 Add favicon route and minor cleanup
- Add /favicon.ico route (204) to avoid browser 404
- CSS formatting tweaks
- Pipfile trailing newline

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-20 11:49:11 +13:00
2be0640622 Remove WiFi station (client) support
- Drop station connect/status/credentials from wifi util and settings API
- Remove station activation from main
- Remove station UI and JS from index, settings template, and help.js
- Device settings now only configure WiFi Access Point

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-20 11:49:04 +13:00
0e96223bf6 Send tab defaults with presets.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 19:40:22 +13:00
d8b33923d5 Fix heartbeat LED pin.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 19:40:14 +13:00
4ce515be1c Update Python dependencies for device tooling.
This adds ampy support and refreshes lockfile versions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 13:51:28 +13:00
f88bf03939 Update browser tests for mobile preset layout.
This keeps UI checks aligned with the new tab/preset flows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 13:51:21 +13:00
7cd4a91350 Add favicon handler and heartbeat LED blink.
This keeps the UI console clean and makes device status visible.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 13:51:15 +13:00
d907ca37ad Refresh tabs/presets UI and add a mobile menu.
This improves navigation and profile workflows on smaller screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 13:51:09 +13:00
6c6ed22dbe Scope presets to active profiles and support cloning.
This keeps data isolated per profile while letting users duplicate setups quickly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 13:51:02 +13:00
00514f0525 Add in-app settings menu and fix settings API
Move WiFi and device name configuration into a modal menu, reuse existing settings endpoints, and harden settings serialization and startup for MicroPython.
2026-01-29 00:54:20 +13:00
cf1d831b5a Align controller backend and data with new presets
Update palettes, profiles, tabs, preset sending, and ESPNow message format to match the new preset defaults and driver short-field schema.
2026-01-29 00:04:23 +13:00
fd37183400 Update frontend for presets, tabs, and help
Align frontend with new preset ID usage and shortened driver fields, improve tab/preset interactions, and refine help and editor UI.
2026-01-28 23:27:50 +13:00
5fdeb57b74 Extend endpoint and browser tests for ESPNow and UI
Add coverage for /presets/send and updated tab/preset UI workflows in HTTP and Selenium tests.
2026-01-28 04:44:41 +13:00
1576383d09 Update tab UI, presets interactions, and help
Refine tab presets selection and editing, add per-tab removal, improve layout, and provide an in-app help modal.
2026-01-28 04:44:30 +13:00
8503315bef Add ESPNow preset send backend support
Implement ESPNow helper model, WebSocket forwarding, and /presets/send endpoint that chunks and broadcasts presets to devices.
2026-01-28 04:43:45 +13:00
928263fbd8 Remove Python cache files from version control
- Remove __pycache__ directories that should not be tracked
- These are now ignored via .gitignore
2026-01-27 13:05:22 +13:00
7e33f7db6a Add additional configuration and utility files
- Add install script and message configuration
- Add settings controller and templates
- Add ESP-NOW message utility
- Update API documentation
2026-01-27 13:05:09 +13:00
e74ef6d64f Update main application and dependencies
- Update main.py and run_web.py for local development
- Update microdot session handling
- Update wifi utility
2026-01-27 13:05:07 +13:00
3ed435824c Add Selenium dependency for browser tests 2026-01-27 13:05:04 +13:00
d7fabf58a4 Fix MicroPython compatibility issues in Model class
- Fix JSONDecodeError handling (use ValueError for MicroPython)
- Fix sys.print_exception argument issues
- Improve error handling in load() method
- Add proper file persistence with flush() and os.sync()
2026-01-27 13:05:02 +13:00
a7e921805a Update controllers to return JSON and fix parameter handling
- Fix decorator parameter order issues with @with_session
- Return JSON responses instead of HTML fragments
- Add proper error handling with JSON error responses
- Fix route parameter conflicts in delete and update endpoints
2026-01-27 13:05:01 +13:00
c56739c5fa Refactor UI to use JavaScript instead of HTMX
- Replace HTMX with plain JavaScript for tab management
- Consolidate tab UI into single button like profiles
- Add cookie-based current tab storage (client-side)
- Update profiles.js to work with new JSON response format
2026-01-27 13:05:00 +13:00
fd52e40d17 Add endpoint tests and consolidate test directory
- Add HTTP endpoint tests to mimic browser interactions
- Move old test files from test/ to tests/ directory
- Add comprehensive endpoint tests for tabs, profiles, presets, patterns
- Add README documenting test structure and how to run tests
2026-01-27 13:04:56 +13:00
f48c8789c7 Add browser automation tests for UI workflows
- Add Selenium-based browser tests for tabs, profiles, presets, and color palette
- Test drag and drop functionality for presets in tabs
- Include cleanup functionality to remove test data after tests
- Tests run against device at 192.168.4.1
2026-01-27 13:04:54 +13:00
80ff216e54 Update preset format with n7/n8 parameters
- Add n7 and n8 fields to preset definitions
- Update preset data format
2026-01-17 21:40:38 +13:00
1fb3dee942 Update tab storage to 2D grid format
- Change presets from flat array to 2D grid layout
- Add presets_flat array for backward compatibility
- Support 3-column grid layout for preset positioning
2026-01-17 21:40:37 +13:00
a4502055fb Add test utilities and scripts
- Add test directory with main.py, p2p.py, ws.py
- Add send_empty_json.py WebSocket test script
2026-01-17 21:40:11 +13:00
6e61ec8de6 Add P2P communication module
- Implement ESP-NOW async communication
- Support sending string, dict, or bytes data
- Use asend for async broadcast messaging
2026-01-17 21:40:10 +13:00
48d02f0e70 Update watch script path in Pipfile
- Fix watch script to use relative paths
2026-01-17 21:40:08 +13:00
cacaa3505e Add pattern definitions endpoint
- Add /definitions endpoint to pattern controller
- Load pattern.json with fallback paths for local dev and MicroPython
2026-01-17 21:40:07 +13:00
97ffc69b12 Add drag-and-drop for presets and colors, max_colors validation, and 2D grid layout
- Add drag-and-drop to reorder presets in tabs (2D grid layout)
- Add drag-and-drop to reorder colors within presets
- Add max_colors field to pattern definitions
- Hide color section when max_colors is 0
- Validate color count against pattern max_colors limit
- Store presets in 2D grid format (3 columns)
- Remove left panel from tab content, show only presets
- Update color palette to show swatches instead of hex codes
- Improve preset editor UI with visual color swatches
2026-01-17 00:58:50 +13:00
9f37dbbff0 Add data files and local tooling 2026-01-16 22:31:47 +13:00
df37f15f73 Update UI for palettes, presets, and patterns 2026-01-16 22:31:36 +13:00
9c43a0a22b Update backend models, controllers, and session 2026-01-16 22:31:24 +13:00
d41faddfca Update static files and templates
- Add htmx library
- Update main.js and styles.css
- Update index.html template
2026-01-11 21:34:19 +13:00
9e2409430c Add documentation and utility modules
- Add API specification documentation
- Add system specification document
- Add UI mockups and documentation
- Add utility modules (wifi)
2026-01-11 21:34:18 +13:00
5f6e45af09 Clean up obsolete files
- Remove old web.py, wifi.py, patterns.py
- Remove old static files from root
- Remove unused component files
2026-01-11 21:34:17 +13:00
cccda24448 Add comprehensive model tests
- Add tests for all model CRUD operations
- Add test for base Model class
- Add test runner for all model tests
- Tests include cleanup and validation
2026-01-11 21:34:17 +13:00
5cca60d830 Update main.py with controllers and static route
- Mount model controllers as subroutes
- Add static file serving route
- Integrate controllers into main application
2026-01-11 21:34:16 +13:00
ac750a36e7 Add controllers for models
- Add REST API controllers for all models
- Controllers use Microdot subroutes
- Support CRUD operations via HTTP endpoints
- Controllers: preset, profile, group, sequence, tab, palette
2026-01-11 21:34:15 +13:00
01f373f0bd Add model base class and models
- Add Model base class with get_next_id() method
- Add Preset model with CRUD operations
- Add Profile model with tabs and palette support
- Add Group model for device grouping
- Add Sequence model for preset sequencing
- Add Tab model for organizing device controls
- Add Palette model for color collections
2026-01-11 21:34:14 +13:00
d00d21e2b6 Split up main.js 2025-07-08 18:24:54 +12:00
deca1b6c37 Add basic draggable item that saves to the server 2025-07-08 17:39:09 +12:00
5c35e68ab2 Rename to styles.css 2025-07-07 18:20:11 +12:00
8b6bbdeb56 Update wifi.py 2025-05-18 21:31:03 +12:00
09bc09cca3 Update web.py 2025-05-18 21:31:00 +12:00
e57feda131 Delete index_html.py 2025-05-18 21:30:57 +12:00
3242aa464b Update index.html 2025-05-18 21:30:50 +12:00
72b7ba39ef Update main.js 2025-05-18 21:30:47 +12:00
c2a0cfaef4 Update main.css 2025-05-18 21:30:44 +12:00
4c3337a232 Update settings.py 2025-05-18 21:30:40 +12:00
825ae1f637 Update main.py 2025-05-18 21:30:37 +12:00
14a70cb024 Update boot.py 2025-05-18 21:30:34 +12:00
425511d41f Create Pipfile.lock 2025-05-18 21:30:30 +12:00
3e5239f3c6 Create patterns.py 2025-05-18 21:30:24 +12:00
136 changed files with 23036 additions and 828 deletions

116
.cursor/debug.log Normal file
View File

@@ -0,0 +1,116 @@
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434706543}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434706552}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434707852}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434707860}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434708466}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434708474}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/create-form-fragment","targetId":""},"timestamp":1768434709765}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/create-form-fragment","targetId":""},"timestamp":1768434709787}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs","targetId":"tabs-list"},"timestamp":1768434717888}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":201,"responseURL":"http://localhost:5000/tabs","targetId":"tabs-list"},"timestamp":1768434717903}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434717904}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434717913}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434738084}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434738093}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434739031}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434739040}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434746453}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434746496}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/create-form-fragment","targetId":""},"timestamp":1768434748859}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/create-form-fragment","targetId":""},"timestamp":1768434748866}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs","targetId":"tabs-list"},"timestamp":1768434773921}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":201,"responseURL":"http://localhost:5000/tabs","targetId":"tabs-list"},"timestamp":1768434773931}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434773931}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434773940}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/create-form-fragment","targetId":""},"timestamp":1768434810105}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/create-form-fragment","targetId":""},"timestamp":1768434810119}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs","targetId":"tabs-list"},"timestamp":1768434816383}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":201,"responseURL":"http://localhost:5000/tabs","targetId":"tabs-list"},"timestamp":1768434816399}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434816400}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434816414}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434944656}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434944756}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434945369}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434945427}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434946108}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434946162}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434946680}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434946736}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/create-form-fragment","targetId":""},"timestamp":1768434947640}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/create-form-fragment","targetId":""},"timestamp":1768434947656}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs","targetId":"tabs-list"},"timestamp":1768434953064}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":201,"responseURL":"http://localhost:5000/tabs","targetId":"tabs-list"},"timestamp":1768434953079}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434953080}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434953093}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435103720}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435103776}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435104593}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435104647}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435105158}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435105253}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435275247}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435275315}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435276178}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435276278}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435276945}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435276998}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/create-form-fragment","targetId":""},"timestamp":1768435278150}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/create-form-fragment","targetId":""},"timestamp":1768435278162}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs","targetId":"tabs-list"},"timestamp":1768435281966}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":400,"responseURL":"http://localhost:5000/tabs","targetId":"tabs-list"},"timestamp":1768435281988}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435387623}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435387680}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435388399}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435388454}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/create-form-fragment","targetId":""},"timestamp":1768435389910}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/create-form-fragment","targetId":""},"timestamp":1768435389922}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs","targetId":"tabs-list"},"timestamp":1768435393213}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs","targetId":"tabs-list"},"timestamp":1768435393231}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435393233}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435393245}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/12/content-fragment","targetId":"tab-content"},"timestamp":1768435395729}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/12/content-fragment","targetId":"tab-content"},"timestamp":1768435395748}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/13/content-fragment","targetId":"tab-content"},"timestamp":1768435396771}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/13/content-fragment","targetId":"tab-content"},"timestamp":1768435396788}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/12/content-fragment","targetId":"tab-content"},"timestamp":1768435398656}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/12/content-fragment","targetId":"tab-content"},"timestamp":1768435398674}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/13/content-fragment","targetId":"tab-content"},"timestamp":1768435399748}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/13/content-fragment","targetId":"tab-content"},"timestamp":1768435399774}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435668310}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:DOMContentLoaded","message":"palette elements presence","data":{"hasPaletteButton":true,"hasPaletteModal":false,"hasPaletteContainer":false,"hasLightingController":false},"timestamp":1768435668311}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435668355}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435669841}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:DOMContentLoaded","message":"palette elements presence","data":{"hasPaletteButton":true,"hasPaletteModal":false,"hasPaletteContainer":false,"hasLightingController":false},"timestamp":1768435669842}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435669852}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:color-palette-btn","message":"color palette button clicked","data":{"hasPaletteModal":false,"modalActive":null},"timestamp":1768435672686}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:color-palette-btn","message":"color palette button clicked","data":{"hasPaletteModal":false,"modalActive":null},"timestamp":1768435673713}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:color-palette-btn","message":"color palette button clicked","data":{"hasPaletteModal":false,"modalActive":null},"timestamp":1768435674316}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:color-palette-btn","message":"color palette button clicked","data":{"hasPaletteModal":false,"modalActive":null},"timestamp":1768435674560}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:color-palette-btn","message":"color palette button clicked","data":{"hasPaletteModal":false,"modalActive":null},"timestamp":1768435680419}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:color-palette-btn","message":"color palette button clicked","data":{"hasPaletteModal":false,"modalActive":null},"timestamp":1768435680897}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435814285}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:DOMContentLoaded","message":"palette elements presence","data":{"hasPaletteButton":true,"hasPaletteModal":true,"hasPaletteContainer":false,"hasLightingController":false},"timestamp":1768435814287}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H3","location":"src/static/color_palette.js:DOMContentLoaded","message":"palette script loaded","data":{"hasButton":true,"hasModal":true,"hasClose":true},"timestamp":1768435814287}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435814350}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435815080}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H3","location":"src/static/color_palette.js:DOMContentLoaded","message":"palette script loaded","data":{"hasButton":true,"hasModal":true,"hasClose":true},"timestamp":1768435815081}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:DOMContentLoaded","message":"palette elements presence","data":{"hasPaletteButton":true,"hasPaletteModal":true,"hasPaletteContainer":false,"hasLightingController":false},"timestamp":1768435815082}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435815135}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435815724}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H3","location":"src/static/color_palette.js:DOMContentLoaded","message":"palette script loaded","data":{"hasButton":true,"hasModal":true,"hasClose":true},"timestamp":1768435815725}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:DOMContentLoaded","message":"palette elements presence","data":{"hasPaletteButton":true,"hasPaletteModal":true,"hasPaletteContainer":false,"hasLightingController":false},"timestamp":1768435815725}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435815778}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H4","location":"src/static/color_palette.js:openModal","message":"palette modal opened","data":{"active":true},"timestamp":1768435817104}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:color-palette-btn","message":"color palette button clicked","data":{"hasPaletteModal":true,"modalActive":true},"timestamp":1768435817105}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H4","location":"src/static/color_palette.js:closeModal","message":"palette modal closed","data":{"active":false},"timestamp":1768435820180}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435931118}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:DOMContentLoaded","message":"palette elements presence","data":{"hasPaletteButton":true,"hasPaletteModal":true,"hasPaletteContainer":true,"hasLightingController":false},"timestamp":1768435931120}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H3","location":"src/static/color_palette.js:DOMContentLoaded","message":"palette script loaded","data":{"hasButton":true,"hasModal":true,"hasClose":true,"hasContainer":true,"hasAddButton":true},"timestamp":1768435931119}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435931173}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435931791}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:DOMContentLoaded","message":"palette elements presence","data":{"hasPaletteButton":true,"hasPaletteModal":true,"hasPaletteContainer":true,"hasLightingController":false},"timestamp":1768435931793}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H3","location":"src/static/color_palette.js:DOMContentLoaded","message":"palette script loaded","data":{"hasButton":true,"hasModal":true,"hasClose":true,"hasContainer":true,"hasAddButton":true},"timestamp":1768435931793}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435931895}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:color-palette-btn","message":"color palette button clicked","data":{"hasPaletteModal":true,"modalActive":true},"timestamp":1768435933111}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H4","location":"src/static/color_palette.js:openModal","message":"palette modal opened","data":{"active":true},"timestamp":1768435933110}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H4","location":"src/static/color_palette.js:closeModal","message":"palette modal closed","data":{"active":false},"timestamp":1768435943332}

26
.cursor/rules/commit.mdc Normal file
View File

@@ -0,0 +1,26 @@
---
description: Git commit messages and how to split work into commits
alwaysApply: true
---
# Commits
When preparing commits (especially when the user asks to commit):
1. **Prefer multiple commits** over one large commit when changes span distinct concerns (e.g. UI vs docs vs API). One logical unit per commit.
2. **Message format:** `type(scope): short imperative subject` (lowercase subject after the colon; no trailing period).
- **Types:** `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `perf` (use what fits).
- **Scope:** optional but encouraged — e.g. `ui`, `api`, `profiles`, `presets`, `esp32`.
3. **Subject line:** ~50 characters or less; describe *what* changed, not the ticket number alone.
4. **Body:** only when needed (breaking change, non-obvious rationale, or multiple bullets). Otherwise subject is enough.
**Examples**
- `feat(ui): gate profile delete to edit mode`
- `docs: document run vs edit in API`
- `fix(api): resolve preset delete route argument clash`
**Do not**
- Squash unrelated fixes and doc tweaks into one commit unless the user explicitly wants a single commit.
- Use vague messages like `update`, `fixes`, or `wip`.

View File

@@ -0,0 +1,10 @@
---
description: British spelling for user-facing text; technical identifiers stay as-is
alwaysApply: true
---
# Spelling: colour
- **User-facing strings** (Help modal, button labels, README prose, `docs/`, error messages shown in the UI): use **British English** — **colour**, **favour**, **behaviour**, etc., unless quoting existing product names.
- **Do not rename** existing code for spelling: **identifiers**, file names, URL paths, JSON keys, CSS properties (`color`), HTML attributes (`type="color"`), and API field names stay as they are (`color`, `colors`, `palette`, etc.) so nothing breaks.
- **New** UI copy and docs should follow **colour** in prose; new code symbols may still use `color` when matching surrounding APIs or conventions.

30
.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
# Virtual environments
venv/
env/
ENV/
.venv
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Project specific
docs/.help-print.html
settings.json
*.log
*.db
*.sqlite

6
.gitmodules vendored Normal file
View File

@@ -0,0 +1,6 @@
[submodule "led-driver"]
path = led-driver
url = git@git.technical.kiwi:technicalkiwi/led-driver.git
[submodule "led-tool"]
path = led-tool
url = git@git.technical.kiwi:technicalkiwi/led-tool.git

15
Pipfile
View File

@@ -7,8 +7,23 @@ name = "pypi"
mpremote = "*"
pyserial = "*"
esptool = "*"
pyjwt = "*"
watchfiles = "*"
requests = "*"
selenium = "*"
adafruit-ampy = "*"
microdot = "*"
[dev-packages]
pytest = "*"
[requires]
python_version = "3.12"
[scripts]
web = "python /home/pi/led-controller/tests/web.py"
watch = "python -m watchfiles 'python tests/web.py' src tests"
install = "pipenv install"
run = "sh -c 'cd src && python main.py'"
dev = "watchfiles \"sh -c 'cd src && python main.py'\" src"
help-pdf = "sh scripts/build_help_pdf.sh"

958
Pipfile.lock generated Normal file
View File

@@ -0,0 +1,958 @@
{
"_meta": {
"hash": {
"sha256": "6cec0fe6dec67c9177363a558131f333153b6caa47e1ddeca303cb0d19954cf8"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.12"
},
"sources": [
{
"name": "pypi",
"url": "https://pypi.org/simple",
"verify_ssl": true
}
]
},
"default": {
"adafruit-ampy": {
"hashes": [
"sha256:4a74812226e53c17d01eb828633424bc4f4fe76b9499a7b35eba6fc2532635b7",
"sha256:f4cba36f564096f2aafd173f7fbabb845365cc3bb3f41c37541edf98b58d3976"
],
"index": "pypi",
"version": "==1.1.0"
},
"anyio": {
"hashes": [
"sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708",
"sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc"
],
"markers": "python_version >= '3.10'",
"version": "==4.13.0"
},
"attrs": {
"hashes": [
"sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309",
"sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"
],
"markers": "python_version >= '3.9'",
"version": "==26.1.0"
},
"bitarray": {
"hashes": [
"sha256:004d518fa410e6da43386d20e07b576a41eb417ac67abf9f30fa75e125697199",
"sha256:014df8a9430276862392ac5d471697de042367996c49f32d0008585d2c60755a",
"sha256:01c5f0dc080b0ebb432f7a68ee1e88a76bd34f6d89c9568fcec65fb16ed71f0e",
"sha256:025d133bf4ca8cf75f904eeb8ea946228d7c043231866143f31946a6f4dd0bf3",
"sha256:0ca70ccf789446a6dfde40b482ec21d28067172cd1f8efd50d5548159fccad9e",
"sha256:0df69d26f21a9d2f1b20266f6737fa43f08aa5015c99900fb69f255fbe4dabb4",
"sha256:0f8069a807a3e6e3c361ce302ece4bf1c3b49962c1726d1d56587e8f48682861",
"sha256:15a2eff91f54d2b1f573cca8ca6fb58763ce8fea80e7899ab028f3987ef71cd5",
"sha256:15e8d0597cc6e8496de6f4dea2a6880c57e1251502a7072f5631108a1aa28521",
"sha256:165052a0e61c880f7093808a0c524ce1b3555bfa114c0dfb5c809cd07918a60d",
"sha256:178c5a4c7fdfb5cd79e372ae7f675390e670f3732e5bc68d327e01a5b3ff8d55",
"sha256:18214bac86341f1cc413772e66447d6cca10981e2880b70ecaf4e826c04f95e9",
"sha256:1a54d7e7999735faacdcbe8128e30207abc2caf9f9fd7102d180b32f1b78bfce",
"sha256:1a926fa554870642607fd10e66ee25b75fdd9a7ca4bbffa93d424e4ae2bf734a",
"sha256:1c8f2a5d8006db5a555e06f9437e76bf52537d3dfd130cb8ae2b30866aca32c9",
"sha256:21ca6a47bf20db9e7ad74ca04b3d479e4d76109b68333eb23535553d2705339e",
"sha256:22c540ed20167d3dbb1e2d868ca935180247d620c40eace90efa774504a40e3b",
"sha256:239578587b9c29469ab61149dda40a2fe714a6a4eca0f8ff9ea9439ec4b7bc30",
"sha256:25b9cff6c9856bc396232e2f609ea0c5ec1a8a24c500cee4cca96ba8a3cd50b6",
"sha256:26714898eb0d847aac8af94c4441c9cb50387847d0fe6b9fc4217c086cd68b80",
"sha256:28a85b056c0eb7f5d864c0ceef07034117e8ebfca756f50648c71950a568ba11",
"sha256:2a3d1b05ffdd3e95687942ae7b13c63689f85d3f15c39b33329e3cb9ce6c015f",
"sha256:2c3bb96b6026643ce24677650889b09073f60b9860a71765f843c99f9ab38b25",
"sha256:2fa23fdb3beab313950bbb49674e8a161e61449332d3997089fe3944953f1b77",
"sha256:2fe8c54b15a9cd4f93bc2aaceab354ec65af93370aa1496ba2f9c537a4855ee0",
"sha256:30989a2451b693c3f9359d91098a744992b5431a0be4858f1fdf0ec76b457125",
"sha256:31a4ad2b730128e273f1c22300da3e3631f125703e4fee0ac44d385abfb15671",
"sha256:337c8cd46a4c6568d367ed676cbf2d7de16f890bb31dbb54c44c1d6bb6d4a1de",
"sha256:33af25c4ff7723363cb8404dfc2eefeab4110b654f6c98d26aba8a08c745d860",
"sha256:3ea52df96566457735314794422274bd1962066bfb609e7eea9113d70cf04ffe",
"sha256:3eae38daffd77c9621ae80c16932eea3fb3a4af141fb7cc724d4ad93eff9210d",
"sha256:41b53711f89008ba2de62e4c2d2260a8b357072fd4f18e1351b28955db2719dc",
"sha256:451f9958850ea98440d542278368c8d1e1ea821e2494b204570ba34a340759df",
"sha256:46cf239856b87fe1c86dfbb3d459d840a8b1649e7922b1e0bfb6b6464692644a",
"sha256:4779f356083c62e29b4198d290b7b17a39a69702d150678b7efff0fdddf494a8",
"sha256:4902f4ecd5fcb6a5f482d7b0ae1c16c21f26fc5279b3b6127363d13ad8e7a9d9",
"sha256:4d73d4948dcc5591d880db8933004e01f1dd2296df9de815354d53469beb26fe",
"sha256:4d9984017314da772f5f7460add7a0301a4ffc06c72c2998bb16c300a6253607",
"sha256:4f298daaaea58d45e245a132d6d2bdfb6f856da50dc03d75ebb761439fb626cf",
"sha256:50ddbe3a7b4b6ab96812f5a4d570f401a2cdb95642fd04c062f98939610bbeee",
"sha256:5338a313f998e1be7267191b7caaae82563b4a2b42b393561055412a34042caa",
"sha256:5591daf81313096909d973fb2612fccd87528fdfdd39f6478bdce54543178954",
"sha256:56896ceeffe25946c4010320629e2d858ca763cd8ded273c81672a5edbcb1e0a",
"sha256:58a01ea34057463f7a98a4d6ff40160f65f945e924fec08a5b39e327e372875d",
"sha256:5bfac7f236ba1a4d402644bdce47fb9db02a7cf3214a1f637d3a88390f9e5428",
"sha256:5c5a8a83df95e51f7a7c2b083eaea134cbed39fc42c6aeb2e764ddb7ccccd43e",
"sha256:5f2fb10518f6b365f5b720e43a529c3b2324ca02932f609631a44edb347d8d54",
"sha256:64af877116edf051375b45f0bda648143176a017b13803ec7b3a3111dc05f4c5",
"sha256:6d70fa9c6d2e955bde8cd327ffc11f2cc34bc21944e5571a46ca501e7eadef24",
"sha256:6d79f659965290af60d6acc8e2716341865fe74609a7ede2a33c2f86ad893b8f",
"sha256:720963fee259291a88348ae9735d9deb5d334e84a016244f61c89f5a49aa400a",
"sha256:75a3b6e9c695a6570ea488db75b84bb592ff70a944957efa1c655867c575018b",
"sha256:792462abfeeca6cc8c6c1e6d27e14319682f0182f6b0ba37befe911af794db70",
"sha256:79ec4498a545733ecace48d780d22407411b07403a2e08b9a4d7596c0b97ebd7",
"sha256:7f14d6b303e55bd7d19b28309ef8014370e84a3806c5e452e078e7df7344d97a",
"sha256:7f65bd5d4cdb396295b6aa07f84ca659ac65c5c68b53956a6d95219e304b0ada",
"sha256:81c6b4a6c1af800d52a6fa32389ef8f4281583f4f99dc1a40f2bb47667281541",
"sha256:82a07de83dce09b4fa1bccbdc8bde8f188b131666af0dc9048ba0a0e448d8a3b",
"sha256:847c7f61964225fc489fe1d49eda7e0e0d253e98862c012cecf845f9ad45cdf4",
"sha256:84b52b2cf77bb7f703d16c4007b021078dbbe6cf8ffb57abe81a7bacfc175ef2",
"sha256:86685fa04067f7175f9718489ae755f6acde03593a1a9ca89305554af40e14fd",
"sha256:8a9c962c64a4c08def58b9799333e33af94ec53038cf151d36edacdb41f81646",
"sha256:8cbd4bfc933b33b85c43ef4c1f4d5e3e9d91975ea6368acf5fbac02bac06ea89",
"sha256:8ffe660e963ae711cb9e2b8d8461c9b1ad6167823837fc17d59d5e539fb898fa",
"sha256:94652da1a4ca7cfb69c15dd6986b205e0bd9c63a05029c3b48b4201085f527bd",
"sha256:969fd67de8c42affdb47b38b80f1eaa79ac0ef17d65407cdd931db1675315af1",
"sha256:9858dcbc23ba7eaadcd319786b982278a1a2b2020720b19db43e309579ff76fb",
"sha256:99d25aff3745c54e61ab340b98400c52ebec04290a62078155e0d7eb30380220",
"sha256:99f55e14e7c56f4fafe1343480c32b110ef03836c21ff7c48bae7add6818f77c",
"sha256:9d35d8f8a1c9ed4e2b08187b513f8a3c71958600129db3aa26d85ea3abfd1310",
"sha256:a2ba92f59e30ce915e9e79af37649432e3a212ddddf416d4d686b1b4825bcdb2",
"sha256:a2cb35a6efaa0e3623d8272471371a12c7e07b51a33e5efce9b58f655d864b4e",
"sha256:a358277122456666a8b2a0b9aa04f1b89d34e8aa41d08a6557d693e6abb6667c",
"sha256:a60da2f9efbed355edb35a1fb6829148676786c829fad708bb6bb47211b3593a",
"sha256:aa7dec53c25f1949513457ef8b0ea1fb40e76c672cc4d2daa8ad3c8d6b73491a",
"sha256:b1572ee0eb1967e71787af636bb7d1eb9c6735d5337762c450650e7f51844594",
"sha256:b4f10d3f304be7183fac79bf2cd997f82e16aa9a9f37343d76c026c6e435a8a8",
"sha256:bbbbfbb7d039b20d289ce56b1beb46138d65769d04af50c199c6ac4cb6054d52",
"sha256:c394a3f055b49f92626f83c1a0b6d6cd2c628f1ccd72481c3e3c6aa4695f3b20",
"sha256:c396358023b876cff547ce87f4e8ff8a2280598873a137e8cc69e115262260b8",
"sha256:c5ba07e58fd98c9782201e79eb8dd4225733d212a5a3700f9a84d329bd0463a6",
"sha256:c764fb167411d5afaef88138542a4bfa28bd5e5ded5e8e42df87cef965efd6e9",
"sha256:cbba763d99de0255a3e4938f25a8579930ac8aa089233cb2fb2ed7d04d4aff02",
"sha256:cbd1660fb48827381ce3a621a4fdc237959e1cd4e98b098952a8f624a0726425",
"sha256:cd761d158f67e288fd0ebe00c3b158095ce80a4bc7c32b60c7121224003ba70d",
"sha256:cdfbb27f2c46bb5bbdcee147530cbc5ca8ab858d7693924e88e30ada21b2c5e2",
"sha256:d2dbe8a3baf2d842e342e8acb06ae3844765d38df67687c144cdeb71f1bcb5d7",
"sha256:d5c931ec1c03111718cabf85f6012bb2815fa0ce578175567fa8d6f2cc15d3b4",
"sha256:df6d7bf3e15b7e6e202a16ff4948a51759354016026deb04ab9b5acbbe35e096",
"sha256:dfbe2aa45b273f49e715c5345d94874cb65a28482bf231af408891c260601b8d",
"sha256:e12769d3adcc419e65860de946df8d2ed274932177ac1cdb05186e498aaa9149",
"sha256:e5aed4754895942ae15ffa48c52d181e1c1463236fda68d2dba29c03aa61786b",
"sha256:e645b4c365d6f1f9e0799380ad6395268f3c3b898244a650aaeb8d9d27b74c35",
"sha256:ed3493a369fe849cce98542d7405c88030b355e4d2e113887cb7ecc86c205773",
"sha256:f08342dc8d19214faa7ef99574dea6c37a2790d6d04a9793ef8fa76c188dc08d",
"sha256:f0a55cf02d2cdd739b40ce10c09bbdd520e141217696add7a48b56e67bdfdfe6",
"sha256:f0ce9d9e07c75da8027c62b4c9f45771d1d8aae7dc9ad7fb606c6a5aedbe9741",
"sha256:f1f723e260c35e1c7c57a09d3a6ebe681bd56c83e1208ae3ce1869b7c0d10d4f",
"sha256:f2fcbe9b3a5996b417e030aa33a562e7e20dfc86271e53d7e841fc5df16268b8",
"sha256:f3fd8df63c41ff6a676d031956aebf68ebbc687b47c507da25501eb22eec341f",
"sha256:f8d3417db5e14a6789073b21ae44439a755289477901901bae378a57b905e148",
"sha256:fbf05678c2ae0064fb1b8de7e9e8f0fc30621b73c8477786dd0fb3868044a8c8",
"sha256:fc98ff43abad61f00515ad9a06213b7716699146e46eabd256cdfe7cb522bd97",
"sha256:ff1863f037dad765ef5963efc2e37d399ac023e192a6f2bb394e2377d023cefe"
],
"version": "==3.8.0"
},
"bitstring": {
"hashes": [
"sha256:e682ac522bb63e041d16cbc9d0ca86a4f00194db16d0847c7efe066f836b2e37",
"sha256:feac49524fcf3ef27e6081e86f02b10d2adf6c3773bf22fbe0e7eea9534bc737"
],
"markers": "python_version >= '3.8'",
"version": "==4.4.0"
},
"certifi": {
"hashes": [
"sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa",
"sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"
],
"markers": "python_version >= '3.7'",
"version": "==2026.2.25"
},
"cffi": {
"hashes": [
"sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb",
"sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b",
"sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f",
"sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9",
"sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44",
"sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2",
"sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c",
"sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75",
"sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65",
"sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e",
"sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a",
"sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e",
"sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25",
"sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a",
"sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe",
"sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b",
"sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91",
"sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592",
"sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187",
"sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c",
"sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1",
"sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94",
"sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba",
"sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb",
"sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165",
"sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529",
"sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca",
"sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c",
"sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6",
"sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c",
"sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0",
"sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743",
"sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63",
"sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5",
"sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5",
"sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4",
"sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d",
"sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b",
"sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93",
"sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205",
"sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27",
"sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512",
"sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d",
"sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c",
"sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037",
"sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26",
"sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322",
"sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb",
"sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c",
"sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8",
"sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4",
"sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414",
"sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9",
"sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664",
"sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9",
"sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775",
"sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739",
"sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc",
"sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062",
"sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe",
"sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9",
"sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92",
"sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5",
"sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13",
"sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d",
"sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26",
"sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f",
"sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495",
"sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b",
"sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6",
"sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c",
"sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef",
"sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5",
"sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18",
"sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad",
"sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3",
"sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7",
"sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5",
"sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534",
"sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49",
"sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2",
"sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5",
"sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453",
"sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"
],
"markers": "python_full_version >= '3.9' and platform_python_implementation != 'PyPy'",
"version": "==2.0.0"
},
"charset-normalizer": {
"hashes": [
"sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e",
"sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c",
"sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5",
"sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815",
"sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f",
"sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0",
"sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484",
"sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407",
"sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6",
"sha256:1cf0a70018692f85172348fe06d3a4b63f94ecb055e13a00c644d368eb82e5b8",
"sha256:1ed80ff870ca6de33f4d953fda4d55654b9a2b340ff39ab32fa3adbcd718f264",
"sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815",
"sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2",
"sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4",
"sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579",
"sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f",
"sha256:2bd9d128ef93637a5d7a6af25363cf5dec3fa21cf80e68055aad627f280e8afa",
"sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95",
"sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab",
"sha256:2f7fdd9b6e6c529d6a2501a2d36b240109e78a8ceaef5687cfcfa2bbe671d297",
"sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a",
"sha256:31215157227939b4fb3d740cd23fe27be0439afef67b785a1eb78a3ae69cba9e",
"sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84",
"sha256:3516bbb8d42169de9e61b8520cbeeeb716f12f4ecfe3fd30a9919aa16c806ca8",
"sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0",
"sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9",
"sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f",
"sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1",
"sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843",
"sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565",
"sha256:461598cd852bfa5a61b09cae2b1c02e2efcd166ee5516e243d540ac24bfa68a7",
"sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c",
"sha256:48696db7f18afb80a068821504296eb0787d9ce239b91ca15059d1d3eaacf13b",
"sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7",
"sha256:4d1d02209e06550bdaef34af58e041ad71b88e624f5d825519da3a3308e22687",
"sha256:4f41da960b196ea355357285ad1316a00099f22d0929fe168343b99b254729c9",
"sha256:517ad0e93394ac532745129ceabdf2696b609ec9f87863d337140317ebce1c14",
"sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89",
"sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f",
"sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0",
"sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9",
"sha256:54fae94be3d75f3e573c9a1b5402dc593de19377013c9a0e4285e3d402dd3a2a",
"sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389",
"sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0",
"sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30",
"sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd",
"sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e",
"sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9",
"sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc",
"sha256:659a1e1b500fac8f2779dd9e1570464e012f43e580371470b45277a27baa7532",
"sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d",
"sha256:69dd852c2f0ad631b8b60cfbe25a28c0058a894de5abb566619c205ce0550eae",
"sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2",
"sha256:71be7e0e01753a89cf024abf7ecb6bca2c81738ead80d43004d9b5e3f1244e64",
"sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f",
"sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557",
"sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e",
"sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff",
"sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398",
"sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db",
"sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a",
"sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43",
"sha256:802168e03fba8bbc5ce0d866d589e4b1ca751d06edee69f7f3a19c5a9fe6b597",
"sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c",
"sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e",
"sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2",
"sha256:8761ac29b6c81574724322a554605608a9960769ea83d2c73e396f3df896ad54",
"sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e",
"sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4",
"sha256:8bc5f0687d796c05b1e28ab0d38a50e6309906ee09375dd3aff6a9c09dd6e8f4",
"sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7",
"sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6",
"sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5",
"sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194",
"sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69",
"sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f",
"sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316",
"sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e",
"sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73",
"sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8",
"sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923",
"sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88",
"sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f",
"sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21",
"sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4",
"sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6",
"sha256:ab30e5e3e706e3063bc6de96b118688cb10396b70bb9864a430f67df98c61ecc",
"sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2",
"sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866",
"sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021",
"sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2",
"sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d",
"sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8",
"sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de",
"sha256:bf625105bb9eef28a56a943fec8c8a98aeb80e7d7db99bd3c388137e6eb2d237",
"sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4",
"sha256:c45a03a4c69820a399f1dda9e1d8fbf3562eda46e7720458180302021b08f778",
"sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb",
"sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc",
"sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602",
"sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4",
"sha256:d08ec48f0a1c48d75d0356cea971921848fb620fdeba805b28f937e90691209f",
"sha256:d1a2ee9c1499fc8f86f4521f27a973c914b211ffa87322f4ee33bb35392da2c5",
"sha256:d5f5d1e9def3405f60e3ca8232d56f35c98fb7bf581efcc60051ebf53cb8b611",
"sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8",
"sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf",
"sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d",
"sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b",
"sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db",
"sha256:df01808ee470038c3f8dc4f48620df7225c49c2d6639e38f96e6d6ac6e6f7b0e",
"sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077",
"sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd",
"sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef",
"sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e",
"sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8",
"sha256:e8aeb10fcbe92767f0fa69ad5a72deca50d0dca07fbde97848997d778a50c9fe",
"sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058",
"sha256:ecbbd45615a6885fe3240eb9db73b9e62518b611850fdf8ab08bd56de7ad2b17",
"sha256:ee4ec14bc1680d6b0afab9aea2ef27e26d2024f18b24a2d7155a52b60da7e833",
"sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421",
"sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550",
"sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff",
"sha256:f50498891691e0864dc3da965f340fada0771f6142a378083dc4608f4ea513e2",
"sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc",
"sha256:f61aa92e4aad0be58eb6eb4e0c21acf32cf8065f4b2cae5665da756c4ceef982",
"sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d",
"sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed",
"sha256:f98059e4fcd3e3e4e2d632b7cf81c2faae96c43c60b569e9c621468082f1d104",
"sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659"
],
"markers": "python_version >= '3.7'",
"version": "==3.4.6"
},
"click": {
"hashes": [
"sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a",
"sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"
],
"markers": "python_version >= '3.10'",
"version": "==8.3.1"
},
"cryptography": {
"hashes": [
"sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72",
"sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235",
"sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9",
"sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356",
"sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257",
"sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad",
"sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4",
"sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c",
"sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614",
"sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed",
"sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31",
"sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229",
"sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0",
"sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731",
"sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b",
"sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4",
"sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4",
"sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263",
"sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595",
"sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1",
"sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678",
"sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48",
"sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76",
"sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0",
"sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18",
"sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d",
"sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d",
"sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1",
"sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981",
"sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7",
"sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82",
"sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2",
"sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4",
"sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663",
"sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c",
"sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d",
"sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a",
"sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a",
"sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d",
"sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b",
"sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a",
"sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826",
"sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee",
"sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9",
"sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648",
"sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da",
"sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2",
"sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2",
"sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87"
],
"markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'",
"version": "==46.0.5"
},
"esptool": {
"hashes": [
"sha256:9c355b7d6331cc92979cc710ae5c41f59830d1ea29ec24c467c6005a092c06d6"
],
"index": "pypi",
"version": "==5.2.0"
},
"h11": {
"hashes": [
"sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1",
"sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"
],
"markers": "python_version >= '3.8'",
"version": "==0.16.0"
},
"idna": {
"hashes": [
"sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea",
"sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"
],
"markers": "python_version >= '3.8'",
"version": "==3.11"
},
"intelhex": {
"hashes": [
"sha256:87cc5225657524ec6361354be928adfd56bcf2a3dcc646c40f8f094c39c07db4",
"sha256:892b7361a719f4945237da8ccf754e9513db32f5628852785aea108dcd250093"
],
"version": "==2.3.0"
},
"markdown-it-py": {
"hashes": [
"sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147",
"sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"
],
"markers": "python_version >= '3.10'",
"version": "==4.0.0"
},
"mdurl": {
"hashes": [
"sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8",
"sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"
],
"markers": "python_version >= '3.7'",
"version": "==0.1.2"
},
"microdot": {
"hashes": [
"sha256:363e3ebfc80b7e0415779848c9332e4e7fb7bd365ee54d3620abffe42ed82946",
"sha256:abfb82ca31cc430174e4761cc7356adc4bff00ea758d437c2b258883dc63f464"
],
"index": "pypi",
"version": "==2.6.0"
},
"mpremote": {
"hashes": [
"sha256:11d134c69b21b487dae3d03eed54c8ccbf84c916c8732a3e069a97cae47be3d4",
"sha256:6bb75774648091dad6833af4f86c5bf6505f8d7aec211380f9e6996c01d23cb5"
],
"index": "pypi",
"version": "==1.27.0"
},
"outcome": {
"hashes": [
"sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8",
"sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b"
],
"markers": "python_version >= '3.7'",
"version": "==1.3.0.post0"
},
"platformdirs": {
"hashes": [
"sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934",
"sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868"
],
"markers": "python_version >= '3.10'",
"version": "==4.9.4"
},
"pycparser": {
"hashes": [
"sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29",
"sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"
],
"markers": "implementation_name != 'PyPy'",
"version": "==3.0"
},
"pygments": {
"hashes": [
"sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887",
"sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"
],
"markers": "python_version >= '3.8'",
"version": "==2.19.2"
},
"pyjwt": {
"hashes": [
"sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c",
"sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"
],
"index": "pypi",
"version": "==2.12.1"
},
"pyserial": {
"hashes": [
"sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb",
"sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0"
],
"index": "pypi",
"version": "==3.5"
},
"pysocks": {
"hashes": [
"sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299",
"sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5",
"sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"
],
"version": "==1.7.1"
},
"python-dotenv": {
"hashes": [
"sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a",
"sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"
],
"markers": "python_version >= '3.10'",
"version": "==1.2.2"
},
"pyyaml": {
"hashes": [
"sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c",
"sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a",
"sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3",
"sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956",
"sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6",
"sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c",
"sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65",
"sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a",
"sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0",
"sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b",
"sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1",
"sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6",
"sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7",
"sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e",
"sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007",
"sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310",
"sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4",
"sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9",
"sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295",
"sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea",
"sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0",
"sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e",
"sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac",
"sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9",
"sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7",
"sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35",
"sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb",
"sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b",
"sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69",
"sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5",
"sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b",
"sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c",
"sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369",
"sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd",
"sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824",
"sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198",
"sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065",
"sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c",
"sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c",
"sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764",
"sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196",
"sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b",
"sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00",
"sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac",
"sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8",
"sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e",
"sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28",
"sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3",
"sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5",
"sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4",
"sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b",
"sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf",
"sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5",
"sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702",
"sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8",
"sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788",
"sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da",
"sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d",
"sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc",
"sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c",
"sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba",
"sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f",
"sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917",
"sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5",
"sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26",
"sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f",
"sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b",
"sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be",
"sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c",
"sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3",
"sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6",
"sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926",
"sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"
],
"markers": "python_version >= '3.8'",
"version": "==6.0.3"
},
"reedsolo": {
"hashes": [
"sha256:2b6a3e402a1ee3e1eea3f932f81e6c0b7bbc615588074dca1dbbcdeb055002bd",
"sha256:c1359f02742751afe0f1c0de9f0772cc113835aa2855d2db420ea24393c87732"
],
"version": "==1.7.0"
},
"requests": {
"hashes": [
"sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6",
"sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"
],
"index": "pypi",
"version": "==2.32.5"
},
"rich": {
"hashes": [
"sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d",
"sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b"
],
"markers": "python_full_version >= '3.8.0'",
"version": "==14.3.3"
},
"rich-click": {
"hashes": [
"sha256:022997c1e30731995bdbc8ec2f82819340d42543237f033a003c7b1f843fc5dc",
"sha256:2f99120fca78f536e07b114d3b60333bc4bb2a0969053b1250869bcdc1b5351b"
],
"markers": "python_version >= '3.8'",
"version": "==1.9.7"
},
"selenium": {
"hashes": [
"sha256:003e971f805231ad63e671783a2b91a299355d10cefb9de964c36ff3819115aa",
"sha256:b8ccde8d2e7642221ca64af184a92c19eee6accf2e27f20f30472f5efae18eb1"
],
"index": "pypi",
"version": "==4.41.0"
},
"sniffio": {
"hashes": [
"sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2",
"sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"
],
"markers": "python_version >= '3.7'",
"version": "==1.3.1"
},
"sortedcontainers": {
"hashes": [
"sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88",
"sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"
],
"version": "==2.4.0"
},
"tibs": {
"hashes": [
"sha256:01ea5258bdf942d21560dc07d532082cd04f07cfef65fedd58ae84f7d0d2562a",
"sha256:0a7ce857ef05c59dc61abadc31c4b9b1e3c62f9e5fb29217988c308936aea71e",
"sha256:130bc68ff500fc8185677df7a97350b5d5339e6ba7e325bc3031337f6424ede7",
"sha256:173dfbecb2309edd9771f453580c88cf251e775613461566b23dbd756b3d54cb",
"sha256:1906729038b85c3b4c040aa28a456d85bc976d0c5007177350eb73374ffa0fd0",
"sha256:1b56583db148e5094d781c3d746815dbcbb6378c6f813c8ce291efd4ab21da8b",
"sha256:1d5521cc6768bfa6282a0c591ba06b079ab91b5c7d5696925ad2abac59779a54",
"sha256:1f95d5db62960205a1e9eba73ce67dc14e7366ae080cd4e5b6f005ebd90faf02",
"sha256:29480bf03e3372a5f9cc59ea0541f76f8efd696d4f0d214715e94247c342a037",
"sha256:2a618de62004d9217d2d2ab0f7f9bbdd098c12642dc01f07b3fb00f0b5f3131a",
"sha256:42725200f1b02687ed6e6a1c01e0ec150dc829d21d901ffc74cc0ac4d821f57f",
"sha256:477608f9b87e24a22ab6d50b81da04a5cb59bfa49598ff7ec5165035a18fb392",
"sha256:4b7510235379368b7523f624d46e0680f3706e3a3965877a6583cdcb598b8bac",
"sha256:501728d096e10d9a165aa526743d47418a6bbfd7b084fa47ecb22be7641d3edb",
"sha256:63255749f937c5e6fedcc7d54e7bd359aef711017e6855f373b0510a14ee2215",
"sha256:6a9feed5931b881809a950eca0e01e757113e2383a2af06a3e6982f110c869e2",
"sha256:76746f01b3db9dbd802f5e615f11f68df7a29ecef521b082dca53f3fa7d0084f",
"sha256:77103a9f1af72ac4cf5006828d0fb21578d19ce55fd990e9a1c8e46fd549561f",
"sha256:7d6592ed93c6748acd39df484c1ee24d40ee247c2a20ca38ba03363506fd24f3",
"sha256:847709c108800ad6a45efaf9a040628278956938a4897f7427a2587013dc3b98",
"sha256:859f05315ffb307d3474c505d694f3a547f00730a024c982f5f60316a5505b3c",
"sha256:a61d36155f8ab8642e1b6744e13822f72050fc7ec4f86ec6965295afa04949e2",
"sha256:a883ca13a922a66b2c1326a9c188123a574741a72510a4bf52fd6f97db191e44",
"sha256:ac0aa2aae38f7325c91c261ce1d18f769c4c7033c98d6ea3ea5534585cf16452",
"sha256:ace018a057459e3dccd06a4aae1c5c8cd57e352b263dcef534ae39bf3e03b5cf",
"sha256:ad61df93b50f875b277ab736c5d37b6bce56f9abce489a22f4e02d9daa2966e3",
"sha256:b9535dc7b7484904a58b51bd8e64da7efbf1d8466ff7e84ed1d78f4ddc561c99",
"sha256:d4f3ff613d486650816bc5516760c0382a2cc0ca8aeddd8914d011bc3b81d9a2",
"sha256:e13b9c7ff2604b0146772025e1ac6f85c8c625bf6ac73736ff671eaf357dda41",
"sha256:f5eea45851c960628a2bd29847765d55e19a687c5374456ad2c8cf6410eb1efa",
"sha256:f70bd250769381c73110d6f24feaf8b6fcd44f680b3cb28a20ea06db3d04fb6f"
],
"markers": "python_version >= '3.8'",
"version": "==0.5.7"
},
"trio": {
"hashes": [
"sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b",
"sha256:a29b92b73f09d4b48ed249acd91073281a7f1063f09caba5dc70465b5c7aa970"
],
"markers": "python_version >= '3.10'",
"version": "==0.33.0"
},
"trio-websocket": {
"hashes": [
"sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae",
"sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6"
],
"markers": "python_version >= '3.8'",
"version": "==0.12.2"
},
"typing-extensions": {
"hashes": [
"sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466",
"sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"
],
"markers": "python_version >= '3.9'",
"version": "==4.15.0"
},
"urllib3": {
"hashes": [
"sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed",
"sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"
],
"markers": "python_version >= '3.9'",
"version": "==2.6.3"
},
"watchfiles": {
"hashes": [
"sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c",
"sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43",
"sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510",
"sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0",
"sha256:08af70fd77eee58549cd69c25055dc344f918d992ff626068242259f98d598a2",
"sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b",
"sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18",
"sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219",
"sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3",
"sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4",
"sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803",
"sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94",
"sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6",
"sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce",
"sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099",
"sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae",
"sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4",
"sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43",
"sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd",
"sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10",
"sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374",
"sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051",
"sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d",
"sha256:3dbd8cbadd46984f802f6d479b7e3afa86c42d13e8f0f322d669d79722c8ec34",
"sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49",
"sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7",
"sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844",
"sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77",
"sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b",
"sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741",
"sha256:4b943d3668d61cfa528eb949577479d3b077fd25fb83c641235437bc0b5bc60e",
"sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33",
"sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42",
"sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab",
"sha256:5524298e3827105b61951a29c3512deb9578586abf3a7c5da4a8069df247cccc",
"sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5",
"sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da",
"sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e",
"sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05",
"sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a",
"sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d",
"sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701",
"sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863",
"sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2",
"sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101",
"sha256:6c3631058c37e4a0ec440bf583bc53cdbd13e5661bb6f465bc1d88ee9a0a4d02",
"sha256:6c9c9262f454d1c4d8aaa7050121eb4f3aea197360553699520767daebf2180b",
"sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6",
"sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb",
"sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620",
"sha256:74472234c8370669850e1c312490f6026d132ca2d396abfad8830b4f1c096957",
"sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6",
"sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d",
"sha256:79ff6c6eadf2e3fc0d7786331362e6ef1e51125892c75f1004bd6b52155fb956",
"sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef",
"sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261",
"sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02",
"sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af",
"sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9",
"sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21",
"sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336",
"sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d",
"sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c",
"sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31",
"sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81",
"sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9",
"sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff",
"sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2",
"sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e",
"sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc",
"sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404",
"sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01",
"sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18",
"sha256:acb08650863767cbc58bca4813b92df4d6c648459dcaa3d4155681962b2aa2d3",
"sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606",
"sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04",
"sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3",
"sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14",
"sha256:b9c4702f29ca48e023ffd9b7ff6b822acdf47cb1ff44cb490a3f1d5ec8987e9c",
"sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82",
"sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610",
"sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0",
"sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150",
"sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5",
"sha256:c1f5210f1b8fc91ead1283c6fd89f70e76fb07283ec738056cf34d51e9c1d62c",
"sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a",
"sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b",
"sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d",
"sha256:c882d69f6903ef6092bedfb7be973d9319940d56b8427ab9187d1ecd73438a70",
"sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70",
"sha256:cdab464fee731e0884c35ae3588514a9bcf718d0e2c82169c1c4a85cc19c3c7f",
"sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24",
"sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e",
"sha256:cf57a27fb986c6243d2ee78392c503826056ffe0287e8794503b10fb51b881be",
"sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5",
"sha256:d6ff426a7cb54f310d51bfe83fe9f2bbe40d540c741dc974ebc30e6aa238f52e",
"sha256:d7e7067c98040d646982daa1f37a33d3544138ea155536c2e0e63e07ff8a7e0f",
"sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88",
"sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb",
"sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849",
"sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d",
"sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c",
"sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44",
"sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac",
"sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428",
"sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b",
"sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5",
"sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa",
"sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf"
],
"index": "pypi",
"version": "==1.1.1"
},
"websocket-client": {
"hashes": [
"sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98",
"sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef"
],
"markers": "python_version >= '3.9'",
"version": "==1.9.0"
},
"wsproto": {
"hashes": [
"sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584",
"sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294"
],
"markers": "python_version >= '3.10'",
"version": "==1.3.2"
}
},
"develop": {
"iniconfig": {
"hashes": [
"sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730",
"sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"
],
"markers": "python_version >= '3.10'",
"version": "==2.3.0"
},
"packaging": {
"hashes": [
"sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4",
"sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"
],
"markers": "python_version >= '3.8'",
"version": "==26.0"
},
"pluggy": {
"hashes": [
"sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3",
"sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"
],
"markers": "python_version >= '3.9'",
"version": "==1.6.0"
},
"pygments": {
"hashes": [
"sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887",
"sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"
],
"markers": "python_version >= '3.8'",
"version": "==2.19.2"
},
"pytest": {
"hashes": [
"sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b",
"sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"
],
"index": "pypi",
"version": "==9.0.2"
}
}
}

View File

@@ -1,2 +1,37 @@
# led-controller
LED controller web app for managing profiles, tabs, presets, and colour palettes, and sending commands to LED devices over the serial -> ESP-NOW bridge.
## Run
- One-time setup for port 80 without root: `sudo scripts/setup-port80.sh`
- Start app: `pipenv run run`
- Dev watcher (auto-restart on `src/` changes): `pipenv run dev`
- Regenerate **`docs/help.pdf`** from **`docs/help.md`**: `pipenv run help-pdf` (requires **pandoc** and **chromium** on the host)
## UI modes
- **Run mode**: focused control view. Select tabs/presets and apply profiles. Editing actions are hidden.
- **Edit mode**: management view. Shows Tabs, Presets, Patterns, Colour Palette, and Send Presets controls, plus per-tile preset edit/remove and drag-reorder.
## Profiles
- Applying a profile updates session scope and refreshes the active tab content.
- In **Run mode**, Profiles supports apply-only behavior (no create/clone/delete).
- In **Edit mode**, Profiles supports create/clone/delete.
- Creating a profile always creates a populated `default` tab (starter presets).
- Optional **DJ tab** seeding creates:
- `dj` tab bound to device name `dj`
- starter DJ presets (rainbow, single colour, transition)
## Preset colours and palette linking
- In preset editor, selecting a colour picker value auto-adds it when the picker closes.
- Use **From Palette** to add a palette-linked preset colour.
- Linked colours are stored as palette references and shown with a `P` badge.
- When profile palette colours change, linked preset colours update across that profile.
## API docs
- Main API reference: `docs/API.md`

1
db/device.json Normal file
View File

@@ -0,0 +1 @@
{}

1
db/group.json Normal file
View File

@@ -0,0 +1 @@
{"1": {"name": "Main Group", "devices": ["1", "2", "3"]}, "2": {"name": "Accent Group", "devices": ["4", "5"]}}

1
db/palette.json Normal file
View File

@@ -0,0 +1 @@
{"1": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "2": [], "3": [], "4": [], "5": [], "6": [], "7": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "8": [], "9": [], "10": [], "11": [], "12": ["#890b0b", "#0b8935"]}

54
db/pattern.json Normal file
View File

@@ -0,0 +1,54 @@
{
"on": {
"min_delay": 10,
"max_delay": 10000,
"max_colors": 1
},
"off": {
"min_delay": 10,
"max_delay": 10000,
"max_colors": 0
},
"rainbow": {
"n1": "Step Rate",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 0
},
"transition": {
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10
},
"chase": {
"n1": "Colour 1 Length",
"n2": "Colour 2 Length",
"n3": "Step 1",
"n4": "Step 2",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 2
},
"pulse": {
"n1": "Attack",
"n2": "Hold",
"n3": "Decay",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10
},
"circle": {
"n1": "Head Rate",
"n2": "Max Length",
"n3": "Tail Rate",
"n4": "Min Length",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 2
},
"blink": {
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10
}
}

1
db/preset.json Normal file
View File

@@ -0,0 +1 @@
{"1": {"name": "on", "pattern": "on", "colors": ["#FFFFFF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "2": {"name": "off", "pattern": "off", "colors": [], "brightness": 0, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "3": {"name": "rainbow", "pattern": "rainbow", "colors": [], "brightness": 255, "delay": 100, "auto": true, "n1": 2, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "4": {"name": "transition", "pattern": "transition", "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFFFF", "#0000FF", "#FFFF00"], "brightness": 255, "delay": 5000, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null, 6, 2, 3]}, "5": {"name": "chase", "pattern": "chase", "colors": ["#FF0000", "#0000FF"], "brightness": 255, "delay": 200, "auto": true, "n1": 5, "n2": 5, "n3": 1, "n4": 1, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "6": {"name": "pulse", "pattern": "pulse", "colors": ["#00FF00"], "brightness": 255, "delay": 500, "auto": true, "n1": 1000, "n2": 500, "n3": 1000, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "7": {"name": "circle", "pattern": "circle", "colors": ["#FFA500", "#800080"], "brightness": 255, "delay": 200, "auto": true, "n1": 2, "n2": 10, "n3": 2, "n4": 5, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "8": {"name": "blink", "pattern": "blink", "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00"], "brightness": 255, "delay": 1000, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "9": {"name": "warm white", "pattern": "on", "colors": ["#FFF5E6"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "10": {"name": "cool white", "pattern": "on", "colors": ["#E6F2FF"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "11": {"name": "red", "pattern": "on", "colors": ["#FF0000"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "12": {"name": "blue", "pattern": "on", "colors": ["#0000FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "13": {"name": "rainbow slow", "pattern": "rainbow", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "14": {"name": "pulse slow", "pattern": "pulse", "colors": ["#FF6600"], "brightness": 255, "delay": 800, "auto": true, "n1": 2000, "n2": 1000, "n3": 2000, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "15": {"name": "blink red green", "pattern": "blink", "colors": ["#FF0000", "#00FF00"], "brightness": 255, "delay": 500, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "30": {"name": "rainbow slow", "pattern": "rainbow", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "31": {"name": "DJ Rainbow", "pattern": "rainbow", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "32": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "33": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "34": {"name": "DJ Rainbow", "pattern": "rainbow", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "35": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "36": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "37": {"name": "tranistion2", "pattern": "transition", "colors": ["#FF0000", "#FFFF00", "#FF00FF"], "brightness": 128, "delay": 1000, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [0, 3, 4]}}

1
db/profile.json Normal file
View File

@@ -0,0 +1 @@
{"1": {"name": "default", "type": "tabs", "tabs": ["1", "8"], "scenes": [], "palette_id": "1"}, "2": {"name": "test", "type": "tabs", "tabs": ["6", "7"], "scenes": [], "palette_id": "12"}}

22
db/scene.json Normal file
View File

@@ -0,0 +1,22 @@
{
"1": {
"name": "Default Scene",
"sequences": [
"1"
],
"groups": [
"1"
]
},
"2": {
"name": "Party Mode",
"sequences": [
"1",
"2"
],
"groups": [
"1",
"2"
]
}
}

1
db/sequence.json Normal file
View File

@@ -0,0 +1 @@
{"1": {"group_name": "Main Group", "presets": ["1", "2"], "sequence_duration": 3000, "sequence_transition": 500, "sequence_loop": true, "sequence_repeat_count": 0, "sequence_active": false, "sequence_index": 0, "sequence_start_time": 0}, "2": {"group_name": "Accent Group", "presets": ["2", "3"], "sequence_duration": 2000, "sequence_transition": 300, "sequence_loop": true, "sequence_repeat_count": 0, "sequence_active": false, "sequence_index": 0, "sequence_start_time": 0}}

1
db/tab.json Normal file
View File

@@ -0,0 +1 @@
{"1": {"name": "default", "names": ["1", "2", "3", "4", "5", "6", "7", "8", "0", "a"], "presets": [["4", "2", "7"], ["15", "3", "14"], ["5", "8", "10"], ["11", "9", "12"], ["1", "13", "37"]], "presets_flat": ["4", "2", "7", "15", "3", "14", "5", "8", "10", "11", "9", "12", "1", "13", "37"], "default_preset": "15"}, "2": {"name": "default", "names": ["1", "2", "3", "4", "5", "6", "7", "8", "0", "a"], "presets": [["16", "17", "18"], ["19", "20", "21"], ["22", "23", "24"], ["25", "26", "27"], ["28", "29", "30"]], "presets_flat": ["16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30"]}, "3": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "4": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "5": {"name": "dj", "names": ["dj"], "presets": [["31", "32", "33"]], "default_preset": "31", "presets_flat": ["31", "32", "33"]}, "6": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "7": {"name": "dj", "names": ["dj"], "presets": [["34", "35", "36"]], "default_preset": "34", "presets_flat": ["34", "35", "36"]}, "8": {"name": "test", "names": ["11"], "presets": [["1", "2", "3"], ["4", "5"]], "default_preset": "1", "presets_flat": ["1", "2", "3", "4", "5"]}}

50
dev.py
View File

@@ -6,28 +6,48 @@ import sys
print(sys.argv)
port = sys.argv[1]
# Extract port (first arg if it's not a command)
commands = ["src", "lib", "ls", "reset", "follow", "db"]
port = None
if len(sys.argv) > 1 and sys.argv[1] not in commands:
port = sys.argv[1]
cmd = sys.argv[1]
for cmd in sys.argv[1:]:
print(cmd)
match cmd:
case "src":
subprocess.call(["mpremote", "connect", port, "fs", "cp", "-r", ".", ":" ], cwd="src")
if port:
subprocess.call(["mpremote", "connect", port, "fs", "cp", "-r", ".", ":" ], cwd="src")
else:
print("Error: Port required for 'src' command")
case "lib":
subprocess.call(["mpremote", "connect", port, "fs", "cp", "-r", "lib", ":" ])
if port:
subprocess.call(["mpremote", "connect", port, "fs", "cp", "-r", "lib", ":" ])
else:
print("Error: Port required for 'lib' command")
case "ls":
subprocess.call(["mpremote", "connect", port, "fs", "ls", ":" ])
if port:
subprocess.call(["mpremote", "connect", port, "fs", "ls", ":" ])
else:
print("Error: Port required for 'ls' command")
case "reset":
with serial.Serial(port, baudrate=115200) as ser:
ser.write(b'\x03\x03\x04')
if port:
with serial.Serial(port, baudrate=115200) as ser:
ser.write(b'\x03\x03\x04')
else:
print("Error: Port required for 'reset' command")
case "follow":
with serial.Serial(port, baudrate=115200) as ser:
while True:
if ser.in_waiting > 0: # Check if there is data in the buffer
data = ser.readline().decode('utf-8').strip() # Read and decode the data
print(data)
if port:
with serial.Serial(port, baudrate=115200) as ser:
while True:
if ser.in_waiting > 0: # Check if there is data in the buffer
data = ser.readline().decode('utf-8').strip() # Read and decode the data
print(data)
else:
print("Error: Port required for 'follow' command")
case "db":
if port:
subprocess.call(["mpremote", "connect", port, "fs", "cp", "-r", "db", ":" ])
else:
print("Error: Port required for 'db' command")

318
docs/API.md Normal file
View File

@@ -0,0 +1,318 @@
# LED Controller API
This document covers:
1. **HTTP and WebSocket** exposed by the Raspberry Pi app (`src/main.py`) — profiles, presets, transport send, and related resources.
2. **LED driver JSON** — the compact message format sent over the serial→ESP-NOW bridge to devices (same logical API as ESP-NOW payloads).
Default listen address: `0.0.0.0`. Port defaults to **80**; override with the `PORT` environment variable (see `pipenv run run`).
All JSON APIs use `Content-Type: application/json` for bodies and responses unless noted.
---
## UI behavior notes
The main UI has two modes controlled by the mode toggle:
- **Run mode**: optimized for operation (tab/preset selection and profile apply).
- **Edit mode**: shows editing/management controls (tabs, presets, patterns, colour palette, send presets, and profile management actions).
Profiles are available in both modes, but behavior differs:
- **Run mode**: profile **apply** only.
- **Edit mode**: profile **create/clone/delete/apply**.
`POST /presets/send` is wired to the **Send Presets** UI action, which is exposed in Edit mode.
---
## Session and scoping
Several routes use **`@with_session`**: the server stores a **current profile** in the session (cookie). Endpoints that scope data to “the current profile” (notably **`/presets`**) only return or mutate presets whose `profile_id` matches that session value.
Profiles are selected with **`POST /profiles/<id>/apply`**, which sets `current_profile` in the session.
---
## Static pages and assets
| Method | Path | Description |
|--------|------|-------------|
| GET | `/` | Main UI (`templates/index.html`) |
| GET | `/settings` | Settings page (`templates/settings.html`) |
| GET | `/favicon.ico` | Empty response (204) |
| GET | `/static/<path>` | Static files under `src/static/` |
---
## WebSocket: `/ws`
Connect to **`ws://<host>:<port>/ws`**.
- Send **JSON**: the object is forwarded to the transport (serial bridge → ESP-NOW) as JSON. Optional key **`to`**: 12-character hex MAC address; if present it is removed from the object and the payload is sent to that peer; otherwise the default destination is used.
- Send **non-JSON text**: forwarded as raw bytes with the default address.
- On send failure, the server may reply with `{"error": "Send failed"}`.
---
## HTTP API by resource
Below, `<id>` values are string identifiers used by the JSON stores (numeric strings in practice).
### Settings — `/settings`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/settings` | Full settings object (from `settings.json` / `Settings` model). |
| PUT | `/settings/settings` | Merge keys into settings and save. Returns `{"message": "Settings updated successfully"}`. |
| GET | `/settings/wifi/ap` | Saved WiFi AP fields: `saved_ssid`, `saved_password`, `saved_channel`, `active` (Pi: `active` is always false). |
| POST | `/settings/wifi/ap` | Body: `ssid` (required), `password`, `channel` (111). Persists AP-related settings. |
| GET | `/settings/page` | Serves `templates/settings.html` (same page as `GET /settings` from the root app, for convenience). |
### Profiles — `/profiles`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/profiles` | `{"profiles": {...}, "current_profile_id": "<id>"}`. Ensures a default current profile when possible. |
| GET | `/profiles/current` | `{"id": "...", "profile": {...}}` |
| GET | `/profiles/<id>` | Single profile. If `<id>` is `current`, same as `/profiles/current`. |
| POST | `/profiles` | Create profile. Body may include `name` and other fields. Optional `seed_dj_tab` (request-only) seeds a DJ tab + presets. New profiles always get a populated `default` tab. Returns `{ "<id>": { ... } }` with status 201. |
| POST | `/profiles/<id>/apply` | Sets session current profile to `<id>`. |
| POST | `/profiles/<id>/clone` | Clone profile (tabs, palettes, presets). Body may include `name`. |
| PUT | `/profiles/current` | Update the current profile (from session). |
| PUT | `/profiles/<id>` | Update profile by id. |
| DELETE | `/profiles/<id>` | Delete profile. |
### Presets — `/presets`
Scoped to **current profile** in session (see above).
| Method | Path | Description |
|--------|------|-------------|
| GET | `/presets` | Map of preset id → preset object for the current profile only. |
| GET | `/presets/<id>` | One preset, 404 if missing or wrong profile. |
| POST | `/presets` | Create preset; server assigns id and sets `profile_id`. Body fields stored on the preset. Returns `{ "<id>": { ... } }`, 201. |
| PUT | `/presets/<id>` | Update preset (must belong to current profile). |
| DELETE | `/presets/<id>` | Delete preset. |
| POST | `/presets/send` | Push presets to the LED driver over the configured transport (see below). |
**`POST /presets/send` body:**
```json
{
"preset_ids": ["1", "2"],
"save": true,
"default": "1",
"destination_mac": "aabbccddeeff"
}
```
- **`preset_ids`** (or **`ids`**): non-empty list of preset ids to include.
- **`save`**: if true, the outgoing message includes `"save": true` so the driver may persist presets (default true).
- **`default`**: optional preset id string; forwarded as top-level `"default"` in the driver message (startup selection on device).
- **`destination_mac`** (or **`to`**): optional 12-character hex MAC for unicast; omitted uses the transport default (e.g. broadcast).
Response on success includes `presets_sent`, `messages_sent` (chunking splits payloads so each JSON string stays ≤ 240 bytes).
Stored preset records can include:
- `colors`: resolved hex colours for editor/display.
- `palette_refs`: optional array of palette indexes parallel to `colors`. If a slot contains an integer index, the colour is linked to the current profile palette at that index.
### Tabs — `/tabs`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/tabs` | `tabs`, `tab_order`, `current_tab_id`, `profile_id` for the session-backed profile. |
| GET | `/tabs/current` | Current tab from cookie/session. |
| POST | `/tabs` | Create tab; optional JSON `name`, `names`, `presets`; can append to current profiles tab list. |
| GET | `/tabs/<id>` | Tab JSON. |
| PUT | `/tabs/<id>` | Update tab. |
| DELETE | `/tabs/<id>` | Delete tab; can delete `current` to remove the active tab; updates profile tab list. |
| POST | `/tabs/<id>/set-current` | Sets `current_tab` cookie. |
| POST | `/tabs/<id>/clone` | Clone tab into current profile. |
### Palettes — `/palettes`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/palettes` | Map of id → colour list. |
| GET | `/palettes/<id>` | `{"colors": [...], "id": "<id>"}` |
| POST | `/palettes` | Body may include `colors`. Returns palette object with `id`, 201. |
| PUT | `/palettes/<id>` | Update colours (`name` ignored). |
| DELETE | `/palettes/<id>` | Delete palette. |
### Groups — `/groups`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/groups` | All groups. |
| GET | `/groups/<id>` | One group. |
| POST | `/groups` | Create; optional `name` and fields. |
| PUT | `/groups/<id>` | Update. |
| DELETE | `/groups/<id>` | Delete. |
### Scenes — `/scenes`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/scenes` | All scenes. |
| GET | `/scenes/<id>` | One scene. |
| POST | `/scenes` | Create (body JSON stored on scene). |
| PUT | `/scenes/<id>` | Update. |
| DELETE | `/scenes/<id>` | Delete. |
### Sequences — `/sequences`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/sequences` | All sequences. |
| GET | `/sequences/<id>` | One sequence. |
| POST | `/sequences` | Create; may use `group_name`, `presets` in body. |
| PUT | `/sequences/<id>` | Update. |
| DELETE | `/sequences/<id>` | Delete. |
### Patterns — `/patterns`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/patterns/definitions` | Contents of `pattern.json` (pattern metadata for the UI). |
| GET | `/patterns` | All pattern records. |
| GET | `/patterns/<id>` | One pattern. |
| POST | `/patterns` | Create (`name`, optional `data`). |
| PUT | `/patterns/<id>` | Update. |
| DELETE | `/patterns/<id>` | Delete. |
---
## LED driver message format (transport / ESP-NOW)
Messages are JSON objects. The Pi **`build_message()`** helper (`src/util/espnow_message.py`) produces the same shape sent over serial and forwarded by the ESP32 bridge.
### Top-level fields
```json
{
"v": "1",
"presets": { },
"select": { },
"save": true,
"default": "preset_id",
"b": 255
}
```
- **`v`** (required): Must be `"1"` or the driver ignores the message.
- **`presets`**: Map of **preset id** (string) → preset object (see below). Optional **`name`** field on each value is accepted for display; the driver keys presets by map key.
- **`select`**: Map of **device name** (as in device settings) → `[ "preset_id" ]` or `[ "preset_id", step ]`.
- **`save`**: If present (e.g. true), the driver may persist presets to flash after applying.
- **`default`**: Preset id string to use as startup default on the device.
- **`b`**: Optional **global** brightness 0255 (driver applies this in addition to per-preset brightness).
### Preset object (wire / driver keys)
On the wire, presets use **short keys** (saves space in the ≤240-byte chunks):
| Key | Meaning | Notes |
|-----|---------|--------|
| `p` | Pattern id | `off`, `on`, `blink`, `rainbow`, `pulse`, `transition`, `chase`, `circle` |
| `c` | Colours | Array of `"#RRGGBB"` hex strings; converted to RGB on device |
| `d` | Delay ms | Default 100 |
| `b` | Preset brightness | 0255; combined with global `b` on the device |
| `a` | Auto | `true`: run continuously; `false`: one step/cycle per “beat” |
| `n1``n6` | Pattern parameters | See below |
The HTTP apps **`POST /presets/send`** path builds this from stored presets via **`build_preset_dict()`** (long names like `pattern` / `colors` in the DB are translated to `p` / `c` / …).
### Pattern-specific parameters (`n1``n6`)
#### Rainbow
- **`n1`**: Step increment on the colour wheel per update (default 1).
#### Pulse
- **`n1`**: Attack (fade in) ms
- **`n2`**: Hold ms
- **`n3`**: Decay (fade out) ms
- **`d`**: Off time between pulses ms
#### Transition
- **`d`**: Transition duration ms
#### Chase
- **`n1`**: LEDs with first colour
- **`n2`**: LEDs with second colour
- **`n3`**: Movement on even steps (may be negative)
- **`n4`**: Movement on odd steps (may be negative)
#### Circle
- **`n1`**: Head speed (LEDs/s)
- **`n2`**: Max length
- **`n3`**: Tail speed (LEDs/s)
- **`n4`**: Min length
### Select messages
```json
{
"select": {
"device_name": ["preset_id"],
"other_device": ["preset_id", 10]
}
}
```
- One element: select preset; step behavior follows driver rules (reset on `off`, etc.).
- Two elements: explicit **step** for sync.
### Beat and sync behavior
- Sending **`select`** again with the **same** preset name acts as a **beat** (advances manual patterns / restarts generators per driver logic).
- Choosing **`off`** resets step as a sync point; then selecting a pattern aligns step 0 across devices unless a step is passed explicitly.
### Example (compact preset map)
```json
{
"v": "1",
"save": true,
"presets": {
"1": {
"name": "Red blink",
"p": "blink",
"c": ["#FF0000"],
"d": 200,
"b": 255,
"a": true,
"n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0
}
},
"select": {
"living-room": ["1"]
}
}
```
---
## Processing summary (driver)
1. Reject if `v != "1"`.
2. Apply optional top-level **`b`** (global brightness).
3. For each entry in **`presets`**, normalize colours and upsert preset by id.
4. If this devices **`name`** appears in **`select`**, run selection (optional step).
5. If **`default`** is set, store startup preset id.
6. If **`save`** is set, persist presets.
---
## Error handling (HTTP)
Controllers typically return JSON with an **`error`** string and 4xx/5xx status codes. Invalid JSON bodies often yield `{"error": "Invalid JSON"}`.
---
## Notes
- **Human-readable preset fields** (`pattern`, `colors`, `delay`, …) are fine in the **web app / database**; the **send path** converts them to **`p` / `c` / `d`** for the driver.
- For a copy of the older long-key reference, see **`led-driver/docs/API.md`** in this repo (conceptually the same behavior; wire format prefers short keys).

1846
docs/SPECIFICATION.md Normal file

File diff suppressed because it is too large Load Diff

112
docs/help.md Normal file
View File

@@ -0,0 +1,112 @@
# LED controller — user guide
This page describes the **main web UI** served from the Raspberry Pi app: profiles, tabs, presets, colour palettes, and sending commands to LED devices over the serial → ESP-NOW bridge.
For HTTP routes and the wire format the driver expects, see **[API.md](API.md)**. For running the app locally, see the project **README**.
Figures below are **schematic** (layout and ideas), not pixel-perfect screenshots.
---
## Run mode and Edit mode
The header has a mode toggle (desktop and mobile menu). The **label on the button is the mode you switch to** when you press it.
![Schematic: tab buttons on the left; Profiles, Tabs, Presets, Patterns, and the mode toggle on the right (example shows Edit mode with “Run mode” on the button).](images/help/header-toolbar.svg)
*The active tab is highlighted. Extra management buttons appear only in Edit mode.*
| Mode | Purpose |
|------|--------|
| **Run mode** | Day-to-day control: choose a tab, tap presets, apply profiles. Management buttons are hidden. |
| **Edit mode** | Full setup: tabs, presets, patterns, colour palette, **Send Presets**, profile create/clone/delete, preset reordering, and per-tile **Edit** on the strip. |
**Profiles** is available in both modes: in Run mode you can only **apply** a profile; in Edit mode you can also **create**, **clone**, and **delete** profiles.
---
## Tabs
- **Select a tab**: click its button in the top bar. The main area shows that tabs preset strip and controls.
- **Edit mode — open tab settings**: **right-click** a tab button to change its name, **device IDs** (comma-separated), and which presets appear on the tab. Device identifiers are matched to each devices **name** when the app builds `select` messages for the driver.
- **Tabs modal** (Edit mode): create new tabs from the header **Tabs** button. New tabs need a name and device ID list (defaults to `1` if you leave a simple placeholder).
- **Brightness slider** (per tab): adjusts **global** brightness sent to devices (`b` in the driver message), with a short debounce so small drags do not flood the link.
---
## Presets on the tab strip
- **Run and Edit mode**: click the **main part** of a preset tile to **select** that preset on all devices assigned to the current tab (same logical action as a `select` in the driver API).
- **Edit mode only**:
- **Edit** beside a tile opens the preset editor for that preset, scoped to the current tab (so you can **Remove from tab** without deleting the preset from the profile).
- **Drag and drop** tiles to reorder them; order is saved for that tab.
![Schematic: tab title, brightness slider, and a row of preset tiles; Edit mode adds an Edit control and drag handles for reordering.](images/help/tab-preset-strip.svg)
*The slider controls global brightness for the tabs devices. Click the coloured area of a tile to select that preset.*
The **Presets** header button (Edit mode) opens a **profile-wide** list: **Add** new presets, **Edit**, **Send** (push definition over the transport), and **Delete** (removes the preset from the profile entirely).
---
## Preset editor
- **Pattern**: chosen from the dropdown; optional **n1n8** fields depend on the pattern (see **Pattern-specific parameters** in [API.md](API.md)).
- **Colours**: choosing a value in the colour picker **adds** a swatch when the picker closes. Swatches can be **reordered** by dragging. Changing a swatch with the picker **clears** palette linkage for that slot.
- **From Palette**: inserts a colour **linked** to the current profiles palette. Linked slots show a **P** badge; if you change that palette entry later, presets using it update.
- **Brightness (0255)** and **Delay (ms)**: stored on the preset and sent with the compact preset payload.
- **Try**: sends the current form values to devices on the **current tab**, then selects that preset — **without** `save` on the device (good for auditioning).
- **Default**: updates the tabs **default preset** and sends a **default** hint for those devices; it does not force the same live selection behaviour as clicking a tile.
- **Save & Send**: writes the preset to the server, then pushes definitions with **save** so devices may persist them. It does **not** auto-select the preset on devices (use the strip or **Try** if you want that).
- **Remove from tab** (when you opened the editor from a tab): removes the preset from **this tabs list only**; the preset remains in the profile for other tabs.
![Schematic: preset editor with name, pattern, colour swatches (one with a P badge for palette-linked), and action buttons.](images/help/preset-editor.svg)
*Try previews without persisting on the device; **Save & Send** stores the preset and pushes definitions with save.*
---
## Profiles
- **Apply**: sets the **current profile** in your session. Tabs and presets you see are scoped to that profile.
- **Edit mode — Create**: new profiles always get a populated **default** tab. Optionally tick **DJ tab** to also create a `dj` tab (device name `dj`) with starter DJ-oriented presets.
- **Clone** / **Delete**: available in Edit mode from the profile list.
---
## Send Presets (Edit mode)
**Send Presets** walks **every tab** in the **current profile**, collects each tabs preset IDs, and calls **`POST /presets/send`** per tab (including each tabs **default** preset when set). Use this to bulk-push definitions to hardware after editing, without clicking **Send** on every preset individually.
---
## Patterns
The **Patterns** dialog (Edit mode) is a **read-only reference**: pattern names and typical **delay** ranges from the pattern definitions. It does not change device behaviour by itself; patterns are chosen inside the preset editor.
---
## Colour palette
**Colour Palette** (Edit mode) edits the **current profiles** palette swatches. Those colours are reused by **From Palette** in the preset editor and stay in sync while the **P** link remains.
![Schematic: palette modal with a row of swatches for the current profile.](images/help/colour-palette.svg)
*Add or change swatches here; linked preset colours update automatically.*
---
## Mobile layout
On narrow screens, use **Menu** to reach the same actions as the desktop header (Profiles, Tabs, Presets, Help, mode toggle, etc.).
![Schematic: narrow layout with Menu and the same header actions in a dropdown.](images/help/mobile-menu.svg)
*Preset tiles behave the same once a tab is selected.*
---
## Further reading
- **[API.md](API.md)** — REST routes, session scoping, WebSocket `/ws`, and LED driver JSON (`presets`, `select`, `save`, `default`, pattern keys).
- **README** — `pipenv run run`, port 80 setup, and high-level behaviour.

BIN
docs/help.pdf Normal file

Binary file not shown.

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 140" width="480" height="140">
<title>Colour Palette modal (concept)</title>
<rect width="480" height="140" fill="#2a2a2a" stroke="#555" stroke-width="1" rx="6"/>
<text x="20" y="28" fill="#fff" font-family="sans-serif" font-size="15" font-weight="600">Colour Palette</text>
<text x="20" y="48" fill="#888" font-family="sans-serif" font-size="10">Profile: current profile name</text>
<rect x="20" y="58" width="44" height="44" rx="4" fill="#e53935" stroke="#333"/>
<rect x="72" y="58" width="44" height="44" rx="4" fill="#fdd835" stroke="#333"/>
<rect x="124" y="58" width="44" height="44" rx="4" fill="#43a047" stroke="#333"/>
<rect x="176" y="58" width="44" height="44" rx="4" fill="#1e88e5" stroke="#333"/>
<rect x="228" y="58" width="44" height="44" rx="4" fill="#8e24aa" stroke="#333"/>
<rect x="280" y="70" width="36" height="28" rx="3" fill="#1a1a1a" stroke="#666"/>
<text x="288" y="88" fill="#ccc" font-family="sans-serif" font-size="10">+</text>
<text x="20" y="122" fill="#aaa" font-family="sans-serif" font-size="10">Swatches belong to the profile; preset editor uses them via From Palette.</text>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 820 108" width="820" height="108">
<title>Header: tab buttons and action bar</title>
<rect width="820" height="108" fill="#1a1a1a"/>
<rect x="0" y="106" width="820" height="2" fill="#4a4a4a"/>
<text x="16" y="28" fill="#888" font-family="sans-serif" font-size="11">Tabs</text>
<rect x="16" y="40" width="72" height="36" rx="4" fill="#333" stroke="#666" stroke-width="1"/>
<text x="34" y="63" fill="#ccc" font-family="sans-serif" font-size="13">default</text>
<rect x="96" y="40" width="88" height="36" rx="4" fill="#2a4a7a" stroke="#5a8fd4" stroke-width="2"/>
<text x="108" y="63" fill="#fff" font-family="sans-serif" font-size="13">lounge</text>
<rect x="192" y="40" width="56" height="36" rx="4" fill="#333" stroke="#666" stroke-width="1"/>
<text x="204" y="63" fill="#ccc" font-family="sans-serif" font-size="13">dj</text>
<text x="380" y="28" fill="#888" font-family="sans-serif" font-size="11">Actions (Edit mode)</text>
<rect x="380" y="40" width="72" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="396" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Profiles</text>
<rect x="458" y="40" width="52" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="470" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Tabs</text>
<rect x="516" y="40" width="64" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="524" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Presets</text>
<rect x="586" y="40" width="78" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="598" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Patterns</text>
<rect x="670" y="40" width="134" height="30" rx="3" fill="#4a4a6a" stroke="#7a7aaf" stroke-width="1"/>
<text x="688" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Run mode</text>
<text x="16" y="98" fill="#aaa" font-family="sans-serif" font-size="10">Active tab highlighted. Mode button shows the mode you switch to next.</text>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 340" width="300" height="340" role="img" aria-labelledby="t">
<title id="t">Narrow screen: Menu aggregates header actions</title>
<rect width="300" height="340" fill="#2e2e2e"/>
<rect x="0" y="0" width="300" height="52" fill="#1a1a1a"/>
<rect x="12" y="12" width="56" height="28" rx="4" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="22" y="30" fill="#eee" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="12">Menu <20></text>
<rect x="76" y="14" width="52" height="24" rx="4" fill="#333" stroke="#666" stroke-width="1"/>
<text x="86" y="30" fill="#ccc" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="11">tab</text>
<rect x="136" y="14" width="52" height="24" rx="4" fill="#2a4a7a" stroke="#5a8fd4" stroke-width="1"/>
<text x="142" y="30" fill="#fff" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="11">tab</text>
<rect x="12" y="60" width="276" height="168" rx="4" fill="#252525" stroke="#4a4a4a" stroke-width="1"/>
<text x="24" y="84" fill="#888" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="10">Dropdown (same actions as desktop header)</text>
<g font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="12" fill="#e8e8e8">
<text x="24" y="108">Run mode</text>
<text x="24" y="132">Profiles</text>
<text x="24" y="156">Tabs</text>
<text x="24" y="180">Presets</text>
<text x="24" y="204">Help</text>
</g>
<rect x="12" y="240" width="276" height="80" rx="6" fill="#222" stroke="#444" stroke-width="1"/>
<text x="24" y="268" fill="#aaa" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="10">Content area  presets as on desktop</text>
<rect x="24" y="278" width="80" height="32" rx="4" fill="#333" stroke="#555"/>
<text x="36" y="298" fill="#ddd" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="11">preset</text>
<rect x="112" y="278" width="80" height="32" rx="4" fill="#333" stroke="#555"/>
<text x="124" y="298" fill="#ddd" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="11">preset</text>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 400" width="520" height="400">
<title>Preset editor modal (simplified)</title>
<rect width="520" height="400" fill="#1e1e1e"/>
<rect x="40" y="28" width="440" height="344" rx="8" fill="#2a2a2a" stroke="#555" stroke-width="1"/>
<text x="60" y="58" fill="#fff" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="17" font-weight="600">Preset</text>
<text x="60" y="86" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Name</text>
<rect x="60" y="92" width="200" height="28" rx="3" fill="#1a1a1a" stroke="#444" stroke-width="1"/>
<text x="72" y="111" fill="#ddd" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="12">evening glow</text>
<text x="280" y="86" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Pattern</text>
<rect x="280" y="92" width="160" height="28" rx="3" fill="#1a1a1a" stroke="#444" stroke-width="1"/>
<text x="292" y="111" fill="#ddd" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="12">pulse</text>
<text x="60" y="148" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Colours</text>
<rect x="60" y="156" width="48" height="48" rx="4" fill="#7e57c2" stroke="#333" stroke-width="1"/>
<circle cx="66" cy="162" r="8" fill="#3f51b5" stroke="#fff" stroke-width="1"/>
<text x="63" y="166" fill="#fff" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="7" font-weight="700">P</text>
<rect x="116" y="156" width="48" height="48" rx="4" fill="#26a69a" stroke="#333" stroke-width="1"/>
<text x="176" y="184" fill="#888" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="10">P = palette-linked</text>
<text x="60" y="232" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Brightness, delay, n1-n8</text>
<rect x="60" y="238" width="120" height="24" rx="3" fill="#1a1a1a" stroke="#444" stroke-width="1"/>
<text x="68" y="254" fill="#888" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">0-255</text>
<text x="60" y="290" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="10">Actions</text>
<rect x="60" y="298" width="44" height="26" rx="3" fill="#444" stroke="#666" stroke-width="1"/>
<text x="72" y="315" fill="#eee" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Try</text>
<rect x="112" y="298" width="56" height="26" rx="3" fill="#444" stroke="#666" stroke-width="1"/>
<text x="120" y="315" fill="#eee" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Default</text>
<rect x="176" y="298" width="88" height="26" rx="3" fill="#3d5a80" stroke="#5a7ab8" stroke-width="1"/>
<text x="188" y="315" fill="#fff" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Save+Send</text>
<rect x="272" y="298" width="48" height="26" rx="3" fill="#444" stroke="#666" stroke-width="1"/>
<text x="284" y="315" fill="#eee" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Close</text>
<text x="60" y="352" fill="#888" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="9">Try: preview without device save. Save+Send: store and push with save.</text>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -0,0 +1,35 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 220" width="800" height="220">
<title>Main area: brightness and preset tiles</title>
<defs>
<linearGradient id="rg1" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#ffd54f"/><stop offset="100%" style="stop-color:#fff8e1"/>
</linearGradient>
<linearGradient id="rg2" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#e53935"/><stop offset="33%" style="stop-color:#fdd835"/>
<stop offset="66%" style="stop-color:#43a047"/><stop offset="100%" style="stop-color:#1e88e5"/>
</linearGradient>
<linearGradient id="rg3" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#00897b"/><stop offset="100%" style="stop-color:#4db6ac"/>
</linearGradient>
</defs>
<rect width="800" height="220" fill="#2e2e2e"/>
<text x="20" y="32" fill="#fff" font-family="sans-serif" font-size="16" font-weight="600">lounge</text>
<text x="20" y="56" fill="#aaa" font-family="sans-serif" font-size="11">Brightness (global)</text>
<rect x="20" y="64" width="320" height="8" rx="4" fill="#444"/>
<rect x="20" y="64" width="200" height="8" rx="4" fill="#6a9ee2"/>
<circle cx="220" cy="68" r="10" fill="#ccc" stroke="#333" stroke-width="1"/>
<text x="360" y="74" fill="#888" font-family="sans-serif" font-size="11">drag to adjust</text>
<text x="20" y="108" fill="#aaa" font-family="sans-serif" font-size="11">Click tile body to select on tab devices</text>
<rect x="20" y="118" width="200" height="72" rx="6" fill="#252525" stroke="#555" stroke-width="1"/>
<rect x="28" y="128" width="184" height="36" rx="4" fill="url(#rg1)"/>
<text x="32" y="180" fill="#eee" font-family="sans-serif" font-size="13" font-weight="600">warm white</text>
<rect x="232" y="118" width="200" height="72" rx="6" fill="#252525" stroke="#6a9ee2" stroke-width="2"/>
<rect x="240" y="128" width="184" height="36" rx="4" fill="url(#rg2)"/>
<text x="244" y="180" fill="#eee" font-family="sans-serif" font-size="13" font-weight="600">rainbow</text>
<rect x="444" y="118" width="200" height="72" rx="6" fill="#252525" stroke="#555" stroke-width="1"/>
<rect x="452" y="128" width="184" height="36" rx="4" fill="url(#rg3)"/>
<text x="456" y="180" fill="#eee" font-family="sans-serif" font-size="13" font-weight="600">chase</text>
<rect x="656" y="130" width="56" height="48" rx="4" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="670" y="158" fill="#ddd" font-family="sans-serif" font-size="11">Edit</text>
<text x="656" y="198" fill="#888" font-family="sans-serif" font-size="10">Edit mode: drag tiles to reorder</text>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,239 @@
# Custom Colour Picker Component
A cross-platform, cross-browser colour picker component that provides a consistent user experience across all operating systems and browsers.
## Features
**Consistent UI** - Same appearance and behavior on Windows, macOS, Linux, iOS, and Android
**Browser Support** - Works in Chrome, Firefox, Safari, Edge, Opera, and mobile browsers
**Touch Support** - Full touch/gesture support for mobile devices
**HSB Colour Model** - Uses Hue, Saturation, Brightness for intuitive colour selection
**Multiple Input Methods** - Hex input, RGB inputs, and visual picker
**Accessible** - Keyboard accessible and screen reader friendly
**Customizable** - Easy to style and integrate
## Files
- `color-picker.js` - Main JavaScript component (14KB)
- `color-picker.css` - Stylesheet (4KB)
- `color-picker-demo.html` - Demo page showing usage examples
## Quick Start
### 1. Include the files
```html
<link rel="stylesheet" href="color-picker.css">
<script src="color-picker.js"></script>
```
### 2. Create a container element
```html
<div id="my-color-picker"></div>
```
### 3. Initialize the colour picker
```javascript
const picker = new ColorPicker('#my-color-picker', {
initialColor: '#FF0000',
onColorChange: (color) => {
console.log('Color changed to:', color);
}
});
```
## API
### Constructor
```javascript
new ColorPicker(container, options)
```
**Parameters:**
- `container` (string|HTMLElement) - CSS selector or DOM element
- `options` (object) - Configuration options
**Options:**
- `initialColor` (string) - Initial colour in hex format (default: '#FF0000')
- `onColorChange` (function) - Callback when colour changes (receives hex colour string)
- `showHexInput` (boolean) - Show hex input field (default: true)
### Methods
```javascript
// Get current color
const color = picker.getColor(); // Returns hex string like '#FF0000'
// Set color programmatically
picker.setColor('#00FF00');
// Open the picker panel
picker.open();
// Close the picker panel
picker.close();
// Toggle the picker panel
picker.toggle();
```
## Usage Examples
### Basic Usage
```javascript
const picker = new ColorPicker('#picker1', {
initialColor: '#FF0000'
});
```
### With Callback
```javascript
const picker = new ColorPicker('#picker1', {
initialColor: '#FF0000',
onColorChange: (color) => {
document.body.style.backgroundColor = color;
}
});
```
### Multiple Colour Pickers
```javascript
const colors = ['#FF0000', '#00FF00', '#0000FF'];
const pickers = colors.map((color, index) => {
return new ColorPicker(`#picker-${index}`, {
initialColor: color,
onColorChange: (newColor) => {
colors[index] = newColor;
updateLEDColors(colors);
}
});
});
```
### Dynamic Colour Picker Creation
```javascript
function addColorPicker(containerId, initialColor = '#000000') {
const container = document.createElement('div');
container.id = containerId;
document.getElementById('color-list').appendChild(container);
return new ColorPicker(container, {
initialColor: initialColor,
onColorChange: (color) => {
console.log(`Color ${containerId} changed to ${color}`);
}
});
}
// Add multiple pickers
addColorPicker('color-1', '#FF0000');
addColorPicker('color-2', '#00FF00');
```
## Styling
The colour picker uses CSS classes that can be customized:
- `.color-picker-container` - Main container
- `.color-picker-preview` - Colour preview button
- `.color-picker-panel` - Dropdown panel
- `.color-picker-main` - Main colour area
- `.color-picker-hue` - Hue slider
- `.color-picker-controls` - Controls section
### Custom Styling Example
```css
.color-picker-preview {
width: 80px;
height: 80px;
border-radius: 12px;
}
.color-picker-panel {
background: #2d3748;
border-color: #4a5568;
}
```
## Browser Compatibility
| Browser | Version | Status |
|---------|---------|--------|
| Chrome | 60+ | ✅ Full support |
| Firefox | 55+ | ✅ Full support |
| Safari | 12+ | ✅ Full support |
| Edge | 79+ | ✅ Full support |
| Opera | 47+ | ✅ Full support |
| Mobile Safari | iOS 12+ | ✅ Full support |
| Chrome Mobile | Android 7+ | ✅ Full support |
## Operating System Compatibility
- ✅ Windows 10/11
- ✅ macOS 10.14+
- ✅ Linux (all major distributions)
- ✅ iOS 12+
- ✅ Android 7+
## Colour Format
The colour picker uses **hex colour format** (`#RRGGBB`):
- Always returns uppercase hex strings (e.g., `#FF0000`)
- Accepts both uppercase and lowercase input
- Automatically validates hex format
## Integration with LED Driver Mockups
The colour picker is integrated into:
- `dashboard.html` - Colour selection for patterns
- `presets.html` - Colour selection when creating/editing presets
### Example: Getting Colours from Multiple Pickers
```javascript
const colorPickers = [];
function getSelectedColors() {
return colorPickers.map(picker => picker.getColor());
}
function sendColorsToDevice() {
const colors = getSelectedColors();
// Send to LED device via API
fetch('/api/colors', {
method: 'POST',
body: JSON.stringify({ colors: colors })
});
}
```
## Performance
- Lightweight: ~14KB JavaScript, ~4KB CSS
- Fast rendering: Uses Canvas API for colour gradients
- Smooth interactions: Optimized event handling
- Memory efficient: No external dependencies
## Accessibility
- Keyboard navigation support
- ARIA labels on interactive elements
- High contrast cursor indicators
- Screen reader compatible
## License
Part of the LED Driver project. Use freely in your projects.
## Demo
See `color-picker-demo.html` for a live demonstration of the colour picker component.

56
docs/mockups/README.md Normal file
View File

@@ -0,0 +1,56 @@
# UI Mockups
This directory contains HTML mockups and generated images for the LED Driver user interface.
## Files
### HTML Mockups
- **index.html** - Navigation page linking to all mockups
- **dashboard.html** - Main control panel for managing LED patterns and devices
- **pattern-selector.html** - Visual pattern selection interface
- **device-management.html** - Device and group management interface
- **settings.html** - Comprehensive settings configuration panel
### Generated Images
Images are automatically generated in the `images/` directory:
- `dashboard.png`
- `pattern-selector.png`
- `device-management.png`
- `settings.png`
- `index.png`
## Generating Images
To generate images from the HTML files, use the provided script:
```bash
# Install dependencies (if not already installed)
pipenv install playwright
pipenv run playwright install chromium
# Generate images
pipenv run python generate_images.py
```
The script will:
1. Check for available screenshot libraries (Playwright, Selenium, or html2image)
2. Generate PNG images from all HTML files
3. Save images to the `images/` directory
### Requirements
The script supports multiple screenshot libraries (in order of preference):
1. **Playwright** (recommended) - `pip install playwright && playwright install chromium`
2. **Selenium** - `pip install selenium` (requires ChromeDriver)
3. **html2image** - `pip install html2image`
## Viewing Mockups
Simply open any HTML file in a web browser to view the mockup. Start with `index.html` for navigation to all mockups.
## Notes
- All mockups are responsive and work on desktop and mobile devices
- The mockups use modern CSS with gradients and smooth animations
- Interactive elements (buttons, sliders, etc.) are functional in the HTML but are mockups (no backend connection)

View File

@@ -0,0 +1,210 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chromium Color Picker Demo</title>
<link rel="stylesheet" href="color-picker-chromium.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&family=Roboto+Mono&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 40px 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 12px;
padding: 40px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
h1 {
color: #202124;
margin-bottom: 8px;
font-weight: 400;
font-size: 24px;
}
p {
color: #5f6368;
margin-bottom: 32px;
font-size: 14px;
}
.demo-section {
margin-bottom: 40px;
padding: 24px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e8eaed;
}
.demo-section h2 {
color: #202124;
margin-bottom: 16px;
font-size: 16px;
font-weight: 500;
}
.color-pickers {
display: flex;
gap: 24px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.color-display {
margin-top: 16px;
padding: 12px;
background: white;
border-radius: 6px;
font-family: 'Roboto Mono', 'Courier New', monospace;
font-size: 13px;
border: 1px solid #e8eaed;
}
.color-display strong {
color: #4285f4;
}
.comparison {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-top: 24px;
}
.comparison-item {
padding: 16px;
background: white;
border-radius: 6px;
border: 1px solid #e8eaed;
}
.comparison-item h3 {
font-size: 14px;
font-weight: 500;
color: #202124;
margin-bottom: 12px;
}
</style>
</head>
<body>
<div class="container">
<h1>Chromium-style Color Picker</h1>
<p>Color picker that matches the native Chromium browser color picker design</p>
<div class="demo-section">
<h2>Single Color Picker</h2>
<div class="color-pickers">
<div id="picker1"></div>
</div>
<div class="color-display">
Selected color: <strong id="color1-display">#FF0000</strong>
</div>
</div>
<div class="demo-section">
<h2>Multiple Color Pickers</h2>
<p style="margin-bottom: 16px; color: #5f6368; font-size: 14px;">Example: Multiple colors for LED patterns</p>
<div class="color-pickers">
<div id="picker2"></div>
<div id="picker3"></div>
<div id="picker4"></div>
</div>
<div class="color-display">
Colors: <strong id="colors-display">#FF0000, #00FF00, #0000FF</strong>
</div>
</div>
<div class="demo-section">
<h2>Features</h2>
<ul style="color: #5f6368; line-height: 1.8; font-size: 14px;">
<li>✅ Matches native Chromium browser color picker design</li>
<li>✅ Clean, minimal interface with native system fonts</li>
<li>✅ RGB number inputs (no sliders) - Chromium style</li>
<li>✅ Hex input with uppercase formatting</li>
<li>✅ HSB (Hue, Saturation, Brightness) color model</li>
<li>✅ Touch support for mobile devices</li>
<li>✅ Keyboard accessible</li>
<li>✅ Dark mode support</li>
</ul>
</div>
<div class="demo-section">
<h2>Design Notes</h2>
<div class="comparison">
<div class="comparison-item">
<h3>Chromium Style</h3>
<ul style="color: #5f6368; line-height: 1.8; font-size: 13px; list-style: none; padding-left: 0;">
<li>• RGB number inputs only</li>
<li>• Compact preview button</li>
<li>• Native system fonts</li>
<li>• Minimal borders and shadows</li>
<li>• Chromium color scheme</li>
</ul>
</div>
<div class="comparison-item">
<h3>Standard Style</h3>
<ul style="color: #5f6368; line-height: 1.8; font-size: 13px; list-style: none; padding-left: 0;">
<li>• RGB sliders + inputs</li>
<li>• Larger preview button</li>
<li>• Custom styling</li>
<li>• Enhanced shadows</li>
<li>• Custom color scheme</li>
</ul>
</div>
</div>
</div>
</div>
<script src="color-picker-chromium.js"></script>
<script>
// Initialize Chromium-style color pickers
const picker1 = new ColorPickerChromium('#picker1', {
initialColor: '#FF0000',
onColorChange: (color) => {
document.getElementById('color1-display').textContent = color;
}
});
const picker2 = new ColorPickerChromium('#picker2', {
initialColor: '#FF0000',
onColorChange: updateColors
});
const picker3 = new ColorPickerChromium('#picker3', {
initialColor: '#00FF00',
onColorChange: updateColors
});
const picker4 = new ColorPickerChromium('#picker4', {
initialColor: '#0000FF',
onColorChange: updateColors
});
function updateColors() {
const colors = [
picker2.getColor(),
picker3.getColor(),
picker4.getColor()
];
document.getElementById('colors-display').textContent = colors.join(', ');
}
</script>
</body>
</html>

View File

@@ -0,0 +1,253 @@
/* Chromium-style Color Picker - Matches native browser color picker dialog */
.color-picker-container {
position: relative;
display: inline-block;
}
/* Preview button - opens the picker */
.color-picker-preview {
width: 40px;
height: 32px;
border: 1px solid #d0d0d0;
border-radius: 4px;
cursor: pointer;
padding: 0;
background: none;
transition: border-color 0.15s;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
}
.color-picker-preview:hover {
border-color: #8ab4f8;
}
.color-picker-preview:active {
border-color: #4285f4;
}
/* Main picker panel - always visible when open, styled like Chromium dialog */
.color-picker-panel {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 1000;
background: #ffffff;
border: 1px solid #dadce0;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15), 0 4px 16px rgba(0, 0, 0, 0.1);
padding: 16px;
min-width: 260px;
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 13px;
}
/* Color area - main saturation/brightness square + hue slider */
.color-picker-area {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
/* Main color square - saturation (left-right) and brightness (top-bottom) */
.color-picker-main {
position: relative;
width: 200px;
height: 200px;
border: 1px solid #dadce0;
border-radius: 4px;
overflow: hidden;
cursor: crosshair;
touch-action: none;
background: #ffffff;
flex-shrink: 0;
}
.color-picker-canvas {
display: block;
width: 100%;
height: 100%;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
/* Cursor for main color area */
.color-picker-cursor {
position: absolute;
width: 18px;
height: 18px;
border: 2px solid #ffffff;
border-radius: 50%;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2), 0 2px 4px rgba(0, 0, 0, 0.1);
pointer-events: none;
transform: translate(-50%, -50%);
top: 0;
left: 0;
}
/* Hue slider - vertical strip on the right */
.color-picker-hue {
position: relative;
width: 24px;
height: 200px;
border: 1px solid #dadce0;
border-radius: 4px;
overflow: hidden;
cursor: pointer;
touch-action: none;
background: #ffffff;
flex-shrink: 0;
}
/* Hue slider cursor/indicator */
.color-picker-hue-cursor {
position: absolute;
left: 0;
right: 0;
width: 100%;
height: 6px;
border: 2px solid #ffffff;
border-radius: 2px;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2), 0 1px 2px rgba(0, 0, 0, 0.1);
pointer-events: none;
transform: translateY(-50%);
top: 0;
}
/* Controls section - hex and RGB inputs */
.color-picker-controls {
display: flex;
flex-direction: column;
gap: 12px;
}
/* Hex input field */
.color-picker-hex {
width: 100%;
padding: 7px 10px;
border: 1px solid #dadce0;
border-radius: 4px;
font-family: 'Roboto Mono', 'Courier New', monospace;
font-size: 13px;
text-transform: uppercase;
transition: border-color 0.15s, box-shadow 0.15s;
background: #ffffff;
}
.color-picker-hex:focus {
outline: none;
border-color: #4285f4;
box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.2);
}
/* RGB inputs container */
.color-picker-rgb {
display: flex;
gap: 12px;
align-items: flex-end;
}
.color-picker-rgb-item {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
}
.color-picker-rgb-item label {
font-size: 11px;
font-weight: 500;
color: #5f6368;
text-align: left;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* RGB number input fields */
.color-picker-rgb-input {
width: 100%;
padding: 7px 10px;
border: 1px solid #dadce0;
border-radius: 4px;
font-size: 13px;
text-align: left;
transition: border-color 0.15s, box-shadow 0.15s;
background: #ffffff;
font-family: 'Roboto', sans-serif;
-moz-appearance: textfield;
}
.color-picker-rgb-input::-webkit-outer-spin-button,
.color-picker-rgb-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.color-picker-rgb-input:focus {
outline: none;
border-color: #4285f4;
box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.2);
}
/* Hide RGB sliders - Chromium uses only number inputs */
.color-picker-rgb-slider {
display: none;
}
/* Responsive adjustments */
@media (max-width: 480px) {
.color-picker-panel {
left: auto;
right: 0;
}
.color-picker-main {
width: 180px;
height: 180px;
}
.color-picker-hue {
height: 180px;
}
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.color-picker-panel {
background: #202124;
border-color: #5f6368;
}
.color-picker-preview {
border-color: #5f6368;
}
.color-picker-main,
.color-picker-hue {
border-color: #5f6368;
background: #202124;
}
.color-picker-hex,
.color-picker-rgb-input {
background: #303134;
border-color: #5f6368;
color: #e8eaed;
}
.color-picker-rgb-item label {
color: #9aa0a6;
}
.color-picker-hex:focus,
.color-picker-rgb-input:focus {
border-color: #8ab4f8;
box-shadow: 0 0 0 2px rgba(138, 180, 248, 0.2);
}
.color-picker-preview:hover {
border-color: #8ab4f8;
}
}

View File

@@ -0,0 +1,452 @@
/**
* Chromium-style Color Picker Component
* Matches native Chromium browser color picker design
*/
class ColorPickerChromium {
constructor(container, options = {}) {
this.container = typeof container === 'string' ? document.querySelector(container) : container;
this.options = {
initialColor: options.initialColor || '#FF0000',
onColorChange: options.onColorChange || null,
showHexInput: options.showHexInput !== false,
...options
};
this.currentColor = this.options.initialColor;
this.isOpen = false;
this.init();
}
init() {
this.createPicker();
this.setupEventListeners();
this.updateColor(this.options.initialColor);
}
createPicker() {
this.container.innerHTML = '';
this.container.className = 'color-picker-container';
// Color preview button
this.previewBtn = document.createElement('button');
this.previewBtn.className = 'color-picker-preview';
this.previewBtn.type = 'button';
this.previewBtn.style.backgroundColor = this.currentColor;
this.previewBtn.setAttribute('aria-label', 'Open color picker');
// Dropdown panel
this.panel = document.createElement('div');
this.panel.className = 'color-picker-panel';
this.panel.style.display = 'none';
// Main color area (hue/saturation)
this.mainArea = document.createElement('div');
this.mainArea.className = 'color-picker-main';
this.mainCanvas = document.createElement('canvas');
this.mainCanvas.width = 200;
this.mainCanvas.height = 200;
this.mainCanvas.className = 'color-picker-canvas';
this.mainArea.appendChild(this.mainCanvas);
// Main area cursor
this.mainCursor = document.createElement('div');
this.mainCursor.className = 'color-picker-cursor';
this.mainArea.appendChild(this.mainCursor);
// Hue slider
this.hueArea = document.createElement('div');
this.hueArea.className = 'color-picker-hue';
this.hueCanvas = document.createElement('canvas');
this.hueCanvas.width = 24;
this.hueCanvas.height = 200;
this.hueCanvas.className = 'color-picker-canvas';
this.hueArea.appendChild(this.hueCanvas);
// Hue slider cursor
this.hueCursor = document.createElement('div');
this.hueCursor.className = 'color-picker-hue-cursor';
this.hueArea.appendChild(this.hueCursor);
// Controls section
this.controls = document.createElement('div');
this.controls.className = 'color-picker-controls';
// Hex input
if (this.options.showHexInput) {
this.hexInput = document.createElement('input');
this.hexInput.type = 'text';
this.hexInput.className = 'color-picker-hex';
this.hexInput.placeholder = '#000000';
this.hexInput.maxLength = 7;
this.controls.appendChild(this.hexInput);
}
// RGB inputs (Chromium style - no sliders, just number inputs)
this.rgbContainer = document.createElement('div');
this.rgbContainer.className = 'color-picker-rgb';
['R', 'G', 'B'].forEach((label) => {
const wrapper = document.createElement('div');
wrapper.className = 'color-picker-rgb-item';
wrapper.dataset.channel = label.toLowerCase();
const labelEl = document.createElement('label');
labelEl.textContent = label;
const input = document.createElement('input');
input.type = 'number';
input.className = 'color-picker-rgb-input';
input.min = 0;
input.max = 255;
input.value = 0;
input.dataset.channel = label.toLowerCase();
wrapper.appendChild(labelEl);
wrapper.appendChild(input);
this.rgbContainer.appendChild(wrapper);
this[`rgb${label}`] = input;
});
this.controls.appendChild(this.rgbContainer);
// Assemble panel
const pickerArea = document.createElement('div');
pickerArea.className = 'color-picker-area';
pickerArea.appendChild(this.mainArea);
pickerArea.appendChild(this.hueArea);
this.panel.appendChild(pickerArea);
this.panel.appendChild(this.controls);
// Assemble container
this.container.appendChild(this.previewBtn);
this.container.appendChild(this.panel);
// Draw canvases
this.drawHueCanvas();
this.drawMainCanvas(1.0); // Start with full saturation
}
setupEventListeners() {
// Toggle panel
this.previewBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.toggle();
});
// Close on outside click
document.addEventListener('click', (e) => {
if (!this.container.contains(e.target) && this.isOpen) {
this.close();
}
});
// Main area interaction
let isMainDragging = false;
this.mainCanvas.addEventListener('mousedown', (e) => {
isMainDragging = true;
this.handleMainAreaClick(e);
});
this.mainCanvas.addEventListener('mousemove', (e) => {
if (isMainDragging) {
this.handleMainAreaClick(e);
}
});
document.addEventListener('mouseup', () => {
isMainDragging = false;
});
// Touch support for main area
this.mainCanvas.addEventListener('touchstart', (e) => {
e.preventDefault();
isMainDragging = true;
this.handleMainAreaClick(e.touches[0]);
});
this.mainCanvas.addEventListener('touchmove', (e) => {
e.preventDefault();
if (isMainDragging) {
this.handleMainAreaClick(e.touches[0]);
}
});
this.mainCanvas.addEventListener('touchend', () => {
isMainDragging = false;
});
// Hue slider interaction
let isHueDragging = false;
this.hueCanvas.addEventListener('mousedown', (e) => {
isHueDragging = true;
this.handleHueClick(e);
});
this.hueCanvas.addEventListener('mousemove', (e) => {
if (isHueDragging) {
this.handleHueClick(e);
}
});
document.addEventListener('mouseup', () => {
isHueDragging = false;
});
// Touch support for hue slider
this.hueCanvas.addEventListener('touchstart', (e) => {
e.preventDefault();
isHueDragging = true;
this.handleHueClick(e.touches[0]);
});
this.hueCanvas.addEventListener('touchmove', (e) => {
e.preventDefault();
if (isHueDragging) {
this.handleHueClick(e.touches[0]);
}
});
this.hueCanvas.addEventListener('touchend', () => {
isHueDragging = false;
});
// Hex input
if (this.hexInput) {
this.hexInput.addEventListener('input', (e) => {
const value = e.target.value;
if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
this.updateColor(value);
}
});
this.hexInput.addEventListener('blur', (e) => {
const value = e.target.value;
if (!/^#[0-9A-Fa-f]{6}$/.test(value) && value.length > 0) {
e.target.value = this.currentColor;
}
});
}
// RGB inputs (Chromium style - only number inputs)
['R', 'G', 'B'].forEach(label => {
this[`rgb${label}`].addEventListener('input', (e) => {
let value = parseInt(e.target.value) || 0;
value = Math.max(0, Math.min(255, value)); // Clamp to 0-255
e.target.value = value;
const r = parseInt(this.rgbR.value) || 0;
const g = parseInt(this.rgbG.value) || 0;
const b = parseInt(this.rgbB.value) || 0;
const hex = this.rgbToHex(r, g, b);
this.updateColor(hex, false); // Don't update RGB inputs to avoid loop
});
this[`rgb${label}`].addEventListener('blur', (e) => {
let value = parseInt(e.target.value) || 0;
value = Math.max(0, Math.min(255, value));
e.target.value = value;
});
});
}
drawHueCanvas() {
const ctx = this.hueCanvas.getContext('2d');
const gradient = ctx.createLinearGradient(0, 0, 0, 200);
for (let i = 0; i <= 6; i++) {
const hue = i * 60;
gradient.addColorStop(i / 6, `hsl(${hue}, 100%, 50%)`);
}
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 24, 200);
}
drawMainCanvas(hue) {
const ctx = this.mainCanvas.getContext('2d');
// Saturation gradient (left to right)
const satGradient = ctx.createLinearGradient(0, 0, 200, 0);
satGradient.addColorStop(0, `hsl(${hue}, 0%, 50%)`);
satGradient.addColorStop(1, `hsl(${hue}, 100%, 50%)`);
ctx.fillStyle = satGradient;
ctx.fillRect(0, 0, 200, 200);
// Brightness gradient (top to bottom)
const brightGradient = ctx.createLinearGradient(0, 0, 0, 200);
brightGradient.addColorStop(0, 'rgba(255, 255, 255, 0)');
brightGradient.addColorStop(1, 'rgba(0, 0, 0, 1)');
ctx.fillStyle = brightGradient;
ctx.fillRect(0, 0, 200, 200);
}
handleMainAreaClick(e) {
const rect = this.mainCanvas.getBoundingClientRect();
const x = Math.max(0, Math.min(200, e.clientX - rect.left));
const y = Math.max(0, Math.min(200, e.clientY - rect.top));
const saturation = x / 200;
const brightness = 1 - (y / 200);
this.updateColorFromHSB(this.hue, saturation, brightness);
this.updateCursor(x, y);
}
handleHueClick(e) {
const rect = this.hueCanvas.getBoundingClientRect();
const y = Math.max(0, Math.min(200, e.clientY - rect.top));
const hue = (y / 200) * 360;
this.hue = hue;
this.drawMainCanvas(hue);
this.updateHueCursor(y);
// Recalculate color with new hue
const rect2 = this.mainCanvas.getBoundingClientRect();
const x = parseFloat(this.mainCursor.style.left) || 0;
const y2 = parseFloat(this.mainCursor.style.top) || 0;
const saturation = x / 200;
const brightness = 1 - (y2 / 200);
this.updateColorFromHSB(hue, saturation, brightness);
}
updateColorFromHSB(h, s, v) {
const rgb = this.hsbToRgb(h, s, v);
const hex = this.rgbToHex(rgb.r, rgb.g, rgb.b);
this.updateColor(hex);
}
hsbToRgb(h, s, v) {
h = h / 360;
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s);
const q = v * (1 - f * s);
const t = v * (1 - (1 - f) * s);
let r, g, b;
switch (i % 6) {
case 0: r = v; g = t; b = p; break;
case 1: r = q; g = v; b = p; break;
case 2: r = p; g = v; b = t; break;
case 3: r = p; g = q; b = v; break;
case 4: r = t; g = p; b = v; break;
case 5: r = v; g = p; b = q; break;
}
return {
r: Math.round(r * 255),
g: Math.round(g * 255),
b: Math.round(b * 255)
};
}
rgbToHex(r, g, b) {
return '#' + [r, g, b].map(x => {
const hex = x.toString(16);
return hex.length === 1 ? '0' + hex : hex;
}).join('').toUpperCase();
}
hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
}
rgbToHsb(r, g, b) {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const diff = max - min;
let h = 0;
if (diff !== 0) {
if (max === r) {
h = ((g - b) / diff) % 6) * 60;
} else if (max === g) {
h = ((b - r) / diff + 2) * 60;
} else {
h = ((r - g) / diff + 4) * 60;
}
}
if (h < 0) h += 360;
const s = max === 0 ? 0 : diff / max;
const v = max;
return { h, s, v };
}
updateColor(hex, updateInputs = true) {
this.currentColor = hex.toUpperCase();
this.previewBtn.style.backgroundColor = this.currentColor;
const rgb = this.hexToRgb(this.currentColor);
if (!rgb) return;
const hsb = this.rgbToHsb(rgb.r, rgb.g, rgb.b);
this.hue = hsb.h;
// Update main canvas
this.drawMainCanvas(this.hue);
// Update cursors
const x = hsb.s * 200;
const y = (1 - hsb.v) * 200;
this.updateCursor(x, y);
this.updateHueCursor((this.hue / 360) * 200);
// Update inputs
if (updateInputs) {
if (this.hexInput) {
this.hexInput.value = this.currentColor;
}
if (this.rgbR) {
this.rgbR.value = rgb.r;
this.rgbG.value = rgb.g;
this.rgbB.value = rgb.b;
}
}
// Callback
if (this.options.onColorChange) {
this.options.onColorChange(this.currentColor);
}
}
updateCursor(x, y) {
this.mainCursor.style.left = `${x}px`;
this.mainCursor.style.top = `${y}px`;
}
updateHueCursor(y) {
this.hueCursor.style.top = `${y}px`;
}
toggle() {
this.isOpen ? this.close() : this.open();
}
open() {
this.panel.style.display = 'block';
this.isOpen = true;
}
close() {
this.panel.style.display = 'none';
this.isOpen = false;
}
getColor() {
return this.currentColor;
}
setColor(color) {
this.updateColor(color);
}
}
// Export for use in other scripts
if (typeof module !== 'undefined' && module.exports) {
module.exports = ColorPickerChromium;
}

View File

@@ -0,0 +1,153 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Color Picker Demo - Cross-Platform</title>
<link rel="stylesheet" href="color-picker.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 40px 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 12px;
padding: 40px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
h1 {
color: #667eea;
margin-bottom: 8px;
}
p {
color: #666;
margin-bottom: 32px;
}
.demo-section {
margin-bottom: 40px;
padding: 24px;
background: #f7fafc;
border-radius: 8px;
}
.demo-section h2 {
color: #333;
margin-bottom: 16px;
font-size: 1.25rem;
}
.color-pickers {
display: flex;
gap: 24px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.color-display {
margin-top: 16px;
padding: 12px;
background: white;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 0.875rem;
}
.color-display strong {
color: #667eea;
}
</style>
</head>
<body>
<div class="container">
<h1>Custom Color Picker</h1>
<p>Consistent color picker that works the same across all operating systems and browsers</p>
<div class="demo-section">
<h2>Single Color Picker</h2>
<div class="color-pickers">
<div id="picker1"></div>
</div>
<div class="color-display">
Selected color: <strong id="color1-display">#FF0000</strong>
</div>
</div>
<div class="demo-section">
<h2>Multiple Color Pickers</h2>
<p style="margin-bottom: 16px;">Example: Multiple colors for LED patterns</p>
<div class="color-pickers">
<div id="picker2"></div>
<div id="picker3"></div>
<div id="picker4"></div>
</div>
<div class="color-display">
Colors: <strong id="colors-display">#FF0000, #00FF00, #0000FF</strong>
</div>
</div>
<div class="demo-section">
<h2>Features</h2>
<ul style="color: #666; line-height: 1.8;">
<li>✅ Consistent UI across Windows, macOS, Linux, iOS, Android</li>
<li>✅ Works in Chrome, Firefox, Safari, Edge, Opera</li>
<li>✅ Touch support for mobile devices</li>
<li>✅ HSB (Hue, Saturation, Brightness) color model</li>
<li>✅ Hex and RGB input support</li>
<li>✅ Keyboard accessible</li>
<li>✅ Customizable styling</li>
</ul>
</div>
</div>
<script src="color-picker.js"></script>
<script>
// Initialize color pickers
const picker1 = new ColorPicker('#picker1', {
initialColor: '#FF0000',
onColorChange: (color) => {
document.getElementById('color1-display').textContent = color;
}
});
const picker2 = new ColorPicker('#picker2', {
initialColor: '#FF0000',
onColorChange: updateColors
});
const picker3 = new ColorPicker('#picker3', {
initialColor: '#00FF00',
onColorChange: updateColors
});
const picker4 = new ColorPicker('#picker4', {
initialColor: '#0000FF',
onColorChange: updateColors
});
function updateColors() {
const colors = [
picker2.getColor(),
picker3.getColor(),
picker4.getColor()
];
document.getElementById('colors-display').textContent = colors.join(', ');
}
</script>
</body>
</html>

View File

@@ -0,0 +1,282 @@
/* Color Picker Styles - Consistent across all browsers and OS */
.color-picker-container {
position: relative;
display: inline-block;
}
.color-picker-preview {
width: 60px;
height: 60px;
border: 2px solid #e0e0e0;
border-radius: 8px;
cursor: pointer;
padding: 0;
background: none;
transition: all 0.2s;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.color-picker-preview:hover {
border-color: #667eea;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
}
.color-picker-preview:active {
transform: translateY(0);
}
.color-picker-panel {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 1000;
background: white;
border: 1px solid #e0e0e0;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
padding: 16px;
min-width: 280px;
}
.color-picker-area {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.color-picker-main {
position: relative;
width: 200px;
height: 200px;
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
cursor: crosshair;
touch-action: none;
}
.color-picker-canvas {
display: block;
width: 100%;
height: 100%;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.color-picker-cursor {
position: absolute;
width: 16px;
height: 16px;
border: 2px solid white;
border-radius: 50%;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3);
pointer-events: none;
transform: translate(-50%, -50%);
top: 0;
left: 0;
}
.color-picker-hue {
position: relative;
width: 20px;
height: 200px;
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
touch-action: none;
}
.color-picker-hue-cursor {
position: absolute;
left: 0;
right: 0;
width: 100%;
height: 4px;
border: 2px solid white;
border-radius: 2px;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3);
pointer-events: none;
transform: translateY(-50%);
top: 0;
}
.color-picker-controls {
display: flex;
flex-direction: column;
gap: 12px;
}
.color-picker-hex {
width: 100%;
padding: 8px 12px;
border: 1px solid #e0e0e0;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 0.875rem;
text-transform: uppercase;
transition: border-color 0.2s;
}
.color-picker-hex:focus {
outline: none;
border-color: #667eea;
}
.color-picker-rgb {
display: flex;
gap: 8px;
}
.color-picker-rgb-item {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
}
.color-picker-rgb-item label {
font-size: 0.75rem;
font-weight: 600;
color: #666;
text-align: center;
}
.color-picker-rgb-slider {
width: 100%;
height: 6px;
border-radius: 3px;
background: #e0e0e0;
outline: none;
-webkit-appearance: none;
cursor: pointer;
}
.color-picker-rgb-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #667eea;
cursor: pointer;
border: 2px solid white;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
transition: all 0.2s;
}
.color-picker-rgb-slider::-webkit-slider-thumb:hover {
transform: scale(1.1);
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.3);
}
.color-picker-rgb-slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #667eea;
cursor: pointer;
border: 2px solid white;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
transition: all 0.2s;
}
.color-picker-rgb-slider::-moz-range-thumb:hover {
transform: scale(1.1);
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.3);
}
/* Color-specific slider backgrounds */
.color-picker-rgb-item[data-channel="r"] .color-picker-rgb-slider {
background: linear-gradient(to right, #000000, #ff0000);
}
.color-picker-rgb-item[data-channel="g"] .color-picker-rgb-slider {
background: linear-gradient(to right, #000000, #00ff00);
}
.color-picker-rgb-item[data-channel="b"] .color-picker-rgb-slider {
background: linear-gradient(to right, #000000, #0000ff);
}
.color-picker-rgb-input {
width: 100%;
padding: 6px 8px;
border: 1px solid #e0e0e0;
border-radius: 6px;
font-size: 0.875rem;
text-align: center;
transition: border-color 0.2s;
-moz-appearance: textfield;
}
.color-picker-rgb-input::-webkit-outer-spin-button,
.color-picker-rgb-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.color-picker-rgb-input:focus {
outline: none;
border-color: #667eea;
}
/* Responsive adjustments */
@media (max-width: 480px) {
.color-picker-panel {
left: auto;
right: 0;
}
.color-picker-main {
width: 180px;
height: 180px;
}
.color-picker-hue {
height: 180px;
}
}
/* Dark mode support (optional) */
@media (prefers-color-scheme: dark) {
.color-picker-panel {
background: #2d3748;
border-color: #4a5568;
}
.color-picker-preview {
border-color: #4a5568;
}
.color-picker-main,
.color-picker-hue {
border-color: #4a5568;
}
.color-picker-hex,
.color-picker-rgb-input {
background: #1a202c;
border-color: #4a5568;
color: #e2e8f0;
}
.color-picker-rgb-item label {
color: #a0aec0;
}
.color-picker-rgb-slider {
background: #4a5568 !important;
}
.color-picker-rgb-slider::-webkit-slider-thumb,
.color-picker-rgb-slider::-moz-range-thumb {
background: #667eea;
border-color: #2d3748;
}
}

View File

@@ -0,0 +1,474 @@
/**
* Custom Color Picker Component
* Consistent across all operating systems and browsers
*/
class ColorPicker {
constructor(container, options = {}) {
this.container = typeof container === 'string' ? document.querySelector(container) : container;
this.options = {
initialColor: options.initialColor || '#FF0000',
onColorChange: options.onColorChange || null,
showHexInput: options.showHexInput !== false,
...options
};
this.currentColor = this.options.initialColor;
this.isOpen = false;
this.init();
}
init() {
this.createPicker();
this.setupEventListeners();
this.updateColor(this.options.initialColor);
}
createPicker() {
this.container.innerHTML = '';
this.container.className = 'color-picker-container';
// Color preview button
this.previewBtn = document.createElement('button');
this.previewBtn.className = 'color-picker-preview';
this.previewBtn.type = 'button';
this.previewBtn.style.backgroundColor = this.currentColor;
this.previewBtn.setAttribute('aria-label', 'Open color picker');
// Dropdown panel
this.panel = document.createElement('div');
this.panel.className = 'color-picker-panel';
this.panel.style.display = 'none';
// Main color area (hue/saturation)
this.mainArea = document.createElement('div');
this.mainArea.className = 'color-picker-main';
this.mainCanvas = document.createElement('canvas');
this.mainCanvas.width = 200;
this.mainCanvas.height = 200;
this.mainCanvas.className = 'color-picker-canvas';
this.mainArea.appendChild(this.mainCanvas);
// Main area cursor
this.mainCursor = document.createElement('div');
this.mainCursor.className = 'color-picker-cursor';
this.mainArea.appendChild(this.mainCursor);
// Hue slider
this.hueArea = document.createElement('div');
this.hueArea.className = 'color-picker-hue';
this.hueCanvas = document.createElement('canvas');
this.hueCanvas.width = 20;
this.hueCanvas.height = 200;
this.hueCanvas.className = 'color-picker-canvas';
this.hueArea.appendChild(this.hueCanvas);
// Hue slider cursor
this.hueCursor = document.createElement('div');
this.hueCursor.className = 'color-picker-hue-cursor';
this.hueArea.appendChild(this.hueCursor);
// Controls section
this.controls = document.createElement('div');
this.controls.className = 'color-picker-controls';
// Hex input
if (this.options.showHexInput) {
this.hexInput = document.createElement('input');
this.hexInput.type = 'text';
this.hexInput.className = 'color-picker-hex';
this.hexInput.placeholder = '#000000';
this.hexInput.maxLength = 7;
this.controls.appendChild(this.hexInput);
}
// RGB inputs and sliders
this.rgbContainer = document.createElement('div');
this.rgbContainer.className = 'color-picker-rgb';
['R', 'G', 'B'].forEach((label, index) => {
const wrapper = document.createElement('div');
wrapper.className = 'color-picker-rgb-item';
wrapper.dataset.channel = label.toLowerCase();
const labelEl = document.createElement('label');
labelEl.textContent = label;
const slider = document.createElement('input');
slider.type = 'range';
slider.className = 'color-picker-rgb-slider';
slider.min = 0;
slider.max = 255;
slider.value = 0;
slider.dataset.channel = label.toLowerCase();
const input = document.createElement('input');
input.type = 'number';
input.className = 'color-picker-rgb-input';
input.min = 0;
input.max = 255;
input.value = 0;
input.dataset.channel = label.toLowerCase();
wrapper.appendChild(labelEl);
wrapper.appendChild(slider);
wrapper.appendChild(input);
this.rgbContainer.appendChild(wrapper);
this[`rgb${label}Slider`] = slider;
this[`rgb${label}`] = input;
});
this.controls.appendChild(this.rgbContainer);
// Assemble panel
const pickerArea = document.createElement('div');
pickerArea.className = 'color-picker-area';
pickerArea.appendChild(this.mainArea);
pickerArea.appendChild(this.hueArea);
this.panel.appendChild(pickerArea);
this.panel.appendChild(this.controls);
// Assemble container
this.container.appendChild(this.previewBtn);
this.container.appendChild(this.panel);
// Draw canvases
this.drawHueCanvas();
this.drawMainCanvas(1.0); // Start with full saturation
}
setupEventListeners() {
// Toggle panel
this.previewBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.toggle();
});
// Close on outside click
document.addEventListener('click', (e) => {
if (!this.container.contains(e.target) && this.isOpen) {
this.close();
}
});
// Main area interaction
let isMainDragging = false;
this.mainCanvas.addEventListener('mousedown', (e) => {
isMainDragging = true;
this.handleMainAreaClick(e);
});
this.mainCanvas.addEventListener('mousemove', (e) => {
if (isMainDragging) {
this.handleMainAreaClick(e);
}
});
document.addEventListener('mouseup', () => {
isMainDragging = false;
});
// Touch support for main area
this.mainCanvas.addEventListener('touchstart', (e) => {
e.preventDefault();
isMainDragging = true;
this.handleMainAreaClick(e.touches[0]);
});
this.mainCanvas.addEventListener('touchmove', (e) => {
e.preventDefault();
if (isMainDragging) {
this.handleMainAreaClick(e.touches[0]);
}
});
this.mainCanvas.addEventListener('touchend', () => {
isMainDragging = false;
});
// Hue slider interaction
let isHueDragging = false;
this.hueCanvas.addEventListener('mousedown', (e) => {
isHueDragging = true;
this.handleHueClick(e);
});
this.hueCanvas.addEventListener('mousemove', (e) => {
if (isHueDragging) {
this.handleHueClick(e);
}
});
document.addEventListener('mouseup', () => {
isHueDragging = false;
});
// Touch support for hue slider
this.hueCanvas.addEventListener('touchstart', (e) => {
e.preventDefault();
isHueDragging = true;
this.handleHueClick(e.touches[0]);
});
this.hueCanvas.addEventListener('touchmove', (e) => {
e.preventDefault();
if (isHueDragging) {
this.handleHueClick(e.touches[0]);
}
});
this.hueCanvas.addEventListener('touchend', () => {
isHueDragging = false;
});
// Hex input
if (this.hexInput) {
this.hexInput.addEventListener('input', (e) => {
const value = e.target.value;
if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
this.updateColor(value);
}
});
this.hexInput.addEventListener('blur', (e) => {
const value = e.target.value;
if (!/^#[0-9A-Fa-f]{6}$/.test(value) && value.length > 0) {
e.target.value = this.currentColor;
}
});
}
// RGB inputs and sliders
['R', 'G', 'B'].forEach(label => {
// Slider change
this[`rgb${label}Slider`].addEventListener('input', (e) => {
const value = parseInt(e.target.value) || 0;
this[`rgb${label}`].value = value;
const r = parseInt(this.rgbR.value) || 0;
const g = parseInt(this.rgbG.value) || 0;
const b = parseInt(this.rgbB.value) || 0;
const hex = this.rgbToHex(r, g, b);
this.updateColor(hex, false); // Don't update RGB inputs/sliders to avoid loop
});
// Input change
this[`rgb${label}`].addEventListener('input', (e) => {
let value = parseInt(e.target.value) || 0;
value = Math.max(0, Math.min(255, value)); // Clamp to 0-255
e.target.value = value;
this[`rgb${label}Slider`].value = value;
const r = parseInt(this.rgbR.value) || 0;
const g = parseInt(this.rgbG.value) || 0;
const b = parseInt(this.rgbB.value) || 0;
const hex = this.rgbToHex(r, g, b);
this.updateColor(hex, false); // Don't update RGB inputs/sliders to avoid loop
});
});
}
drawHueCanvas() {
const ctx = this.hueCanvas.getContext('2d');
const gradient = ctx.createLinearGradient(0, 0, 0, 200);
for (let i = 0; i <= 6; i++) {
const hue = i * 60;
gradient.addColorStop(i / 6, `hsl(${hue}, 100%, 50%)`);
}
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 20, 200);
}
drawMainCanvas(hue) {
const ctx = this.mainCanvas.getContext('2d');
// Saturation gradient (left to right)
const satGradient = ctx.createLinearGradient(0, 0, 200, 0);
satGradient.addColorStop(0, `hsl(${hue}, 0%, 50%)`);
satGradient.addColorStop(1, `hsl(${hue}, 100%, 50%)`);
ctx.fillStyle = satGradient;
ctx.fillRect(0, 0, 200, 200);
// Brightness gradient (top to bottom)
const brightGradient = ctx.createLinearGradient(0, 0, 0, 200);
brightGradient.addColorStop(0, 'rgba(255, 255, 255, 0)');
brightGradient.addColorStop(1, 'rgba(0, 0, 0, 1)');
ctx.fillStyle = brightGradient;
ctx.fillRect(0, 0, 200, 200);
}
handleMainAreaClick(e) {
const rect = this.mainCanvas.getBoundingClientRect();
const x = Math.max(0, Math.min(200, e.clientX - rect.left));
const y = Math.max(0, Math.min(200, e.clientY - rect.top));
const saturation = x / 200;
const brightness = 1 - (y / 200);
this.updateColorFromHSB(this.hue, saturation, brightness);
this.updateCursor(x, y);
}
handleHueClick(e) {
const rect = this.hueCanvas.getBoundingClientRect();
const y = Math.max(0, Math.min(200, e.clientY - rect.top));
const hue = (y / 200) * 360;
this.hue = hue;
this.drawMainCanvas(hue);
this.updateHueCursor(y);
// Recalculate color with new hue
const rect2 = this.mainCanvas.getBoundingClientRect();
const x = parseFloat(this.mainCursor.style.left) || 0;
const y2 = parseFloat(this.mainCursor.style.top) || 0;
const saturation = x / 200;
const brightness = 1 - (y2 / 200);
this.updateColorFromHSB(hue, saturation, brightness);
}
updateColorFromHSB(h, s, v) {
const rgb = this.hsbToRgb(h, s, v);
const hex = this.rgbToHex(rgb.r, rgb.g, rgb.b);
this.updateColor(hex);
}
hsbToRgb(h, s, v) {
h = h / 360;
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s);
const q = v * (1 - f * s);
const t = v * (1 - (1 - f) * s);
let r, g, b;
switch (i % 6) {
case 0: r = v; g = t; b = p; break;
case 1: r = q; g = v; b = p; break;
case 2: r = p; g = v; b = t; break;
case 3: r = p; g = q; b = v; break;
case 4: r = t; g = p; b = v; break;
case 5: r = v; g = p; b = q; break;
}
return {
r: Math.round(r * 255),
g: Math.round(g * 255),
b: Math.round(b * 255)
};
}
rgbToHex(r, g, b) {
return '#' + [r, g, b].map(x => {
const hex = x.toString(16);
return hex.length === 1 ? '0' + hex : hex;
}).join('').toUpperCase();
}
hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
}
rgbToHsb(r, g, b) {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const diff = max - min;
let h = 0;
if (diff !== 0) {
if (max === r) {
h = ((g - b) / diff) % 6) * 60;
} else if (max === g) {
h = ((b - r) / diff + 2) * 60;
} else {
h = ((r - g) / diff + 4) * 60;
}
}
if (h < 0) h += 360;
const s = max === 0 ? 0 : diff / max;
const v = max;
return { h, s, v };
}
updateColor(hex, updateInputs = true) {
this.currentColor = hex.toUpperCase();
this.previewBtn.style.backgroundColor = this.currentColor;
const rgb = this.hexToRgb(this.currentColor);
if (!rgb) return;
const hsb = this.rgbToHsb(rgb.r, rgb.g, rgb.b);
this.hue = hsb.h;
// Update main canvas
this.drawMainCanvas(this.hue);
// Update cursors
const x = hsb.s * 200;
const y = (1 - hsb.v) * 200;
this.updateCursor(x, y);
this.updateHueCursor((this.hue / 360) * 200);
// Update inputs
if (updateInputs) {
if (this.hexInput) {
this.hexInput.value = this.currentColor;
}
if (this.rgbR) {
this.rgbR.value = rgb.r;
this.rgbG.value = rgb.g;
this.rgbB.value = rgb.b;
}
if (this.rgbRSlider) {
this.rgbRSlider.value = rgb.r;
this.rgbGSlider.value = rgb.g;
this.rgbBSlider.value = rgb.b;
}
}
// Callback
if (this.options.onColorChange) {
this.options.onColorChange(this.currentColor);
}
}
updateCursor(x, y) {
this.mainCursor.style.left = `${x}px`;
this.mainCursor.style.top = `${y}px`;
}
updateHueCursor(y) {
this.hueCursor.style.top = `${y}px`;
}
toggle() {
this.isOpen ? this.close() : this.open();
}
open() {
this.panel.style.display = 'block';
this.isOpen = true;
}
close() {
this.panel.style.display = 'none';
this.isOpen = false;
}
getColor() {
return this.currentColor;
}
setColor(color) {
this.updateColor(color);
}
}
// Export for use in other scripts
if (typeof module !== 'undefined' && module.exports) {
module.exports = ColorPicker;
}

359
docs/mockups/dashboard.html Normal file
View File

@@ -0,0 +1,359 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LED Driver - Dashboard</title>
<link rel="stylesheet" href="color-picker.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
color: #333;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
background: white;
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.header h1 {
color: #667eea;
margin-bottom: 8px;
}
.header p {
color: #666;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
margin-bottom: 24px;
}
.card {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 12px rgba(0, 0, 0, 0.15);
}
.card h2 {
color: #667eea;
margin-bottom: 16px;
font-size: 1.25rem;
}
.pattern-selector {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 12px;
}
.pattern-btn {
padding: 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
background: white;
cursor: pointer;
transition: all 0.2s;
text-align: center;
font-weight: 500;
}
.pattern-btn:hover {
border-color: #667eea;
background: #f0f0ff;
}
.pattern-btn.active {
border-color: #667eea;
background: #667eea;
color: white;
}
.slider-group {
margin-bottom: 20px;
}
.slider-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
}
.slider {
width: 100%;
height: 8px;
border-radius: 4px;
background: #e0e0e0;
outline: none;
-webkit-appearance: none;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #667eea;
cursor: pointer;
}
.slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #667eea;
cursor: pointer;
border: none;
}
.value-display {
display: inline-block;
margin-left: 12px;
font-weight: 600;
color: #667eea;
}
.color-picker-group {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.color-picker-wrapper {
display: inline-block;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.btn-secondary {
background: #e0e0e0;
color: #333;
}
.btn-secondary:hover {
background: #d0d0d0;
}
.device-list {
list-style: none;
}
.device-item {
padding: 12px;
border: 1px solid #e0e0e0;
border-radius: 8px;
margin-bottom: 8px;
display: flex;
justify-content: space-between;
align-items: center;
}
.device-status {
width: 12px;
height: 12px;
border-radius: 50%;
background: #4caf50;
}
.device-status.offline {
background: #f44336;
}
.actions {
display: flex;
gap: 12px;
margin-top: 20px;
}
.btn-full {
width: 100%;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>LED Driver Control Panel</h1>
<p>Manage your LED devices and patterns</p>
</div>
<div class="grid">
<!-- Pattern Selection -->
<div class="card">
<h2>Pattern Selection</h2>
<div class="pattern-selector">
<div class="pattern-btn active">On</div>
<div class="pattern-btn">Off</div>
<div class="pattern-btn">Blink</div>
<div class="pattern-btn">Chase</div>
<div class="pattern-btn">Circle</div>
<div class="pattern-btn">Pulse</div>
<div class="pattern-btn">Rainbow</div>
<div class="pattern-btn">Transition</div>
</div>
</div>
<!-- Brightness & Speed -->
<div class="card">
<h2>Brightness & Speed</h2>
<div class="slider-group">
<label>
Brightness
<span class="value-display" id="brightness-value">100</span>%
</label>
<input type="range" class="slider" id="brightness" min="0" max="100" value="100">
</div>
<div class="slider-group">
<label>
Delay
<span class="value-display" id="delay-value">100</span>ms
</label>
<input type="range" class="slider" id="delay" min="10" max="1000" value="100" step="10">
</div>
</div>
<!-- Color Selection -->
<div class="card">
<h2>Colors</h2>
<div class="color-picker-group">
<input type="color" class="color-input" value="#000000">
<input type="color" class="color-input" value="#FF0000">
<input type="color" class="color-input" value="#00FF00">
<input type="color" class="color-input" value="#0000FF">
</div>
<div class="actions">
<button class="btn btn-secondary btn-full">Add Color</button>
</div>
</div>
<!-- Device Status -->
<div class="card">
<h2>Connected Devices</h2>
<ul class="device-list">
<li class="device-item">
<div>
<strong>led-device1</strong>
<div style="font-size: 0.875rem; color: #666;">Group: group1</div>
</div>
<div class="device-status"></div>
</li>
<li class="device-item">
<div>
<strong>led-device2</strong>
<div style="font-size: 0.875rem; color: #666;">Group: group2</div>
</div>
<div class="device-status"></div>
</li>
<li class="device-item">
<div>
<strong>led-device3</strong>
<div style="font-size: 0.875rem; color: #666;">No group</div>
</div>
<div class="device-status offline"></div>
</li>
</ul>
</div>
</div>
<!-- Action Buttons -->
<div class="card">
<div class="actions">
<button class="btn btn-primary btn-full">Apply Settings</button>
<button class="btn btn-secondary btn-full">Save to Device</button>
</div>
</div>
</div>
<script>
// Brightness slider
document.getElementById('brightness').addEventListener('input', function(e) {
document.getElementById('brightness-value').textContent = e.target.value;
});
// Delay slider
document.getElementById('delay').addEventListener('input', function(e) {
document.getElementById('delay-value').textContent = e.target.value;
});
// Pattern selection
document.querySelectorAll('.pattern-btn').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.pattern-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
});
});
// Initialize color pickers
const colorPickers = [];
const initialColors = ['#000000', '#FF0000'];
function addColorPicker(color = '#000000') {
const container = document.createElement('div');
container.className = 'color-picker-wrapper';
document.getElementById('color-pickers').appendChild(container);
const picker = new ColorPicker(container, {
initialColor: color,
onColorChange: (newColor) => {
console.log('Color changed:', newColor);
// Update device colors
}
});
colorPickers.push(picker);
return picker;
}
// Add initial color pickers
initialColors.forEach(color => addColorPicker(color));
</script>
<script src="color-picker.js"></script>
</body>
</html>

View File

@@ -0,0 +1,418 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LED Driver - Device Management</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
background: white;
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
color: #667eea;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.tabs {
display: flex;
gap: 8px;
margin-bottom: 24px;
background: white;
padding: 8px;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.tab {
flex: 1;
padding: 12px 24px;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.tab.active {
background: #667eea;
color: white;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.card {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
margin-bottom: 24px;
}
.card h2 {
color: #667eea;
margin-bottom: 20px;
}
.device-item, .group-item {
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.2s;
}
.device-item:hover, .group-item:hover {
border-color: #667eea;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.2);
}
.device-info, .group-info {
flex: 1;
}
.device-name, .group-name {
font-weight: 600;
font-size: 1.125rem;
margin-bottom: 4px;
}
.device-details, .group-details {
font-size: 0.875rem;
color: #666;
}
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
margin-right: 12px;
}
.status-online {
background: #d4edda;
color: #155724;
}
.status-offline {
background: #f8d7da;
color: #721c24;
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
background: #4caf50;
margin-right: 8px;
}
.status-indicator.offline {
background: #f44336;
}
.device-actions, .group-actions {
display: flex;
gap: 8px;
}
.btn-icon {
padding: 8px 12px;
border: 1px solid #e0e0e0;
background: white;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.btn-icon:hover {
border-color: #667eea;
color: #667eea;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
}
.form-group input, .form-group select {
width: 100%;
padding: 12px;
border: 1px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
}
.form-group input:focus, .form-group select:focus {
outline: none;
border-color: #667eea;
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 24px;
}
.btn-secondary {
background: #e0e0e0;
color: #333;
}
.btn-secondary:hover {
background: #d0d0d0;
}
.group-devices {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e0e0e0;
}
.group-device-tag {
display: inline-block;
padding: 4px 8px;
background: #f0f0f0;
border-radius: 4px;
font-size: 0.75rem;
margin-right: 8px;
margin-top: 8px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Device & Group Management</h1>
<button class="btn btn-primary" onclick="showAddDeviceModal()">+ Add Device</button>
</div>
<div class="tabs">
<button class="tab active" onclick="switchTab('devices')">Devices</button>
<button class="tab" onclick="switchTab('groups')">Groups</button>
</div>
<!-- Devices Tab -->
<div id="devices-tab" class="tab-content active">
<div class="card">
<h2>Connected Devices</h2>
<div class="device-item">
<div class="device-info">
<div class="device-name">
<span class="status-indicator"></span>
led-device1
</div>
<div class="device-details">
<span class="status-badge status-online">Online</span>
MAC: AA:BB:CC:DD:EE:01 | Group: group1 | Pattern: Rainbow
</div>
</div>
<div class="device-actions">
<button class="btn-icon" title="Edit">✏️</button>
<button class="btn-icon" title="Settings">⚙️</button>
<button class="btn-icon" title="Remove">🗑️</button>
</div>
</div>
<div class="device-item">
<div class="device-info">
<div class="device-name">
<span class="status-indicator"></span>
led-device2
</div>
<div class="device-details">
<span class="status-badge status-online">Online</span>
MAC: AA:BB:CC:DD:EE:02 | Group: group2 | Pattern: Chase
</div>
</div>
<div class="device-actions">
<button class="btn-icon" title="Edit">✏️</button>
<button class="btn-icon" title="Settings">⚙️</button>
<button class="btn-icon" title="Remove">🗑️</button>
</div>
</div>
<div class="device-item">
<div class="device-info">
<div class="device-name">
<span class="status-indicator offline"></span>
led-device3
</div>
<div class="device-details">
<span class="status-badge status-offline">Offline</span>
MAC: AA:BB:CC:DD:EE:03 | No group | Pattern: On
</div>
</div>
<div class="device-actions">
<button class="btn-icon" title="Edit">✏️</button>
<button class="btn-icon" title="Settings">⚙️</button>
<button class="btn-icon" title="Remove">🗑️</button>
</div>
</div>
</div>
</div>
<!-- Groups Tab -->
<div id="groups-tab" class="tab-content">
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2>Groups</h2>
<button class="btn btn-primary" onclick="showAddGroupModal()">+ Create Group</button>
</div>
<div class="group-item">
<div class="group-info">
<div class="group-name">group1</div>
<div class="group-details">
Pattern: On | Brightness: 100% | Delay: 100ms
</div>
<div class="group-devices">
<span class="group-device-tag">led-device1</span>
</div>
</div>
<div class="group-actions">
<button class="btn-icon" title="Edit">✏️</button>
<button class="btn-icon" title="Apply">▶️</button>
<button class="btn-icon" title="Delete">🗑️</button>
</div>
</div>
<div class="group-item">
<div class="group-info">
<div class="group-name">group2</div>
<div class="group-details">
Pattern: Chase | Brightness: 75% | Delay: 200ms
</div>
<div class="group-devices">
<span class="group-device-tag">led-device2</span>
</div>
</div>
<div class="group-actions">
<button class="btn-icon" title="Edit">✏️</button>
<button class="btn-icon" title="Apply">▶️</button>
<button class="btn-icon" title="Delete">🗑️</button>
</div>
</div>
</div>
</div>
</div>
<!-- Modal (simplified) -->
<div id="modal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 1000; align-items: center; justify-content: center;">
<div class="card" style="max-width: 500px; margin: 20px;">
<h2 id="modal-title">Add Device</h2>
<div class="form-group">
<label>Device Name</label>
<input type="text" id="device-name" placeholder="led-device4">
</div>
<div class="form-group">
<label>MAC Address</label>
<input type="text" id="device-mac" placeholder="AA:BB:CC:DD:EE:04">
</div>
<div class="form-group">
<label>Group</label>
<select id="device-group">
<option value="">No group</option>
<option value="group1">group1</option>
<option value="group2">group2</option>
</select>
</div>
<div class="form-actions">
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveDevice()">Save</button>
</div>
</div>
</div>
<script>
function switchTab(tab) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
event.target.classList.add('active');
document.getElementById(tab + '-tab').classList.add('active');
}
function showAddDeviceModal() {
document.getElementById('modal').style.display = 'flex';
document.getElementById('modal-title').textContent = 'Add Device';
}
function showAddGroupModal() {
document.getElementById('modal').style.display = 'flex';
document.getElementById('modal-title').textContent = 'Create Group';
}
function closeModal() {
document.getElementById('modal').style.display = 'none';
}
function saveDevice() {
alert('Device saved! (This is a mockup)');
closeModal();
}
</script>
</body>
</html>

155
docs/mockups/generate_images.py Executable file
View File

@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""
Generate images from HTML mockup files
Uses Playwright to render HTML and take screenshots
"""
import os
import sys
from pathlib import Path
try:
from playwright.sync_api import sync_playwright
PLAYWRIGHT_AVAILABLE = True
except ImportError:
PLAYWRIGHT_AVAILABLE = False
try:
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
SELENIUM_AVAILABLE = True
except ImportError:
SELENIUM_AVAILABLE = False
try:
from html2image import Html2Image
HTML2IMAGE_AVAILABLE = True
except ImportError:
HTML2IMAGE_AVAILABLE = False
def generate_with_playwright(html_file, output_file, width=1920, height=1080):
"""Generate image using Playwright"""
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={'width': width, 'height': height})
page.goto(f'file://{html_file.absolute()}')
# Wait for page to load
page.wait_for_timeout(1000)
page.screenshot(path=str(output_file), full_page=True)
browser.close()
print(f"✓ Generated {output_file.name} using Playwright")
def generate_with_selenium(html_file, output_file, width=1920, height=1080):
"""Generate image using Selenium"""
chrome_options = Options()
chrome_options.add_argument('--headless')
chrome_options.add_argument('--no-sandbox')
chrome_options.add_argument('--disable-dev-shm-usage')
chrome_options.add_argument(f'--window-size={width},{height}')
driver = webdriver.Chrome(options=chrome_options)
try:
driver.get(f'file://{html_file.absolute()}')
# Wait for page to load
import time
time.sleep(2)
driver.save_screenshot(str(output_file))
print(f"✓ Generated {output_file.name} using Selenium")
finally:
driver.quit()
def generate_with_html2image(html_file, output_file, width=1920, height=1080):
"""Generate image using html2image"""
hti = Html2Image(size=(width, height))
hti.screenshot(
html_file=str(html_file),
save_as=output_file.name,
size=(width, height)
)
print(f"✓ Generated {output_file.name} using html2image")
def generate_image(html_file, output_dir, width=1920, height=1080):
"""Generate image from HTML file using available method"""
html_path = Path(html_file)
output_path = output_dir / f"{html_path.stem}.png"
if PLAYWRIGHT_AVAILABLE:
try:
generate_with_playwright(html_path, output_path, width, height)
return True
except Exception as e:
print(f"Playwright failed: {e}, trying alternatives...")
if SELENIUM_AVAILABLE:
try:
generate_with_selenium(html_path, output_path, width, height)
return True
except Exception as e:
print(f"Selenium failed: {e}, trying alternatives...")
if HTML2IMAGE_AVAILABLE:
try:
generate_with_html2image(html_path, output_path, width, height)
return True
except Exception as e:
print(f"html2image failed: {e}")
return False
def main():
"""Main function to generate images from all HTML files"""
script_dir = Path(__file__).parent
output_dir = script_dir / "images"
output_dir.mkdir(exist_ok=True)
html_files = list(script_dir.glob("*.html"))
if not html_files:
print("No HTML files found in mockups directory")
return
print(f"Found {len(html_files)} HTML file(s)")
print(f"Output directory: {output_dir}")
print()
# Check available libraries
if not any([PLAYWRIGHT_AVAILABLE, SELENIUM_AVAILABLE, HTML2IMAGE_AVAILABLE]):
print("ERROR: No screenshot library available!")
print("\nPlease install one of the following:")
print(" pip install playwright && playwright install chromium")
print(" pip install selenium")
print(" pip install html2image")
sys.exit(1)
print("Available screenshot libraries:")
if PLAYWRIGHT_AVAILABLE:
print(" ✓ Playwright")
if SELENIUM_AVAILABLE:
print(" ✓ Selenium")
if HTML2IMAGE_AVAILABLE:
print(" ✓ html2image")
print()
# Generate images
success_count = 0
for html_file in html_files:
print(f"Generating image from {html_file.name}...")
if generate_image(html_file, output_dir):
success_count += 1
else:
print(f"✗ Failed to generate image from {html_file.name}")
print()
print(f"Successfully generated {success_count}/{len(html_files)} images")
print(f"Images saved to: {output_dir}")
if __name__ == "__main__":
main()

Binary file not shown.

After

Width:  |  Height:  |  Size: 625 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 585 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 904 KiB

136
docs/mockups/index.html Normal file
View File

@@ -0,0 +1,136 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LED Driver - UI Mockups</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 40px 20px;
display: flex;
align-items: center;
justify-content: center;
}
.container {
max-width: 800px;
width: 100%;
}
.header {
text-align: center;
color: white;
margin-bottom: 40px;
}
.header h1 {
font-size: 3rem;
margin-bottom: 12px;
}
.header p {
font-size: 1.25rem;
opacity: 0.9;
}
.mockups-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
}
.mockup-card {
background: white;
border-radius: 16px;
padding: 32px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
transition: all 0.3s;
text-decoration: none;
color: inherit;
display: block;
}
.mockup-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.3);
}
.mockup-icon {
font-size: 3rem;
margin-bottom: 16px;
}
.mockup-title {
font-size: 1.5rem;
font-weight: 600;
color: #667eea;
margin-bottom: 8px;
}
.mockup-description {
color: #666;
line-height: 1.6;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>LED Driver UI Mockups</h1>
<p>Example user interfaces for the LED driver system</p>
</div>
<div class="mockups-grid">
<a href="dashboard.html" class="mockup-card">
<div class="mockup-icon">📊</div>
<div class="mockup-title">Dashboard</div>
<div class="mockup-description">
Main control panel for managing LED patterns, brightness, colors, and device status.
</div>
</a>
<a href="pattern-selector.html" class="mockup-card">
<div class="mockup-icon">🎨</div>
<div class="mockup-title">Pattern Selector</div>
<div class="mockup-description">
Visual interface for selecting from available LED patterns: On, Off, Blink, Chase, Circle, Pulse, Rainbow, and Transition.
</div>
</a>
<a href="device-management.html" class="mockup-card">
<div class="mockup-icon">🔧</div>
<div class="mockup-title">Device Management</div>
<div class="mockup-description">
Manage connected LED devices and groups. View device status, assign groups, and configure device settings.
</div>
</a>
<a href="settings.html" class="mockup-card">
<div class="mockup-icon">⚙️</div>
<div class="mockup-title">Settings</div>
<div class="mockup-description">
Comprehensive settings panel for configuring LED pin, color order, pattern parameters, and network settings.
</div>
</a>
<a href="presets.html" class="mockup-card">
<div class="mockup-icon">💾</div>
<div class="mockup-title">Presets</div>
<div class="mockup-description">
Save, load, and manage preset configurations with pattern, colors, delay, and all N1-N8 parameters for quick pattern switching.
</div>
</a>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,310 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LED Driver - Pattern Selector</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
.header {
text-align: center;
margin-bottom: 40px;
color: #2d3748;
}
.header h1 {
font-size: 2.5rem;
margin-bottom: 8px;
}
.header p {
font-size: 1.125rem;
color: #718096;
}
.patterns-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 24px;
}
.pattern-card {
background: white;
border-radius: 16px;
padding: 24px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: all 0.3s;
border: 3px solid transparent;
}
.pattern-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
border-color: #667eea;
}
.pattern-card.selected {
border-color: #667eea;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.pattern-icon {
width: 100%;
height: 180px;
border-radius: 12px;
margin-bottom: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
background: #f7fafc;
position: relative;
overflow: hidden;
}
.pattern-card.selected .pattern-icon {
background: rgba(255, 255, 255, 0.2);
}
.pattern-name {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 8px;
}
.pattern-description {
font-size: 0.875rem;
color: #718096;
line-height: 1.5;
}
.pattern-card.selected .pattern-description {
color: rgba(255, 255, 255, 0.9);
}
.pattern-preview {
display: flex;
gap: 4px;
margin-top: 12px;
}
.preview-dot {
flex: 1;
height: 8px;
border-radius: 4px;
background: #e2e8f0;
}
.pattern-card.selected .preview-dot {
background: rgba(255, 255, 255, 0.5);
}
.actions {
margin-top: 40px;
text-align: center;
}
.btn {
padding: 16px 48px;
border: none;
border-radius: 12px;
font-size: 1.125rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
margin: 0 12px;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(102, 126, 234, 0.4);
}
.btn-secondary {
background: white;
color: #667eea;
border: 2px solid #667eea;
}
.btn-secondary:hover {
background: #f0f0ff;
}
/* Pattern-specific icons */
.icon-on { background: linear-gradient(135deg, #ffd700 0%, #ffed4e 100%); }
.icon-off { background: #2d3748; }
.icon-blink { background: linear-gradient(90deg, #ffd700 25%, #2d3748 25%, #2d3748 50%, #ffd700 50%); }
.icon-chase { background: linear-gradient(90deg, #ff0000 0%, #ff6666 50%, #ff0000 100%); }
.icon-circle { background: radial-gradient(circle, #00ff00 0%, #66ff66 50%, #00ff00 100%); }
.icon-pulse { background: radial-gradient(circle, #0000ff 0%, #6666ff 50%, #0000ff 100%); }
.icon-rainbow { background: linear-gradient(90deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #4b0082, #9400d3); }
.icon-transition { background: linear-gradient(135deg, #ff0000 0%, #0000ff 100%); }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Select LED Pattern</h1>
<p>Choose a pattern to display on your LED devices</p>
</div>
<div class="patterns-grid">
<div class="pattern-card" data-pattern="on">
<div class="pattern-icon icon-on">💡</div>
<div class="pattern-name">On</div>
<div class="pattern-description">Solid color display - LEDs stay on with selected color</div>
<div class="pattern-preview">
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
</div>
</div>
<div class="pattern-card" data-pattern="off">
<div class="pattern-icon icon-off"></div>
<div class="pattern-name">Off</div>
<div class="pattern-description">Turn all LEDs off</div>
<div class="pattern-preview">
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
</div>
</div>
<div class="pattern-card" data-pattern="blink">
<div class="pattern-icon icon-blink"></div>
<div class="pattern-name">Blink</div>
<div class="pattern-description">All LEDs blink on and off together</div>
<div class="pattern-preview">
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
</div>
</div>
<div class="pattern-card" data-pattern="chase">
<div class="pattern-icon icon-chase">🏃</div>
<div class="pattern-name">Chase</div>
<div class="pattern-description">Light chases along the LED strip</div>
<div class="pattern-preview">
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
</div>
</div>
<div class="pattern-card" data-pattern="circle">
<div class="pattern-icon icon-circle"></div>
<div class="pattern-name">Circle</div>
<div class="pattern-description">Circular pattern that rotates around the strip</div>
<div class="pattern-preview">
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
</div>
</div>
<div class="pattern-card" data-pattern="pulse">
<div class="pattern-icon icon-pulse">💓</div>
<div class="pattern-name">Pulse</div>
<div class="pattern-description">Pulsing effect that fades in and out</div>
<div class="pattern-preview">
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
</div>
</div>
<div class="pattern-card" data-pattern="rainbow">
<div class="pattern-icon icon-rainbow">🌈</div>
<div class="pattern-name">Rainbow</div>
<div class="pattern-description">Smooth rainbow color transition across LEDs</div>
<div class="pattern-preview">
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
</div>
</div>
<div class="pattern-card" data-pattern="transition">
<div class="pattern-icon icon-transition">🔄</div>
<div class="pattern-name">Transition</div>
<div class="pattern-description">Smooth color transition between selected colors</div>
<div class="pattern-preview">
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
</div>
</div>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="window.history.back()">Cancel</button>
<button class="btn btn-primary" id="apply-btn">Apply Pattern</button>
</div>
</div>
<script>
let selectedPattern = null;
document.querySelectorAll('.pattern-card').forEach(card => {
card.addEventListener('click', function() {
document.querySelectorAll('.pattern-card').forEach(c => c.classList.remove('selected'));
this.classList.add('selected');
selectedPattern = this.dataset.pattern;
});
});
document.getElementById('apply-btn').addEventListener('click', function() {
if (selectedPattern) {
alert(`Applying pattern: ${selectedPattern}`);
// In real implementation, this would send the pattern to the device
} else {
alert('Please select a pattern first');
}
});
</script>
</body>
</html>

968
docs/mockups/presets.html Normal file
View File

@@ -0,0 +1,968 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LED Driver - Presets</title>
<link rel="stylesheet" href="color-picker.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
color: #333;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
.header {
background: white;
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
color: #667eea;
margin-bottom: 8px;
}
.header p {
color: #666;
}
.controls {
background: white;
border-radius: 12px;
padding: 20px;
margin-bottom: 24px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
display: flex;
gap: 16px;
flex-wrap: wrap;
align-items: center;
}
.search-box {
flex: 1;
min-width: 200px;
padding: 12px;
border: 1px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
}
.search-box:focus {
outline: none;
border-color: #667eea;
}
.filter-group {
display: flex;
gap: 12px;
align-items: center;
}
.filter-select {
padding: 12px;
border: 1px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
background: white;
cursor: pointer;
}
.filter-select:focus {
outline: none;
border-color: #667eea;
}
.view-toggle {
display: flex;
gap: 8px;
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
}
.view-btn {
padding: 8px 16px;
border: none;
background: white;
cursor: pointer;
transition: all 0.2s;
}
.view-btn.active {
background: #667eea;
color: white;
}
.presets-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 24px;
margin-bottom: 24px;
}
.preset-card {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: all 0.3s;
cursor: pointer;
border: 3px solid transparent;
}
.preset-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
border-color: #667eea;
}
.preset-card.selected {
border-color: #667eea;
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
}
.preset-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 16px;
}
.preset-name {
font-size: 1.5rem;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.pattern-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
background: #667eea;
color: white;
margin-bottom: 12px;
}
.pattern-badge.on { background: #4caf50; }
.pattern-badge.off { background: #757575; }
.pattern-badge.blink { background: #ff9800; }
.pattern-badge.chase { background: #f44336; }
.pattern-badge.circle { background: #00bcd4; }
.pattern-badge.pulse { background: #e91e63; }
.pattern-badge.rainbow { background: linear-gradient(90deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #4b0082, #9400d3); }
.pattern-badge.transition { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
.color-preview {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.color-swatch {
width: 40px;
height: 40px;
border-radius: 8px;
border: 2px solid #e0e0e0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.preset-info {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 16px;
font-size: 0.875rem;
color: #666;
}
.info-item {
display: flex;
align-items: center;
gap: 8px;
}
.info-label {
font-weight: 500;
}
.info-value {
color: #667eea;
font-weight: 600;
}
.preset-actions {
display: flex;
gap: 8px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #e0e0e0;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
flex: 1;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.btn-secondary {
background: #e0e0e0;
color: #333;
}
.btn-secondary:hover {
background: #d0d0d0;
}
.btn-danger {
background: #f44336;
color: white;
}
.btn-danger:hover {
background: #d32f2f;
}
.btn-icon {
padding: 8px;
min-width: 40px;
}
.btn-large {
padding: 16px 32px;
font-size: 1rem;
}
/* Modal Styles */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
padding: 20px;
}
.modal.active {
display: flex;
}
.modal-content {
background: white;
border-radius: 12px;
padding: 32px;
max-width: 600px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.modal-header {
margin-bottom: 24px;
}
.modal-header h2 {
color: #667eea;
margin-bottom: 8px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
}
.form-group input,
.form-group select {
width: 100%;
padding: 12px;
border: 1px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #667eea;
}
.form-group small {
display: block;
margin-top: 4px;
color: #666;
font-size: 0.875rem;
}
.color-inputs {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.color-input-wrapper {
display: flex;
flex-direction: column;
gap: 8px;
}
.color-input-wrapper {
display: inline-block;
}
.params-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.n-value-input {
width: 100%;
}
.n-value-input:focus {
border-color: #667eea;
outline: none;
}
.param-input {
display: flex;
flex-direction: column;
}
.param-input label {
font-size: 0.75rem;
margin-bottom: 4px;
}
.param-input input {
padding: 8px;
font-size: 0.875rem;
}
.modal-actions {
display: flex;
gap: 12px;
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid #e0e0e0;
}
.empty-state {
background: white;
border-radius: 12px;
padding: 60px 40px;
text-align: center;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.empty-state-icon {
font-size: 4rem;
margin-bottom: 16px;
}
.empty-state h2 {
color: #667eea;
margin-bottom: 8px;
}
.empty-state p {
color: #666;
margin-bottom: 24px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div>
<h1>Preset Management</h1>
<p>Save and manage your favorite LED pattern configurations</p>
</div>
<div style="display: flex; gap: 12px; align-items: center;">
<button class="btn btn-secondary btn-large" onclick="syncPresets()" title="Sync all presets to all devices">🔄 Sync Presets to All Devices</button>
<button class="btn btn-primary btn-large" onclick="showCreateModal()">+ Create Preset</button>
</div>
</div>
<div class="controls">
<input type="text" class="search-box" placeholder="Search presets..." id="search-input">
<div class="filter-group">
<select class="filter-select" id="pattern-filter">
<option value="">All Patterns</option>
<option value="on">On</option>
<option value="off">Off</option>
<option value="blink">Blink</option>
<option value="chase">Chase</option>
<option value="circle">Circle</option>
<option value="pulse">Pulse</option>
<option value="rainbow">Rainbow</option>
<option value="transition">Transition</option>
</select>
<select class="filter-select" id="sort-select">
<option value="name">Sort by Name</option>
<option value="recent">Recently Used</option>
<option value="created">Recently Created</option>
</select>
<div class="view-toggle">
<button class="view-btn active" onclick="setView('grid')" id="view-grid">Grid</button>
<button class="view-btn" onclick="setView('list')" id="view-list">List</button>
</div>
</div>
</div>
<div class="presets-grid" id="presets-container">
<!-- Preset Card 1 -->
<div class="preset-card" data-pattern="rainbow" data-name="Fast Rainbow">
<div class="preset-header">
<div>
<div class="preset-name">Fast Rainbow</div>
<span class="pattern-badge rainbow">Rainbow</span>
</div>
</div>
<div class="color-preview">
<div class="color-swatch" style="background: #FF0000;"></div>
<div class="color-swatch" style="background: #00FF00;"></div>
<div class="color-swatch" style="background: #0000FF;"></div>
</div>
<div class="preset-info">
<div class="info-item">
<span class="info-label">Delay:</span>
<span class="info-value">30ms</span>
</div>
<div class="info-item">
<span class="info-label">N1:</span>
<span class="info-value">10</span>
</div>
</div>
<div class="preset-actions">
<button class="btn btn-primary" onclick="applyPreset('Fast Rainbow')">Apply</button>
<button class="btn btn-secondary btn-icon" onclick="editPreset('Fast Rainbow')" title="Edit">✏️</button>
<button class="btn btn-danger btn-icon" onclick="deletePreset('Fast Rainbow')" title="Delete">🗑️</button>
</div>
</div>
<!-- Preset Card 2 -->
<div class="preset-card" data-pattern="pulse" data-name="Slow Pulse">
<div class="preset-header">
<div>
<div class="preset-name">Slow Pulse</div>
<span class="pattern-badge pulse">Pulse</span>
</div>
</div>
<div class="color-preview">
<div class="color-swatch" style="background: #FF0000;"></div>
<div class="color-swatch" style="background: #0000FF;"></div>
</div>
<div class="preset-info">
<div class="info-item">
<span class="info-label">Delay:</span>
<span class="info-value">200ms</span>
</div>
<div class="info-item">
<span class="info-label">N1:</span>
<span class="info-value">500</span>
</div>
</div>
<div class="preset-actions">
<button class="btn btn-primary" onclick="applyPreset('Slow Pulse')">Apply</button>
<button class="btn btn-secondary btn-icon" onclick="editPreset('Slow Pulse')" title="Edit">✏️</button>
<button class="btn btn-danger btn-icon" onclick="deletePreset('Slow Pulse')" title="Delete">🗑️</button>
</div>
</div>
<!-- Preset Card 3 -->
<div class="preset-card" data-pattern="chase" data-name="Red Blue Chase">
<div class="preset-header">
<div>
<div class="preset-name">Red Blue Chase</div>
<span class="pattern-badge chase">Chase</span>
</div>
</div>
<div class="color-preview">
<div class="color-swatch" style="background: #FF0000;"></div>
<div class="color-swatch" style="background: #0000FF;"></div>
</div>
<div class="preset-info">
<div class="info-item">
<span class="info-label">Delay:</span>
<span class="info-value">100ms</span>
</div>
<div class="info-item">
<span class="info-label">N1:</span>
<span class="info-value">5</span>
</div>
</div>
<div class="preset-actions">
<button class="btn btn-primary" onclick="applyPreset('Red Blue Chase')">Apply</button>
<button class="btn btn-secondary btn-icon" onclick="editPreset('Red Blue Chase')" title="Edit">✏️</button>
<button class="btn btn-danger btn-icon" onclick="deletePreset('Red Blue Chase')" title="Delete">🗑️</button>
</div>
</div>
<!-- Preset Card 4 -->
<div class="preset-card" data-pattern="circle" data-name="Loading Circle">
<div class="preset-header">
<div>
<div class="preset-name">Loading Circle</div>
<span class="pattern-badge circle">Circle</span>
</div>
</div>
<div class="color-preview">
<div class="color-swatch" style="background: #00FF00;"></div>
</div>
<div class="preset-info">
<div class="info-item">
<span class="info-label">Delay:</span>
<span class="info-value">50ms</span>
</div>
<div class="info-item">
<span class="info-label">N1:</span>
<span class="info-value">50</span>
</div>
</div>
<div class="preset-actions">
<button class="btn btn-primary" onclick="applyPreset('Loading Circle')">Apply</button>
<button class="btn btn-secondary btn-icon" onclick="editPreset('Loading Circle')" title="Edit">✏️</button>
<button class="btn btn-danger btn-icon" onclick="deletePreset('Loading Circle')" title="Delete">🗑️</button>
</div>
</div>
<!-- Preset Card 5 -->
<div class="preset-card" data-pattern="blink" data-name="Party Blink">
<div class="preset-header">
<div>
<div class="preset-name">Party Blink</div>
<span class="pattern-badge blink">Blink</span>
</div>
</div>
<div class="color-preview">
<div class="color-swatch" style="background: #FF00FF;"></div>
<div class="color-swatch" style="background: #00FFFF;"></div>
<div class="color-swatch" style="background: #FFFF00;"></div>
</div>
<div class="preset-info">
<div class="info-item">
<span class="info-label">Delay:</span>
<span class="info-value">150ms</span>
</div>
<div class="info-item">
<span class="info-label">N1:</span>
<span class="info-value">0</span>
</div>
</div>
<div class="preset-actions">
<button class="btn btn-primary" onclick="applyPreset('Party Blink')">Apply</button>
<button class="btn btn-secondary btn-icon" onclick="editPreset('Party Blink')" title="Edit">✏️</button>
<button class="btn btn-danger btn-icon" onclick="deletePreset('Party Blink')" title="Delete">🗑️</button>
</div>
</div>
<!-- Preset Card 6 -->
<div class="preset-card" data-pattern="transition" data-name="Smooth Transition">
<div class="preset-header">
<div>
<div class="preset-name">Smooth Transition</div>
<span class="pattern-badge transition">Transition</span>
</div>
</div>
<div class="color-preview">
<div class="color-swatch" style="background: #FF0000;"></div>
<div class="color-swatch" style="background: #00FF00;"></div>
<div class="color-swatch" style="background: #0000FF;"></div>
<div class="color-swatch" style="background: #FFFF00;"></div>
</div>
<div class="preset-info">
<div class="info-item">
<span class="info-label">Delay:</span>
<span class="info-value">100ms</span>
</div>
<div class="info-item">
<span class="info-label">N1:</span>
<span class="info-value">0</span>
</div>
</div>
<div class="preset-actions">
<button class="btn btn-primary" onclick="applyPreset('Smooth Transition')">Apply</button>
<button class="btn btn-secondary btn-icon" onclick="editPreset('Smooth Transition')" title="Edit">✏️</button>
<button class="btn btn-danger btn-icon" onclick="deletePreset('Smooth Transition')" title="Delete">🗑️</button>
</div>
</div>
</div>
</div>
<!-- Create/Edit Preset Modal -->
<div class="modal" id="preset-modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="modal-title">Create Preset</h2>
<p>Configure your preset settings</p>
</div>
<form id="preset-form">
<div class="form-group">
<label for="preset-name">Preset Name *</label>
<input type="text" id="preset-name" required placeholder="Enter preset name">
<small>Unique identifier for this preset</small>
</div>
<div class="form-group">
<label for="preset-pattern">Pattern *</label>
<select id="preset-pattern" required>
<option value="on">On</option>
<option value="off">Off</option>
<option value="blink">Blink</option>
<option value="chase">Chase</option>
<option value="circle">Circle</option>
<option value="pulse">Pulse</option>
<option value="rainbow">Rainbow</option>
<option value="transition">Transition</option>
</select>
</div>
<div class="form-group">
<label>Colors *</label>
<div class="color-inputs" id="color-inputs">
<!-- Color pickers will be added here -->
</div>
<button type="button" class="btn btn-secondary" onclick="addColorPicker()" style="margin-top: 8px;">+ Add Color</button>
<small>Minimum 2 colors required</small>
</div>
<div class="form-group">
<label for="preset-delay">
Delay (ms) *
<span id="delay-value-display" style="margin-left: 12px; color: #667eea; font-weight: 600;">100</span>
</label>
<input type="range" id="preset-delay" min="10" max="1000" value="100" step="10" required>
<small>Animation speed (10-1000 milliseconds)</small>
</div>
<div class="form-group">
<label for="step-offset">Step Offset</label>
<input type="number" id="step-offset" value="0" min="-1000" max="1000">
<small>Step offset for group synchronization. Applied per device when preset is used in a group.</small>
</div>
<div class="form-group">
<label for="step-increment">Step Increment</label>
<input type="number" id="step-increment" value="1" min="1" max="255">
<small>Amount step counter increments per cycle. Controls pattern advancement speed.</small>
</div>
<div class="form-group">
<label>Pattern Parameters (N1-N8)</label>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<small style="margin: 0;">Pattern-specific parameters (0-255, varies by pattern)</small>
<button type="button" class="btn btn-secondary" onclick="setAllNValues(0)" style="padding: 6px 12px; font-size: 0.75rem;">Reset All to 0</button>
</div>
<div class="params-grid">
<div class="param-input">
<label>N1</label>
<input type="number" id="n1" min="0" max="255" value="0" class="n-value-input">
</div>
<div class="param-input">
<label>N2</label>
<input type="number" id="n2" min="0" max="255" value="0" class="n-value-input">
</div>
<div class="param-input">
<label>N3</label>
<input type="number" id="n3" min="0" max="255" value="0" class="n-value-input">
</div>
<div class="param-input">
<label>N4</label>
<input type="number" id="n4" min="0" max="255" value="0" class="n-value-input">
</div>
<div class="param-input">
<label>N5</label>
<input type="number" id="n5" min="0" max="255" value="0" class="n-value-input">
</div>
<div class="param-input">
<label>N6</label>
<input type="number" id="n6" min="0" max="255" value="0" class="n-value-input">
</div>
<div class="param-input">
<label>N7</label>
<input type="number" id="n7" min="0" max="255" value="0" class="n-value-input">
</div>
<div class="param-input">
<label>N8</label>
<input type="number" id="n8" min="0" max="255" value="0" class="n-value-input">
</div>
</div>
<div style="margin-top: 12px; display: flex; gap: 8px; flex-wrap: wrap;">
<button type="button" class="btn btn-secondary" onclick="setAllNValues(0)" style="padding: 8px 16px; font-size: 0.875rem;">Set All to 0</button>
<button type="button" class="btn btn-secondary" onclick="copyNValuesFromCurrent()" style="padding: 8px 16px; font-size: 0.875rem;">Copy from Current Settings</button>
<button type="button" class="btn btn-secondary" onclick="showNValueHelp()" style="padding: 8px 16px; font-size: 0.875rem;"> Parameter Help</button>
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save Preset</button>
</div>
</form>
</div>
</div>
<script>
let currentView = 'grid';
let editingPreset = null;
// Delay slider update
document.getElementById('preset-delay').addEventListener('input', function(e) {
document.getElementById('delay-value-display').textContent = e.target.value;
});
// Search functionality
document.getElementById('search-input').addEventListener('input', function(e) {
filterPresets();
});
// Filter functionality
document.getElementById('pattern-filter').addEventListener('change', function(e) {
filterPresets();
});
// Sort functionality
document.getElementById('sort-select').addEventListener('change', function(e) {
sortPresets(e.target.value);
});
function filterPresets() {
const search = document.getElementById('search-input').value.toLowerCase();
const patternFilter = document.getElementById('pattern-filter').value;
const cards = document.querySelectorAll('.preset-card');
cards.forEach(card => {
const name = card.dataset.name.toLowerCase();
const pattern = card.dataset.pattern;
const matchesSearch = name.includes(search);
const matchesPattern = !patternFilter || pattern === patternFilter;
if (matchesSearch && matchesPattern) {
card.style.display = '';
} else {
card.style.display = 'none';
}
});
}
function sortPresets(sortBy) {
const container = document.getElementById('presets-container');
const cards = Array.from(container.querySelectorAll('.preset-card'));
cards.sort((a, b) => {
if (sortBy === 'name') {
return a.dataset.name.localeCompare(b.dataset.name);
} else if (sortBy === 'recent') {
// In real implementation, would use actual usage data
return 0;
} else if (sortBy === 'created') {
// In real implementation, would use creation timestamps
return 0;
}
return 0;
});
cards.forEach(card => container.appendChild(card));
}
function setView(view) {
currentView = view;
const container = document.getElementById('presets-container');
const gridBtn = document.getElementById('view-grid');
const listBtn = document.getElementById('view-list');
if (view === 'grid') {
container.style.gridTemplateColumns = 'repeat(auto-fill, minmax(300px, 1fr))';
gridBtn.classList.add('active');
listBtn.classList.remove('active');
} else {
container.style.gridTemplateColumns = '1fr';
gridBtn.classList.remove('active');
listBtn.classList.add('active');
}
}
function showCreateModal() {
editingPreset = null;
document.getElementById('modal-title').textContent = 'Create Preset';
document.getElementById('preset-form').reset();
document.getElementById('preset-delay').value = 100;
document.getElementById('delay-value-display').textContent = '100';
// Reset to 2 colors
initializeColorPickers();
document.getElementById('preset-modal').classList.add('active');
}
// Initialize color pickers function (defined before showCreateModal)
const presetColorPickers = [];
function initializeColorPickers() {
const colorInputs = document.getElementById('color-inputs');
colorInputs.innerHTML = '';
presetColorPickers.length = 0;
addColorPicker('#FF0000');
addColorPicker('#0000FF');
}
function addColorPicker(color = '#00FF00') {
const colorInputs = document.getElementById('color-inputs');
const wrapper = document.createElement('div');
wrapper.className = 'color-input-wrapper';
colorInputs.appendChild(wrapper);
const picker = new ColorPicker(wrapper, {
initialColor: color,
onColorChange: (newColor) => {
console.log('Preset color changed:', newColor);
}
});
presetColorPickers.push(picker);
return picker;
}
function editPreset(name) {
editingPreset = name;
document.getElementById('modal-title').textContent = 'Edit Preset';
// In real implementation, would load preset data
document.getElementById('preset-name').value = name;
document.getElementById('preset-modal').classList.add('active');
}
function closeModal() {
document.getElementById('preset-modal').classList.remove('active');
editingPreset = null;
}
function applyPreset(name) {
alert(`Applying preset: ${name}\n(In real implementation, this would send preset configuration to device(s))`);
}
function deletePreset(name) {
if (confirm(`Delete preset "${name}"?`)) {
alert(`Preset "${name}" deleted\n(In real implementation, this would remove the preset from storage)`);
}
}
function syncPresets() {
if (confirm('Sync all presets to all devices?\nThis will send all presets from master to all devices via ESPNow.')) {
alert('Syncing presets to all devices...\n(In real implementation, this would send all presets via ESPNow to all devices)');
}
}
function setAllNValues(value) {
for (let i = 1; i <= 8; i++) {
document.getElementById(`n${i}`).value = value;
}
}
function copyNValuesFromCurrent() {
// In real implementation, this would copy from current device settings
alert('Copying N values from current device settings...\n(In real implementation, this would load current N1-N8 values from the active device)');
// Example: would set values like this:
// document.getElementById('n1').value = currentSettings.n1;
// ... etc
}
function showNValueHelp() {
const helpText = `
Pattern Parameter Guide:
Rainbow:
N1: Step increment (1-255, default: 1)
Pulse:
N1: Attack time in ms (0-255)
N2: Hold time in ms (0-255)
N3: Decay time in ms (0-255)
Chase:
N1: LEDs of color 0 (1-255)
N2: LEDs of color 1 (1-255)
N3: Step movement on odd steps (can be negative)
N4: Step movement on even steps (can be negative)
Circle:
N1: Head moves per second (1-255)
N2: Max length in LEDs (1-255)
N3: Tail moves per second (1-255)
N4: Min length in LEDs (0-255)
Other patterns:
N1-N8: Reserved for future pattern enhancements
All values range from 0-255 unless otherwise specified.
`;
alert(helpText);
}
// Form submission
document.getElementById('preset-form').addEventListener('submit', function(e) {
e.preventDefault();
const name = document.getElementById('preset-name').value;
const action = editingPreset ? 'updated' : 'created';
alert(`Preset "${name}" ${action}!\n(In real implementation, this would save the preset to storage)`);
closeModal();
});
// Close modal on outside click
document.getElementById('preset-modal').addEventListener('click', function(e) {
if (e.target === this) {
closeModal();
}
});
</script>
<script src="color-picker.js"></script>
</body>
</html>

491
docs/mockups/settings.html Normal file
View File

@@ -0,0 +1,491 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LED Driver - Settings</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1000px;
margin: 0 auto;
}
.header {
background: white;
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.header h1 {
color: #667eea;
margin-bottom: 8px;
}
.header p {
color: #666;
}
.card {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
margin-bottom: 24px;
}
.card h2 {
color: #667eea;
margin-bottom: 20px;
font-size: 1.5rem;
border-bottom: 2px solid #e0e0e0;
padding-bottom: 12px;
}
.form-group {
margin-bottom: 24px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
}
.form-group input[type="text"],
.form-group input[type="number"],
.form-group input[type="password"],
.form-group select {
width: 100%;
padding: 12px;
border: 1px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #667eea;
}
.form-group input[type="range"] {
width: 100%;
height: 8px;
border-radius: 4px;
background: #e0e0e0;
outline: none;
-webkit-appearance: none;
}
.form-group input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #667eea;
cursor: pointer;
}
.form-group input[type="range"]::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #667eea;
cursor: pointer;
border: none;
}
.value-display {
display: inline-block;
margin-left: 12px;
font-weight: 600;
color: #667eea;
min-width: 60px;
}
.form-group small {
display: block;
margin-top: 4px;
color: #666;
font-size: 0.875rem;
}
.checkbox-group {
display: flex;
align-items: center;
gap: 12px;
}
.checkbox-group input[type="checkbox"] {
width: 20px;
height: 20px;
cursor: pointer;
}
.color-order {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.color-order-option {
flex: 1;
min-width: 120px;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.color-order-option:hover {
border-color: #667eea;
}
.color-order-option.selected {
border-color: #667eea;
background: #667eea;
color: white;
}
.color-order-option .color-boxes {
display: flex;
justify-content: center;
gap: 4px;
margin-top: 8px;
}
.color-box {
width: 30px;
height: 30px;
border-radius: 4px;
}
.color-box.r { background: #ff0000; }
.color-box.g { background: #00ff00; }
.color-box.b { background: #0000ff; }
.actions {
display: flex;
gap: 12px;
margin-top: 32px;
padding-top: 24px;
border-top: 2px solid #e0e0e0;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.btn-secondary {
background: #e0e0e0;
color: #333;
}
.btn-secondary:hover {
background: #d0d0d0;
}
.btn-danger {
background: #f44336;
color: white;
}
.btn-danger:hover {
background: #d32f2f;
}
.btn-full {
flex: 1;
}
.section-divider {
height: 1px;
background: #e0e0e0;
margin: 24px 0;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Device Settings</h1>
<p>Configure your LED driver device settings</p>
</div>
<!-- Basic Settings -->
<div class="card">
<h2>Basic Settings</h2>
<div class="form-group">
<label>Device Name</label>
<input type="text" id="device-name" value="led-device1" placeholder="led-device1">
<small>Unique identifier for this device</small>
</div>
<div class="form-group">
<label>LED Pin</label>
<input type="number" id="led-pin" value="10" min="0" max="40">
<small>GPIO pin number connected to LED data line</small>
</div>
<div class="form-group">
<label>Number of LEDs</label>
<input type="number" id="num-leds" value="50" min="1" max="1000">
<small>Total number of LEDs in your strip</small>
</div>
<div class="form-group">
<label>Color Order</label>
<div class="color-order">
<div class="color-order-option selected" data-order="rgb">
RGB
<div class="color-boxes">
<div class="color-box r"></div>
<div class="color-box g"></div>
<div class="color-box b"></div>
</div>
</div>
<div class="color-order-option" data-order="rbg">
RBG
<div class="color-boxes">
<div class="color-box r"></div>
<div class="color-box b"></div>
<div class="color-box g"></div>
</div>
</div>
<div class="color-order-option" data-order="grb">
GRB
<div class="color-boxes">
<div class="color-box g"></div>
<div class="color-box r"></div>
<div class="color-box b"></div>
</div>
</div>
<div class="color-order-option" data-order="gbr">
GBR
<div class="color-boxes">
<div class="color-box g"></div>
<div class="color-box b"></div>
<div class="color-box r"></div>
</div>
</div>
<div class="color-order-option" data-order="brg">
BRG
<div class="color-boxes">
<div class="color-box b"></div>
<div class="color-box r"></div>
<div class="color-box g"></div>
</div>
</div>
<div class="color-order-option" data-order="bgr">
BGR
<div class="color-boxes">
<div class="color-box b"></div>
<div class="color-box g"></div>
<div class="color-box r"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Pattern Settings -->
<div class="card">
<h2>Pattern Settings</h2>
<div class="form-group">
<label>Pattern</label>
<select id="pattern">
<option value="on">On</option>
<option value="off">Off</option>
<option value="blink">Blink</option>
<option value="chase">Chase</option>
<option value="circle">Circle</option>
<option value="pulse">Pulse</option>
<option value="rainbow">Rainbow</option>
<option value="transition">Transition</option>
</select>
</div>
<div class="form-group">
<label>
Brightness
<span class="value-display" id="brightness-value">100</span>%
</label>
<input type="range" id="brightness" min="0" max="100" value="100">
</div>
<div class="form-group">
<label>
Delay
<span class="value-display" id="delay-value">100</span>ms
</label>
<input type="range" id="delay" min="10" max="1000" value="100" step="10">
</div>
</div>
<!-- Advanced Settings -->
<div class="card">
<h2>Advanced Settings</h2>
<div class="form-group">
<label>Step Counter</label>
<input type="text" id="step-counter" value="0" readonly style="background: #f5f5f5; cursor: not-allowed;">
<small>Current step position in pattern (read-only)</small>
</div>
<div class="form-group">
<label for="step-increment">
Step Increment
</label>
<input type="number" id="step-increment" value="1" min="1" max="255">
<small>Amount step counter increments per cycle. Controls pattern advancement speed.</small>
</div>
<div class="form-group">
<label>Pattern Parameters</label>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;">
<div>
<label style="font-size: 0.875rem;">N1</label>
<input type="number" id="n1" value="0" min="0" max="255">
</div>
<div>
<label style="font-size: 0.875rem;">N2</label>
<input type="number" id="n2" value="0" min="0" max="255">
</div>
<div>
<label style="font-size: 0.875rem;">N3</label>
<input type="number" id="n3" value="0" min="0" max="255">
</div>
<div>
<label style="font-size: 0.875rem;">N4</label>
<input type="number" id="n4" value="0" min="0" max="255">
</div>
<div>
<label style="font-size: 0.875rem;">N5</label>
<input type="number" id="n5" value="0" min="0" max="255">
</div>
<div>
<label style="font-size: 0.875rem;">N6</label>
<input type="number" id="n6" value="0" min="0" max="255">
</div>
</div>
<small>Pattern-specific parameters (varies by pattern)</small>
</div>
<div class="form-group">
<label>Device ID</label>
<input type="number" id="device-id" value="1" min="0">
<small>Unique numeric identifier</small>
</div>
<div class="form-group">
<div class="checkbox-group">
<input type="checkbox" id="debug" checked>
<label for="debug" style="margin: 0;">Debug Mode</label>
</div>
<small>Enable debug logging</small>
</div>
</div>
<!-- Network Settings -->
<div class="card">
<h2>Network Settings</h2>
<div class="form-group">
<label>Access Point Name</label>
<input type="text" id="ap-name" value="led-AA:BB:CC:DD:EE:01" placeholder="led-device">
<small>WiFi access point name for device configuration</small>
</div>
<div class="form-group">
<label>Access Point Password</label>
<input type="password" id="ap-password" placeholder="Leave empty for open network">
<small>Password for the access point (optional)</small>
</div>
<div class="form-group">
<div class="checkbox-group">
<input type="checkbox" id="ap-enabled" checked>
<label for="ap-enabled" style="margin: 0;">Enable Access Point</label>
</div>
<small>Allow device to create its own WiFi network</small>
</div>
</div>
<!-- Actions -->
<div class="card">
<div class="actions">
<button class="btn btn-secondary btn-full" onclick="resetSettings()">Reset to Defaults</button>
<button class="btn btn-primary btn-full" onclick="saveSettings()">Save Settings</button>
</div>
</div>
</div>
<script>
// Brightness slider
document.getElementById('brightness').addEventListener('input', function(e) {
document.getElementById('brightness-value').textContent = e.target.value;
});
// Delay slider
document.getElementById('delay').addEventListener('input', function(e) {
document.getElementById('delay-value').textContent = e.target.value;
});
// Color order selection
document.querySelectorAll('.color-order-option').forEach(option => {
option.addEventListener('click', function() {
document.querySelectorAll('.color-order-option').forEach(o => o.classList.remove('selected'));
this.classList.add('selected');
});
});
function saveSettings() {
alert('Settings saved! (This is a mockup)');
}
function resetSettings() {
if (confirm('Reset all settings to defaults?')) {
alert('Settings reset! (This is a mockup)');
}
}
</script>
</body>
</html>

23
docs/msg.json Normal file
View File

@@ -0,0 +1,23 @@
{
"g":{
"df": {
"pt": "on",
"cl": ["#ff0000"],
"br": 200,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10,
"dl": 100
},
"dj": {
"pt": "blink",
"cl": ["#00ff00"],
"dl": 500
}
},
"sv": true,
"st": 0
}

112
esp32/benchmark_peers.py Normal file
View File

@@ -0,0 +1,112 @@
# Benchmark: LRU eviction vs add-then-remove-after-use on ESP32.
# Run on device: mpremote run esp32/benchmark_peers.py
# (add/del_peer are timed; send() may fail if no peer is listening - timing still valid)
import espnow
import network
import time
BROADCAST = b"\xff\xff\xff\xff\xff\xff"
MAX_PEERS = 20
ITERATIONS = 50
PAYLOAD = b"x" * 32 # small payload
network.WLAN(network.STA_IF).active(True)
esp = espnow.ESPNow()
esp.active(True)
esp.add_peer(BROADCAST)
# Build 19 dummy MACs so we have 20 peers total (broadcast + 19).
def mac(i):
return bytes([0, 0, 0, 0, 0, i])
peers_list = [mac(i) for i in range(1, 20)]
for p in peers_list:
esp.add_peer(p)
# One "new" MAC we'll add/remove.
new_mac = bytes([0, 0, 0, 0, 0, 99])
def bench_lru():
"""LRU: ensure_peer (evict oldest + add new), send, update last_used."""
last_used = {BROADCAST: time.ticks_ms()}
for p in peers_list:
last_used[p] = time.ticks_ms()
# Pre-remove one so we have 19; ensure_peer(new) will add 20th.
esp.del_peer(peers_list[-1])
last_used.pop(peers_list[-1], None)
# Now 19 peers. Each iteration: ensure_peer(new) -> add_peer(new), send, update.
# Next iter: ensure_peer(new) -> already there, just send. So we need to force
# eviction each time: use a different "new" each time so we always evict+add.
t0 = time.ticks_us()
for i in range(ITERATIONS):
addr = bytes([0, 0, 0, 0, 0, 50 + (i % 30)]) # 30 different "new" MACs
peers = esp.get_peers()
peer_macs = [p[0] for p in peers]
if addr not in peer_macs:
if len(peer_macs) >= MAX_PEERS:
oldest_mac = None
oldest_ts = time.ticks_ms()
for m in peer_macs:
if m == BROADCAST:
continue
ts = last_used.get(m, 0)
if ts <= oldest_ts:
oldest_ts = ts
oldest_mac = m
if oldest_mac is not None:
esp.del_peer(oldest_mac)
last_used.pop(oldest_mac, None)
esp.add_peer(addr)
esp.send(addr, PAYLOAD)
last_used[addr] = time.ticks_ms()
t1 = time.ticks_us()
return time.ticks_diff(t1, t0)
def bench_add_then_remove():
"""Add peer, send, del_peer (remove after use). At 20 we must del one first."""
# Start full: 20 peers. To add new we del any one, add new, send, del new.
victim = peers_list[0]
t0 = time.ticks_us()
for i in range(ITERATIONS):
esp.del_peer(victim) # make room
esp.add_peer(new_mac)
esp.send(new_mac, PAYLOAD)
esp.del_peer(new_mac)
esp.add_peer(victim) # put victim back so we're at 20 again
t1 = time.ticks_us()
return time.ticks_diff(t1, t0)
def bench_send_existing():
"""Baseline: send to existing peer only (no add/del)."""
t0 = time.ticks_us()
for _ in range(ITERATIONS):
esp.send(peers_list[0], PAYLOAD)
t1 = time.ticks_us()
return time.ticks_diff(t1, t0)
print("ESP-NOW peer benchmark ({} iterations)".format(ITERATIONS))
print()
# Baseline: send to existing peer
try:
us = bench_send_existing()
print("Send to existing peer only: {:>8} us total {:>7.1f} us/iter".format(us, us / ITERATIONS))
except Exception as e:
print("Send existing failed:", e)
print()
# LRU: evict oldest then add new, send
try:
us = bench_lru()
print("LRU (evict oldest + add + send): {:>8} us total {:>7.1f} us/iter".format(us, us / ITERATIONS))
except Exception as e:
print("LRU failed:", e)
print()
# Add then remove after use
try:
us = bench_add_then_remove()
print("Add then remove after use: {:>8} us total {:>7.1f} us/iter".format(us, us / ITERATIONS))
except Exception as e:
print("Add-then-remove failed:", e)
print()
print("Done.")

72
esp32/main.py Normal file
View File

@@ -0,0 +1,72 @@
# Serial-to-ESP-NOW bridge: receives from Pi on UART, forwards to ESP-NOW peers.
# Wire format: first 6 bytes = destination MAC, rest = payload. Address is always 6 bytes.
from machine import Pin, UART
import espnow
import network
import time
UART_BAUD = 912000
BROADCAST = b"\xff\xff\xff\xff\xff\xff"
MAX_PEERS = 20
# Match led-driver / controller default settings wifi_channel (111)
WIFI_CHANNEL = 6
sta = network.WLAN(network.STA_IF)
sta.active(True)
sta.config(pm=network.WLAN.PM_NONE, channel=WIFI_CHANNEL)
print("WiFi STA channel:", sta.config("channel"), "(WIFI_CHANNEL=%s)" % WIFI_CHANNEL)
esp = espnow.ESPNow()
esp.active(True)
esp.add_peer(BROADCAST)
uart = UART(1, UART_BAUD, tx=Pin(21), rx=Pin(6))
# Track last send time per peer for LRU eviction (remove oldest when at limit).
last_used = {BROADCAST: time.ticks_ms()}
# ESP_ERR_ESPNOW_EXIST: peer already registered (ignore when adding).
ESP_ERR_ESPNOW_EXIST = -12395
def ensure_peer(addr):
"""Ensure addr is in the peer list. When at 20 peers, remove the oldest-used (LRU)."""
peers = esp.get_peers()
peer_macs = [p[0] for p in peers]
if addr in peer_macs:
return
if len(peer_macs) >= MAX_PEERS:
# Remove the peer we used least recently (oldest).
oldest_mac = None
oldest_ts = time.ticks_ms()
for mac in peer_macs:
if mac == BROADCAST:
continue
ts = last_used.get(mac, 0)
if ts <= oldest_ts:
oldest_ts = ts
oldest_mac = mac
if oldest_mac is not None:
esp.del_peer(oldest_mac)
last_used.pop(oldest_mac, None)
try:
esp.add_peer(addr)
except OSError as e:
if e.args[0] != ESP_ERR_ESPNOW_EXIST:
raise
print("Starting ESP32 main.py")
while True:
if uart.any():
data = uart.read()
if not data or len(data) < 6:
continue
print(f"Received data: {data}")
addr = data[:6]
payload = data[6:]
ensure_peer(addr)
esp.send(addr, payload)
last_used[addr] = time.ticks_ms()

1
led-driver Submodule

Submodule led-driver added at c42dff8975

1
led-tool Submodule

Submodule led-tool added at 3844aa9d6a

225
lib/microdot/session.py Normal file
View File

@@ -0,0 +1,225 @@
try:
import jwt
HAS_JWT = True
except ImportError:
HAS_JWT = False
try:
import ubinascii
except ImportError:
import binascii as ubinascii
try:
import uhashlib as hashlib
except ImportError:
import hashlib
try:
import uhmac as hmac
except ImportError:
try:
import hmac
except ImportError:
hmac = None
import json
from microdot.microdot import invoke_handler
from microdot.helpers import wraps
class SessionDict(dict):
"""A session dictionary.
The session dictionary is a standard Python dictionary that has been
extended with convenience ``save()`` and ``delete()`` methods.
"""
def __init__(self, request, session_dict):
super().__init__(session_dict)
self.request = request
def save(self):
"""Update the session cookie."""
self.request.app._session.update(self.request, self)
def delete(self):
"""Delete the session cookie."""
self.request.app._session.delete(self.request)
class Session:
"""Session handling
:param app: The application instance.
:param secret_key: The secret key, as a string or bytes object.
:param cookie_options: A dictionary with cookie options to pass as
arguments to :meth:`Response.set_cookie()
<microdot.Response.set_cookie>`.
"""
secret_key = None
def __init__(self, app=None, secret_key=None, cookie_options=None):
self.secret_key = secret_key
self.cookie_options = cookie_options or {}
if app is not None:
self.initialize(app)
def initialize(self, app, secret_key=None, cookie_options=None):
if secret_key is not None:
self.secret_key = secret_key
if cookie_options is not None:
self.cookie_options = cookie_options
if 'path' not in self.cookie_options:
self.cookie_options['path'] = '/'
if 'http_only' not in self.cookie_options:
self.cookie_options['http_only'] = True
app._session = self
def get(self, request):
"""Retrieve the user session.
:param request: The client request.
The return value is a session dictionary with the data stored in the
user's session, or ``{}`` if the session data is not available or
invalid.
"""
if not self.secret_key:
raise ValueError('The session secret key is not configured')
if hasattr(request.g, '_session'):
return request.g._session
session = request.cookies.get('session')
if session is None:
request.g._session = SessionDict(request, {})
return request.g._session
request.g._session = SessionDict(request, self.decode(session))
return request.g._session
def update(self, request, session):
"""Update the user session.
:param request: The client request.
:param session: A dictionary with the update session data for the user.
Applications would normally not call this method directly, instead they
would use the :meth:`SessionDict.save` method on the session
dictionary, which calls this method. For example::
@app.route('/')
@with_session
def index(request, session):
session['foo'] = 'bar'
session.save()
return 'Hello, World!'
Calling this method adds a cookie with the updated session to the
request currently being processed.
"""
if not self.secret_key:
raise ValueError('The session secret key is not configured')
encoded_session = self.encode(session)
@request.after_request
def _update_session(request, response):
response.set_cookie('session', encoded_session,
**self.cookie_options)
return response
def delete(self, request):
"""Remove the user session.
:param request: The client request.
Applications would normally not call this method directly, instead they
would use the :meth:`SessionDict.delete` method on the session
dictionary, which calls this method. For example::
@app.route('/')
@with_session
def index(request, session):
session.delete()
return 'Hello, World!'
Calling this method adds a cookie removal header to the request
currently being processed.
"""
@request.after_request
def _delete_session(request, response):
response.delete_cookie('session', **self.cookie_options)
return response
def encode(self, payload, secret_key=None):
"""Encode session data using JWT if available, otherwise use simple HMAC."""
if HAS_JWT:
return jwt.encode(payload, secret_key or self.secret_key,
algorithm='HS256')
else:
# Simple encoding for MicroPython: base64(json) + HMAC signature
key = (secret_key or self.secret_key).encode() if isinstance(secret_key or self.secret_key, str) else (secret_key or self.secret_key)
payload_json = json.dumps(payload)
payload_b64 = ubinascii.b2a_base64(payload_json.encode()).decode().strip()
# Create HMAC signature
if hmac:
# Use hmac module if available
h = hmac.new(key, payload_json.encode(), hashlib.sha256)
else:
# Fallback: simple SHA256(key + message)
h = hashlib.sha256(key + payload_json.encode())
signature = ubinascii.b2a_base64(h.digest()).decode().strip()
return f"{payload_b64}.{signature}"
def decode(self, session, secret_key=None):
"""Decode session data using JWT if available, otherwise use simple HMAC."""
if HAS_JWT:
try:
payload = jwt.decode(session, secret_key or self.secret_key,
algorithms=['HS256'])
except jwt.exceptions.PyJWTError: # pragma: no cover
return {}
return payload
else:
try:
# Simple decoding for MicroPython
if '.' not in session:
return {}
payload_b64, signature = session.rsplit('.', 1)
payload_json = ubinascii.a2b_base64(payload_b64).decode()
# Verify HMAC signature
key = (secret_key or self.secret_key).encode() if isinstance(secret_key or self.secret_key, str) else (secret_key or self.secret_key)
if hmac:
# Use hmac module if available
h = hmac.new(key, payload_json.encode(), hashlib.sha256)
else:
# Fallback: simple SHA256(key + message)
h = hashlib.sha256(key + payload_json.encode())
expected_signature = ubinascii.b2a_base64(h.digest()).decode().strip()
if signature != expected_signature:
return {}
return json.loads(payload_json)
except Exception:
return {}
def with_session(f):
"""Decorator that passes the user session to the route handler.
The session dictionary is passed to the decorated function as an argument
after the request object. Example::
@app.route('/')
@with_session
def index(request, session):
return 'Hello, World!'
Note that the decorator does not save the session. To update the session,
call the :func:`session.save() <microdot.session.SessionDict.save>` method.
"""
@wraps(f)
async def wrapper(request, *args, **kwargs):
return await invoke_handler(
f, request, request.app._session.get(request), *args, **kwargs)
return wrapper

4
pytest.ini Normal file
View File

@@ -0,0 +1,4 @@
[pytest]
testpaths = tests
python_files = test_endpoints_pytest.py

19
scripts/build_help_pdf.sh Executable file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env sh
# Build docs/help.pdf from docs/help.md.
# Requires: pandoc, chromium (headless print-to-PDF).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# HTML next to docs/help.md so relative image paths (e.g. images/help/*.svg) resolve.
HTML="$ROOT/docs/.help-print.html"
trap 'rm -f "$HTML"' EXIT
pandoc "$ROOT/docs/help.md" -s \
--css="$ROOT/scripts/help-pdf.css" \
--metadata title="LED controller — user guide" \
-o "$HTML"
chromium --headless --no-sandbox --disable-gpu \
--print-to-pdf="$ROOT/docs/help.pdf" \
"file://${HTML}"
echo "Wrote $ROOT/docs/help.pdf ($(wc -c < "$ROOT/docs/help.pdf") bytes)"

4
scripts/cp-esp32-main.sh Normal file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env bash
# Copy esp32/main.py to the connected ESP32 as /main.py (single line, no wrap).
cd "$(dirname "$0")/.."
pipenv run mpremote fs cp esp32/main.py :/main.py

96
scripts/help-pdf.css Normal file
View File

@@ -0,0 +1,96 @@
/* Print stylesheet for docs/help.md → PDF (Chromium headless) */
@page {
margin: 18mm;
size: A4;
}
html {
font-size: 11pt;
line-height: 1.4;
}
body {
font-family: "DejaVu Sans", "Liberation Sans", Helvetica, Arial, sans-serif;
color: #222;
max-width: 100%;
}
h1 {
font-size: 1.45rem;
border-bottom: 2px solid #333;
padding-bottom: 0.25em;
margin-top: 0;
}
h2 {
font-size: 1.15rem;
margin-top: 1.25em;
page-break-after: avoid;
}
h3 {
font-size: 1.05rem;
margin-top: 1em;
page-break-after: avoid;
}
code {
font-family: "DejaVu Sans Mono", "Liberation Mono", Consolas, monospace;
font-size: 0.92em;
background: #f3f3f3;
padding: 0.1em 0.35em;
border-radius: 3px;
}
pre {
font-family: "DejaVu Sans Mono", "Liberation Mono", Consolas, monospace;
font-size: 0.88em;
background: #f5f5f5;
border: 1px solid #ddd;
padding: 0.65em 0.85em;
overflow-x: auto;
page-break-inside: avoid;
}
pre code {
background: none;
padding: 0;
}
table {
border-collapse: collapse;
width: 100%;
margin: 0.75em 0;
font-size: 0.95em;
page-break-inside: avoid;
}
th, td {
border: 1px solid #bbb;
padding: 6px 8px;
text-align: left;
vertical-align: top;
}
th {
background: #eee;
}
a {
color: #1a5276;
text-decoration: none;
}
hr {
border: none;
border-top: 1px solid #ccc;
margin: 1.25em 0;
}
ul, ol {
padding-left: 1.35em;
}
li {
margin: 0.2em 0;
}
/* Images in docs/help.md */
img {
max-width: 100%;
height: auto;
page-break-inside: avoid;
border: 1px solid #ccc;
border-radius: 4px;
}
p.help-figure-caption {
font-size: 0.9em;
color: #555;
margin: 0.35em 0 1em 0;
line-height: 1.35;
}

20
scripts/install-boot-service.sh Executable file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Install systemd service so LED controller starts at boot.
# Run once: sudo scripts/install-boot-service.sh
set -e
cd "$(dirname "$0")/.."
REPO="$(pwd)"
SERVICE_NAME="led-controller.service"
UNIT_PATH="/etc/systemd/system/$SERVICE_NAME"
if [ ! -f "scripts/led-controller.service" ]; then
echo "Run this script from the repo root."
exit 1
fi
chmod +x scripts/start.sh
sudo cp "scripts/led-controller.service" "$UNIT_PATH"
sudo systemctl daemon-reload
sudo systemctl enable "$SERVICE_NAME"
echo "Installed and enabled $SERVICE_NAME"
echo "Start now: sudo systemctl start $SERVICE_NAME"
echo "Status: sudo systemctl status $SERVICE_NAME"
echo "Logs: journalctl -u $SERVICE_NAME -f"

View File

@@ -0,0 +1,17 @@
[Unit]
Description=LED Controller web server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi/led-controller
Environment=PORT=80
Environment=PATH=/home/pi/.local/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=/bin/bash /home/pi/led-controller/scripts/start.sh
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target

35
scripts/setup-port80.sh Executable file
View File

@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Allow the app to bind to port 80 without root.
# Run once: sudo scripts/setup-port80.sh (from repo root)
# Or: scripts/setup-port80.sh (will prompt for sudo only for setcap)
set -e
cd "$(dirname "$0")/.."
REPO_ROOT="$(pwd)"
# If run under sudo, use the invoking user's pipenv so the venv is found
if [ -n "$SUDO_USER" ]; then
VENV="$(sudo -u "$SUDO_USER" bash -c "cd '$REPO_ROOT' && pipenv --venv" 2>/dev/null)" || true
else
VENV="$(pipenv --venv 2>/dev/null)" || true
fi
if [ -z "$VENV" ]; then
echo "Run 'pipenv install' first, then run this script again."
exit 1
fi
PYTHON="${VENV}/bin/python3"
if [ ! -f "$PYTHON" ]; then
PYTHON="${VENV}/bin/python"
fi
if [ ! -f "$PYTHON" ]; then
echo "Python not found in venv: $VENV"
exit 1
fi
# Use the real binary (setcap can fail on symlinks or some filesystems)
REAL_PYTHON="$(readlink -f "$PYTHON" 2>/dev/null)" || REAL_PYTHON="$PYTHON"
if sudo setcap 'cap_net_bind_service=+ep' "$REAL_PYTHON" 2>/dev/null; then
echo "OK: port 80 enabled for $REAL_PYTHON"
echo "Start the app with: pipenv run run"
else
echo "setcap failed on $REAL_PYTHON"
exit 1
fi

5
scripts/start.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# Start the LED controller web server (port 80 by default).
cd "$(dirname "$0")/.."
export PORT="${PORT:-80}"
pipenv run run

33
scripts/test-port80.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Test the app on port 80. Run after: sudo scripts/setup-port80.sh
# Usage: ./scripts/test-port80.sh
set -e
cd "$(dirname "$0")/.."
APP_URL="${APP_URL:-http://127.0.0.1:80}"
echo "Starting app on port 80 in background..."
pipenv run run &
PID=$!
trap "kill $PID 2>/dev/null; exit" EXIT
echo "Waiting for server to start..."
for i in 1 2 3 4 5 6 7 8 9 10; do
if curl -s -o /dev/null -w "%{http_code}" "$APP_URL/" 2>/dev/null | grep -q 200; then
echo "Server is up."
break
fi
sleep 1
done
echo "Requesting $APP_URL/ ..."
CODE=$(curl -s -o /dev/null -w "%{http_code}" "$APP_URL/")
if [ "$CODE" = "200" ]; then
echo "OK: GET / returned HTTP $CODE"
curl -s "$APP_URL/" | head -5
echo "..."
exit 0
else
echo "FAIL: GET / returned HTTP $CODE (expected 200)"
exit 1
fi

View File

@@ -1,19 +1,6 @@
import wifi
import time
# Boot script (ESP only; no-op on Pi)
import settings # noqa: F401
from settings import Settings
print(wifi.ap('qwerty'))
settings = Settings()
ssid = settings.get('wifi', {}).get('ssid', None)
password = settings.get('wifi', {}).get('password', None)
ip = settings.get('wifi', {}).get('ip', None)
gateway = settings.get('wifi', {}).get('gateway', None)
for i in range(10):
config = wifi.connect(ssid, password, ip, gateway)
if config:
print(config)
break
time.sleep(0.1)
s = Settings()
# AP setup was here when running on ESP; Pi uses system networking.

View File

@@ -0,0 +1 @@
# Controllers package

68
src/controllers/device.py Normal file
View File

@@ -0,0 +1,68 @@
from microdot import Microdot
from models.device import Device
import json
controller = Microdot()
devices = Device()
@controller.get("")
async def list_devices(request):
"""List all devices."""
devices_data = {}
for dev_id in devices.list():
d = devices.read(dev_id)
if d:
devices_data[dev_id] = d
return json.dumps(devices_data), 200, {"Content-Type": "application/json"}
@controller.get("/<id>")
async def get_device(request, id):
"""Get a device by ID."""
dev = devices.read(id)
if dev:
return json.dumps(dev), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Device not found"}), 404
@controller.post("")
async def create_device(request):
"""Create a new device."""
try:
data = request.json or {}
name = data.get("name", "").strip()
address = data.get("address")
default_pattern = data.get("default_pattern")
tabs = data.get("tabs")
if isinstance(tabs, list):
tabs = [str(t) for t in tabs]
else:
tabs = []
dev_id = devices.create(name=name, address=address, default_pattern=default_pattern, tabs=tabs)
dev = devices.read(dev_id)
return json.dumps({dev_id: dev}), 201, {"Content-Type": "application/json"}
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.put("/<id>")
async def update_device(request, id):
"""Update a device."""
try:
data = request.json or {}
if "tabs" in data and isinstance(data["tabs"], list):
data["tabs"] = [str(t) for t in data["tabs"]]
if devices.update(id, data):
return json.dumps(devices.read(id)), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Device not found"}), 404
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.delete("/<id>")
async def delete_device(request, id):
"""Delete a device."""
if devices.delete(id):
return json.dumps({"message": "Device deleted successfully"}), 200
return json.dumps({"error": "Device not found"}), 404

50
src/controllers/group.py Normal file
View File

@@ -0,0 +1,50 @@
from microdot import Microdot
from models.group import Group
import json
controller = Microdot()
groups = Group()
@controller.get('')
async def list_groups(request):
"""List all groups."""
return json.dumps(groups), 200, {'Content-Type': 'application/json'}
@controller.get('/<id>')
async def get_group(request, id):
"""Get a specific group by ID."""
group = groups.read(id)
if group:
return json.dumps(group), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Group not found"}), 404
@controller.post('')
async def create_group(request):
"""Create a new group."""
try:
data = request.json or {}
name = data.get("name", "")
group_id = groups.create(name)
if data:
groups.update(group_id, data)
return json.dumps(groups.read(group_id)), 201, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.put('/<id>')
async def update_group(request, id):
"""Update an existing group."""
try:
data = request.json
if groups.update(id, data):
return json.dumps(groups.read(id)), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Group not found"}), 404
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.delete('/<id>')
async def delete_group(request, id):
"""Delete a group."""
if groups.delete(id):
return json.dumps({"message": "Group deleted successfully"}), 200
return json.dumps({"error": "Group not found"}), 404

View File

@@ -0,0 +1,58 @@
from microdot import Microdot
from models.pallet import Palette
import json
controller = Microdot()
palettes = Palette()
@controller.get('')
async def list_palettes(request):
"""List all palettes."""
data = {}
for pid in palettes.list():
colors = palettes.read(pid)
data[pid] = colors
return json.dumps(data), 200, {'Content-Type': 'application/json'}
@controller.get('/<id>')
async def get_palette(request, id):
"""Get a specific palette by ID."""
if str(id) in palettes:
palette = palettes.read(id)
return json.dumps({"colors": palette or [], "id": str(id)}), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Palette not found"}), 404
@controller.post('')
async def create_palette(request):
"""Create a new palette."""
try:
data = request.json or {}
colors = data.get("colors", None)
# Palette no longer needs a name; only colors are stored.
palette_id = palettes.create("", colors)
created_colors = palettes.read(palette_id) or []
return json.dumps({"id": str(palette_id), "colors": created_colors}), 201, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.put('/<id>')
async def update_palette(request, id):
"""Update an existing palette."""
try:
data = request.json or {}
# Ignore any name field; only colors are relevant.
if "name" in data:
data.pop("name", None)
if palettes.update(id, data):
colors = palettes.read(id) or []
return json.dumps({"id": str(id), "colors": colors}), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Palette not found"}), 404
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.delete('/<id>')
async def delete_palette(request, id):
"""Delete a palette."""
if palettes.delete(id):
return json.dumps({"message": "Palette deleted successfully"}), 200
return json.dumps({"error": "Palette not found"}), 404

View File

@@ -0,0 +1,89 @@
from microdot import Microdot
from models.pattern import Pattern
import json
import sys
controller = Microdot()
patterns = Pattern()
def load_pattern_definitions():
"""Load pattern definitions from pattern.json file."""
try:
# Try different paths for local development vs MicroPython
paths = ['db/pattern.json', 'pattern.json', '/db/pattern.json']
for path in paths:
try:
with open(path, 'r') as f:
return json.load(f)
except OSError:
continue
return {}
except Exception as e:
print(f"Error loading pattern.json: {e}")
return {}
@controller.get('/definitions')
async def get_pattern_definitions(request):
"""Get pattern definitions from pattern.json."""
definitions = load_pattern_definitions()
return json.dumps(definitions), 200, {'Content-Type': 'application/json'}
@controller.get('')
async def list_patterns(request):
"""List all patterns."""
return json.dumps(patterns), 200, {'Content-Type': 'application/json'}
@controller.get('/<id>')
async def get_pattern(request, id):
"""Get a specific pattern by ID."""
pattern = patterns.read(id)
if pattern is not None:
return json.dumps(pattern), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Pattern not found"}), 404
@controller.post('')
async def create_pattern(request):
"""Create a new pattern."""
try:
payload = request.json or {}
name = payload.get("name", "")
pattern_data = payload.get("data", {})
# IMPORTANT:
# `patterns.create()` stores `pattern_data` as the underlying dict value.
# If we then call `patterns.update(pattern_id, payload)` with the full
# request object, it may assign `payload["data"]` back onto that same
# dict object, creating a circular reference (json.dumps fails).
pattern_id = patterns.create(name, pattern_data)
# Only merge "extra" metadata fields (anything except name/data).
extra = dict(payload)
extra.pop("name", None)
extra.pop("data", None)
if extra:
patterns.update(pattern_id, extra)
return json.dumps(patterns.read(pattern_id)), 201, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.put('/<id>')
async def update_pattern(request, id):
"""Update an existing pattern."""
try:
data = request.json
if patterns.update(id, data):
return json.dumps(patterns.read(id)), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Pattern not found"}), 404
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.delete('/<id>')
async def delete_pattern(request, id):
"""Delete a pattern."""
if patterns.delete(id):
return json.dumps({"message": "Pattern deleted successfully"}), 200
return json.dumps({"error": "Pattern not found"}), 404

223
src/controllers/preset.py Normal file
View File

@@ -0,0 +1,223 @@
from microdot import Microdot
from microdot.session import with_session
from models.preset import Preset
from models.profile import Profile
from models.transport import get_current_sender
from util.espnow_message import build_message, build_preset_dict
import asyncio
import json
controller = Microdot()
presets = Preset()
profiles = Profile()
def get_current_profile_id(session=None):
"""Get the current active profile ID from session or fallback to first."""
profile_list = profiles.list()
session_profile = None
if session is not None:
session_profile = session.get('current_profile')
if session_profile and session_profile in profile_list:
return session_profile
if profile_list:
return profile_list[0]
return None
@controller.get('')
@with_session
async def list_presets(request, session):
"""List presets for the current profile."""
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return json.dumps({}), 200, {'Content-Type': 'application/json'}
scoped = {
pid: pdata for pid, pdata in presets.items()
if isinstance(pdata, dict) and str(pdata.get("profile_id")) == str(current_profile_id)
}
return json.dumps(scoped), 200, {'Content-Type': 'application/json'}
@controller.get('/<preset_id>')
@with_session
async def get_preset(request, session, preset_id):
"""Get a specific preset by ID (current profile only)."""
preset = presets.read(preset_id)
current_profile_id = get_current_profile_id(session)
if preset and str(preset.get("profile_id")) == str(current_profile_id):
return json.dumps(preset), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Preset not found"}), 404
@controller.post('')
@with_session
async def create_preset(request, session):
"""Create a new preset for the current profile."""
try:
try:
data = request.json or {}
except Exception:
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return json.dumps({"error": "No profile available"}), 404
preset_id = presets.create(current_profile_id)
if not isinstance(data, dict):
data = {}
data = dict(data)
data["profile_id"] = str(current_profile_id)
if presets.update(preset_id, data):
preset_data = presets.read(preset_id)
return json.dumps({preset_id: preset_data}), 201, {'Content-Type': 'application/json'}
return json.dumps({"error": "Failed to create preset"}), 400
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.put('/<preset_id>')
@with_session
async def update_preset(request, session, preset_id):
"""Update an existing preset (current profile only)."""
try:
preset = presets.read(preset_id)
current_profile_id = get_current_profile_id(session)
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
return json.dumps({"error": "Preset not found"}), 404
try:
data = request.json or {}
except Exception:
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
if not isinstance(data, dict):
data = {}
data = dict(data)
data["profile_id"] = str(current_profile_id)
if presets.update(preset_id, data):
return json.dumps(presets.read(preset_id)), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Preset not found"}), 404
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.delete('/<preset_id>')
@with_session
async def delete_preset(request, *args, **kwargs):
"""Delete a preset (current profile only)."""
# Be tolerant of wrapper/arg-order variations.
session = None
preset_id = None
if len(args) > 0:
session = args[0]
if len(args) > 1:
preset_id = args[1]
if 'session' in kwargs and kwargs.get('session') is not None:
session = kwargs.get('session')
if 'preset_id' in kwargs and kwargs.get('preset_id') is not None:
preset_id = kwargs.get('preset_id')
if 'id' in kwargs and kwargs.get('id') is not None and preset_id is None:
preset_id = kwargs.get('id')
if preset_id is None:
return json.dumps({"error": "Preset ID is required"}), 400
preset = presets.read(preset_id)
current_profile_id = get_current_profile_id(session)
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
return json.dumps({"error": "Preset not found"}), 404
if presets.delete(preset_id):
return json.dumps({"message": "Preset deleted successfully"}), 200
return json.dumps({"error": "Preset not found"}), 404
@controller.post('/send')
@with_session
async def send_presets(request, session):
"""
Send one or more presets to the LED driver (via serial transport).
Body JSON:
{"preset_ids": ["1", "2", ...]} or {"ids": ["1", "2", ...]}
The controller looks up each preset, converts to API format, chunks into
<= 240-byte messages, and sends them over the configured transport.
"""
try:
data = request.json or {}
except Exception:
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
preset_ids = data.get('preset_ids') or data.get('ids')
if not isinstance(preset_ids, list) or not preset_ids:
return json.dumps({"error": "preset_ids must be a non-empty list"}), 400, {'Content-Type': 'application/json'}
save_flag = data.get('save', True)
save_flag = bool(save_flag)
default_id = data.get('default')
# Optional 12-char hex MAC to send to one device; omit for default (e.g. broadcast).
destination_mac = data.get('destination_mac') or data.get('to')
# Build API-compliant preset map keyed by preset ID, include name
current_profile_id = get_current_profile_id(session)
presets_by_name = {}
for pid in preset_ids:
preset_data = presets.read(str(pid))
if not preset_data:
continue
if str(preset_data.get("profile_id")) != str(current_profile_id):
continue
preset_key = str(pid)
preset_payload = build_preset_dict(preset_data)
preset_payload["name"] = preset_data.get("name", "")
presets_by_name[preset_key] = preset_payload
if not presets_by_name:
return json.dumps({"error": "No matching presets found"}), 404, {'Content-Type': 'application/json'}
if default_id is not None and str(default_id) not in presets_by_name:
default_id = None
sender = get_current_sender()
if not sender:
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
async def send_chunk(chunk_presets, is_last):
# Save/default should only be sent with the final presets chunk.
msg = build_message(
presets=chunk_presets,
save=save_flag and is_last,
default=default_id if is_last else None,
)
await sender.send(msg, addr=destination_mac)
MAX_BYTES = 240
send_delay_s = 0.1
entries = list(presets_by_name.items())
total_presets = len(entries)
messages_sent = 0
batch = {}
last_msg = None
for name, preset_obj in entries:
test_batch = dict(batch)
test_batch[name] = preset_obj
test_msg = build_message(presets=test_batch, save=save_flag, default=default_id)
size = len(test_msg)
if size <= MAX_BYTES or not batch:
batch = test_batch
last_msg = test_msg
else:
try:
await send_chunk(batch, False)
except Exception:
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
await asyncio.sleep(send_delay_s)
messages_sent += 1
batch = {name: preset_obj}
last_msg = build_message(presets=batch, save=save_flag, default=default_id)
if batch:
try:
await send_chunk(batch, True)
except Exception:
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
await asyncio.sleep(send_delay_s)
messages_sent += 1
return json.dumps({
"message": "Presets sent",
"presets_sent": total_presets,
"messages_sent": messages_sent
}), 200, {'Content-Type': 'application/json'}

348
src/controllers/profile.py Normal file
View File

@@ -0,0 +1,348 @@
from microdot import Microdot
from microdot.session import with_session
from models.profile import Profile
from models.tab import Tab
from models.preset import Preset
import json
controller = Microdot()
profiles = Profile()
tabs = Tab()
presets = Preset()
@controller.get('')
@with_session
async def list_profiles(request, session):
"""List all profiles with current profile info."""
profile_list = profiles.list()
current_id = session.get('current_profile')
if current_id and current_id not in profile_list:
current_id = None
# If no current profile in session, use first one
if not current_id and profile_list:
current_id = profile_list[0]
session['current_profile'] = str(current_id)
session.save()
# Build profiles object
profiles_data = {}
for profile_id in profile_list:
profile_data = profiles.read(profile_id)
if profile_data:
profiles_data[profile_id] = profile_data
return json.dumps({
"profiles": profiles_data,
"current_profile_id": current_id
}), 200, {'Content-Type': 'application/json'}
@controller.get('/current')
@with_session
async def get_current_profile(request, session):
"""Get the current profile ID from session (or fallback)."""
profile_list = profiles.list()
current_id = session.get('current_profile')
if current_id and current_id not in profile_list:
current_id = None
if not current_id and profile_list:
current_id = profile_list[0]
session['current_profile'] = str(current_id)
session.save()
if current_id:
profile = profiles.read(current_id)
return json.dumps({"id": current_id, "profile": profile}), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "No profile available"}), 404
@controller.get('/<id>')
@with_session
async def get_profile(request, id, session):
"""Get a specific profile by ID."""
# Handle 'current' as a special case
if id == 'current':
return await get_current_profile(request, session)
profile = profiles.read(id)
if profile:
return json.dumps(profile), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Profile not found"}), 404
@controller.post('/<id>/apply')
@with_session
async def apply_profile(request, session, id):
"""Apply a profile by saving it to session."""
if not profiles.read(id):
return json.dumps({"error": "Profile not found"}), 404
session['current_profile'] = str(id)
session.save()
return json.dumps({"message": "Profile applied", "id": str(id)}), 200, {'Content-Type': 'application/json'}
@controller.post('')
async def create_profile(request):
"""Create a new profile."""
try:
data = dict(request.json or {})
name = data.get("name", "")
seed_raw = data.get("seed_dj_tab", False)
if isinstance(seed_raw, str):
seed_dj_tab = seed_raw.strip().lower() in ("1", "true", "yes", "on")
else:
seed_dj_tab = bool(seed_raw)
# Request-only flag: do not persist on profile records.
data.pop("seed_dj_tab", None)
profile_id = profiles.create(name)
# Avoid persisting request-only fields.
data.pop("name", None)
if data:
profiles.update(profile_id, data)
# New profiles always start with a default tab pre-populated with starter presets.
default_preset_ids = []
default_preset_defs = [
{
"name": "on",
"pattern": "on",
"colors": ["#FFFFFF"],
"brightness": 255,
"delay": 100,
"auto": True,
},
{
"name": "off",
"pattern": "off",
"colors": [],
"brightness": 0,
"delay": 100,
"auto": True,
},
{
"name": "rainbow",
"pattern": "rainbow",
"colors": [],
"brightness": 255,
"delay": 100,
"auto": True,
"n1": 2,
},
{
"name": "transition",
"pattern": "transition",
"colors": ["#FF0000", "#00FF00", "#0000FF"],
"brightness": 255,
"delay": 500,
"auto": True,
},
]
for preset_data in default_preset_defs:
pid = presets.create(profile_id)
presets.update(pid, preset_data)
default_preset_ids.append(str(pid))
default_tab_id = tabs.create(name="default", names=["1"], presets=[default_preset_ids])
tabs.update(default_tab_id, {
"presets_flat": default_preset_ids,
"default_preset": default_preset_ids[0] if default_preset_ids else None,
})
profile = profiles.read(profile_id) or {}
profile_tabs = profile.get("tabs", []) if isinstance(profile.get("tabs", []), list) else []
profile_tabs.append(str(default_tab_id))
if seed_dj_tab:
# Seed a DJ-focused tab with three starter presets.
seeded_preset_ids = []
preset_defs = [
{
"name": "DJ Rainbow",
"pattern": "rainbow",
"colors": [],
"brightness": 220,
"delay": 60,
"n1": 12,
},
{
"name": "DJ Single Color",
"pattern": "on",
"colors": ["#ff00ff"],
"brightness": 220,
"delay": 100,
},
{
"name": "DJ Transition",
"pattern": "transition",
"colors": ["#ff0000", "#00ff00", "#0000ff"],
"brightness": 220,
"delay": 250,
},
]
for preset_data in preset_defs:
pid = presets.create(profile_id)
presets.update(pid, preset_data)
seeded_preset_ids.append(str(pid))
dj_tab_id = tabs.create(name="dj", names=["dj"], presets=[seeded_preset_ids])
tabs.update(dj_tab_id, {
"presets_flat": seeded_preset_ids,
"default_preset": seeded_preset_ids[0] if seeded_preset_ids else None,
})
profile_tabs.append(str(dj_tab_id))
profiles.update(profile_id, {"tabs": profile_tabs})
profile_data = profiles.read(profile_id)
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.post('/<id>/clone')
async def clone_profile(request, id):
"""Clone an existing profile along with its tabs and palette."""
try:
source = profiles.read(id)
if not source:
return json.dumps({"error": "Profile not found"}), 404
data = request.json or {}
source_name = source.get("name") or f"Profile {id}"
new_name = data.get("name") or source_name
profile_type = source.get("type", "tabs")
def allocate_id(model, cache):
if "next" not in cache:
max_id = max((int(k) for k in model.keys() if str(k).isdigit()), default=0)
cache["next"] = max_id + 1
next_id = str(cache["next"])
cache["next"] += 1
return next_id
def map_preset_container(value, id_map, preset_cache, new_profile_id, new_presets):
if isinstance(value, list):
return [map_preset_container(v, id_map, preset_cache, new_profile_id, new_presets) for v in value]
if value is None:
return None
preset_id = str(value)
if preset_id in id_map:
return id_map[preset_id]
preset_data = presets.read(preset_id)
if not preset_data:
return None
new_preset_id = allocate_id(presets, preset_cache)
clone_data = dict(preset_data)
clone_data["profile_id"] = str(new_profile_id)
new_presets[new_preset_id] = clone_data
id_map[preset_id] = new_preset_id
return new_preset_id
# Prepare new IDs without writing until everything is ready.
profile_cache = {}
palette_cache = {}
tab_cache = {}
preset_cache = {}
new_profile_id = allocate_id(profiles, profile_cache)
new_palette_id = allocate_id(profiles._palette_model, palette_cache)
# Clone palette colors into the new profile's palette
src_palette_id = source.get("palette_id")
palette_colors = []
if src_palette_id:
try:
palette_colors = profiles._palette_model.read(src_palette_id)
except Exception:
palette_colors = []
# Clone tabs and presets used by those tabs
source_tabs = source.get("tabs")
if not isinstance(source_tabs, list) or len(source_tabs) == 0:
source_tabs = source.get("tab_order", [])
source_tabs = source_tabs or []
cloned_tab_ids = []
preset_id_map = {}
new_tabs = {}
new_presets = {}
for tab_id in source_tabs:
tab = tabs.read(tab_id)
if not tab:
continue
tab_name = tab.get("name") or f"Tab {tab_id}"
clone_name = tab_name
mapped_presets = map_preset_container(tab.get("presets"), preset_id_map, preset_cache, new_profile_id, new_presets)
clone_id = allocate_id(tabs, tab_cache)
clone_data = {
"name": clone_name,
"names": tab.get("names") or [],
"presets": mapped_presets if mapped_presets is not None else []
}
extra = {k: v for k, v in tab.items() if k not in ("name", "names", "presets")}
if "presets_flat" in extra:
extra["presets_flat"] = map_preset_container(extra.get("presets_flat"), preset_id_map, preset_cache, new_profile_id, new_presets)
if extra:
clone_data.update(extra)
new_tabs[clone_id] = clone_data
cloned_tab_ids.append(clone_id)
new_profile_data = {
"name": new_name,
"type": profile_type,
"tabs": cloned_tab_ids,
"scenes": list(source.get("scenes", [])) if isinstance(source.get("scenes", []), list) else [],
"palette_id": str(new_palette_id),
}
# Commit all changes and save once per model.
profiles._palette_model[str(new_palette_id)] = list(palette_colors) if palette_colors else []
for pid, pdata in new_presets.items():
presets[pid] = pdata
for tid, tdata in new_tabs.items():
tabs[tid] = tdata
profiles[str(new_profile_id)] = new_profile_data
profiles._palette_model.save()
presets.save()
tabs.save()
profiles.save()
return json.dumps({new_profile_id: new_profile_data}), 201, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.put('/current')
@with_session
async def update_current_profile(request, session):
"""Update the current profile using session (or fallback)."""
try:
data = request.json or {}
profile_list = profiles.list()
current_id = session.get('current_profile')
if not current_id and profile_list:
current_id = profile_list[0]
session['current_profile'] = str(current_id)
session.save()
if not current_id:
return json.dumps({"error": "No profile available"}), 404
if profiles.update(current_id, data):
return json.dumps(profiles.read(current_id)), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Profile not found"}), 404
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.put('/<id>')
async def update_profile(request, id):
"""Update an existing profile."""
try:
data = request.json
if profiles.update(id, data):
return json.dumps(profiles.read(id)), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Profile not found"}), 404
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.delete('/<id>')
async def delete_profile(request, id):
"""Delete a profile."""
if profiles.delete(id):
return json.dumps({"message": "Profile deleted successfully"}), 200
return json.dumps({"error": "Profile not found"}), 404

49
src/controllers/scene.py Normal file
View File

@@ -0,0 +1,49 @@
from microdot import Microdot
from models.scene import Scene
import json
controller = Microdot()
scenes = Scene()
@controller.get('')
async def list_scenes(request):
"""List all scenes."""
return json.dumps(scenes), 200, {'Content-Type': 'application/json'}
@controller.get('/<id>')
async def get_scene(request, id):
"""Get a specific scene by ID."""
scene = scenes.read(id)
if scene:
return json.dumps(scene), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Scene not found"}), 404
@controller.post('')
async def create_scene(request):
"""Create a new scene."""
try:
data = request.json
scene_id = scenes.create()
if scenes.update(scene_id, data):
return json.dumps(scenes.read(scene_id)), 201, {'Content-Type': 'application/json'}
return json.dumps({"error": "Failed to create scene"}), 400
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.put('/<id>')
async def update_scene(request, id):
"""Update an existing scene."""
try:
data = request.json
if scenes.update(id, data):
return json.dumps(scenes.read(id)), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Scene not found"}), 404
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.delete('/<id>')
async def delete_scene(request, id):
"""Delete a scene."""
if scenes.delete(id):
return json.dumps({"message": "Scene deleted successfully"}), 200
return json.dumps({"error": "Scene not found"}), 404

View File

@@ -0,0 +1,51 @@
from microdot import Microdot
from models.squence import Sequence
import json
controller = Microdot()
sequences = Sequence()
@controller.get('')
async def list_sequences(request):
"""List all sequences."""
return json.dumps(sequences), 200, {'Content-Type': 'application/json'}
@controller.get('/<id>')
async def get_sequence(request, id):
"""Get a specific sequence by ID."""
sequence = sequences.read(id)
if sequence:
return json.dumps(sequence), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Sequence not found"}), 404
@controller.post('')
async def create_sequence(request):
"""Create a new sequence."""
try:
data = request.json or {}
group_name = data.get("group_name", "")
preset_names = data.get("presets", None)
sequence_id = sequences.create(group_name, preset_names)
if data:
sequences.update(sequence_id, data)
return json.dumps(sequences.read(sequence_id)), 201, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.put('/<id>')
async def update_sequence(request, id):
"""Update an existing sequence."""
try:
data = request.json
if sequences.update(id, data):
return json.dumps(sequences.read(id)), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Sequence not found"}), 404
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.delete('/<id>')
async def delete_sequence(request, id):
"""Delete a sequence."""
if sequences.delete(id):
return json.dumps({"message": "Sequence deleted successfully"}), 200
return json.dumps({"error": "Sequence not found"}), 404

View File

@@ -0,0 +1,87 @@
from microdot import Microdot, send_file
from settings import Settings
import json
controller = Microdot()
settings = Settings()
@controller.get('')
async def get_settings(request):
"""Get all settings."""
# Settings is already a dict subclass; avoid dict() wrapper which can
# trigger MicroPython's "dict update sequence has wrong length" quirk.
return json.dumps(settings), 200, {'Content-Type': 'application/json'}
@controller.get('/wifi/ap')
async def get_ap_config(request):
"""Get saved AP configuration (Pi: no in-device AP)."""
config = {
'saved_ssid': settings.get('wifi_ap_ssid'),
'saved_password': settings.get('wifi_ap_password'),
'saved_channel': settings.get('wifi_ap_channel'),
'active': False,
}
return json.dumps(config), 200, {'Content-Type': 'application/json'}
@controller.post('/wifi/ap')
async def configure_ap(request):
"""Save AP configuration to settings (Pi: no in-device AP)."""
try:
data = request.json
ssid = data.get('ssid')
password = data.get('password', '')
channel = data.get('channel')
if not ssid:
return json.dumps({"error": "SSID is required"}), 400
# Validate channel (1-11 for 2.4GHz)
if channel is not None:
channel = int(channel)
if channel < 1 or channel > 11:
return json.dumps({"error": "Channel must be between 1 and 11"}), 400
settings['wifi_ap_ssid'] = ssid
settings['wifi_ap_password'] = password
if channel is not None:
settings['wifi_ap_channel'] = channel
settings.save()
return json.dumps({
"message": "AP settings saved",
"ssid": ssid,
"channel": channel
}), 200, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 500
def _validate_wifi_channel(value):
"""Return int 111 or raise ValueError."""
ch = int(value)
if ch < 1 or ch > 11:
raise ValueError("wifi_channel must be between 1 and 11")
return ch
@controller.put('/settings')
async def update_settings(request):
"""Update general settings."""
try:
data = request.json
for key, value in data.items():
if key == 'wifi_channel' and value is not None:
settings[key] = _validate_wifi_channel(value)
else:
settings[key] = value
settings.save()
return json.dumps({"message": "Settings updated successfully"}), 200, {'Content-Type': 'application/json'}
except ValueError as e:
return json.dumps({"error": str(e)}), 400
except Exception as e:
return json.dumps({"error": str(e)}), 500
@controller.get('/page')
async def settings_page(request):
"""Serve the settings page."""
return send_file('templates/settings.html')

346
src/controllers/tab.py Normal file
View File

@@ -0,0 +1,346 @@
from microdot import Microdot, send_file
from microdot.session import with_session
from models.tab import Tab
from models.profile import Profile
import json
import os
import time
controller = Microdot()
tabs = Tab()
profiles = Profile()
def get_current_profile_id(session=None):
"""Get the current active profile ID from session or fallback to first."""
profile_list = profiles.list()
session_profile = None
if session is not None:
session_profile = session.get('current_profile')
if session_profile and session_profile in profile_list:
return session_profile
if profile_list:
return profile_list[0]
return None
def get_profile_tab_order(profile_id):
"""Get the tab order for a profile."""
if not profile_id:
return []
profile = profiles.read(profile_id)
if profile:
# Support both "tab_order" (old) and "tabs" (new) format
return profile.get("tabs", profile.get("tab_order", []))
return []
def get_current_tab_id(request, session=None):
"""Get the current tab ID from cookie."""
# Read from cookie first
current_tab = request.cookies.get('current_tab')
if current_tab:
return current_tab
# Fallback to first tab in current profile
profile_id = get_current_profile_id(session)
if profile_id:
profile = profiles.read(profile_id)
if profile:
# Support both "tabs" (new) and "tab_order" (old) format
tabs_list = profile.get("tabs", profile.get("tab_order", []))
if tabs_list:
return tabs_list[0]
return None
def _render_tabs_list_fragment(request, session):
"""Helper function to render tabs list HTML fragment."""
profile_id = get_current_profile_id(session)
# #region agent log
try:
os.makedirs('/home/pi/led-controller/.cursor', exist_ok=True)
with open('/home/pi/led-controller/.cursor/debug.log', 'a') as _log:
_log.write(json.dumps({
"sessionId": "debug-session",
"runId": "tabs-pre-fix",
"hypothesisId": "H1",
"location": "src/controllers/tab.py:_render_tabs_list_fragment",
"message": "tabs list fragment",
"data": {
"profile_id": profile_id,
"profile_count": len(profiles.list())
},
"timestamp": int(time.time() * 1000)
}) + "\n")
except Exception:
pass
# #endregion
if not profile_id:
return '<div class="tabs-list">No profile selected</div>', 200, {'Content-Type': 'text/html'}
tab_order = get_profile_tab_order(profile_id)
current_tab_id = get_current_tab_id(request, session)
html = '<div class="tabs-list">'
for tab_id in tab_order:
tab_data = tabs.read(tab_id)
if tab_data:
active_class = 'active' if str(tab_id) == str(current_tab_id) else ''
tab_name = tab_data.get('name', 'Tab ' + str(tab_id))
html += (
'<button class="tab-button ' + active_class + '" '
'hx-get="/tabs/' + str(tab_id) + '/content-fragment" '
'hx-target="#tab-content" '
'hx-swap="innerHTML" '
'hx-push-url="true" '
'hx-trigger="click" '
'onclick="document.querySelectorAll(\'.tab-button\').forEach(b => b.classList.remove(\'active\')); this.classList.add(\'active\');">'
+ tab_name +
'</button>'
)
html += '</div>'
return html, 200, {'Content-Type': 'text/html'}
def _render_tab_content_fragment(request, session, id):
"""Helper function to render tab content HTML fragment."""
# Handle 'current' as a special case
if id == 'current':
current_tab_id = get_current_tab_id(request, session)
if not current_tab_id:
accept_header = request.headers.get('Accept', '')
wants_html = 'text/html' in accept_header
if wants_html:
return '<div class="error">No current tab set</div>', 404, {'Content-Type': 'text/html'}
return json.dumps({"error": "No current tab set"}), 404
id = current_tab_id
tab = tabs.read(id)
if not tab:
return '<div>Tab not found</div>', 404, {'Content-Type': 'text/html'}
# Set this tab as the current tab in session
session['current_tab'] = str(id)
session.save()
# If this is a direct page load (not HTMX), return full UI so CSS loads.
if not request.headers.get('HX-Request'):
return send_file('templates/index.html')
tab_name = tab.get('name', 'Tab ' + str(id))
html = (
'<div class="presets-section" data-tab-id="' + str(id) + '">'
'<h3>Presets</h3>'
'<div class="profiles-actions" style="margin-bottom: 1rem;"></div>'
'<div id="presets-list-tab" class="presets-list">'
'<!-- Presets will be loaded here -->'
'</div>'
'</div>'
)
return html, 200, {'Content-Type': 'text/html'}
@controller.get('')
@with_session
async def list_tabs(request, session):
"""List all tabs with current tab info."""
profile_id = get_current_profile_id(session)
current_tab_id = get_current_tab_id(request, session)
# Get tab order for current profile
tab_order = get_profile_tab_order(profile_id) if profile_id else []
# Build tabs list with metadata
tabs_data = {}
for tab_id in tabs.list():
tab_data = tabs.read(tab_id)
if tab_data:
tabs_data[tab_id] = tab_data
return json.dumps({
"tabs": tabs_data,
"tab_order": tab_order,
"current_tab_id": current_tab_id,
"profile_id": profile_id
}), 200, {'Content-Type': 'application/json'}
# Get current tab - returns JSON with tab data and content info
@controller.get('/current')
@with_session
async def get_current_tab(request, session):
"""Get the current tab from session."""
current_tab_id = get_current_tab_id(request, session)
if not current_tab_id:
return json.dumps({"error": "No current tab set", "tab": None, "tab_id": None}), 404
tab = tabs.read(current_tab_id)
if tab:
return json.dumps({
"tab": tab,
"tab_id": current_tab_id
}), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Tab not found", "tab": None, "tab_id": None}), 404
@controller.post('/<id>/set-current')
async def set_current_tab(request, id):
"""Set a tab as the current tab in cookie."""
tab = tabs.read(id)
if not tab:
return json.dumps({"error": "Tab not found"}), 404
# Set cookie with current tab
response_data = json.dumps({"message": "Current tab set", "tab_id": id})
response = response_data, 200, {
'Content-Type': 'application/json',
'Set-Cookie': f'current_tab={id}; Path=/; Max-Age=31536000' # 1 year expiry
}
return response
@controller.get('/<id>')
async def get_tab(request, id):
"""Get a specific tab by ID."""
tab = tabs.read(id)
if tab:
return json.dumps(tab), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Tab not found"}), 404
@controller.put('/<id>')
async def update_tab(request, id):
"""Update an existing tab."""
try:
data = request.json
if tabs.update(id, data):
return json.dumps(tabs.read(id)), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Tab not found"}), 404
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.delete('/<id>')
@with_session
async def delete_tab(request, session, id):
"""Delete a tab."""
try:
# Handle 'current' tab ID
if id == 'current':
current_tab_id = get_current_tab_id(request, session)
if current_tab_id:
id = current_tab_id
else:
return json.dumps({"error": "No current tab to delete"}), 404
if tabs.delete(id):
# Remove from profile's tabs
profile_id = get_current_profile_id(session)
if profile_id:
profile = profiles.read(profile_id)
if profile:
# Support both "tabs" (new) and "tab_order" (old) format
tabs_list = profile.get('tabs', profile.get('tab_order', []))
if id in tabs_list:
tabs_list.remove(id)
profile['tabs'] = tabs_list
# Remove old tab_order if it exists
if 'tab_order' in profile:
del profile['tab_order']
profiles.update(profile_id, profile)
# Clear cookie if the deleted tab was the current tab
current_tab_id = get_current_tab_id(request, session)
if current_tab_id == id:
response_data = json.dumps({"message": "Tab deleted successfully"})
response = response_data, 200, {
'Content-Type': 'application/json',
'Set-Cookie': 'current_tab=; Path=/; Max-Age=0' # Clear cookie
}
return response
return json.dumps({"message": "Tab deleted successfully"}), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Tab not found"}), 404
except Exception as e:
import sys
try:
sys.print_exception(e)
except:
pass
return json.dumps({"error": str(e)}), 500, {'Content-Type': 'application/json'}
@controller.post('')
@with_session
async def create_tab(request, session):
"""Create a new tab."""
try:
# Handle form data or JSON
if request.form:
name = request.form.get('name', '').strip()
ids_str = request.form.get('ids', '1').strip()
names = [id.strip() for id in ids_str.split(',') if id.strip()]
preset_ids = None
else:
data = request.json or {}
name = data.get("name", "")
names = data.get("names", None)
preset_ids = data.get("presets", None)
if not name:
return json.dumps({"error": "Tab name cannot be empty"}), 400
tab_id = tabs.create(name, names, preset_ids)
# Add to current profile's tabs
profile_id = get_current_profile_id(session)
if profile_id:
profile = profiles.read(profile_id)
if profile:
# Support both "tabs" (new) and "tab_order" (old) format
tabs_list = profile.get('tabs', profile.get('tab_order', []))
if tab_id not in tabs_list:
tabs_list.append(tab_id)
profile['tabs'] = tabs_list
# Remove old tab_order if it exists
if 'tab_order' in profile:
del profile['tab_order']
profiles.update(profile_id, profile)
# Return JSON response with tab ID
tab_data = tabs.read(tab_id)
return json.dumps({tab_id: tab_data}), 201, {'Content-Type': 'application/json'}
except Exception as e:
import sys
sys.print_exception(e)
return json.dumps({"error": str(e)}), 400
@controller.post('/<id>/clone')
@with_session
async def clone_tab(request, session, id):
"""Clone an existing tab and add it to the current profile."""
try:
source = tabs.read(id)
if not source:
return json.dumps({"error": "Tab not found"}), 404
data = request.json or {}
source_name = source.get("name") or f"Tab {id}"
new_name = data.get("name") or f"{source_name} Copy"
clone_id = tabs.create(new_name, source.get("names"), source.get("presets"))
extra = {k: v for k, v in source.items() if k not in ("name", "names", "presets")}
if extra:
tabs.update(clone_id, extra)
profile_id = get_current_profile_id(session)
if profile_id:
profile = profiles.read(profile_id)
if profile:
tabs_list = profile.get('tabs', profile.get('tab_order', []))
if clone_id not in tabs_list:
tabs_list.append(clone_id)
profile['tabs'] = tabs_list
if 'tab_order' in profile:
del profile['tab_order']
profiles.update(profile_id, profile)
tab_data = tabs.read(clone_id)
return json.dumps({clone_id: tab_data}), 201, {'Content-Type': 'application/json'}
except Exception as e:
import sys
try:
sys.print_exception(e)
except:
pass
return json.dumps({"error": str(e)}), 400

View File

@@ -1,55 +1,127 @@
import asyncio
import json
import os
from microdot import Microdot, send_file
from microdot.websocket import with_websocket
from microdot.session import Session
from settings import Settings
from web import web
from patterns import Patterns
import gc
import utime
import machine
import ntptime
import time
import wifi
import controllers.preset as preset
import controllers.profile as profile
import controllers.group as group
import controllers.sequence as sequence
import controllers.tab as tab
import controllers.palette as palette
import controllers.scene as scene
import controllers.pattern as pattern
import controllers.settings as settings_controller
from models.transport import get_sender, set_sender
async def main():
async def main(port=80):
settings = Settings()
patterns = Patterns(4, settings["num_leds"], selected=settings["selected_pattern"])
patterns.set_color1(tuple(int(settings["color1"][i:i+2], 16) for i in (1, 5, 3)))
patterns.set_color2(tuple(int(settings["color2"][i:i+2], 16) for i in (1, 5, 3)))
patterns.set_brightness(int(settings["brightness"]))
patterns.set_delay(int(settings["delay"]))
w = web(settings, patterns)
print(settings)
# start the server in a bacakground task
print("Starting")
server = asyncio.create_task(w.start_server(host="0.0.0.0", port=80))
wdt = machine.WDT(timeout=10000)
wdt.feed()
async def tick():
# Initialize transport (serial to ESP32 bridge)
sender = get_sender(settings)
set_sender(sender)
app = Microdot()
# Initialize sessions with a secret key from settings
secret_key = settings.get('session_secret_key', 'led-controller-secret-key-change-in-production')
Session(app, secret_key=secret_key)
# Mount model controllers as subroutes
# Verify controllers are Microdot instances before mounting
controllers_to_mount = [
('/presets', preset, 'preset'),
('/profiles', profile, 'profile'),
('/groups', group, 'group'),
('/sequences', sequence, 'sequence'),
('/tabs', tab, 'tab'),
('/palettes', palette, 'palette'),
('/scenes', scene, 'scene'),
]
# Mount model controllers as subroutes
app.mount(preset.controller, '/presets')
app.mount(profile.controller, '/profiles')
app.mount(group.controller, '/groups')
app.mount(sequence.controller, '/sequences')
app.mount(tab.controller, '/tabs')
app.mount(palette.controller, '/palettes')
app.mount(scene.controller, '/scenes')
app.mount(pattern.controller, '/patterns')
app.mount(settings_controller.controller, '/settings')
# Serve index.html at root (cwd is src/ when run via pipenv run run)
@app.route('/')
def index(request):
"""Serve the main web UI."""
return send_file('templates/index.html')
# Serve settings page
@app.route('/settings')
def settings_page(request):
"""Serve the settings page."""
return send_file('templates/settings.html')
# Favicon: avoid 404 in browser console (no file needed)
@app.route('/favicon.ico')
def favicon(request):
return '', 204
# Static file route
@app.route("/static/<path:path>")
def static_handler(request, path):
"""Serve static files."""
if '..' in path:
# Directory traversal is not allowed
return 'Not found', 404
return send_file('static/' + path)
@app.route('/ws')
@with_websocket
async def ws(request, ws):
while True:
patterns.tick()
await asyncio.sleep_ms(1)
data = await ws.receive()
print(data)
if data:
try:
parsed = json.loads(data)
print("WS received JSON:", parsed)
# Optional "to": 12-char hex MAC; rest is payload (sent with that address).
addr = parsed.pop("to", None)
payload = json.dumps(parsed) if parsed else data
await sender.send(payload, addr=addr)
except json.JSONDecodeError:
# Not JSON: send raw with default address
try:
await sender.send(data)
except Exception:
try:
await ws.send(json.dumps({"error": "Send failed"}))
except Exception:
pass
except Exception:
try:
await ws.send(json.dumps({"error": "Send failed"}))
except Exception:
pass
else:
break
asyncio.create_task(tick())
first = True
server = asyncio.create_task(app.start_server(host="0.0.0.0", port=port))
while True:
#print(time.localtime())
# gc.collect()
for i in range(60):
wdt.feed()
await asyncio.sleep_ms(500)
await asyncio.sleep(30)
# cleanup before ending the application
await server
asyncio.run(main())
if __name__ == "__main__":
import os
port = int(os.environ.get("PORT", 80))
asyncio.run(main(port=port))

1
src/models/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Models package

54
src/models/device.py Normal file
View File

@@ -0,0 +1,54 @@
from models.model import Model
def _normalize_address(addr):
"""Normalize 6-byte ESP32 address to 12-char lowercase hex (no colons)."""
if addr is None:
return None
s = str(addr).strip().lower().replace(":", "").replace("-", "")
if len(s) == 12 and all(c in "0123456789abcdef" for c in s):
return s
return None
class Device(Model):
def __init__(self):
super().__init__()
def create(self, name="", address=None, default_pattern=None, tabs=None):
next_id = self.get_next_id()
addr = _normalize_address(address)
self[next_id] = {
"name": name,
"address": addr,
"default_pattern": default_pattern if default_pattern else None,
"tabs": list(tabs) if tabs else [],
}
self.save()
return next_id
def read(self, id):
id_str = str(id)
return self.get(id_str, None)
def update(self, id, data):
id_str = str(id)
if id_str not in self:
return False
if "address" in data and data["address"] is not None:
data = dict(data)
data["address"] = _normalize_address(data["address"])
self[id_str].update(data)
self.save()
return True
def delete(self, id):
id_str = str(id)
if id_str not in self:
return False
self.pop(id_str)
self.save()
return True
def list(self):
return list(self.keys())

51
src/models/group.py Normal file
View File

@@ -0,0 +1,51 @@
from models.model import Model
class Group(Model):
def __init__(self):
super().__init__()
def create(self, name=""):
next_id = self.get_next_id()
self[next_id] = {
"name": name,
"devices": [],
"pattern": "on",
"colors": ["000000", "FF0000"],
"brightness": 100,
"delay": 100,
"step_offset": 0,
"step_increment": 1,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0
}
self.save()
return next_id
def read(self, id):
id_str = str(id)
return self.get(id_str, None)
def update(self, id, data):
id_str = str(id)
if id_str not in self:
return False
self[id_str].update(data)
self.save()
return True
def delete(self, id):
id_str = str(id)
if id_str not in self:
return False
self.pop(id_str)
self.save()
return True
def list(self):
return list(self.keys())

113
src/models/model.py Normal file
View File

@@ -0,0 +1,113 @@
import json
import os
import traceback
# DB directory: project root / db (writable without root)
def _db_dir():
try:
# src/models/model.py -> project root
base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
return os.path.join(base, "db")
except Exception:
return "db"
class Model(dict):
def __new__(cls, *args, **kwargs):
# Singleton pattern: return existing instance if it exists
if not hasattr(cls, '_instance'):
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
# Only initialize once (check if already initialized)
if hasattr(self, '_initialized'):
return
db_dir = _db_dir()
try:
os.makedirs(db_dir, exist_ok=True)
except OSError:
pass
self.class_name = self.__class__.__name__
self.file = os.path.join(db_dir, f"{self.class_name.lower()}.json")
super().__init__()
self.load() # Load settings from file during initialization
self._initialized = True
def set_defaults(self):
self.clear()
def get_next_id(self):
"""Get the next available ID for creating a new record."""
if not self:
return "1"
max_id = max((int(k) for k in self.keys() if k.isdigit()), default=0)
return str(max_id + 1)
def save(self):
try:
db_dir = os.path.dirname(self.file)
try:
os.makedirs(db_dir, exist_ok=True)
except OSError:
pass
j = json.dumps(self)
with open(self.file, 'w') as file:
file.write(j)
file.flush() # Ensure data is written to buffer
# Try to sync filesystem if available (MicroPython)
try:
os.sync()
except (AttributeError, OSError):
pass # os.sync() not available on all platforms
print(f"{self.class_name} saved successfully to {self.file}")
except Exception as e:
print(f"Error saving {self.class_name} to {self.file}: {e}")
traceback.print_exception(type(e), e, e.__traceback__)
def load(self):
try:
# Check if file exists first
try:
with open(self.file, 'r') as file:
content = file.read().strip()
except OSError:
# File doesn't exist
raise
if not content:
# Empty file
loaded_settings = {}
else:
# Parse JSON content
loaded_settings = json.loads(content)
# Verify it's a dictionary
if not isinstance(loaded_settings, dict):
raise ValueError(f"File does not contain a dictionary, got {type(loaded_settings)}")
# Clear and update with loaded data
# Clear first
self.clear()
# Manually copy items to avoid any update() method issues
for key, value in loaded_settings.items():
self[key] = value
print(f"{self.class_name} loaded successfully.")
except OSError as e:
# File doesn't exist yet - this is normal on first run
# Create an empty file with defaults
self.set_defaults()
self.save()
print(f"{self.class_name} initialized (new file created).")
except ValueError:
# JSON parsing error - file exists but is corrupted
# Note: MicroPython uses ValueError for JSON errors, not JSONDecodeError
print(f"Error loading {self.class_name}: Invalid JSON format. Resetting to defaults.")
self.set_defaults()
self.save()
except Exception:
# Other unexpected errors - avoid trying to format exception to prevent further errors
print(f"Error loading {self.class_name}. Resetting to defaults.")
self.set_defaults()
self.save()

45
src/models/pallet.py Normal file
View File

@@ -0,0 +1,45 @@
from models.model import Model
class Palette(Model):
def __init__(self):
super().__init__()
def create(self, name="", colors=None):
next_id = self.get_next_id()
# Store palette as a simple list of colors; name is ignored.
self[next_id] = list(colors) if colors else []
self.save()
return next_id
def read(self, id):
id_str = str(id)
value = self.get(id_str, None)
# Backwards compatibility: if stored as {"colors": [...]}, unwrap.
if isinstance(value, dict) and "colors" in value:
return value.get("colors") or []
# Otherwise, expect a list of colors.
return value or []
def update(self, id, data):
id_str = str(id)
if id_str not in self:
return False
# Accept either {"colors": [...]} or a raw list.
if isinstance(data, dict):
colors = data.get("colors", [])
else:
colors = data
self[id_str] = list(colors) if colors else []
self.save()
return True
def delete(self, id):
id_str = str(id)
if id_str not in self:
return False
self.pop(id_str)
self.save()
return True
def list(self):
return list(self.keys())

38
src/models/pattern.py Normal file
View File

@@ -0,0 +1,38 @@
from models.model import Model
class Pattern(Model):
def __init__(self):
super().__init__()
def create(self, name="", data=None):
pattern_name = str(name).strip()
if not pattern_name:
pattern_name = self.get_next_id()
self[pattern_name] = data if isinstance(data, dict) else {}
self.save()
return pattern_name
def read(self, id):
id_str = str(id)
return self.get(id_str, None)
def update(self, id, data):
id_str = str(id)
if id_str not in self:
return False
if isinstance(data, dict):
self[id_str].update(data)
self.save()
return True
def delete(self, id):
id_str = str(id)
if id_str not in self:
return False
self.pop(id_str)
self.save()
return True
def list(self):
return list(self.keys())

69
src/models/preset.py Normal file
View File

@@ -0,0 +1,69 @@
from models.model import Model
from models.profile import Profile
class Preset(Model):
def __init__(self):
super().__init__()
# Backfill profile ownership for existing presets.
try:
profiles = Profile()
profile_list = profiles.list()
default_profile_id = profile_list[0] if profile_list else None
changed = False
for preset_id, preset_data in list(self.items()):
if isinstance(preset_data, dict) and "profile_id" not in preset_data:
if default_profile_id is not None:
preset_data["profile_id"] = str(default_profile_id)
changed = True
if changed:
self.save()
except Exception:
pass
def create(self, profile_id=None):
next_id = self.get_next_id()
self[next_id] = {
"name": "",
"pattern": "",
"colors": [],
"brightness": 0,
"delay": 0,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": str(profile_id) if profile_id is not None else None,
}
self.save()
return next_id
def read(self, id):
id_str = str(id)
if id_str not in self:
return None
return self[id_str]
def update(self, id, data):
id_str = str(id)
if id_str not in self:
return False
self[id_str].update(data)
self.save()
return True
def delete(self, id):
id_str = str(id)
if id_str not in self:
return False
self.pop(id_str)
self.save()
return True
def list(self):
return list(self.keys())

69
src/models/profile.py Normal file
View File

@@ -0,0 +1,69 @@
from models.model import Model
from models.pallet import Palette
class Profile(Model):
def __init__(self):
"""Profile model.
Each profile owns a single, unique palette stored in the Palette model.
The profile stores a `palette_id` that points to its palette; any legacy
inline `palette` arrays are migrated to a dedicated Palette entry.
"""
super().__init__()
self._palette_model = Palette()
# Migrate legacy inline palettes to separate Palette entries.
changed = False
for pid, pdata in list(self.items()):
if isinstance(pdata, dict):
if "palette" in pdata and "palette_id" not in pdata:
colors = pdata.get("palette") or []
palette_id = self._palette_model.create(colors=colors)
pdata.pop("palette", None)
pdata["palette_id"] = str(palette_id)
changed = True
if changed:
self.save()
def create(self, name="", profile_type="tabs"):
"""Create a new profile and its own empty palette.
profile_type: "tabs" or "scenes" (ignoring scenes for now)
"""
next_id = self.get_next_id()
# Create a unique palette for this profile.
palette_id = self._palette_model.create(colors=[])
self[next_id] = {
"name": name,
"type": profile_type, # "tabs" or "scenes"
"tabs": [], # Array of tab IDs
"scenes": [], # Array of scene IDs (for future use)
"palette_id": str(palette_id),
}
self.save()
return next_id
def read(self, id):
id_str = str(id)
return self.get(id_str, None)
def update(self, id, data):
id_str = str(id)
if id_str not in self:
return False
self[id_str].update(data)
self.save()
return True
def delete(self, id):
id_str = str(id)
if id_str not in self:
return False
self.pop(id_str)
self.save()
return True
def list(self):
return list(self.keys())

38
src/models/scene.py Normal file
View File

@@ -0,0 +1,38 @@
from models.model import Model
class Scene(Model):
def __init__(self):
super().__init__()
def create(self, name="", sequences=None, groups=None):
next_id = self.get_next_id()
self[next_id] = {
"name": name,
"sequences": sequences if sequences else [],
"groups": groups if groups else []
}
self.save()
return next_id
def read(self, id):
id_str = str(id)
return self.get(id_str, None)
def update(self, id, data):
id_str = str(id)
if id_str not in self:
return False
self[id_str].update(data)
self.save()
return True
def delete(self, id):
id_str = str(id)
if id_str not in self:
return False
self.pop(id_str)
self.save()
return True
def list(self):
return list(self.keys())

12
src/models/serial.py Normal file
View File

@@ -0,0 +1,12 @@
class Serial:
def __init__(self, port, baudrate):
self.port = port
self.baudrate = baudrate
self.uart = UART(1, baudrate, tx=Pin(21), rx=Pin(6))
def send(self, data):
self.uart.write(data)
def receive(self):
return self.uart.read()

44
src/models/squence.py Normal file
View File

@@ -0,0 +1,44 @@
from models.model import Model
class Sequence(Model):
def __init__(self):
super().__init__()
def create(self, group_name="", preset_names=None):
next_id = self.get_next_id()
self[next_id] = {
"group_name": group_name,
"presets": preset_names if preset_names else [],
"sequence_duration": 3000, # Duration per preset in ms
"sequence_transition": 500, # Transition time in ms
"sequence_loop": False,
"sequence_repeat_count": 0, # 0 = infinite
"sequence_active": False,
"sequence_index": 0,
"sequence_start_time": 0
}
self.save()
return next_id
def read(self, id):
id_str = str(id)
return self.get(id_str, None)
def update(self, id, data):
id_str = str(id)
if id_str not in self:
return False
self[id_str].update(data)
self.save()
return True
def delete(self, id):
id_str = str(id)
if id_str not in self:
return False
self.pop(id_str)
self.save()
return True
def list(self):
return list(self.keys())

39
src/models/tab.py Normal file
View File

@@ -0,0 +1,39 @@
from models.model import Model
class Tab(Model):
def __init__(self):
super().__init__()
def create(self, name="", names=None, presets=None):
next_id = self.get_next_id()
self[next_id] = {
"name": name,
"names": names if names else [],
"presets": presets if presets else [],
"default_preset": None
}
self.save()
return next_id
def read(self, id):
id_str = str(id)
return self.get(id_str, None)
def update(self, id, data):
id_str = str(id)
if id_str not in self:
return False
self[id_str].update(data)
self.save()
return True
def delete(self, id):
id_str = str(id)
if id_str not in self:
return False
self.pop(id_str)
self.save()
return True
def list(self):
return list(self.keys())

66
src/models/transport.py Normal file
View File

@@ -0,0 +1,66 @@
import asyncio
import json
# Default: broadcast (6 bytes). Pi always sends 6-byte address + payload to ESP32.
BROADCAST_MAC = bytes.fromhex("ffffffffffff")
def _encode_payload(data):
if isinstance(data, str):
return data.encode()
if isinstance(data, dict):
return json.dumps(data).encode()
return data
def _parse_mac(addr):
"""Convert 12-char hex string or 6-byte bytes to 6-byte MAC."""
if addr is None or addr == b"":
return BROADCAST_MAC
if isinstance(addr, bytes) and len(addr) == 6:
return addr
if isinstance(addr, str) and len(addr) == 12:
return bytes.fromhex(addr)
return BROADCAST_MAC
async def _to_thread(func, *args):
to_thread = getattr(asyncio, "to_thread", None)
if to_thread:
return await to_thread(func, *args)
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, func, *args)
class SerialSender:
def __init__(self, port, baudrate, default_addr=None):
import serial
self._serial = serial.Serial(port, baudrate=baudrate, timeout=1)
self._default_addr = _parse_mac(default_addr)
async def send(self, data, addr=None):
mac = _parse_mac(addr) if addr is not None else self._default_addr
payload = _encode_payload(data)
await _to_thread(self._serial.write, mac + payload)
return True
_current_sender = None
def set_sender(sender):
global _current_sender
_current_sender = sender
def get_current_sender():
return _current_sender
def get_sender(settings):
port = settings.get("serial_port", "/dev/ttyS0")
baudrate = settings.get("serial_baudrate", 912000)
default_addr = settings.get("serial_destination_mac", "ffffffffffff")
return SerialSender(port, baudrate, default_addr=default_addr)

0
src/profile.py Normal file
View File

View File

@@ -1,20 +1,53 @@
import json
import os
import binascii
def _settings_path():
"""Path to settings.json in project root (writable without root)."""
try:
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
return os.path.join(base, "settings.json")
except Exception:
return "settings.json"
class Settings(dict):
SETTINGS_FILE = "/settings.json"
SETTINGS_FILE = None # Set in __init__ from _settings_path()
def __init__(self):
super().__init__()
if Settings.SETTINGS_FILE is None:
Settings.SETTINGS_FILE = _settings_path()
self.load() # Load settings from file during initialization
def generate_secret_key(self):
"""Generate a random secret key for session signing."""
try:
# Try to use os.urandom for secure random bytes
random_bytes = os.urandom(32)
return binascii.hexlify(random_bytes).decode('utf-8')
except (AttributeError, NotImplementedError):
# Fallback for MicroPython or systems without os.urandom
try:
import secrets
return secrets.token_hex(32)
except ImportError:
# Last resort: use a combination of time and random
import time
import random
random.seed(time.time())
return binascii.hexlify(bytes([random.randint(0, 255) for _ in range(32)])).decode('utf-8')
def set_defaults(self):
self["num_leds"] = 50
self["selected_pattern"] = "blink"
self["color1"] = "#000f00"
self["color2"] = "#0f0000"
self["delay"] = 100
self["brightness"] = 100
self["wifi"] = {"ssid": "", "password": ""}
"""Set default settings if they don't exist."""
if 'session_secret_key' not in self:
self['session_secret_key'] = self.generate_secret_key()
# Save immediately when generating a new key
self.save()
# ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 111
if 'wifi_channel' not in self:
self['wifi_channel'] = 6
def save(self):
try:
@@ -26,28 +59,19 @@ class Settings(dict):
print(f"Error saving settings: {e}")
def load(self):
loaded_from_file = False
try:
with open(self.SETTINGS_FILE, 'r') as file:
loaded_settings = json.load(file)
self.update(loaded_settings)
loaded_from_file = True
print("Settings loaded successfully.")
except Exception as e:
print(f"Error loading settings")
self.clear()
finally:
# Ensure defaults are set even if file exists but is missing keys
self.set_defaults()
# Example usage
def main():
settings = Settings()
print(f"Number of LEDs: {settings['num_leds']}")
settings['num_leds'] = 100
print(f"Updated number of LEDs: {settings['num_leds']}")
settings.save()
# Create a new Settings object to test loading
new_settings = Settings()
print(f"Loaded number of LEDs: {new_settings['num_leds']}")
print(settings)
# Run the example
if __name__ == "__main__":
main()
# Only save if file didn't exist or was invalid
if not loaded_from_file:
self.save()

1734
src/static/app.js Normal file

File diff suppressed because it is too large Load Diff

194
src/static/color_palette.js Normal file
View File

@@ -0,0 +1,194 @@
document.addEventListener('DOMContentLoaded', () => {
const paletteButton = document.getElementById('color-palette-btn');
const paletteModal = document.getElementById('color-palette-modal');
const closeButton = document.getElementById('color-palette-close-btn');
const paletteContainer = document.getElementById('palette-container');
const paletteNewColor = document.getElementById('palette-new-color');
const profileNameDisplay = document.getElementById('palette-current-profile-name');
if (!paletteButton || !paletteModal || !paletteContainer) {
return;
}
let currentProfileId = null;
let currentPaletteId = null;
let currentPalette = [];
let currentProfileName = null;
const renderPalette = () => {
paletteContainer.innerHTML = '';
if (!currentPalette.length) {
const empty = document.createElement('p');
empty.className = 'muted-text';
empty.textContent = 'No colors in palette.';
paletteContainer.appendChild(empty);
return;
}
currentPalette.forEach((color, index) => {
const row = document.createElement('div');
row.className = 'profiles-row';
row.dataset.color = color;
row.style.cssText = 'display: flex; align-items: center; gap: 1rem;';
// Ensure no text content
row.textContent = '';
const swatch = document.createElement('div');
swatch.style.cssText = `
width: 64px;
height: 64px;
border-radius: 8px;
background-color: ${color};
border: 2px solid #4a4a4a;
cursor: pointer;
flex-shrink: 0;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
`;
swatch.title = color; // Show hex code on hover only
swatch.setAttribute('aria-label', `Color ${color}`);
const removeButton = document.createElement('button');
removeButton.className = 'btn btn-danger btn-small';
removeButton.textContent = 'Remove';
removeButton.style.fontSize = '0.8rem'; // Restore font size for button
removeButton.addEventListener('click', async (e) => {
e.stopPropagation();
const updated = currentPalette.filter((_, i) => i !== index);
await savePalette(updated);
});
row.appendChild(swatch);
row.appendChild(removeButton);
paletteContainer.appendChild(row);
});
};
const loadPalette = async () => {
try {
const currentResponse = await fetch('/profiles/current', {
headers: { Accept: 'application/json' },
});
if (!currentResponse.ok) {
throw new Error('Failed to load current profile');
}
const currentData = await currentResponse.json();
currentProfileId = currentData.id || null;
const profile = currentData.profile || null;
currentProfileName = profile ? profile.name : null;
if (profileNameDisplay) {
profileNameDisplay.textContent = currentProfileName || currentProfileId || 'None';
}
if (!currentProfileId || !profile) {
currentPalette = [];
renderPalette();
return;
}
// Prefer palette_id-based storage; fall back to legacy inline palette.
currentPaletteId = profile.palette_id || profile.paletteId || null;
if (currentPaletteId) {
try {
const palResponse = await fetch(`/palettes/${currentPaletteId}`, {
headers: { Accept: 'application/json' },
});
if (palResponse.ok) {
const palData = await palResponse.json();
currentPalette = (palData.colors) || [];
} else {
currentPalette = [];
}
} catch (e) {
console.error('Failed to load palette by id:', e);
currentPalette = [];
}
} else {
// Legacy: palette stored directly on profile
currentPalette = profile.palette || profile.color_palette || [];
}
renderPalette();
} catch (error) {
console.error('Failed to load palette:', error);
currentPalette = [];
renderPalette();
}
};
const savePalette = async (newPalette) => {
if (!currentProfileId) {
alert('No profile selected.');
return;
}
try {
// Ensure we have a palette ID for this profile.
if (!currentPaletteId) {
const createResponse = await fetch('/palettes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ colors: newPalette }),
});
if (!createResponse.ok) {
throw new Error('Failed to create palette');
}
const pal = await createResponse.json();
currentPaletteId = pal.id || Object.keys(pal)[0];
// Link the new palette to the current profile.
const linkResponse = await fetch('/profiles/current', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
palette_id: currentPaletteId,
}),
});
if (!linkResponse.ok) {
throw new Error('Failed to link palette to profile');
}
} else {
// Update existing palette colors
const updateResponse = await fetch(`/palettes/${currentPaletteId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ colors: newPalette }),
});
if (!updateResponse.ok) {
throw new Error('Failed to save palette');
}
}
currentPalette = newPalette;
renderPalette();
} catch (error) {
console.error('Failed to save palette:', error);
alert('Failed to save palette.');
}
};
const openModal = () => {
paletteModal.classList.add('active');
loadPalette();
};
const closeModal = () => {
paletteModal.classList.remove('active');
};
paletteButton.addEventListener('click', openModal);
if (closeButton) {
closeButton.addEventListener('click', closeModal);
}
if (paletteNewColor) {
const addSelectedColor = async () => {
const color = paletteNewColor.value;
if (!color) {
return;
}
if (currentPalette.includes(color)) {
alert('Color already in palette.');
return;
}
await savePalette([...currentPalette, color]);
};
// Add when the picker closes (user confirms selection).
paletteNewColor.addEventListener('change', addSelectedColor);
}
});

251
src/static/devices.js Normal file
View File

@@ -0,0 +1,251 @@
// Device management: list, create, edit, delete (name and 6-byte address)
const HEX_BOX_COUNT = 12;
function makeHexAddressBoxes(container) {
if (!container || container.querySelector('.hex-addr-box')) return;
container.innerHTML = '';
for (let i = 0; i < HEX_BOX_COUNT; i++) {
const input = document.createElement('input');
input.type = 'text';
input.className = 'hex-addr-box';
input.maxLength = 1;
input.autocomplete = 'off';
input.setAttribute('data-index', i);
input.setAttribute('inputmode', 'numeric');
input.setAttribute('aria-label', `Hex digit ${i + 1}`);
input.addEventListener('input', (e) => {
const v = e.target.value.replace(/[^0-9a-fA-F]/g, '');
e.target.value = v;
if (v && e.target.nextElementSibling && e.target.nextElementSibling.classList.contains('hex-addr-box')) {
e.target.nextElementSibling.focus();
}
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' && !e.target.value && e.target.previousElementSibling) {
e.target.previousElementSibling.focus();
}
});
input.addEventListener('paste', (e) => {
e.preventDefault();
const pasted = (e.clipboardData.getData('text') || '').replace(/[^0-9a-fA-F]/g, '').slice(0, HEX_BOX_COUNT);
const boxes = container.querySelectorAll('.hex-addr-box');
for (let j = 0; j < pasted.length && j < boxes.length; j++) {
boxes[j].value = pasted[j];
}
if (pasted.length > 0) {
const nextIdx = Math.min(pasted.length, boxes.length - 1);
boxes[nextIdx].focus();
}
});
container.appendChild(input);
}
}
function getAddressFromBoxes(container) {
if (!container) return '';
const boxes = container.querySelectorAll('.hex-addr-box');
return Array.from(boxes).map((b) => b.value).join('').toLowerCase();
}
function setAddressToBoxes(container, addrStr) {
if (!container) return;
const s = (addrStr || '').replace(/[^0-9a-fA-F]/g, '').slice(0, HEX_BOX_COUNT);
const boxes = container.querySelectorAll('.hex-addr-box');
boxes.forEach((b, i) => {
b.value = s[i] || '';
});
}
async function loadDevicesModal() {
const container = document.getElementById('devices-list-modal');
if (!container) return;
container.innerHTML = '<span class="muted-text">Loading...</span>';
try {
const response = await fetch('/devices', { headers: { Accept: 'application/json' } });
if (!response.ok) throw new Error('Failed to load devices');
const devices = await response.json();
renderDevicesList(devices || {});
} catch (e) {
console.error('loadDevicesModal:', e);
container.innerHTML = '<span class="muted-text">Failed to load devices.</span>';
}
}
function renderDevicesList(devices) {
const container = document.getElementById('devices-list-modal');
if (!container) return;
container.innerHTML = '';
const ids = Object.keys(devices).filter((k) => devices[k] && typeof devices[k] === 'object');
if (ids.length === 0) {
const p = document.createElement('p');
p.className = 'muted-text';
p.textContent = 'No devices. Create one above.';
container.appendChild(p);
return;
}
ids.forEach((devId) => {
const dev = devices[devId];
const row = document.createElement('div');
row.className = 'profiles-row';
row.style.display = 'flex';
row.style.alignItems = 'center';
row.style.gap = '0.5rem';
row.style.flexWrap = 'wrap';
const label = document.createElement('span');
label.textContent = (dev && dev.name) || devId;
label.style.flex = '1';
label.style.minWidth = '100px';
const meta = document.createElement('span');
meta.className = 'muted-text';
meta.style.fontSize = '0.85em';
const addr = (dev && dev.address) ? dev.address : '—';
meta.textContent = `Address: ${addr}`;
const editBtn = document.createElement('button');
editBtn.className = 'btn btn-secondary btn-small';
editBtn.textContent = 'Edit';
editBtn.addEventListener('click', () => openEditDeviceModal(devId, dev));
const deleteBtn = document.createElement('button');
deleteBtn.className = 'btn btn-secondary btn-small';
deleteBtn.textContent = 'Delete';
deleteBtn.addEventListener('click', async () => {
if (!confirm(`Delete device "${(dev && dev.name) || devId}"?`)) return;
try {
const res = await fetch(`/devices/${devId}`, { method: 'DELETE' });
if (res.ok) await loadDevicesModal();
else {
const data = await res.json().catch(() => ({}));
alert(data.error || 'Delete failed');
}
} catch (err) {
console.error(err);
alert('Delete failed');
}
});
row.appendChild(label);
row.appendChild(meta);
row.appendChild(editBtn);
row.appendChild(deleteBtn);
container.appendChild(row);
});
}
function openEditDeviceModal(devId, dev) {
const modal = document.getElementById('edit-device-modal');
const idInput = document.getElementById('edit-device-id');
const nameInput = document.getElementById('edit-device-name');
const addressBoxes = document.getElementById('edit-device-address-boxes');
if (!modal || !idInput) return;
idInput.value = devId;
if (nameInput) nameInput.value = (dev && dev.name) || '';
setAddressToBoxes(addressBoxes, (dev && dev.address) || '');
modal.classList.add('active');
}
async function createDevice(name, address) {
try {
const res = await fetch('/devices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, address: address || null }),
});
const data = await res.json().catch(() => ({}));
if (res.ok) {
await loadDevicesModal();
return true;
}
alert(data.error || 'Failed to create device');
return false;
} catch (e) {
console.error('createDevice:', e);
alert('Failed to create device');
return false;
}
}
async function updateDevice(devId, name, address) {
try {
const res = await fetch(`/devices/${devId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, address: address || null }),
});
const data = await res.json().catch(() => ({}));
if (res.ok) {
await loadDevicesModal();
return true;
}
alert(data.error || 'Failed to update device');
return false;
} catch (e) {
console.error('updateDevice:', e);
alert('Failed to update device');
return false;
}
}
document.addEventListener('DOMContentLoaded', () => {
makeHexAddressBoxes(document.getElementById('new-device-address-boxes'));
makeHexAddressBoxes(document.getElementById('edit-device-address-boxes'));
const devicesBtn = document.getElementById('devices-btn');
const devicesModal = document.getElementById('devices-modal');
const devicesCloseBtn = document.getElementById('devices-close-btn');
const newName = document.getElementById('new-device-name');
const createBtn = document.getElementById('create-device-btn');
const editForm = document.getElementById('edit-device-form');
const editCloseBtn = document.getElementById('edit-device-close-btn');
const editDeviceModal = document.getElementById('edit-device-modal');
if (devicesBtn && devicesModal) {
devicesBtn.addEventListener('click', () => {
devicesModal.classList.add('active');
loadDevicesModal();
});
}
if (devicesCloseBtn) {
devicesCloseBtn.addEventListener('click', () => devicesModal && devicesModal.classList.remove('active'));
}
const newAddressBoxes = document.getElementById('new-device-address-boxes');
const doCreate = async () => {
const name = (newName && newName.value.trim()) || '';
if (!name) {
alert('Device name is required.');
return;
}
const address = newAddressBoxes ? getAddressFromBoxes(newAddressBoxes) : '';
const ok = await createDevice(name, address);
if (ok && newName) {
newName.value = '';
setAddressToBoxes(newAddressBoxes, '');
}
};
if (createBtn) createBtn.addEventListener('click', doCreate);
if (newName) newName.addEventListener('keypress', (e) => { if (e.key === 'Enter') doCreate(); });
if (editForm) {
editForm.addEventListener('submit', async (e) => {
e.preventDefault();
const idInput = document.getElementById('edit-device-id');
const nameInput = document.getElementById('edit-device-name');
const addressBoxes = document.getElementById('edit-device-address-boxes');
const devId = idInput && idInput.value;
if (!devId) return;
const address = addressBoxes ? getAddressFromBoxes(addressBoxes) : '';
const ok = await updateDevice(
devId,
nameInput ? nameInput.value.trim() : '',
address
);
if (ok) editDeviceModal.classList.remove('active');
});
}
if (editCloseBtn) {
editCloseBtn.addEventListener('click', () => editDeviceModal && editDeviceModal.classList.remove('active'));
}
});

197
src/static/help.js Normal file
View File

@@ -0,0 +1,197 @@
document.addEventListener('DOMContentLoaded', () => {
// Help modal
const helpBtn = document.getElementById('help-btn');
const helpModal = document.getElementById('help-modal');
const helpCloseBtn = document.getElementById('help-close-btn');
const mainMenuBtn = document.getElementById('main-menu-btn');
const mainMenuDropdown = document.getElementById('main-menu-dropdown');
if (helpBtn && helpModal) {
helpBtn.addEventListener('click', () => {
helpModal.classList.add('active');
});
}
if (helpCloseBtn && helpModal) {
helpCloseBtn.addEventListener('click', () => {
helpModal.classList.remove('active');
});
}
// Mobile main menu: forward clicks to existing header buttons
if (mainMenuBtn && mainMenuDropdown) {
mainMenuBtn.addEventListener('click', () => {
mainMenuDropdown.classList.toggle('open');
});
mainMenuDropdown.addEventListener('click', (event) => {
const target = event.target;
if (target && target.matches('button[data-target]')) {
const id = target.getAttribute('data-target');
const realBtn = document.getElementById(id);
if (realBtn) {
realBtn.click();
}
mainMenuDropdown.classList.remove('open');
}
});
}
// Settings modal wiring (reusing existing settings endpoints).
const settingsButton = document.getElementById('settings-btn');
const settingsModal = document.getElementById('settings-modal');
const settingsCloseButton = document.getElementById('settings-close-btn');
const showSettingsMessage = (text, type = 'success') => {
const messageEl = document.getElementById('settings-message');
if (!messageEl) return;
messageEl.textContent = text;
messageEl.className = `message ${type} show`;
setTimeout(() => {
messageEl.classList.remove('show');
}, 5000);
};
async function loadDeviceSettings() {
try {
const response = await fetch('/settings');
const data = await response.json();
const nameInput = document.getElementById('device-name-input');
if (nameInput && data && typeof data === 'object') {
nameInput.value = data.device_name || 'led-controller';
}
const chInput = document.getElementById('wifi-channel-input');
if (chInput && data && typeof data === 'object') {
const ch = data.wifi_channel;
chInput.value =
ch !== undefined && ch !== null && ch !== '' ? String(ch) : '6';
}
} catch (error) {
console.error('Error loading device settings:', error);
}
}
async function loadAPStatus() {
try {
const response = await fetch('/settings/wifi/ap');
const config = await response.json();
const statusEl = document.getElementById('ap-status');
if (!statusEl) return;
if (config.active) {
statusEl.innerHTML = `
<h4>AP Status: <span class="status-connected">Active</span></h4>
<p><strong>SSID:</strong> ${config.ssid || 'N/A'}</p>
<p><strong>Channel:</strong> ${config.channel || 'Auto'}</p>
<p><strong>IP Address:</strong> ${config.ip || 'N/A'}</p>
`;
} else {
statusEl.innerHTML = `
<h4>AP Status: <span class="status-disconnected">Inactive</span></h4>
<p>Access Point is not currently active</p>
`;
}
if (config.saved_ssid) document.getElementById('ap-ssid').value = config.saved_ssid;
if (config.saved_channel) document.getElementById('ap-channel').value = config.saved_channel;
} catch (error) {
console.error('Error loading AP status:', error);
}
}
if (settingsButton && settingsModal) {
settingsButton.addEventListener('click', () => {
settingsModal.classList.add('active');
// Load current WiFi status/config when opening
loadDeviceSettings();
loadAPStatus();
});
}
if (settingsCloseButton && settingsModal) {
settingsCloseButton.addEventListener('click', () => {
settingsModal.classList.remove('active');
});
}
const deviceForm = document.getElementById('device-form');
if (deviceForm) {
deviceForm.addEventListener('submit', async (e) => {
e.preventDefault();
const nameInput = document.getElementById('device-name-input');
const deviceName = nameInput ? nameInput.value.trim() : '';
if (!deviceName) {
showSettingsMessage('Device name is required', 'error');
return;
}
const chRaw = document.getElementById('wifi-channel-input')
? document.getElementById('wifi-channel-input').value
: '6';
const wifiChannel = parseInt(chRaw, 10);
if (Number.isNaN(wifiChannel) || wifiChannel < 1 || wifiChannel > 11) {
showSettingsMessage('WiFi channel must be between 1 and 11', 'error');
return;
}
try {
const response = await fetch('/settings/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
device_name: deviceName,
wifi_channel: wifiChannel,
}),
});
const result = await response.json();
if (response.ok) {
showSettingsMessage(
'Device settings saved. They will apply on next restart where relevant.',
'success',
);
} else {
showSettingsMessage(`Error: ${result.error || 'Failed to save device name'}`, 'error');
}
} catch (error) {
showSettingsMessage(`Error: ${error.message}`, 'error');
}
});
}
const apForm = document.getElementById('ap-form');
if (apForm) {
apForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = {
ssid: document.getElementById('ap-ssid').value,
password: document.getElementById('ap-password').value,
channel: document.getElementById('ap-channel').value || null,
};
if (formData.password && formData.password.length > 0 && formData.password.length < 8) {
showSettingsMessage('AP password must be at least 8 characters', 'error');
return;
}
if (formData.channel) {
formData.channel = parseInt(formData.channel, 10);
if (formData.channel < 1 || formData.channel > 11) {
showSettingsMessage('Channel must be between 1 and 11', 'error');
return;
}
}
try {
const response = await fetch('/settings/wifi/ap', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
const result = await response.json();
if (response.ok) {
showSettingsMessage('Access Point configured successfully!', 'success');
setTimeout(loadAPStatus, 1000);
} else {
showSettingsMessage(`Error: ${result.error || 'Failed to configure AP'}`, 'error');
}
} catch (error) {
showSettingsMessage(`Error: ${error.message}`, 'error');
}
});
}
});

1
src/static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,75 +0,0 @@
body {
font-family: Arial, sans-serif;
max-width: 600px;
margin: 0 auto;
padding: 20px;
line-height: 1.6;
}
h1 {
text-align: center;
}
form {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
}
input[type="text"], input[type="submit"], input[type="range"], input[type="color"] {
width: 100%;
margin-bottom: 10px;
box-sizing: border-box;
}
input[type="range"] {
-webkit-appearance: none;
appearance: none;
height: 25px;
background: #d3d3d3;
outline: none;
opacity: 0.7;
transition: opacity .2s;
}
input[type="range"]:hover {
opacity: 1;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 25px;
height: 25px;
background: #4CAF50;
cursor: pointer;
border-radius: 50%;
}
input[type="range"]::-moz-range-thumb {
width: 25px;
height: 25px;
background: #4CAF50;
cursor: pointer;
border-radius: 50%;
}
#pattern_buttons {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 20px;
}
#pattern_buttons button {
flex: 1 0 calc(33.333% - 10px);
padding: 10px;
background-color: #4CAF50;
color: white;
border: none;
cursor: pointer;
transition: background-color 0.3s;
}
#pattern_buttons button:hover {
background-color: #45a049;
}
@media (max-width: 480px) {
#pattern_buttons button {
flex: 1 0 calc(50% - 10px);
}
}

View File

@@ -1,156 +1,81 @@
let delayTimeout;
let brightnessTimeout;
let colorTimeout;
let color2Timeout;
let socket;
import "./rgb-slider.js";
const host = window.location.host;
const ws = new WebSocket("ws://localhost:8000/ws");
async function post(path, data) {
console.log(`POST to ${path}`, data);
try {
const response = await fetch(path, {
method: "POST",
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data) // Convert data to JSON string
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
} catch (error) {
console.error('Error during POST request:', error);
ws.onopen = () => {
console.log("WebSocket connection established");
};
ws.onclose = () => {
console.log("WebSocket connection closed");
};
ws.onerror = (error) => {
console.error("WebSocket error:", error);
};
// Number of sliders (tabs) you want to create
const numTabs = 3;
// Select the container for tabs and content
const tabsContainer = document.querySelector(".tabs");
const tabContentContainer = document.querySelector(".tab-content");
// Create tabs dynamically
for (let i = 1; i <= numTabs; i++) {
// Create the tab button
const tabButton = document.createElement("button");
tabButton.classList.add("tab");
tabButton.id = `tab${i}`;
tabButton.textContent = `Tab ${i}`;
// Add the tab button to the container
tabsContainer.appendChild(tabButton);
// Create the corresponding tab content (RGB slider)
const tabContent = document.createElement("div");
tabContent.classList.add("tab-pane");
tabContent.id = `content${i}`;
const slider = document.createElement("rgb-slider");
slider.id = i;
tabContent.appendChild(slider);
// Add the tab content to the container
tabContentContainer.appendChild(tabContent);
// Listen for color change on each RGB slider
slider.addEventListener("color-change", (e) => {
const { r, g, b } = e.detail;
console.log(`Color changed in tab ${i}:`, e.detail);
// Send RGB data to WebSocket server
if (ws.readyState === WebSocket.OPEN) {
const colorData = { r, g, b };
ws.send(JSON.stringify(colorData));
}
});
}
async function get(path) {
try {
const response = await fetch(path);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return await response.json(); // Assuming you are expecting JSON response
} catch (error) {
console.error('Error during GET request:', error);
}
// Function to switch tabs
function switchTab(tabId) {
const tabs = document.querySelectorAll(".tab");
const tabContents = document.querySelectorAll(".tab-pane");
tabs.forEach((tab) => tab.classList.remove("active"));
tabContents.forEach((content) => content.classList.remove("active"));
// Activate the clicked tab and corresponding content
document.getElementById(tabId).classList.add("active");
document
.getElementById("content" + tabId.replace("tab", ""))
.classList.add("active");
}
async function updateColor(event) {
event.preventDefault();
clearTimeout(colorTimeout);
colorTimeout = setTimeout(async function() {
const color = document.getElementById('color').value;
await post("/color", { color }); // Send as JSON
}, 500);
}
async function updateColor2(event) {
event.preventDefault();
clearTimeout(color2Timeout);
color2Timeout = setTimeout(async function() {
const color = document.getElementById('color2').value;
await post("/color2", { color }); // Send as JSON
}, 500);
}
async function updatePattern(pattern) {
await post("/pattern", { pattern }); // Send as JSON
//socket.send(JSON.stringify({"selected_pattern":pattern}))
}
async function updateBrightness(event) {
event.preventDefault();
clearTimeout(brightnessTimeout);
brightnessTimeout = setTimeout(async function() {
const brightness = document.getElementById('brightness').value;
//await post('/brightness', { brightness }); // Send as JSON
}, 500);
}
async function updateDelay(event) {
event.preventDefault();
clearTimeout(delayTimeout);
delayTimeout = setTimeout(async function() {
const delay = document.getElementById('delay').value;
await post('/delay', { delay }); // Send as JSON
}, 500);
}
async function updateNumLeds(event) {
event.preventDefault();
const numLeds = document.getElementById('num_leds').value;
await post('/num_leds', { num_leds: numLeds }); // Send as JSON
}
async function updateWifi(event) {
event.preventDefault();
const ssid = document.getElementById('ssid').value;
const password = document.getElementById('password').value;
const ip = document.getElementById('ip').value;
const gateway = document.getElementById('gateway').value;
const wifiSettings = { ssid, password, ip, gateway }; // Create JSON object
console.log(wifiSettings);
const response = await post('/wifi_settings', wifiSettings); // Send as JSON
if (response === 500) {
alert("Failed to connect to Wi-Fi");
}
}
function createPatternButtons(patterns) {
const container = document.getElementById('pattern_buttons');
container.innerHTML = ''; // Clear previous buttons
patterns.forEach(pattern => {
const button = document.createElement('button');
button.type = 'button'; // Use 'button' instead of 'submit'
button.textContent = pattern;
button.value = pattern;
button.addEventListener('click', async function(event) {
event.preventDefault();
await updatePattern(pattern);
});
container.appendChild(button);
});
}
document.addEventListener('DOMContentLoaded', async function() {
document.getElementById('color').addEventListener('input', updateColor);
document.getElementById('color2').addEventListener('input', updateColor2);
document.getElementById('delay').addEventListener('input', updateDelay);
document.getElementById('brightness').addEventListener('input', updateBrightness);
document.getElementById('num_leds_form').addEventListener('submit', updateNumLeds);
document.getElementById('wifi_form').addEventListener('submit', updateWifi);
document.getElementById('delay').addEventListener('touchend', updateDelay);
document.getElementById('brightness').addEventListener('touchend', updateBrightness);
document.querySelectorAll(".pattern_button").forEach(button => {
console.log(button.value);
button.addEventListener('click', async event => {
event.preventDefault();
await updatePattern(button.value);
});
});
socket = new WebSocket(`ws://${host}/settings`)
// Add event listeners to tabs
tabsContainer.addEventListener("click", (e) => {
if (e.target.classList.contains("tab")) {
switchTab(e.target.id);
}
});
// Function to toggle the display of the settings menu
function selectSettings() {
const settingsMenu = document.getElementById('settings_menu');
controls = document.getElementById('controls');
settingsMenu.style.display = 'block';
controls.style.display = 'none';
}
function selectControls() {
const settingsMenu = document.getElementById('settings_menu');
controls = document.getElementById('controls');
settingsMenu.style.display = 'none';
controls.style.display = 'block';
}
// Initially set the first tab as active
switchTab("tab1");

81
src/static/patterns.js Normal file
View File

@@ -0,0 +1,81 @@
document.addEventListener('DOMContentLoaded', () => {
const patternsButton = document.getElementById('patterns-btn');
const patternsModal = document.getElementById('patterns-modal');
const patternsCloseButton = document.getElementById('patterns-close-btn');
const patternsList = document.getElementById('patterns-list');
if (!patternsButton || !patternsModal || !patternsList) {
return;
}
const renderPatterns = (patterns) => {
patternsList.innerHTML = '';
const entries = Object.entries(patterns || {});
if (!entries.length) {
const empty = document.createElement('p');
empty.className = 'muted-text';
empty.textContent = 'No patterns found.';
patternsList.appendChild(empty);
return;
}
entries.forEach(([patternName, data]) => {
const row = document.createElement('div');
row.className = 'profiles-row';
const label = document.createElement('span');
label.textContent = patternName;
const details = document.createElement('span');
const minDelay = data && data.min_delay !== undefined ? data.min_delay : '-';
const maxDelay = data && data.max_delay !== undefined ? data.max_delay : '-';
details.textContent = `${minDelay}${maxDelay} ms`;
details.style.color = '#aaa';
details.style.fontSize = '0.85em';
row.appendChild(label);
row.appendChild(details);
patternsList.appendChild(row);
});
};
const loadPatterns = async () => {
patternsList.innerHTML = '';
const loading = document.createElement('p');
loading.className = 'muted-text';
loading.textContent = 'Loading patterns...';
patternsList.appendChild(loading);
try {
const response = await fetch('/patterns', {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error('Failed to load patterns');
}
const patterns = await response.json();
renderPatterns(patterns);
} catch (error) {
console.error('Load patterns failed:', error);
patternsList.innerHTML = '';
const errorMessage = document.createElement('p');
errorMessage.className = 'muted-text';
errorMessage.textContent = 'Failed to load patterns.';
patternsList.appendChild(errorMessage);
}
};
const openModal = () => {
patternsModal.classList.add('active');
loadPatterns();
};
const closeModal = () => {
patternsModal.classList.remove('active');
};
patternsButton.addEventListener('click', openModal);
if (patternsCloseButton) {
patternsCloseButton.addEventListener('click', closeModal);
}
});

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