48 Commits

Author SHA1 Message Date
ace5770b3a refactor(api): complete fastapi migration and related features
Finish native FastAPI controllers, drop vendored microdot, and add
Wi-Fi driver runtime, beat SSE, simulated BPM, sequence playback
improvements, bridge ESP-NOW sources, UI updates, and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 22:55:28 +12:00
cb9758b97b fix(api): align zone content kind validation with model
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 10:33:42 +12:00
aab62efd4f feat(ui): refresh layout, help assets, and panel styling
Update the main template and client scripts for the revised navigation
and zone/device panels, and add bundled help SVG assets under static.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 10:33:41 +12:00
2382ef16a1 refactor(api): migrate server to fastapi and uvicorn
Replace the Microdot-only entrypoint with a CombinedASGI app that
handles FastAPI routes (audio API, websocket, dev live-reload) while
delegating the rest to Microdot. Suppress noisy /__dev/ access logs
during live-reload polling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 10:33:38 +12:00
cfdd6de291 docs(espnow): update docs and tests for p2p merge
Align API, architecture, and help with devices envelope transport,
bridge wifi/serial settings, and MAC-keyed device registry. Fix
endpoint tests for envelope identify payloads; remove obsolete p2p.py.
Bump led-tool for --serial-usb bridge provisioning.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 21:10:06 +12:00
d682753e42 chore(submodules): bump led-driver
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:01:01 +12:00
53976cdd70 chore(scripts): add mpremote ESP-NOW ch5 send helpers
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:01:01 +12:00
94635a8cc7 chore(db): add devices to test group
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:01:01 +12:00
de0547615c feat(ui): add device from devices modal
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:00:59 +12:00
78dc8ffc77 feat(bridge): add wifi/serial bridge runtime and UI
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 00:38:21 +12:00
2cf019079e chore(submodules): bump led-driver and led-tool
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 22:03:24 +12:00
b87382d2be feat(espnow): broadcast delivery with group-filtered routing
Send presets and select on broadcast with groups; unicast only for
per-device settings. V1 select as [preset_id, step?]. Sequence steps
use beat counts; manual presets get select each beat, auto only on
step change. Bridge downlink router, Pi envelope delivery, and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 01:44:28 +12:00
1a69fabd98 fix(espnow): bridge async rx, uplink framing, driver RX handling
Bridge uses async for on AIOESPNow, pack_ws_uplink to Pi, AP channel
from settings. Driver applies binary wire and JSON commands on receive.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 22:45:13 +12:00
4fc3f46866 feat(espnow): Pi bridge client, binary wire, and espnow-sender firmware
Replace serial/Wi-Fi driver transport paths with WebSocket bridge client,
binary espnow_wire delivery, device announce registry, and restructured
espnow-sender (AP + broadcast passthrough). Includes docs and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 22:44:44 +12:00
f4ef85c182 chore(db): add test group and enable auto on chase/pulse
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 11:07:37 +12:00
f02eaa6bad chore(submodules): bump led-tool for Web Serial fixes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 00:23:21 +12:00
7015032f5c test: cover zone content kind lock and sequence groups
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 00:23:21 +12:00
d7a3fa96c5 feat(db): add Winter profile with 2x3 grid sequences
Winter profile, scoped groups, presets, and five multi-lane sequences;
include setup script for regeneration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 00:23:21 +12:00
7a7bedc07c fix(sequences): target only checked lane groups
Use zone group checkboxes in the editor; empty lane groups no longer
fall back to the whole zone. Remove cross-lane device splitting.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 00:23:21 +12:00
baec87068a feat(ui): lock zone type and start audio from BPM
Zone preset vs sequence is fixed at create; edit shows read-only type.
Header BPM button starts beat detection when the detector is stopped.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 00:23:15 +12:00
b140aedf00 chore(submodules): bump led-tool for settings editor
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 14:54:24 +12:00
15f8c8a039 fix(wifi): limit outbound driver WS to hello-triggered attempts
Remove periodic UDP hello loop; dial each driver at most
wifi_driver_initial_connect_attempts times per discovery hello.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 14:54:22 +12:00
70641c63af feat(led-tool): embed settings editor in main UI
Serve led-tool static editor at /led-tool/editor, filter host serial
ports, and load the modal via iframe instead of the legacy form.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 14:54:18 +12:00
ef15c54593 chore(submodules): bump led-driver and led-tool for file_hashes deploy
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 19:14:54 +12:00
301e1c64bf test: cover audio, sequences, pattern direction, and settings
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 18:32:12 +12:00
c286e504eb feat(ui): numpad, audio readout, and sequence beat controls
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 18:32:12 +12:00
964cfc6d91 feat(audio-sequences): beat phase sync and aligned playback
Add bar-phase tracking, audio reset/anchor APIs, BPM holdover, beat-phase
sequence switching, sync-phase endpoint, and sample sequence data.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 18:32:10 +12:00
7ecb5c3b3e chore(submodules): bump led-driver for pattern reverse
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 18:32:07 +12:00
879db2a7df chore(submodules): bump led-driver and led-tool
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 21:14:57 +12:00
96d1e1b5fd feat(ui): pattern modes, bundles, and zone content kind
Add profile/preset/sequence JSON import and export; map preset mode to
wire n6 with a mode dropdown for multi-mode patterns; zone edit shows
presets or sequences only with content_kind on save; update catalogue
and tests for merged pattern names.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 21:12:42 +12:00
6286297646 feat(patterns): register northern wave, candle glow, starfall, ice sparkle
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 15:11:33 +12:00
ca3fef3f8a feat(patterns): winter icicles blizzard rime in controller catalogue
Register pattern metadata and test presets for new led-driver effects; bump led-driver submodule.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 15:10:02 +12:00
6c9e06f33b feat(zones): profile-scoped groups, zone modes, sequence brightness
- Optional profile_id on groups; UI and API for shared vs profile-only groups\n- Zone content_kind (presets vs sequences); edit modal shows matching sections; devices via groups only\n- Server sequence playback folds zone brightness into preset wire b (per MAC where needed)\n- Related preset/sequence/audio/beat-route and client updates

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 01:58:00 +12:00
c1c3e5d71b feat(ui): edit tab zones, audio readout, live reload
- Zones/presets/sequence strip and Pipfile dev command fix
- Optional live reload and beat test audio asset + generator

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 00:44:20 +12:00
c64dd736f2 feat(api): parallel group sends and batch identify
- asyncio.gather for group brightness and driver-config Wi-Fi pushes
- Batch identify envelope for group members

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 00:44:13 +12:00
cad0aa7e59 feat(sequences): multi-lane playback and per-lane manual beats
- Add sequence_playback with beat and time advance, zone targeting fixes
- Per-lane manual beat routing in beat_driver_route (parallel lanes)
- Sequence API, editor JS, fix sequence model filename, tests

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 00:44:08 +12:00
0ae39ab94b chore(release): beta-1.03
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 16:55:07 +12:00
822d9d8e01 feat(audio): move beat routing server-side and extend presets
Route beat-triggered manual selects from the controller server, add preset background and beat-counter UI support, and bump led-driver to include the matching pattern/runtime fixes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-09 20:08:05 +12:00
1db905eaae feat(patterns): add supports_manual metadata in db/pattern.json
Allow staging db/pattern.json by replacing blanket db/ ignore with a whitelist for tracked db files.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-09 17:06:56 +12:00
3d6ef5c7b4 chore(git): stop tracking runtime db state files
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 20:35:50 +12:00
78a4ce009c feat(ui): refresh preset data flow and bump driver pointer
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 20:28:56 +12:00
7ccab6fbc4 feat(zones): persist per-zone brightness and update submodules
Store zone brightness in model/data flow, apply it in the zones UI, and record updated led-driver, led-simulator, and led-tool submodule pointers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 22:49:06 +12:00
pi
827eb97203 feat(settings): server global brightness and Wi-Fi driver resync
- Serve GET /settings as JSON by removing duplicate HTML route (use /settings/page for the standalone UI).

- Save global_brightness via PUT; broadcast to connected drivers; push saved level when outbound WS connects.

- Zones UI loads brightness from GET /settings only (no localStorage).

- Bump led-driver submodule for settings.save on brightness with save flag.

- Extend API doc and endpoint tests for global_brightness.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 22:15:30 +12:00
pi
3cca0cffc5 chore: bump led-tool and led-driver submodules
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 21:27:32 +12:00
pi
d36828bde2 feat(ui): persist header brightness slider in localStorage
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 21:27:31 +12:00
pi
ed0048c795 chore(service): avoid network-online stall and speed pipenv boot
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 21:27:31 +12:00
pi
b316edbaf9 fix(wifi): stagger driver ws dials and extend initial retry window
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 21:27:31 +12:00
c1b0c41ef2 fix(transport): disable UART ESP-NOW bridge by default
Require serial_enabled true in settings to open serial_port; default false in
set_defaults for Wi-Fi-only and dev machines.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 15:07:16 +12:00
201 changed files with 27359 additions and 4497 deletions

View File

@@ -7,6 +7,8 @@ alwaysApply: true
1. When creating a new pattern under `led-driver/src/patterns/`, also add/update a corresponding test file in `led-driver/tests/patterns/`.
2. When adding a new pattern, ensure led-controller has `db/pattern.json`; if it does not exist, create it. Add the new pattern metadata and parameter mappings there.
2. When adding a new pattern, ensure led-controller has `db/pattern.json`; if it does not exist, create it. Add the new pattern metadata and parameter mappings there. Optionally set **`supports_manual`** to `false` when the pattern is a poor fit for manual mode or audio beat triggers (smooth/blended animations); omit or `true` otherwise.
3. When adding a new pattern, add at least one test preset entry in `db/preset.json` in led-controller that uses the new pattern.
4. For any pattern that supports both auto and manual modes, keep behaviour parity unless explicitly requested otherwise: background colour handling, colour-cycling order, and parameter timing semantics (e.g. `n2`/`n3` meaning) must match between auto and manual paths.

View File

@@ -0,0 +1,18 @@
---
description: Keep led-driver and led-tool git submodules in sync when updating led-controller
alwaysApply: true
---
# Submodule pointers (`led-driver`, `led-tool`)
This repo tracks **`led-driver`** and **`led-tool`** as git submodules (see `.gitmodules`).
When you **update led-controller** work that should ship with matching firmware or CLI behaviour—or when you finish changes **inside** those submodule directories—**record the new submodule commits in the parent repo**:
1. In each submodule, commit and push on its remote if there are local commits (or ensure the checkout is the intended revision).
2. From the **led-controller** root: `git add led-driver led-tool` after their HEADs point at the right commits.
3. Include the parent-repo commit that bumps the gitlinks (so CI and clones get consistent trees).
**Do not** leave submodule directories dirty or forgotten while presenting the parent repo as “done”: either commit the submodule pointer update in led-controller, or leave an explicit note if the user must push submodule remotes first.
If the user only asked for a submodule bump with no code edits, a single `chore(submodules): bump led-driver and led-tool` style commit is appropriate (see commit rule).

12
.gitignore vendored
View File

@@ -25,8 +25,20 @@ ENV/
Thumbs.db
# Project specific
scripts/.led-controller-venv
docs/.help-print.html
settings.json
# Track shared JSON + preset binaries; ignore other db/*.json (e.g. device, zone) locally
db/*
!db/group.json
!db/palette.json
!db/pattern.json
!db/preset.json
!db/profile.json
!db/scene.json
!db/sequence.json
!db/presets/
!db/presets/*.bin
*.log
*.db
*.sqlite

23
Pipfile
View File

@@ -6,25 +6,32 @@ name = "pypi"
[packages]
mpremote = "*"
pyserial = "*"
pyserial-asyncio = "*"
esptool = "*"
pyjwt = "*"
watchfiles = "*"
requests = "*"
selenium = "*"
adafruit-ampy = "*"
microdot = "*"
fastapi = "*"
python-multipart = "*"
websockets = "*"
httpx = "*"
numpy = "*"
sounddevice = "*"
uvicorn = {extras = ["standard"], version = "*"}
[dev-packages]
pytest = "*"
[requires]
python_version = "3.12"
python_version = "3.11"
[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"
web = "python tests/web.py"
watch = "python -m watchfiles \"python tests/web.py\" src tests"
run = "sh -c 'cd src && uvicorn fastapi_app:app --host 0.0.0.0 --port \"${PORT:-80}\"'"
dev = "sh -c 'cd src && LED_CONTROLLER_LIVE_RELOAD=1 uvicorn fastapi_app:app --host 0.0.0.0 --port \"${PORT:-80}\" --reload --reload-dir . --reload-include \"**/*.html\" --reload-include \"**/*.css\" --reload-include \"**/*.js\" --reload-exclude \"**/db/**\" --reload-exclude \"**/settings.json\" --reload-exclude \"**/settings.json.*\"'"
test = "python -m pytest"
test-browser = "sh -c 'python tests/web.py > /tmp/led-controller-web.log 2>&1 & pid=$!; trap \"kill $pid\" EXIT; sleep 2; LED_CONTROLLER_RUN_BROWSER_TESTS=1 LED_CONTROLLER_DEVICE_IP=http://127.0.0.1:5000 python -m pytest tests/test_browser.py'"
test-browser-device = "sh -c 'LED_CONTROLLER_RUN_BROWSER_TESTS=1 python -m pytest tests/test_browser.py'"

832
Pipfile.lock generated
View File

@@ -1,11 +1,11 @@
{
"_meta": {
"hash": {
"sha256": "18691f772c7660e4a087c90560c87a9217a09e9b6db97825d21c092a06d64b89"
"sha256": "898e7932e8decb3f1b5e1fd620883f2727cbd2f1c1295d8cd559105172d814cb"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.12"
"python_version": "3.11"
},
"sources": [
{
@@ -24,6 +24,22 @@
"index": "pypi",
"version": "==1.1.0"
},
"annotated-doc": {
"hashes": [
"sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320",
"sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"
],
"markers": "python_version >= '3.8'",
"version": "==0.0.4"
},
"annotated-types": {
"hashes": [
"sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53",
"sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"
],
"markers": "python_version >= '3.8'",
"version": "==0.7.0"
},
"anyio": {
"hashes": [
"sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708",
@@ -159,11 +175,11 @@
},
"certifi": {
"hashes": [
"sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa",
"sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"
"sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897",
"sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d"
],
"markers": "python_version >= '3.7'",
"version": "==2026.2.25"
"version": "==2026.5.20"
},
"cffi": {
"hashes": [
@@ -252,7 +268,7 @@
"sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453",
"sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"
],
"markers": "python_full_version >= '3.9' and platform_python_implementation != 'PyPy'",
"markers": "python_version >= '3.9'",
"version": "==2.0.0"
},
"charset-normalizer": {
@@ -392,74 +408,83 @@
},
"click": {
"hashes": [
"sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5",
"sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d"
"sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2",
"sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96"
],
"markers": "python_version >= '3.10'",
"version": "==8.3.2"
"version": "==8.4.1"
},
"cryptography": {
"hashes": [
"sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65",
"sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832",
"sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067",
"sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de",
"sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4",
"sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0",
"sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b",
"sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968",
"sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef",
"sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b",
"sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4",
"sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3",
"sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308",
"sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e",
"sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163",
"sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f",
"sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee",
"sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77",
"sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85",
"sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99",
"sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7",
"sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83",
"sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85",
"sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006",
"sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb",
"sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e",
"sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba",
"sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325",
"sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d",
"sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1",
"sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1",
"sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2",
"sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0",
"sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455",
"sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842",
"sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457",
"sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15",
"sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2",
"sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c",
"sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb",
"sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5",
"sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4",
"sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902",
"sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246",
"sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022",
"sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f",
"sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e",
"sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298",
"sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce"
"sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13",
"sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6",
"sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8",
"sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25",
"sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c",
"sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832",
"sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12",
"sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c",
"sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7",
"sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c",
"sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec",
"sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5",
"sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355",
"sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c",
"sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741",
"sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86",
"sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321",
"sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a",
"sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7",
"sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920",
"sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e",
"sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff",
"sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd",
"sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3",
"sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f",
"sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602",
"sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855",
"sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18",
"sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a",
"sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336",
"sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239",
"sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74",
"sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a",
"sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c",
"sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4",
"sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c",
"sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f",
"sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4",
"sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db",
"sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166",
"sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5",
"sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f",
"sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae",
"sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20",
"sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a",
"sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057",
"sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb",
"sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c",
"sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b"
],
"markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'",
"version": "==46.0.7"
"markers": "python_version >= '3.9' and python_full_version not in '3.9.0, 3.9.1'",
"version": "==48.0.0"
},
"esptool": {
"hashes": [
"sha256:9c355b7d6331cc92979cc710ae5c41f59830d1ea29ec24c467c6005a092c06d6"
"sha256:0a077cb3ee8e60e223882c06ab7dae9b3686816c2547904d7472a42e6284e7de"
],
"index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==5.2.0"
"version": "==5.3.0"
},
"fastapi": {
"hashes": [
"sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620",
"sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab"
],
"index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==0.136.3"
},
"h11": {
"hashes": [
@@ -469,13 +494,85 @@
"markers": "python_version >= '3.8'",
"version": "==0.16.0"
},
"idna": {
"httpcore": {
"hashes": [
"sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea",
"sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"
"sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55",
"sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"
],
"markers": "python_version >= '3.8'",
"version": "==3.11"
"version": "==1.0.9"
},
"httptools": {
"hashes": [
"sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683",
"sha256:0ea897f0c729581ebf72131a438a7932d9b14efef72d75ada966700cac3caaeb",
"sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b",
"sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527",
"sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124",
"sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca",
"sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081",
"sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c",
"sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77",
"sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09",
"sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f",
"sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085",
"sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376",
"sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5",
"sha256:5d7fa4ba7292c1139c0526f0b5aad507c6263c948206ea1b1cbca015c8af1b62",
"sha256:5eb911c515b96ee44bbd861e42cbefc488681d450545b1d02127f6136e3a86f5",
"sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8",
"sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681",
"sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999",
"sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1",
"sha256:7b71e7d7031928c650e1006e6c03e911bf967f7c69c011d37d541c3e7bf55005",
"sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d",
"sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d",
"sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d",
"sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d",
"sha256:9878eb2785ba5eb70631ad269b37976f73d647955e26c91d490eb8a4edfda4ba",
"sha256:9fc1644f415372cec4f8a5be3a64183737398f10dbb1263602a036427fe75247",
"sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745",
"sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07",
"sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b",
"sha256:a6f21e2a3b0067bbe7f67e34cfd16276af556e5e52f4c7503be0cb5f90e905e4",
"sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2",
"sha256:b205e5f5523fa039679da0dfe5a10132b2a4abeae6a86fdd1ddc035f7f836557",
"sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d",
"sha256:bf3b6f807c8541503cecfbb8a8dffb385640d0d96102f3d112aa8740f9b7c826",
"sha256:c08ffe3e79756e0963cbc8fe410139f38a5884874b6f2e17761bef6563fdcd9b",
"sha256:c0d726cc107fceb7d45f978483b4b70dd8caa836f5914d3434bb18628eb73813",
"sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0",
"sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150",
"sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e",
"sha256:da684f2e1aa2ee9bdcb083f3f3a68c5956750b375bc5df864d3a5f0c42a40b77",
"sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568",
"sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6",
"sha256:df31ef5494f406ab6cf827b7e64a22841c6e2d654100e6a116ea15b46d02d5e8",
"sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b",
"sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7",
"sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168",
"sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a",
"sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0",
"sha256:fe2a4c95aeba2209434e7b31172da572846cae8ca0bf1e7013e61b99fbbf5e72"
],
"version": "==0.8.0"
},
"httpx": {
"hashes": [
"sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc",
"sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
"version": "==0.28.1"
},
"idna": {
"hashes": [
"sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2",
"sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"
],
"markers": "python_version >= '3.9'",
"version": "==3.18"
},
"intelhex": {
"hashes": [
@@ -486,11 +583,11 @@
},
"markdown-it-py": {
"hashes": [
"sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147",
"sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"
"sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49",
"sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a"
],
"markers": "python_version >= '3.10'",
"version": "==4.0.0"
"version": "==4.2.0"
},
"mdurl": {
"hashes": [
@@ -500,15 +597,6 @@
"markers": "python_version >= '3.7'",
"version": "==0.1.2"
},
"microdot": {
"hashes": [
"sha256:363e3ebfc80b7e0415779848c9332e4e7fb7bd365ee54d3620abffe42ed82946",
"sha256:abfb82ca31cc430174e4761cc7356adc4bff00ea758d437c2b258883dc63f464"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
"version": "==2.6.0"
},
"mpremote": {
"hashes": [
"sha256:2df2a50f3c8098cae8c732dbf2541e7e58185e7896513b45d05196901e049334",
@@ -518,6 +606,85 @@
"markers": "python_version >= '3.4'",
"version": "==1.28.0"
},
"numpy": {
"hashes": [
"sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1",
"sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4",
"sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f",
"sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079",
"sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096",
"sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47",
"sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66",
"sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d",
"sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1",
"sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e",
"sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147",
"sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd",
"sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75",
"sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063",
"sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73",
"sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab",
"sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4",
"sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41",
"sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402",
"sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698",
"sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7",
"sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8",
"sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b",
"sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8",
"sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0",
"sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662",
"sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91",
"sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0",
"sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f",
"sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3",
"sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f",
"sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67",
"sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6",
"sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997",
"sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b",
"sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e",
"sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538",
"sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627",
"sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93",
"sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02",
"sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853",
"sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c",
"sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43",
"sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd",
"sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8",
"sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089",
"sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778",
"sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1",
"sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb",
"sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261",
"sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb",
"sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a",
"sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8",
"sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359",
"sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5",
"sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7",
"sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751",
"sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8",
"sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605",
"sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e",
"sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45",
"sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2",
"sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895",
"sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe",
"sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb",
"sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a",
"sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577",
"sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d",
"sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a",
"sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda",
"sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6",
"sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20"
],
"index": "pypi",
"markers": "python_version >= '3.11'",
"version": "==2.4.6"
},
"outcome": {
"hashes": [
"sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8",
@@ -528,11 +695,11 @@
},
"platformdirs": {
"hashes": [
"sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a",
"sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917"
"sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7",
"sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a"
],
"markers": "python_version >= '3.10'",
"version": "==4.9.6"
"version": "==4.10.0"
},
"pycparser": {
"hashes": [
@@ -542,6 +709,140 @@
"markers": "implementation_name != 'PyPy'",
"version": "==3.0"
},
"pydantic": {
"hashes": [
"sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba",
"sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6"
],
"markers": "python_version >= '3.9'",
"version": "==2.13.4"
},
"pydantic-core": {
"hashes": [
"sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0",
"sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262",
"sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda",
"sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0",
"sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e",
"sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b",
"sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594",
"sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29",
"sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2",
"sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c",
"sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d",
"sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398",
"sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d",
"sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3",
"sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f",
"sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb",
"sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7",
"sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5",
"sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9",
"sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462",
"sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4",
"sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b",
"sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d",
"sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df",
"sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2",
"sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0",
"sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519",
"sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd",
"sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7",
"sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac",
"sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6",
"sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565",
"sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898",
"sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb",
"sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928",
"sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6",
"sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3",
"sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a",
"sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596",
"sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987",
"sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e",
"sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d",
"sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712",
"sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008",
"sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd",
"sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1",
"sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be",
"sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea",
"sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292",
"sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33",
"sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3",
"sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4",
"sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b",
"sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826",
"sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac",
"sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7",
"sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d",
"sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf",
"sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4",
"sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc",
"sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15",
"sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3",
"sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b",
"sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914",
"sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04",
"sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c",
"sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b",
"sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9",
"sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce",
"sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4",
"sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a",
"sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f",
"sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424",
"sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894",
"sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9",
"sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76",
"sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201",
"sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb",
"sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109",
"sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4",
"sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848",
"sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526",
"sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0",
"sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01",
"sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458",
"sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e",
"sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba",
"sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a",
"sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39",
"sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c",
"sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000",
"sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b",
"sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf",
"sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4",
"sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd",
"sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28",
"sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9",
"sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30",
"sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983",
"sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1",
"sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76",
"sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5",
"sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4",
"sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7",
"sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c",
"sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066",
"sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3",
"sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02",
"sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89",
"sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50",
"sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76",
"sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49",
"sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b",
"sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d",
"sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7",
"sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4",
"sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c",
"sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e",
"sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff",
"sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae"
],
"markers": "python_version >= '3.9'",
"version": "==2.46.4"
},
"pygments": {
"hashes": [
"sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f",
@@ -552,12 +853,12 @@
},
"pyjwt": {
"hashes": [
"sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c",
"sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"
"sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423",
"sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==2.12.1"
"version": "==2.13.0"
},
"pyserial": {
"hashes": [
@@ -567,6 +868,14 @@
"index": "pypi",
"version": "==3.5"
},
"pyserial-asyncio": {
"hashes": [
"sha256:b6032923e05e9d75ec17a5af9a98429c46d2839adfaf80604d52e0faacd7a32f",
"sha256:de9337922619421b62b9b1a84048634b3ac520e1d690a674ed246a2af7ce1fc5"
],
"index": "pypi",
"version": "==0.6"
},
"pysocks": {
"hashes": [
"sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299",
@@ -583,6 +892,15 @@
"markers": "python_version >= '3.10'",
"version": "==1.2.2"
},
"python-multipart": {
"hashes": [
"sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e",
"sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23"
],
"index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==0.0.32"
},
"pyyaml": {
"hashes": [
"sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c",
@@ -671,12 +989,12 @@
},
"requests": {
"hashes": [
"sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517",
"sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"
"sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0",
"sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"
],
"index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==2.33.1"
"version": "==2.34.2"
},
"rich": {
"hashes": [
@@ -688,20 +1006,20 @@
},
"rich-click": {
"hashes": [
"sha256:022997c1e30731995bdbc8ec2f82819340d42543237f033a003c7b1f843fc5dc",
"sha256:2f99120fca78f536e07b114d3b60333bc4bb2a0969053b1250869bcdc1b5351b"
"sha256:12873865396e6927835d4eabb1cc3996edcd65b7ac9b2391a29eca4f335a2f93",
"sha256:4008f921da88b5d91646c134ec881c1500e5a6b3f093e90e8f29400e09608371"
],
"markers": "python_version >= '3.8'",
"version": "==1.9.7"
"version": "==1.9.8"
},
"selenium": {
"hashes": [
"sha256:4f97639055dcfa9eadf8ccf549ba7b0e49c655d4e2bde19b9a44e916b754e769",
"sha256:bada5c08a989f812728a4b5bea884d8e91894e939a441cc3a025201ce718581e"
"sha256:b03a831fcfcab9d912b4682f60718c48a04560d6c62f7496c16b7498c9a4427e",
"sha256:d01ea3e5ecad8149460a765f7cf5177194c21dcc0173093fc05427c289b1bf24"
],
"index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==4.43.0"
"version": "==4.44.0"
},
"sniffio": {
"hashes": [
@@ -718,6 +1036,27 @@
],
"version": "==2.4.0"
},
"sounddevice": {
"hashes": [
"sha256:05eb9fd6c54c38d67741441c19164c0dae8ce80453af2d8c4ad2e7823d15b722",
"sha256:1234cc9b4c9df97b6cbe748146ae0ec64dd7d6e44739e8e42eaa5b595313a103",
"sha256:22487b65198cb5bf2208755105b524f78ad173e5ab6b445bdab1c989f6698df3",
"sha256:30ff99f6c107f49d25ad16a45cacd8d91c25a1bcdd3e81a206b921a3a6405b1f",
"sha256:3861901ddd8230d2e0e8ae62ac320cdd4c688d81df89da036dcb812f757bb3e6",
"sha256:cfc6b2c49fb7f555591c78cb8ecf48d6a637fd5b6e1db5fec6ed9365d64b3519"
],
"index": "pypi",
"markers": "python_version >= '3.7'",
"version": "==0.5.5"
},
"starlette": {
"hashes": [
"sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89",
"sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6"
],
"markers": "python_version >= '3.10'",
"version": "==1.2.1"
},
"tibs": {
"hashes": [
"sha256:01ea5258bdf942d21560dc07d532082cd04f07cfef65fedd58ae84f7d0d2562a",
@@ -779,132 +1118,203 @@
"markers": "python_version >= '3.9'",
"version": "==4.15.0"
},
"typing-inspection": {
"hashes": [
"sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7",
"sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"
],
"markers": "python_version >= '3.9'",
"version": "==0.4.2"
},
"urllib3": {
"extras": [
"socks"
],
"hashes": [
"sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed",
"sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"
"sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c",
"sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"
],
"markers": "python_version >= '3.9'",
"version": "==2.6.3"
"markers": "python_version >= '3.10'",
"version": "==2.7.0"
},
"uvicorn": {
"extras": [
"standard"
],
"hashes": [
"sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f",
"sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3"
],
"markers": "python_version >= '3.10'",
"version": "==0.49.0"
},
"uvloop": {
"hashes": [
"sha256:017bd46f9e7b78e81606329d07141d3da446f8798c6baeec124260e22c262772",
"sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e",
"sha256:05e4b5f86e621cf3927631789999e697e58f0d2d32675b67d9ca9eb0bca55743",
"sha256:0ae676de143db2b2f60a9696d7eca5bb9d0dd6cc3ac3dad59a8ae7e95f9e1b54",
"sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec",
"sha256:17d4e97258b0172dfa107b89aa1eeba3016f4b1974ce85ca3ef6a66b35cbf659",
"sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8",
"sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad",
"sha256:286322a90bea1f9422a470d5d2ad82d38080be0a29c4dd9b3e6384320a4d11e7",
"sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35",
"sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289",
"sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142",
"sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77",
"sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733",
"sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd",
"sha256:4a968a72422a097b09042d5fa2c5c590251ad484acf910a651b4b620acd7f193",
"sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74",
"sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0",
"sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6",
"sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473",
"sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21",
"sha256:55502bc2c653ed2e9692e8c55cb95b397d33f9f2911e929dc97c4d6b26d04242",
"sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705",
"sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702",
"sha256:57df59d8b48feb0e613d9b1f5e57b7532e97cbaf0d61f7aa9aa32221e84bc4b6",
"sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f",
"sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e",
"sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d",
"sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370",
"sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4",
"sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792",
"sha256:80eee091fe128e425177fbd82f8635769e2f32ec9daf6468286ec57ec0313efa",
"sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079",
"sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2",
"sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86",
"sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6",
"sha256:b45649628d816c030dba3c80f8e2689bab1c89518ed10d426036cdc47874dfc4",
"sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3",
"sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21",
"sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c",
"sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e",
"sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25",
"sha256:c3e5c6727a57cb6558592a95019e504f605d1c54eb86463ee9f7a2dbd411c820",
"sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9",
"sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88",
"sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2",
"sha256:ea721dd3203b809039fcc2983f14608dae82b212288b346e0bfe46ec2fab0b7c",
"sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c",
"sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42"
],
"version": "==0.22.1"
},
"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"
"sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9",
"sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98",
"sha256:027ae72bfdfd254862065d8b3e2a815c6ab9b1853ce41e6648ece84afd34a551",
"sha256:03b14855c6f35539e2d95c442ae9530a75762f1e26567152b9ed05f96534a74d",
"sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7",
"sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db",
"sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69",
"sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242",
"sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925",
"sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f",
"sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5",
"sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5",
"sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427",
"sha256:11743adfa510bfffebe97659fb280182b5c9b238708f667e866f308c3430dc19",
"sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4",
"sha256:204f299afcbd65918ab78dbc52626b0ae45e9d8cef403fdbf33ecf9e40eac66e",
"sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa",
"sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba",
"sha256:24b2405c0a46738dd9e1cf7135aa5dbdb9d42d024628651b3b13d5117e99f8df",
"sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c",
"sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906",
"sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65",
"sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c",
"sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c",
"sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30",
"sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077",
"sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374",
"sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01",
"sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33",
"sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831",
"sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9",
"sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2",
"sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b",
"sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f",
"sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658",
"sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579",
"sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5",
"sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0",
"sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7",
"sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666",
"sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5",
"sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201",
"sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103",
"sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6",
"sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8",
"sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1",
"sha256:77a0feab9af4c021c581f695258c642b3d10c5fd4c676e33a0d8606425d82631",
"sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898",
"sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d",
"sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44",
"sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2",
"sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5",
"sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a",
"sha256:8c520725602756229f045b032a1ff33d7ef0f7404189d62f6c2438cb6d8ef6a1",
"sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b",
"sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc",
"sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5",
"sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377",
"sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8",
"sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add",
"sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281",
"sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9",
"sha256:a16ffe19bf5cf9f5edaa1ad1dd830c5a816e8feec430c522302ab55483a4b994",
"sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0",
"sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e",
"sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0",
"sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28",
"sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7",
"sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55",
"sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb",
"sha256:b62f042afde2dde21ec1d2c1a74361e804673df86f51e418a999c9acfe671b07",
"sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb",
"sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4",
"sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0",
"sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e",
"sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4",
"sha256:bb68bf4df85abebe5efddc53cf2075520f243a59868d9b3973278b23e76962a9",
"sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06",
"sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26",
"sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7",
"sha256:c16cb06dd17d43b9d185094268459eac92c9538356f050e55b54e82cf700e1d4",
"sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3",
"sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3",
"sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838",
"sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71",
"sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488",
"sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717",
"sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d",
"sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44",
"sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2",
"sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b",
"sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2",
"sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22",
"sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6",
"sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e",
"sha256:e1cfd51e97e13ff3bd047c140764d277fc9b95b7cb5da59e46a47d167adab310",
"sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165",
"sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5",
"sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799",
"sha256:eb72919d93e3a16fc451d3aa3d4b1698423daca1b382d3d959c9ac51297c12a8",
"sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7",
"sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379",
"sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925",
"sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72",
"sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4",
"sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08",
"sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==1.1.1"
"markers": "python_version >= '3.10'",
"version": "==1.2.0"
},
"websocket-client": {
"hashes": [
@@ -1002,11 +1412,11 @@
},
"packaging": {
"hashes": [
"sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4",
"sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"
"sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e",
"sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"
],
"markers": "python_version >= '3.8'",
"version": "==26.0"
"version": "==26.2"
},
"pluggy": {
"hashes": [

View File

@@ -1,15 +1,18 @@
# led-controller
LED controller web app for managing profiles, **zones**, presets, and colour palettes, and sending commands to LED devices. Outbound paths include:
LED controller web app for managing profiles, **zones**, presets, and colour palettes, and sending commands to LED devices over **ESP-NOW** (peer-to-peer on 2.4 GHz WiFi radio).
- **Serial → ESP-NOW bridge**: JSON lines over UART to an ESP32 that forwards ESP-NOW frames (configure `serial_port` and baud in `settings.json` / Settings model).
- **Wi-Fi LED drivers**: TCP JSON lines (default port **8765** on the Pi; drivers discover the controller via **UDP 8766** broadcast).
- **Bridge ESP32**: routes Pi traffic to drivers. The Pi connects over **WebSocket** (`bridge_transport`: `wifi`, `bridge_ws_url` e.g. `ws://192.168.4.1/ws`) or **USB serial** (`bridge_transport`: `serial`, `bridge_serial_port`).
- **LED drivers**: announce on boot via ESP-NOW broadcast; the controller registers them (MAC-keyed) and pushes group membership.
- Optional **Wi-Fi drivers** on the LAN still work over UDP discovery + outbound WebSocket.
- Architecture (diagrams): [docs/espnow-architecture.md](docs/espnow-architecture.md)
- Wire format (byte layouts): [docs/espnow-binary-protocol.md](docs/espnow-binary-protocol.md) (≤250 bytes per ESP-NOW frame; Pi ↔ bridge uses JSON devices envelope)
## Run
- One-time setup for port 80 without root: `sudo scripts/setup-port80.sh`
- Start app: `pipenv run run` (override listen port with the **`PORT`** environment variable)
- Dev watcher (auto-restart on `src/` changes): `pipenv run dev`
- Start app: `pipenv run run` (FastAPI + uvicorn; override listen port with **`PORT`**)
- Dev mode (uvicorn **`--reload`** on `src/` + browser refresh via `dev-live-reload.js`): `pipenv run dev`
- Regenerate **`docs/help.pdf`** from **`docs/help.md`**: `pipenv run help-pdf` (requires **pandoc** and **chromium** on the host)
## UI modes

View File

@@ -0,0 +1,49 @@
"""Wi-Fi radio for ESP-NOW only (hidden AP locks channel)."""
import time
import network
from settings import WIFI_CHANNEL_DEFAULT
def _channel(settings):
try:
return max(1, min(11, int(settings.get("wifi_channel", WIFI_CHANNEL_DEFAULT))))
except (TypeError, ValueError):
return WIFI_CHANNEL_DEFAULT
def init_espnow_radio(settings):
ch = _channel(settings)
name = settings.get("name") or "bridge"
password = settings.get("ap_password") or ""
network.WLAN(network.STA_IF).active(False)
network.WLAN(network.AP_IF).active(False)
time.sleep_ms(100)
ap = network.WLAN(network.AP_IF)
ap.active(True)
time.sleep_ms(50)
if password:
try:
ap.config(essid=name, password=password, channel=ch, hidden=True)
except TypeError:
ap.config(essid=name, channel=ch)
else:
try:
ap.config(essid=name, channel=ch, hidden=True)
except TypeError:
ap.config(essid=name, channel=ch)
sta = network.WLAN(network.STA_IF)
sta.active(True)
sta.config(pm=network.WLAN.PM_NONE)
try:
sta.config(channel=ch)
except Exception:
pass
print("espnow radio ch", ch)
return ch

View File

@@ -0,0 +1,7 @@
"""WebSocket uplink framing (Pi ↔ bridge)."""
BROADCAST_MAC = b"\xff\xff\xff\xff\xff\xff"
def pack_ws_uplink(peer, espnow_packet):
return bytes([0]) + peer + espnow_packet

19
bridge-serial/README.md Normal file
View File

@@ -0,0 +1,19 @@
# bridge-serial
ESP32 ESP-NOW bridge with **USB/serial** uplink to the Pi (GPIO UART). Sync loop only — no asyncio, no Microdot.
```
bridge-serial/
src/
main.py # entry
settings.py # /settings.json on device
```
Deploy:
```bash
cd bridge-serial
python ../led-tool/cli.py -p /dev/ttyUSB0 --src -r -f
```
No `--lib` required. Match `serial_baudrate` on the ESP and Pi (e.g. `921600`).

166
bridge-serial/src/main.py Normal file
View File

@@ -0,0 +1,166 @@
"""ESP-NOW bridge: Pi USB-serial downlink, ESP-NOW to drivers (sync loop)."""
import gc, json, struct, time
import espnow, machine, network
from machine import Pin, UART
from settings import Settings
BROADCAST = b"\xff\xff\xff\xff\xff\xff"
WIRE = 0x4C
MAX_SERIAL = 4096
MAX_ESPNOW = 250
ESPNOW_EXIST = -12395
ESPNOW_FULL = -12392
def add_peer_if_needed(esp, dest, ch):
try:
esp.add_peer(dest, channel=ch)
except TypeError:
try:
esp.add_peer(dest)
except OSError as e:
if e.args[0] != ESPNOW_EXIST:
raise
except OSError as e:
if e.args[0] != ESPNOW_EXIST:
raise
def del_peer_if_present(esp, dest):
try:
esp.del_peer(dest)
except Exception:
pass
def send_unicast_temp_peer(esp, dest, ch, pkt):
try:
add_peer_if_needed(esp, dest, ch)
except OSError as e:
if e.args and e.args[0] == ESPNOW_FULL:
del_peer_if_present(esp, dest)
add_peer_if_needed(esp, dest, ch)
else:
raise
try:
esp.send(dest, pkt, True)
finally:
del_peer_if_present(esp, dest)
def init_radio(ch, name, password):
network.WLAN(network.STA_IF).active(False)
network.WLAN(network.AP_IF).active(False)
time.sleep_ms(100)
ap = network.WLAN(network.AP_IF)
ap.active(True)
time.sleep_ms(50)
if password:
try:
ap.config(essid=name or "bridge", password=password, channel=ch, hidden=True)
except TypeError:
ap.config(essid=name or "bridge", channel=ch)
else:
try:
ap.config(essid=name or "bridge", channel=ch, hidden=True)
except TypeError:
ap.config(essid=name or "bridge", channel=ch)
sta = network.WLAN(network.STA_IF)
sta.active(True)
sta.config(pm=network.WLAN.PM_NONE)
try:
sta.config(channel=ch)
except Exception:
pass
def mac_bytes(addr):
h = str(addr).replace(":", "").replace("-", "").strip().lower()
return bytes.fromhex(h)
def read_serial(uart, buf):
if uart.any():
buf.extend(uart.read(min(uart.any(), 256)))
out = []
while len(buf) >= 2:
n = (buf[0] << 8) | buf[1]
if n > MAX_SERIAL:
buf[:] = buf[1:]
continue
need = 2 + n
if len(buf) < need:
break
out.append(bytes(buf[2:need]))
buf[:] = buf[need:]
return out
def downlink(esp, ch, raw):
if not raw:
return
if raw[0] == WIRE:
if len(raw) < 2:
return
esp.send(BROADCAST, raw, True)
return
if len(raw) < 8 or raw[0] != ord("{"):
return
try:
data = json.loads(raw)
except ValueError:
return
devs = data.get("dv") or data.get("devices")
if data.get("v") != "1" or not isinstance(devs, dict):
return
for mac_s, body in devs.items():
if not isinstance(body, dict):
continue
try:
msg = {"v": "1"}
msg.update(body)
pkt = json.dumps(msg, separators=(",", ":")).encode()
if len(pkt) > MAX_ESPNOW:
continue
dest = mac_bytes(mac_s)
except (ValueError, TypeError):
continue
if dest == BROADCAST:
esp.send(BROADCAST, pkt, True)
else:
send_unicast_temp_peer(esp, dest, ch, pkt)
time.sleep_ms(5)
gc.collect()
s = Settings()
ch = max(1, min(11, int(s.get("wifi_channel", 5))))
init_radio(ch, s.get("name"), s.get("ap_password") or "")
baud = int(s.get("serial_baudrate", 921600))
uart = UART(
int(s.get("serial_uart_id", 1)),
baud,
tx=Pin(int(s.get("serial_tx_pin", 2))),
rx=Pin(int(s.get("serial_rx_pin", 3))),
)
esp = espnow.ESPNow()
esp.active(True)
add_peer_if_needed(esp, BROADCAST, ch)
print("bridge ch", ch, "baud", baud, "heap", gc.mem_free())
wdt = machine.WDT(timeout=10000)
rx_buf = bytearray()
while True:
wdt.feed()
for frame in read_serial(uart, rx_buf):
try:
downlink(esp, ch, frame)
except OSError as e:
print("dl", e)
host, msg = esp.recv(0)
if host:
up = bytes([0]) + host + msg
uart.write(struct.pack(">H", len(up)) + up)
else:
time.sleep_ms(1)

View File

@@ -0,0 +1,62 @@
import json
import time
import ubinascii
import network
WIFI_CHANNEL_DEFAULT = 5
def _sta_mac_hex():
sta = network.WLAN(network.STA_IF)
was_on = sta.active()
if not was_on:
sta.active(True)
time.sleep_ms(50)
try:
mac = ubinascii.hexlify(sta.config("mac")).decode().lower()
except Exception:
mac = "000000000000"
if not was_on:
sta.active(False)
return mac
class Settings(dict):
SETTINGS_FILE = "/settings.json"
def __init__(self):
super().__init__()
self.load()
def set_defaults(self):
self["name"] = "bridge-" + _sta_mac_hex()
self["wifi_channel"] = WIFI_CHANNEL_DEFAULT
self["ap_password"] = ""
self["serial_baudrate"] = 921600
self["serial_uart_id"] = 1
self["serial_tx_pin"] = 2
self["serial_rx_pin"] = 3
self["serial_usb"] = False
def save(self):
try:
with open(self.SETTINGS_FILE, "w") as f:
f.write(json.dumps(self))
except Exception as e:
print("save settings:", e)
def load(self):
try:
with open(self.SETTINGS_FILE, "r") as f:
loaded = json.load(f)
if not isinstance(loaded, dict):
raise ValueError("not object")
except Exception:
self.clear()
self.set_defaults()
self.save()
return
self.clear()
self.set_defaults()
for k, v in loaded.items():
self[k] = v

22
bridge-wifi/README.md Normal file
View File

@@ -0,0 +1,22 @@
# bridge-wifi
ESP32 ESP-NOW bridge with **WiFi AP + WebSocket** (`/ws`). Same ESP-NOW downlink as bridge-serial.
```
bridge-wifi/
src/
main.py
settings.py
wifi_ap.py
espnow_wire.py # uplink frame helper only
lib/microdot/ # WebSocket server
```
Deploy:
```bash
cd bridge-wifi
python ../led-tool/cli.py -p /dev/ttyUSB0 --src --lib -r -f
```
Pi: join bridge AP, `bridge_ws_url``ws://192.168.4.1/ws`.

View File

@@ -0,0 +1,7 @@
"""WebSocket uplink framing (Pi ↔ bridge)."""
BROADCAST_MAC = b"\xff\xff\xff\xff\xff\xff"
def pack_ws_uplink(peer, espnow_packet):
return bytes([0]) + peer + espnow_packet

218
bridge-wifi/src/main.py Normal file
View File

@@ -0,0 +1,218 @@
"""ESP-NOW bridge: Pi WebSocket downlink, ESP-NOW to drivers."""
import asyncio
import gc
import json
import time
import espnow
import machine
from espnow_wire import BROADCAST_MAC, pack_ws_uplink
from microdot import Microdot
from microdot.websocket import WebSocketError, with_websocket
from settings import Settings
from wifi_ap import init_bridge_network
BROADCAST = BROADCAST_MAC
WIRE = 0x4C
MAX_ESPNOW = 250
ESPNOW_EXIST = -12395
ESPNOW_FULL = -12392
def mac_str(mac):
return ":".join("%02x" % b for b in mac)
def dbg(msg):
if DEBUG:
print(msg)
def add_peer_if_needed(esp, dest, ch):
try:
esp.add_peer(dest, channel=ch)
dbg("peer add " + mac_str(dest))
except TypeError:
try:
esp.add_peer(dest)
dbg("peer add " + mac_str(dest))
except OSError as e:
if e.args[0] != ESPNOW_EXIST:
raise
dbg("peer exists " + mac_str(dest))
except OSError as e:
if e.args[0] != ESPNOW_EXIST:
raise
dbg("peer exists " + mac_str(dest))
def del_peer_if_present(esp, dest):
try:
esp.del_peer(dest)
dbg("peer del " + mac_str(dest))
except Exception as e:
dbg("peer del skip " + mac_str(dest) + " " + repr(e))
def send_espnow(esp, dest, pkt):
try:
esp.send(dest, pkt, True)
return True
except OSError as e:
label = "bcast" if dest == BROADCAST else mac_str(dest)
print("send err", label, len(pkt), e)
return False
def send_unicast_temp_peer(esp, dest, ch, pkt):
try:
add_peer_if_needed(esp, dest, ch)
except OSError as e:
# If peer table is full but this peer already exists, delete+retry once.
if e.args and e.args[0] == ESPNOW_FULL:
dbg("peer full " + mac_str(dest) + " retry")
del_peer_if_present(esp, dest)
add_peer_if_needed(esp, dest, ch)
else:
raise
ok = send_espnow(esp, dest, pkt)
del_peer_if_present(esp, dest)
return ok
def downlink(esp, ch, raw):
n = len(raw)
if not raw:
return
if raw[0] == WIRE:
if n < 2:
dbg("dl skip wire short " + str(n))
return
dbg("dl wire bcast " + str(n))
send_espnow(esp, BROADCAST, raw)
return
if n < 8 or raw[0] != ord("{"):
dbg("dl skip json " + str(n))
return
try:
data = json.loads(raw)
except ValueError:
dbg("dl skip json")
return
devs = data.get("dv") or data.get("devices")
if data.get("v") != "1" or not isinstance(devs, dict):
dbg("dl skip envelope")
return
dbg("dl env " + str(len(devs)) + " dev")
for mac_s, body in devs.items():
if not isinstance(body, dict):
dbg("dl skip body " + str(mac_s))
continue
try:
h = str(mac_s).replace(":", "").replace("-", "").strip().lower()
dest = BROADCAST if h == "ffffffffffff" else bytes.fromhex(h)
msg = {"v": "1"}
msg.update(body)
pkt = json.dumps(msg, separators=(",", ":")).encode()
if len(pkt) > MAX_ESPNOW:
dbg("dl skip big " + str(len(pkt)))
continue
except (ValueError, TypeError):
dbg("dl skip mac " + str(mac_s))
continue
if dest == BROADCAST:
dbg("dl bcast " + str(len(pkt)))
send_espnow(esp, BROADCAST, pkt)
else:
dbg("dl uni " + mac_str(dest) + " " + str(len(pkt)))
send_unicast_temp_peer(esp, dest, ch, pkt)
time.sleep_ms(5)
gc.collect()
settings = Settings()
DEBUG = bool(settings.get("debug", True))
ch = max(1, min(11, int(settings.get("wifi_channel", 5))))
init_bridge_network(settings)
esp = espnow.ESPNow()
esp.active(True)
add_peer_if_needed(esp, BROADCAST, ch)
print(
"bridge-wifi ch",
ch,
"debug",
DEBUG,
"heap",
gc.mem_free(),
"ws",
int(settings.get("ws_port", 80)),
)
app = Microdot()
clients = set()
wdt = machine.WDT(timeout=10000)
@app.route("/ws")
@with_websocket
async def ws_handler(request, ws):
clients.add(ws)
print("ws client +", len(clients))
try:
while True:
try:
raw = await ws.receive()
except WebSocketError:
dbg("ws closed")
break
if not raw:
dbg("ws empty")
break
if isinstance(raw, str):
raw = raw.encode("utf-8")
dbg("ws rx " + str(len(raw)))
try:
downlink(esp, ch, raw)
except OSError as e:
print("dl err", e)
finally:
clients.discard(ws)
print("ws client -", len(clients))
async def espnow_rx_loop():
while True:
host, msg = esp.recv(0)
if host:
dbg("up " + mac_str(host) + " " + str(len(msg)))
frame = pack_ws_uplink(host, msg)
dead = []
sent = 0
for ws in list(clients):
try:
await ws.send(frame)
sent += 1
except Exception as e:
dbg("ws up err " + repr(e))
dead.append(ws)
for ws in dead:
clients.discard(ws)
if not clients:
dbg("up no ws clients")
else:
dbg("up ws " + str(sent) + "/" + str(len(clients)))
else:
await asyncio.sleep_ms(1)
wdt.feed()
async def main():
asyncio.create_task(espnow_rx_loop())
port = int(settings.get("ws_port", 80))
print("ws listen", port)
await app.start_server(host="0.0.0.0", port=port)
asyncio.run(main())

View File

@@ -0,0 +1,60 @@
import json
import time
import ubinascii
import network
WIFI_CHANNEL_DEFAULT = 5
def _sta_mac_hex():
sta = network.WLAN(network.STA_IF)
was_on = sta.active()
if not was_on:
sta.active(True)
time.sleep_ms(50)
try:
mac = ubinascii.hexlify(sta.config("mac")).decode().lower()
except Exception:
mac = "000000000000"
if not was_on:
sta.active(False)
return mac
class Settings(dict):
SETTINGS_FILE = "/settings.json"
def __init__(self):
super().__init__()
self.load()
def set_defaults(self):
self["name"] = "bridge-" + _sta_mac_hex()
self["wifi_channel"] = WIFI_CHANNEL_DEFAULT
self["ap_password"] = ""
self["ap_ip"] = "192.168.4.1"
self["ws_port"] = 80
self["debug"] = True
def save(self):
try:
with open(self.SETTINGS_FILE, "w") as f:
f.write(json.dumps(self))
except Exception as e:
print("save settings:", e)
def load(self):
try:
with open(self.SETTINGS_FILE, "r") as f:
loaded = json.load(f)
if not isinstance(loaded, dict):
raise ValueError("not object")
except Exception:
self.clear()
self.set_defaults()
self.save()
return
self.clear()
self.set_defaults()
for k, v in loaded.items():
self[k] = v

View File

@@ -0,0 +1,52 @@
"""AP + STA for ESP-NOW; Pi joins the AP for WebSocket."""
import time
import network
from settings import WIFI_CHANNEL_DEFAULT
def _channel(settings):
try:
return max(1, min(11, int(settings.get("wifi_channel", WIFI_CHANNEL_DEFAULT))))
except (TypeError, ValueError):
return WIFI_CHANNEL_DEFAULT
def init_bridge_network(settings):
ch = _channel(settings)
essid = settings.get("name") or "bridge"
password = settings.get("ap_password") or ""
ap_ip = settings.get("ap_ip") or "192.168.4.1"
sta = network.WLAN(network.STA_IF)
ap = network.WLAN(network.AP_IF)
sta.active(False)
ap.active(False)
time.sleep_ms(100)
ap.active(True)
time.sleep_ms(50)
if password:
try:
ap.config(essid=essid, password=password, channel=ch)
except TypeError:
ap.config(essid=essid, channel=ch)
else:
ap.config(essid=essid, channel=ch)
try:
ap.ifconfig((ap_ip, "255.255.255.0", ap_ip, "8.8.8.8"))
except Exception:
pass
sta.active(True)
sta.config(pm=network.WLAN.PM_NONE)
try:
sta.config(channel=ch)
except Exception:
pass
port = int(settings.get("ws_port", 80))
print("bridge AP", essid, "ch", ch, "ip", ap.ifconfig()[0])
print("bridge_ws_url: ws://%s:%s/ws" % (ap_ip, port))

View File

@@ -1 +0,0 @@
{"188b0e1560a8": {"id": "188b0e1560a8", "name": "led-188b0e1560a8", "type": "led", "transport": "wifi", "address": "10.1.1.192", "default_pattern": null, "zones": []}, "f0f5bdfb9d30": {"id": "f0f5bdfb9d30", "name": "led-f0f5bdfb9d30", "type": "led", "transport": "wifi", "address": "10.1.1.232", "default_pattern": null, "zones": []}}

File diff suppressed because one or more lines are too long

View File

@@ -1 +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"]}
{"1": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000", "#050500"], "2": [], "3": [], "4": [], "5": [], "6": [], "7": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "8": [], "9": [], "10": [], "11": [], "12": ["#890b0b", "#0b8935"], "13": [], "14": ["#E8F4FF", "#9ECFFF", "#5080C8", "#FFFFFF", "#B0DCFF", "#0A1520", "#FF8020", "#071018"], "15": []}

View File

@@ -1 +1,280 @@
{"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}, "colour_cycle": {"n1": "Step Rate", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "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}, "flicker": {"n1": "Min brightness", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "flame": {"n1": "Min brightness", "n2": "Breath period (ms)", "n3": "Spark gap min (ms, 0=default 10\u201330 s, -1=off)", "n4": "Spark gap max (ms)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "twinkle": {"n1": "Twinkle activity (1\u2013255, higher = more changes)", "n2": "Density (0\u2013255, higher = more of the strip lit)", "n3": "Min adjacent LEDs per twinkle (same as max for fixed length)", "n4": "Max adjacent LEDs per twinkle (same as min for fixed length)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "radiate": {"n1": "Node spacing (LEDs)", "n2": "Out time (ms)", "n3": "In time (ms)", "min_delay": 10, "max_delay": 10000, "max_colors": 2}, "meteor_rain": {"n1": "Tail length", "n2": "Speed (LEDs per frame)", "n3": "Fade amount (1-255)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "scanner": {"n1": "Eye width", "n2": "End pause (frames)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "gradient_scroll": {"n1": "Scroll step rate", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "comet_dual": {"n1": "Tail length", "n2": "Speed", "n3": "Gap", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "sparkle_trail": {"n1": "Spark density", "n2": "Decay", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "wave": {"n1": "Wavelength", "n2": "Amplitude", "n3": "Drift speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "plasma": {"n1": "Scale", "n2": "Speed", "n3": "Contrast", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "segment_chase": {"n1": "Segment size", "n2": "Phase step", "n3": "Segment phase offset", "n4": "Gap per segment", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "bar_graph": {"n1": "Level percent", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "breathing_dual": {"n1": "Phase offset", "n2": "Ease", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "strobe_burst": {"n1": "Burst count", "n2": "Burst gap", "n3": "Cooldown", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "rain_drops": {"n1": "Drop rate", "n2": "Ripple width", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "fireflies": {"n1": "Count", "n2": "Twinkle speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "clock_sweep": {"n1": "Hand width", "n2": "Marker interval", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "marquee": {"n1": "On length", "n2": "Off length", "n3": "Step", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "aurora": {"n1": "Band count", "n2": "Shimmer", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "snowfall": {"n1": "Flake density", "n2": "Fall speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "heartbeat": {"n1": "Pulse 1 ms", "n2": "Pulse 2 ms", "n3": "Pause ms", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "orbit": {"n1": "Orbit count", "n2": "Base speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "palette_morph": {"n1": "Morph ms", "n2": "Warp rate", "n3": "Turbulence", "max_colors": 10, "min_delay": 10, "max_delay": 10000}}
{
"on": {
"min_delay": 10,
"max_delay": 10000,
"max_colors": 1,
"supports_manual": true
},
"off": {
"min_delay": 10,
"max_delay": 10000,
"max_colors": 0,
"supports_manual": true
},
"colour_cycle": {
"supports_reverse": true,
"n1": "Step rate",
"mode": {
"0": "Scroll palette gradient",
"1": "Rainbow wheel (preset colours ignored)"
},
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"supports_manual": true
},
"transition": {
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"supports_manual": false
},
"chase": {
"supports_reverse": true,
"n1": "Colour 1 Length",
"n2": "Colour 2 Length",
"n3": "Step 1",
"n4": "Step 2",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 2,
"has_background": true,
"supports_manual": true,
"mode": {
"0": "Two-colour chase",
"1": "Marquee dashes (n1 on length, n2 off, n3 step)"
}
},
"pulse": {
"n1": "Attack",
"n2": "Hold",
"n3": "Decay",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": true
},
"circle": {
"n1": "Head Rate",
"n2": "Max Length",
"n3": "Tail Rate",
"n4": "Min Length",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 2,
"has_background": true,
"supports_manual": false
},
"blink": {
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": false
},
"flicker": {
"n1": "Min brightness",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"supports_manual": true
},
"flame": {
"n1": "Min brightness",
"n2": "Breath period (ms)",
"n3": "Spark gap min (ms, 0=default 10\u201330 s, -1=off)",
"n4": "Spark gap max (ms)",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"supports_manual": false
},
"twinkle": {
"n1": "Twinkle activity (1\u2013255, higher = more changes)",
"n2": "Density (0\u2013255, higher = more of the strip lit)",
"n3": "Min adjacent LEDs per twinkle (same as max for fixed length)",
"n4": "Max adjacent LEDs per twinkle (same as min for fixed length)",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": true
},
"radiate": {
"n1": "Node spacing (LEDs)",
"n2": "Out time (ms)",
"n3": "In time (ms)",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": true
},
"plasma": {
"n1": "Scale",
"n2": "Speed",
"n3": "Contrast",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"supports_manual": false
},
"bar_graph": {
"n1": "Level percent",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": false
},
"strobe_burst": {
"n1": "Burst count",
"n2": "Burst gap",
"n3": "Cooldown",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"rain_drops": {
"n1": "Drop rate",
"n2": "Ripple width",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"clock_sweep": {
"n1": "Hand width",
"n2": "Marker interval",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"aurora": {
"supports_reverse": true,
"n1": "Band count (0) or spatial period LEDs (1)",
"n2": "Shimmer (0) or blend strength (1)",
"n3": "Unused (0) or drift speed (1)",
"mode": {
"0": "Colour bands + shimmer",
"1": "Sine northern wave"
},
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": true
},
"icicles": {
"supports_reverse": true,
"n1": "Anchor spacing (LEDs)",
"n2": "Max icicle length (LEDs)",
"n3": "Phase step per refresh",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"blizzard": {
"supports_reverse": true,
"n1": "Flake density",
"n2": "Fall speed",
"n3": "Wind (128 = centred; lower/raise for drift bias)",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"rime": {
"n1": "Crystallisation rate",
"n2": "Melt (decay) per refresh",
"n3": "Spark cap (LEDs refreshed per cycle)",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"candle_glow": {
"n1": "Candle count",
"n2": "Glow width (LEDs)",
"n3": "Flicker strength",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"orbit": {
"n1": "Orbit count",
"n2": "Base speed",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"palette_morph": {
"n1": "Morph ms",
"n2": "Warp rate",
"n3": "Turbulence",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"supports_manual": false
},
"meteor": {
"supports_reverse": true,
"n1": "Tail length (01) or eye width (2)",
"n2": "Speed (LEDs per frame)",
"n3": "Fade amount (0), comet gap (1), or end pause frames (2)",
"mode": {
"0": "Fading meteor",
"1": "Dual comets",
"2": "Bouncing scanner"
},
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": true
},
"particles": {
"supports_reverse": true,
"n1": "Flake density (0) or spawn rate (1)",
"n2": "Fall speed (LEDs per frame)",
"n3": "Unused (0) or streak length (1)",
"mode": {
"0": "Snowfall flakes",
"1": "Starfall streaks"
},
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": true
},
"sparkle": {
"n1": "Spark density (01) or firefly count (2)",
"n2": "Trail decay (0) or twinkle speed (2)",
"n3": "Ice halo width LEDs (1); unused in 0 and 2",
"mode": {
"0": "Sparkle trail",
"1": "Ice burst + halo",
"2": "Fireflies"
},
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": true
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"1": {"name": "default", "type": "zones", "zones": ["1", "8"], "scenes": [], "palette_id": "1"}, "2": {"name": "test", "type": "zones", "zones": ["6", "7"], "scenes": [], "palette_id": "12"}}
{"1": {"name": "default", "type": "zones", "zones": ["1", "9", "8", "10"], "scenes": [], "palette_id": "1"}, "2": {"name": "test", "type": "zones", "zones": ["6", "7"], "scenes": [], "palette_id": "12"}, "3": {"name": "Winter", "type": "zones", "zones": ["11", "12"], "scenes": [], "palette_id": "14"}, "4": {"name": "t", "type": "zones", "zones": ["13"], "scenes": [], "palette_id": "15"}}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
{"1": {"name": "default", "names": ["led-188b0e1560a8", "led-f0f5bdfb9d30"], "presets": [["4", "2", "7"], ["3", "14", "5"], ["8", "9", "1"], ["6", "38", "42"], ["39", "40", "41"], ["43", "44", "45"], ["46", "47", "48"], ["49", "50", "51"], ["52", "53", "54"], ["55", "56", "57"], ["58", "59", "60"], ["61", "62"]], "presets_flat": ["4", "2", "7", "3", "14", "5", "8", "9", "1", "6", "38", "42", "39", "40", "41", "43", "44", "45", "46", "47", "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "60", "61", "62"], "default_preset": "41"}, "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, "presets_flat": []}, "7": {"name": "dj", "names": ["dj"], "presets": [["34", "35", "36"]], "default_preset": "34", "presets_flat": ["34", "35", "36"]}, "8": {"name": "test", "names": ["led-188b0e1560a8"], "presets": [["1", "2", "3"], ["4", "5"]], "default_preset": "1", "presets_flat": ["1", "2", "3", "4", "5"]}}

View File

@@ -3,11 +3,13 @@
This document covers:
1. **HTTP and WebSocket** exposed by the Raspberry Pi app (`src/main.py`) — profiles, zones, presets, transport send, pattern OTA helpers, and related resources.
2. **LED driver JSON** — the compact **v1** message format. It is sent over the **serial → ESP-NOW bridge** to ESP32 peers and as **single JSON text messages** over the **outbound WebSocket** to **Wi-Fi** drivers (same logical fields).
2. **LED driver JSON** — the compact **v1** message format. ESP-NOW traffic is wrapped in a **devices envelope** (`dv` map keyed by MAC) on the Pi ↔ bridge link (WebSocket or USB serial); drivers receive compact per-device bodies (≤250 bytes). **Wi-Fi** drivers still accept **single JSON text messages** over an outbound WebSocket (same logical fields).
Default HTTP listen address: `0.0.0.0`. Port defaults to **80**; override with the **`PORT`** environment variable (see `pipenv run run`).
**Serial:** UART path and baud come from settings (defaults include `serial_port` such as `/dev/ttyS0` and `serial_baudrate`). **Wi-Fi drivers:** **UDP** on port **8766** is the **discovery** channel: each drivers JSON hello (**`device_name`**, **MAC**, optional **`type`**) **creates or updates** that device in **`db/device.json`** (keyed by MAC); the Pi echoes the datagram. After a valid hello with **`v`:** **`"1"`**, the Pi also opens an **outbound WebSocket** to that IP (**`wifi_driver_ws_port`**, default **80**; **`wifi_driver_ws_path`**, default **`/ws`**) for v1 commands; presets are not pushed automatically on connect (use **Send Presets** / profile apply). The Pi may send periodic UDP **hello** nudges to known WiFi device IPs when the WebSocket is down (**`wifi_driver_hello_interval_s`** in settings).
**ESP-NOW bridge:** Set **`bridge_transport`** to **`wifi`** (default) or **`serial`**. WiFi mode uses **`bridge_ws_url`** (e.g. `ws://192.168.4.1/ws`) after joining the bridge AP; serial mode uses **`bridge_serial_port`** and **`bridge_serial_baudrate`** (default **921600**). Saved bridge profiles and connect helpers live under **`/settings/wifi/*`** (see below). Architecture: [espnow-architecture.md](espnow-architecture.md).
**Wi-Fi drivers (optional):** **UDP** on port **8766** is the **discovery** channel: each drivers JSON hello (**`device_name`**, **MAC**, optional **`type`**) **creates or updates** that device in **`db/device.json`** (keyed by MAC); the Pi echoes the datagram. After a valid hello with **`v`:** **`"1"`**, the Pi also opens an **outbound WebSocket** to that IP (**`wifi_driver_ws_port`**, default **80**; **`wifi_driver_ws_path`**, default **`/ws`**) for v1 commands; presets are not pushed automatically on connect (use **Send Presets** / profile apply). The Pi may send periodic UDP **hello** nudges to known WiFi device IPs when the WebSocket is down (**`wifi_driver_hello_interval_s`** in settings).
All JSON APIs use `Content-Type: application/json` for bodies and responses unless noted.
@@ -42,7 +44,7 @@ Profiles are selected with **`POST /profiles/<id>/apply`**, which sets `current_
| Method | Path | Description |
|--------|------|-------------|
| GET | `/` | Main UI (`templates/index.html`) |
| GET | `/settings` | Settings page (`templates/settings.html`) |
| GET | `/settings/page` | Standalone settings page (`templates/settings.html`) |
| GET | `/favicon.ico` | Empty response (204) |
| GET | `/static/<path>` | Static files under `src/static/` |
@@ -52,7 +54,7 @@ Profiles are selected with **`POST /profiles/<id>/apply`**, which sets `current_
Connect to **`ws://<host>:<port>/ws`**.
- Send **JSON**: the object is forwarded through the **serial sender** (6-byte MAC prefix + payload to the ESP-NOW bridge). 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 from settings is used.
- Send **JSON**: the object is forwarded through the **ESP-NOW bridge** as a **devices envelope** (or legacy MAC-prefixed / binary payload). 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 from settings is used. Example envelope: [msg.json](msg.json).
- Send **non-JSON text**: forwarded as raw bytes with the default address.
- On send failure, the server may reply with `{"error": "Send failed"}`.
@@ -62,7 +64,7 @@ Wi-Fi devices are not targeted by `/ws` directly; use **`POST /presets/send`**,
## HTTP API by resource
Below, `<id>` values are string identifiers used by the JSON stores (numeric strings in practice).
Below, `<id>` values are string identifiers used by the JSON stores. **Device** ids are **12-character lowercase hex MACs** (no colons); other resources typically use numeric string ids.
### Settings — `/settings`
@@ -72,30 +74,48 @@ Below, `<id>` values are string identifiers used by the JSON stores (numeric str
| 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). |
| GET | `/settings/page` | Serves `templates/settings.html`. |
### Devices — `/devices`
### Bridge — `/settings/wifi`
Registry in `db/device.json`: storage key **`<id>`** (string, e.g. `"1"`) maps to an object that always includes:
| Field | Description |
|-------|-------------|
| **`id`** | Same as the storage key (stable handle for URLs). |
| **`name`** | Shown in the UI and used in `select` keys. |
| **`type`** | `led` (only value today; extensible). |
| **`transport`** | `espnow` or `wifi`. |
| **`address`** | For **`espnow`**: optional 12-character lowercase hex MAC. For **`wifi`**: optional IP or hostname string. |
| **`default_pattern`**, **`zones`** | Optional. Legacy **`tabs`** may still appear in old files and is migrated away on load. |
Existing records without `type` / `transport` / `id` are backfilled on load (`led`, `espnow`, and `id` = key).
Pi-side bridge configuration (ESP-NOW path to drivers). Mounted from `controllers/wifi_bridge.py`.
| Method | Path | Description |
|--------|------|-------------|
| GET | `/devices` | Map of device id → device object. |
| GET | `/settings/wifi/interfaces` | List WiFi interfaces via NetworkManager (`nmcli`). |
| GET | `/settings/wifi/scan?device=<ifname>` | Scan SSIDs on the given interface. |
| GET | `/settings/wifi/bridges` | Bridge state: `bridge_transport`, `bridge_ws_url`, `bridge_connected`, `wifi_interface`, saved **`bridges`** profiles, serial port/baud. |
| PUT | `/settings/wifi/bridges` | Merge bridge settings and/or replace the **`bridges`** profile list. |
| DELETE | `/settings/wifi/bridges/<id>` | Remove a saved bridge profile. |
| POST | `/settings/wifi/bridges/<id>/connect` | Connect using a saved profile (`transport`: `wifi` or `serial`). |
| POST | `/settings/wifi/connect` | Join a bridge AP and open its WebSocket. Body: `device`, `ssid`, optional `password`, `ap_ip` (default `192.168.4.1`), `ws_port`, `label`, `save_profile`. |
| POST | `/settings/wifi/serial/connect` | Open the bridge over USB serial. Body: `port`, optional `baudrate`, `label`, `save_profile`. |
### Devices — `/devices`
Registry in `db/device.json`: storage key **`<id>`** is the device **MAC** (12 lowercase hex characters, no colons). Each record includes:
| Field | Description |
|-------|-------------|
| **`id`** | Same as the storage key (12-char hex MAC). |
| **`name`** | Shown in the UI; matched when building zone **`select`** lists. |
| **`type`** | `led` (only value today; extensible). |
| **`transport`** | `espnow` (default) or `wifi`. |
| **`address`** | For **`espnow`**: same as **`id`** (MAC). For **`wifi`**: IP or hostname used for outbound WebSocket / OTA. |
| **`connected`** | Response-only on GET list/detail: always **`null`** today (ESP-NOW has no live session flag on the Pi). |
| **`default_pattern`**, **`zones`** | Optional. Legacy **`tabs`** may still appear in old files and is migrated away on load. |
Drivers also **self-register** on ESP-NOW **ANNOUNCE** (bridge uplink) or WiFi UDP hello; manual **`POST /devices`** is optional.
| Method | Path | Description |
|--------|------|-------------|
| GET | `/devices` | Map of device id → device object (includes **`connected`**). |
| GET | `/devices/<id>` | One device, 404 if missing. |
| POST | `/devices` | Create. Body: **`name`** (required), **`type`** (default `led`), **`transport`** (default `espnow`), optional **`address`**, **`default_pattern`**, **`zones`**. Returns `{ "<id>": { ... } }`, 201. |
| POST | `/devices` | Create. Body: **`name`** (required), **`type`** (default `led`), **`transport`** (default `espnow`), optional **`address`**, **`mac`** (required for WiFi when address is set), **`default_pattern`**, **`zones`**. Returns `{ "<id>": { ... } }`, 201. |
| PUT | `/devices/<id>` | Partial update. **`name`** cannot be cleared. **`id`** in the body is ignored. **`type`** / **`transport`** validated; **`address`** normalised for the resulting transport. |
| DELETE | `/devices/<id>` | Remove device. |
| POST | `/devices/<id>/identify` | ESP-NOW: sends a short red **blink** preset (`__identify`, 10 Hz) via the bridge, then **`off`** after ~2 s. Not persisted on the Pi. |
| POST | `/groups/<id>/identify` | Same identify blink for every device in the group (broadcast envelope; drivers filter by group membership). |
### Profiles — `/profiles`
@@ -228,26 +248,56 @@ Pattern metadata lives in **`db/pattern.json`**; driver source files live under
## LED driver message format (transport / ESP-NOW / Wi-Fi)
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, and the same logical object can be sent as a **single JSON text message** to a Wi-Fi driver over the **WebSocket**.
Messages are JSON objects. The Pi **`build_message()`** helper (`src/util/espnow_message.py`) produces per-device bodies; **`build_devices_envelope()`** (`src/util/bridge_envelope.py`) wraps them for the bridge WebSocket or USB serial link. Wi-Fi drivers accept the same logical body as a **single JSON text message** over the outbound WebSocket.
### Top-level fields
### Devices envelope (Pi → bridge)
On the bridge link, traffic uses a top-level **`dv`** map (long name **`devices`** still accepted on receive):
```json
{
"v": "1",
"presets": { },
"select": { },
"save": true,
"default": "preset_id",
"b": 255
"dv": {
"e8:f6:0a:16:ea:10": {
"p": { "2": { "p": "on", "c": ["#FFFFFF"], "a": true } },
"s": ["2", 0],
"g": ["5"],
"sg": false,
"sv": true
}
}
}
```
See [espnow-architecture.md](espnow-architecture.md) for routing (`sg`, broadcast MAC `ff:ff:ff:ff:ff:ff`, and group filtering).
### Per-device body fields (inside `dv` or Wi-Fi WebSocket)
Short wire keys are used on the bridge and over ESP-NOW (long names still accepted on receive):
```json
{
"v": "1",
"p": { },
"s": ["preset_id", 0],
"sv": true,
"df": "preset_id",
"b": 255,
"g": ["5"],
"sg": false
}
```
| Short | Long | Meaning |
|-------|------|---------|
| `p` | `presets` | Map of preset id → preset object (see below). |
| `s` | `select` | **`["preset_id"]`** or **`["preset_id", step]`** — routing is by MAC envelope / group membership, not by device name. |
| `sv` | `save` | If true, driver may persist presets to flash. |
| `df` | `default` | Startup default preset id. |
| `g` | `groups` | Group ids for membership updates or broadcast filtering. |
| `sg` | `set_groups` | If true, replace stored group list before applying the body. |
- **`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)

View File

@@ -28,7 +28,7 @@
The LED Driver system is a MicroPython-based application for controlling LED strips via ESP32-C3 microcontrollers. The system uses a custom firmware image with usqlite and microdot built-in as frozen modules. The system provides:
- Real-time LED pattern control
- Multi-device management via peer-to-peer communication (ESPNow)
- Multi-device management via ESP-NOW (peer-to-peer on 2.4 GHz; Pi reaches drivers through a bridge ESP32)
- Group-based device control
- Web-based configuration interface
- Binary message protocol for efficient communication
@@ -49,7 +49,7 @@ The LED Driver system is a MicroPython-based application for controlling LED str
- Preset system for saving and loading pattern configurations
- Profile and Scene system for complex lighting setups
- Preset sequencing within groups for time-based transitions
- Peer-to-peer communication via ESPNow
- ESP-NOW peer-to-peer between bridge and led-driver devices; Pi ↔ bridge over WebSocket or USB serial
- Binary message protocol for bandwidth efficiency
- Persistent settings storage (usqlite database)
- Web-based configuration interface (Microdot web server)

198
docs/espnow-architecture.md Normal file
View File

@@ -0,0 +1,198 @@
# ESP-NOW transport architecture
This document describes how **led-controller**, the **bridge ESP32**, and **led-driver** devices work together. Wire-level byte layouts are in [espnow-binary-protocol.md](espnow-binary-protocol.md).
**Pi ↔ bridge:** v1 **devices envelope** (JSON) over **WebSocket** (`bridge_transport`: `wifi`) or **USB serial** (`bridge_transport`: `serial`) — example: [msg.json](msg.json). **ESP-NOW over the air:** JSON driver payloads (≤250 bytes) or legacy binary (`0x4C` wire). The Pi web UI and `db/*.json` use JSON internally.
## System overview
![Three-node ESP-NOW architecture](images/espnow/system-overview.svg)
| Component | Firmware / path | Role |
|-----------|-----------------|------|
| **led-controller** | Raspberry Pi, `src/` | Web app; WebSocket **client** to bridge (auto-reconnect); device registry; builds devices envelope |
| **Bridge** | [`espnow-sender/`](../espnow-sender/) (or [`bridge-serial/`](../bridge-serial/) for UART-only) | WebSocket **server** `/ws` and/or USB serial; routes envelope per MAC; max **20** ESP-NOW peers (LRU) |
| **led-driver** | [`led-driver/`](../led-driver/) submodule | Boot **ANNOUNCE** broadcast; applies **GROUPS**, **CMD**, **GROUP_CMD** |
Configure the Pi in `settings.json`:
```json
{
"bridge_transport": "wifi",
"bridge_ws_url": "ws://192.168.4.1/ws",
"wifi_channel": 5
}
```
For **USB serial** to the bridge ESP32 instead of WiFi:
```json
{
"bridge_transport": "serial",
"bridge_serial_port": "/dev/ttyACM0",
"bridge_serial_baudrate": 921600,
"wifi_channel": 5
}
```
**WiFi mode:** connect the Pi to the **bridge access point** (SSID = bridge `name` in `/settings.json`, default IP **192.168.4.1**). Use **Help → Bridge** or **`POST /settings/wifi/connect`** to join and set `bridge_ws_url`. **Serial mode:** plug in the bridge and set `bridge_serial_port` (or use **`POST /settings/wifi/serial/connect`**).
All nodes must use the same 2.4 GHz **channel**: set `wifi_channel` in the bridge and each led-driver `/settings.json` (applied at boot on those devices). The Pi stores `wifi_channel` in its own `settings.json` for alignment (restart led-controller after changing).
---
## Boot and registration
![Boot and registration sequence](images/espnow/boot-sequence.svg)
1. Driver powers on and sends **ANNOUNCE** to broadcast MAC `ff:ff:ff:ff:ff:ff`.
2. Bridge receives it and forwards a **WebSocket uplink** frame to the Pi (peer MAC + packet).
3. Pi **upserts** the device in `db/device.json` (key = 12-char hex MAC).
4. Pi scans `db/group.json` and sends a **groups** envelope (`set_groups: true`) unicast to that MAC.
5. Driver stores group ids in RAM (`device_groups`) for filtering.
6. Pi bridge client **reconnects** automatically if the WebSocket drops (2 s backoff).
If the Pi or bridge is not up yet, the driver re-sends **ANNOUNCE** periodically until **GROUPS** arrives.
---
## Devices envelope (Pi → bridge)
```json
{
"v": "1",
"dv": {
"ff:ff:ff:ff:ff:ff": {
"p": { "2": { "p": "on", "c": ["#FFFFFF"], "a": true } },
"s": ["2", 0],
"g": ["5", "18"],
"sg": false,
"sv": true
}
}
}
```
Short wire names (long names still accepted on receive): `dv`=devices, `p`=presets, `s`=select (`["preset_id", step?]` — no device name), `g`=groups, `sg`=set_groups, `sv`=save, `df`=default; preset fields `p/c/d/b/a/bg/n1…`.
| `set_groups` | Destination | Bridge | Driver |
|--------------|-------------|--------|--------|
| `true` | any | Unicast only (expand `ff:ff:…` to all known peers) | `groups_replace`, then apply body |
| `false` | `ff:ff:ff:ff:ff:ff` | ESP-NOW air broadcast | Apply only if device is in `groups` |
| `false` | specific MAC | Unicast | Same group filter |
Legacy raw payloads (binary wire or plain v1 JSON without `devices`) are still **broadcast** by the bridge.
## Sending presets and commands
1. UI or API triggers a send (e.g. `POST /presets/push`).
2. Pi builds a **devices envelope** (or legacy binary) and sends it on the bridge WebSocket.
3. Bridge routes each MAC entry to unicast or ESP-NOW broadcast per `set_groups`.
4. Driver `process_data` applies presets, select (`[preset_id, step?]`; legacy name map still accepted), brightness, etc.
---
## Packet layers
![Packet layer stack](images/espnow/packet-layers.svg)
### Layer A — WebSocket frame (Pi ↔ bridge)
| Offset | Size | Field |
|--------|------|--------|
| 0 | 1 | `flags` — bit0 = broadcast (`ff:ff:…`); peer ignored if set |
| 1 | 6 | `peer` — destination MAC (raw bytes) |
| 7 | … | Full ESP-NOW packet (layer B) |
**Uplink** (bridge → Pi): same layout; `flags = 0`, `peer` = sender.
**Ack** (bridge → Pi after downlink): 1 byte — `0x01` ok, `0x00` error.
### Layer B — ESP-NOW packet (on air)
| Offset | Size | Field |
|--------|------|--------|
| 0 | 1 | Magic `0x4C` (`'L'`) |
| 1 | 1 | Message type |
| 2 | … | Body (≤248 bytes so total ≤250) |
![Message types](images/espnow/message-types.svg)
| Type | Value | Direction | Purpose |
|------|-------|-------------|---------|
| ANNOUNCE | `0x01` | Driver → broadcast | Boot settings |
| GROUPS | `0x02` | Pi → driver | Group membership |
| CMD | `0x03` | Pi → driver | Command (v2 envelope) |
| GROUP_CMD | `0x04` | Pi → broadcast | Command scoped to one group |
| BRIDGE_CH | `0x10` | Pi → bridge | Set STA channel 111 |
### Layer C — v2 command envelope (inside CMD / GROUP_CMD)
Used for presets, select, default, brightness. **No JSON.**
| Byte | Field |
|------|--------|
| 0 | Version `2` |
| 1 | Brightness wire 0127 (→ 0255); `128255` = unchanged |
| 2 | `lp` — presets section length |
| 3 | `ls` — select section length |
| 4 | `ld` — default section length |
| 5… | Presets blob (`lp` bytes) |
| … | Select blob (`ls` bytes) |
| … | Default blob (`ld` bytes) |
Optional trailing `0x01` after the envelope in **CMD** means `save` (persist to flash).
Implementation: [`src/util/binary_envelope.py`](../src/util/binary_envelope.py), [`src/util/espnow_wire.py`](../src/util/espnow_wire.py).
---
## Message body reference
### ANNOUNCE (`0x01`)
Sender MAC comes from ESP-NOW headers, not the body.
```
name_len (u8) | name (utf-8) | num_leds (u16 LE) | color_order (u8) | startup_mode (u8) | brightness (u8) | device_type (u8)
```
| `color_order` | `startup_mode` |
|---------------|----------------|
| 0=rgb, 1=rbg, 2=grb, 3=gbr, 4=brg, 5=bgr | 0=default, 1=last, 2=off |
### GROUPS (`0x02`)
```
count (u8) | repeat: id_len (u8) | group_id (utf-8)
```
Group ids match keys in `db/group.json` (e.g. `"5"`, `"18"`).
### GROUP_CMD (`0x04`)
```
group_id_len (u8) | group_id (utf-8) | v2 envelope | [optional 0x01 save]
```
Driver applies only if `group_id` is in its stored list.
---
## Size limits and chunking
- **250 bytes** max per ESP-NOW datagram.
- Large preset libraries → multiple **CMD** packets from the Pi.
- Bridge stores at most **20** peer MACs; oldest peer evicted (LRU) when full.
---
## Related files
| Topic | Location |
|-------|----------|
| Byte-level spec | [espnow-binary-protocol.md](espnow-binary-protocol.md) |
| Pi wire codec | [`src/util/espnow_wire.py`](../src/util/espnow_wire.py) |
| Pi bridge client | [`src/models/bridge_ws_client.py`](../src/models/bridge_ws_client.py) |
| Bridge firmware | [`espnow-sender/src/main.py`](../espnow-sender/src/main.py) |
| Driver ESP-NOW | [`led-driver/src/espnow_transport.py`](../led-driver/src/espnow_transport.py) |

View File

@@ -0,0 +1,114 @@
# ESP-NOW binary protocol
**See also:** [espnow-architecture.md](espnow-architecture.md) (diagrams, flows, configuration).
All ESP-NOW datagrams and Pi↔bridge WebSocket frames use **binary only** (no JSON on the wire). Maximum ESP-NOW payload length: **250 bytes**.
## ESP-NOW packet
| Offset | Field |
|--------|--------|
| 0 | Magic `0x4C` (`'L'`) |
| 1 | Message type |
| 2… | Type-specific body |
### Message types
| Value | Name | Direction |
|-------|------|-----------|
| `0x01` | `ANNOUNCE` | Driver → broadcast |
| `0x02` | `GROUPS` | Controller → driver |
| `0x03` | `CMD` | Controller → driver |
| `0x04` | `GROUP_CMD` | Controller → broadcast |
| `0x05` | `PING_REQ` | Controller → broadcast |
| `0x06` | `PING_RSP` | Driver → controller (unicast) |
| `0x10` | `BRIDGE_CH` | Controller → broadcast |
### ANNOUNCE (`0x01`)
Driver settings at boot. Sender MAC is taken from the ESP-NOW peer address (not repeated in the body).
| Field | Type |
|-------|------|
| name_len | u8 |
| name | UTF-8 |
| num_leds | u16 LE |
| color_order | u8 enum: 0=rgb, 1=rbg, 2=grb, 3=gbr, 4=brg, 5=bgr |
| startup_mode | u8: 0=default, 1=last, 2=off |
| brightness | u8 0255 |
| device_type | u8: 0=led |
### GROUPS (`0x02`)
| Field | Type |
|-------|------|
| count | u8 |
| × count | u8 id_len + UTF-8 group id |
### CMD (`0x03`)
Bytes 2… are a **v2 binary envelope** (see `src/util/binary_envelope.py`): 5-byte header + presets/select/default blobs. Total packet ≤ 250 bytes.
### GROUP_CMD (`0x04`)
| Field | Type |
|-------|------|
| group_id_len | u8 |
| group_id | UTF-8 |
| cmd_envelope | v2 binary envelope |
Drivers apply the nested envelope only if `group_id` is in their stored group list.
### PING_REQ (`0x05`)
Controller discovery ping (broadcast). Drivers reply with **PING_RSP** after a random delay (50500 ms) to reduce ESP-NOW collisions.
| Field | Type |
|-------|------|
| ping_id | u32 LE |
### PING_RSP (`0x06`)
Unicast to the bridge/controller peer that sent the request (ESP-NOW source MAC of the received **PING_REQ**).
| Field | Type |
|-------|------|
| ping_id | u32 LE |
| name_len | u8 |
| name | UTF-8 |
### BRIDGE_CH (`0x10`)
| Field | Type |
|-------|------|
| channel | u8 (111) |
Sets the bridge ESP32 STA channel (not forwarded to LED drivers as a command).
## Pi ↔ bridge WebSocket frame
Binary WebSocket messages only.
| Offset | Field |
|--------|--------|
| 0 | flags: bit0 = broadcast destination; bit1 reserved |
| 16 | peer MAC (6 bytes); ignored if broadcast |
| 7… | ESP-NOW packet (magic + type + body) |
Broadcast destination uses peer `ff:ff:ff:ff:ff:ff`.
The bridge maintains at most **20** ESP-NOW peers (LRU eviction).
## v2 command envelope
Native binary sections (no JSON). Header:
| Byte | Meaning |
|------|---------|
| 0 | Version `2` |
| 1 | Brightness wire 0127 (maps to 0255); 128255 = unchanged |
| 2 | Presets section length |
| 3 | Select section length |
| 4 | Default section length |
See `binary_envelope.py` for blob layouts.

View File

@@ -1,6 +1,6 @@
# LED controller — user guide
This page describes the **main web UI** served from the Raspberry Pi app: profiles, **zones**, presets, colour palettes, and sending commands to LED devices. Traffic may go over the **serial → ESP-NOW bridge** or **Wi-Fi** (TCP to drivers on the LAN), depending on each devices transport.
This page describes the **main web UI** served from the Raspberry Pi app: profiles, **zones**, presets, colour palettes, and sending commands to LED devices. ESP-NOW devices are reached through the **bridge** (Pi connects via **WiFi** to the bridge AP or **USB serial**). Optional **Wi-Fi** drivers on the LAN use a direct outbound WebSocket from the Pi.
For HTTP routes and the wire format the driver expects, see **[API.md](API.md)**. For running the app locally, see the project **README**.
@@ -84,7 +84,9 @@ The **Presets** header button (Edit mode) opens a **profile-wide** list: **Add**
The **Patterns** dialog (Edit mode) lists pattern names and typical **delay** ranges from the pattern definitions. Choosing a pattern still happens inside the preset editor.
**Wi-Fi drivers** can install new pattern modules over HTTP: the REST API exposes **`/patterns/ota/*`**, **`POST /patterns/<name>/send`**, **`POST /patterns/upload`**, and **`POST /patterns/driver`** (see [API.md](API.md)). ESP-NOW devices follow the bridge/serial path you configure for preset traffic.
**Wi-Fi drivers** can install new pattern modules over HTTP: the REST API exposes **`/patterns/ota/*`**, **`POST /patterns/<name>/send`**, **`POST /patterns/upload`**, and **`POST /patterns/driver`** (see [API.md](API.md)). ESP-NOW devices follow the bridge path you configure (**Help → Bridge**: join the bridge AP or connect USB serial).
**Devices** (Edit mode): the registry lists drivers by **MAC**. New ESP-NOW devices appear automatically after **ANNOUNCE**; you can also add rows manually. **Identify** sends a short red blink (~2 s) so you can spot hardware on a wall or bench.
---
@@ -110,5 +112,5 @@ On narrow screens, use **Menu** to reach the same actions as the desktop header
## Further reading
- **[API.md](API.md)** — REST routes, session scoping, WebSocket `/ws`, and LED driver JSON (`presets`, `select`, `save`, `default`, pattern keys, pattern **manifest**).
- **[API.md](API.md)** — REST routes, bridge settings (`/settings/wifi/*`), session scoping, WebSocket `/ws`, and LED driver JSON (devices envelope `dv`, short keys `p`/`s`/`sv`, pattern **manifest**).
- **README** — `pipenv run run`, port 80 setup, and high-level behaviour.

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 780 520" font-family="system-ui, Segoe UI, sans-serif">
<defs>
<marker id="arr" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
<path d="M0,0 L6,3 L0,6 Z" fill="#333"/>
</marker>
<style>
.actor { fill: #f4f6f8; stroke: #2c3e50; stroke-width: 2; }
.lifeline { stroke: #aaa; stroke-width: 1; stroke-dasharray: 4 4; }
.msg { stroke: #2980b9; stroke-width: 1.5; fill: none; marker-end: url(#arr); }
.msgret { stroke: #27ae60; stroke-width: 1.5; fill: none; marker-end: url(#arr); }
.note { fill: #fef9e7; stroke: #d4ac0d; stroke-width: 1; }
.t { font-size: 13px; fill: #222; }
.h { font-size: 14px; font-weight: 700; fill: #111; }
.s { font-size: 11px; fill: #555; }
</style>
</defs>
<text x="390" y="24" text-anchor="middle" class="h" font-size="16">Boot and registration sequence</text>
<!-- Actors -->
<rect class="actor" x="40" y="40" width="120" height="40" rx="6"/>
<text x="100" y="66" text-anchor="middle" class="h">Driver</text>
<line class="lifeline" x1="100" y1="80" x2="100" y2="480"/>
<rect class="actor" x="310" y="40" width="120" height="40" rx="6"/>
<text x="370" y="66" text-anchor="middle" class="h">Bridge</text>
<line class="lifeline" x1="370" y1="80" x2="370" y2="480"/>
<rect class="actor" x="580" y="40" width="140" height="40" rx="6"/>
<text x="650" y="66" text-anchor="middle" class="h">led-controller</text>
<line class="lifeline" x1="650" y1="80" x2="650" y2="480"/>
<!-- Messages -->
<path class="msg" d="M 100 110 L 368 110"/>
<text x="234" y="102" text-anchor="middle" class="t">ESP-NOW broadcast ANNOUNCE</text>
<text x="234" y="128" text-anchor="middle" class="s">dest ff:ff:ff:ff:ff:ff</text>
<path class="msg" d="M 372 150 L 648 150"/>
<text x="510" y="142" text-anchor="middle" class="t">WS uplink: peer MAC + packet</text>
<rect class="note" x="520" y="168" width="200" height="44" rx="4"/>
<text x="620" y="188" text-anchor="middle" class="s">upsert device in</text>
<text x="620" y="204" text-anchor="middle" class="s">db/device.json</text>
<path class="msgret" d="M 648 230 L 372 230"/>
<text x="510" y="222" text-anchor="middle" class="t">WS downlink: GROUPS unicast</text>
<path class="msgret" d="M 368 270 L 102 270"/>
<text x="234" y="262" text-anchor="middle" class="t">ESP-NOW unicast GROUPS</text>
<rect class="note" x="30" y="300" width="140" height="40" rx="4"/>
<text x="100" y="318" text-anchor="middle" class="s">store group ids</text>
<text x="100" y="332" text-anchor="middle" class="s">in RAM</text>
<text x="390" y="380" text-anchor="middle" class="s">Driver re-sends ANNOUNCE until GROUPS received if Pi/bridge late</text>
<text x="390" y="460" text-anchor="middle" class="s">ANNOUNCE body: name, num_leds, color_order, startup_mode, brightness</text>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 780 480" font-family="system-ui, Segoe UI, sans-serif">
<defs>
<marker id="a" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
<path d="M0,0 L6,3 L0,6 Z" fill="#333"/>
</marker>
<style>
.actor { fill: #f4f6f8; stroke: #2c3e50; stroke-width: 2; }
.lifeline { stroke: #aaa; stroke-width: 1; stroke-dasharray: 4 4; }
.msg { stroke: #8e44ad; stroke-width: 1.5; fill: none; marker-end: url(#a); }
.t { font-size: 13px; fill: #222; }
.h { font-size: 14px; font-weight: 700; }
.s { font-size: 11px; fill: #555; }
</style>
</defs>
<text x="390" y="24" text-anchor="middle" class="h" font-size="16">Preset / command delivery</text>
<rect class="actor" x="30" y="44" width="90" height="36" rx="6"/>
<text x="75" y="68" text-anchor="middle" class="h">UI</text>
<line class="lifeline" x1="75" y1="80" x2="75" y2="440"/>
<rect class="actor" x="200" y="44" width="120" height="36" rx="6"/>
<text x="260" y="68" text-anchor="middle" class="h">Pi</text>
<line class="lifeline" x1="260" y1="80" x2="260" y2="440"/>
<rect class="actor" x="400" y="44" width="100" height="36" rx="6"/>
<text x="450" y="68" text-anchor="middle" class="h">Bridge</text>
<line class="lifeline" x1="450" y1="80" x2="450" y2="440"/>
<rect class="actor" x="580" y="44" width="100" height="36" rx="6"/>
<text x="630" y="68" text-anchor="middle" class="h">Driver</text>
<line class="lifeline" x1="630" y1="80" x2="630" y2="440"/>
<path class="msg" d="M 77 110 L 258 110"/>
<text x="168" y="102" text-anchor="middle" class="t">POST /presets/send (JSON)</text>
<text x="260" y="145" text-anchor="middle" class="s">build v2 envelope</text>
<text x="260" y="162" text-anchor="middle" class="s">pack CMD (d250 B)</text>
<path class="msg" d="M 262 190 L 448 190"/>
<text x="355" y="182" text-anchor="middle" class="t">WS downlink + CMD</text>
<path class="msg" d="M 452 230 L 628 230"/>
<text x="540" y="222" text-anchor="middle" class="t">ESP-NOW unicast / broadcast</text>
<text x="630" y="275" text-anchor="middle" class="s">parse CMD</text>
<text x="630" y="292" text-anchor="middle" class="s">apply presets / select</text>
<rect x="140" y="320" width="500" height="90" fill="#f0f0f0" stroke="#999" rx="6"/>
<text x="390" y="345" text-anchor="middle" class="t">GROUP_CMD: one broadcast per group id  only members apply</text>
<text x="390" y="368" text-anchor="middle" class="s">Large libraries multiple CMD chunks from Pi</text>
<text x="390" y="390" text-anchor="middle" class="s">Optional trailing 0x01 on CMD = save to flash</text>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 320" font-family="system-ui, Segoe UI, sans-serif">
<text x="320" y="28" text-anchor="middle" font-size="16" font-weight="700" fill="#111">ESP-NOW message types (byte 1 after 0x4C)</text>
<rect fill="#2c3e50" x="40" y="48" width="560" height="28" rx="4"/>
<text x="70" y="67" fill="#fff" font-size="12" font-weight="600">Value</text>
<text x="150" y="67" fill="#fff" font-size="12" font-weight="600">Name</text>
<text x="280" y="67" fill="#fff" font-size="12" font-weight="600">Direction</text>
<text x="460" y="67" fill="#fff" font-size="12" font-weight="600">Purpose</text>
<rect fill="#fff" stroke="#ddd" x="40" y="76" width="560" height="32"/>
<text x="70" y="97" font-size="12">0x01</text>
<text x="150" y="97" font-size="12" font-weight="600">ANNOUNCE</text>
<text x="280" y="97" font-size="12">Driver ? broadcast</text>
<text x="460" y="97" font-size="12">Boot settings</text>
<rect fill="#f8f9fa" stroke="#ddd" x="40" y="108" width="560" height="32"/>
<text x="70" y="129" font-size="12">0x02</text>
<text x="150" y="129" font-size="12" font-weight="600">GROUPS</text>
<text x="280" y="129" font-size="12">Pi ? driver</text>
<text x="460" y="129" font-size="12">Group membership</text>
<rect fill="#fff" stroke="#ddd" x="40" y="140" width="560" height="32"/>
<text x="70" y="161" font-size="12">0x03</text>
<text x="150" y="161" font-size="12" font-weight="600">CMD</text>
<text x="280" y="161" font-size="12">Pi ? driver</text>
<text x="460" y="161" font-size="12">v2 command envelope</text>
<rect fill="#f8f9fa" stroke="#ddd" x="40" y="172" width="560" height="32"/>
<text x="70" y="193" font-size="12">0x04</text>
<text x="150" y="193" font-size="12" font-weight="600">GROUP_CMD</text>
<text x="280" y="193" font-size="12">Pi ? broadcast</text>
<text x="460" y="193" font-size="12">Filtered by group id</text>
<rect fill="#fff" stroke="#ddd" x="40" y="204" width="560" height="32"/>
<text x="70" y="225" font-size="12">0x10</text>
<text x="150" y="225" font-size="12" font-weight="600">BRIDGE_CH</text>
<text x="280" y="225" font-size="12">Pi ? bridge</text>
<text x="460" y="225" font-size="12">Wi-Fi channel 111</text>
<text x="320" y="270" text-anchor="middle" font-size="12" fill="#555">Every packet: [0x4C magic][type][body…] total ? 250 bytes</text>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 480" font-family="ui-monospace, monospace">
<defs>
<style>
.layer { stroke: #2c3e50; stroke-width: 2; }
.ws { fill: #e8f4fc; }
.esp { fill: #fef9e7; }
.env { fill: #eafaf1; }
.lbl { font-family: system-ui, sans-serif; font-size: 14px; font-weight: 700; fill: #111; }
.byte { font-size: 12px; fill: #333; }
.title { font-family: system-ui, sans-serif; font-size: 17px; font-weight: 700; }
</style>
</defs>
<text x="360" y="28" text-anchor="middle" class="title">Packet layers (outside inside)</text>
<!-- WS layer -->
<rect class="layer ws" x="60" y="50" width="600" height="70" rx="6"/>
<text x="80" y="78" class="lbl">WebSocket frame (Pi ” bridge)</text>
<rect x="80" y="88" width="50" height="24" fill="#fff" stroke="#666"/>
<text x="105" y="104" text-anchor="middle" class="byte">flags</text>
<rect x="138" y="88" width="120" height="24" fill="#fff" stroke="#666"/>
<text x="198" y="104" text-anchor="middle" class="byte">peer MAC ×6</text>
<rect x="268" y="88" width="380" height="24" fill="#fff" stroke="#666"/>
<text x="458" y="104" text-anchor="middle" class="byte">ESP-NOW packet (below)</text>
<!-- ESP layer -->
<rect class="layer esp" x="100" y="140" width="520" height="70" rx="6"/>
<text x="120" y="168" class="lbl">ESP-NOW datagram (d250 bytes)</text>
<rect x="120" y="178" width="40" height="24" fill="#fff" stroke="#666"/>
<text x="140" y="194" text-anchor="middle" class="byte">4C</text>
<rect x="168" y="178" width="50" height="24" fill="#fff" stroke="#666"/>
<text x="193" y="194" text-anchor="middle" class="byte">type</text>
<rect x="230" y="178" width="370" height="24" fill="#fff" stroke="#666"/>
<text x="415" y="194" text-anchor="middle" class="byte">body (ANNOUNCE / GROUPS / CMD / &)</text>
<!-- CMD + envelope -->
<rect class="layer env" x="140" y="230" width="440" height="120" rx="6"/>
<text x="160" y="258" class="lbl">Inside CMD (0x03)  v2 command envelope</text>
<rect x="160" y="268" width="28" height="22" fill="#fff" stroke="#666"/>
<text x="174" y="283" text-anchor="middle" class="byte">02</text>
<rect x="194" y="268" width="28" height="22" fill="#fff" stroke="#666"/>
<text x="208" y="283" text-anchor="middle" class="byte">br</text>
<rect x="228" y="268" width="28" height="22" fill="#fff" stroke="#666"/>
<text x="242" y="283" text-anchor="middle" class="byte">lp</text>
<rect x="262" y="268" width="28" height="22" fill="#fff" stroke="#666"/>
<text x="276" y="283" text-anchor="middle" class="byte">ls</text>
<rect x="296" y="268" width="28" height="22" fill="#fff" stroke="#666"/>
<text x="310" y="283" text-anchor="middle" class="byte">ld</text>
<rect x="334" y="268" width="110" height="22" fill="#fff" stroke="#666"/>
<text x="389" y="283" text-anchor="middle" class="byte">presets</text>
<rect x="450" y="268" width="60" height="22" fill="#fff" stroke="#666"/>
<text x="480" y="283" text-anchor="middle" class="byte">select</text>
<rect x="516" y="268" width="54" height="22" fill="#fff" stroke="#666"/>
<text x="543" y="283" text-anchor="middle" class="byte">def</text>
<rect x="160" y="300" width="60" height="22" fill="#ffeaa7" stroke="#666"/>
<text x="190" y="315" text-anchor="middle" class="byte">save?</text>
<text x="360" y="335" text-anchor="middle" class="byte" font-family="system-ui">optional 0x01 after envelope</text>
<text x="360" y="400" text-anchor="middle" font-family="system-ui" font-size="12" fill="#555">
Pi REST/UI uses JSON · conversion to binary happens at bridge boundary
</text>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 820 420" font-family="system-ui, Segoe UI, sans-serif">
<defs>
<marker id="arrow" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
<path d="M0,0 L6,3 L0,6 Z" fill="#333"/>
</marker>
<style>
.box { fill: #f4f6f8; stroke: #2c3e50; stroke-width: 2; rx: 8; }
.title { font-size: 16px; font-weight: 700; fill: #1a1a1a; }
.label { font-size: 13px; fill: #333; }
.small { font-size: 11px; fill: #555; }
.line { stroke: #333; stroke-width: 1.5; fill: none; marker-end: url(#arrow); }
.dashed { stroke-dasharray: 6 4; }
</style>
</defs>
<text x="410" y="28" text-anchor="middle" class="title" font-size="18">ESP-NOW LED system  three nodes</text>
<!-- Pi -->
<rect class="box" x="40" y="60" width="220" height="300"/>
<text x="150" y="88" text-anchor="middle" class="title">led-controller</text>
<text x="150" y="108" text-anchor="middle" class="small">Raspberry Pi</text>
<rect x="60" y="125" width="180" height="36" fill="#fff" stroke="#888" rx="4"/>
<text x="150" y="148" text-anchor="middle" class="label">Web UI / REST (JSON)</text>
<rect x="60" y="170" width="180" height="36" fill="#fff" stroke="#888" rx="4"/>
<text x="150" y="193" text-anchor="middle" class="label">db/device.json, groups</text>
<rect x="60" y="215" width="180" height="36" fill="#e8f4fc" stroke="#2980b9" rx="4"/>
<text x="150" y="238" text-anchor="middle" class="label">espnow_wire + binary</text>
<rect x="60" y="260" width="180" height="36" fill="#e8f4fc" stroke="#2980b9" rx="4"/>
<text x="150" y="283" text-anchor="middle" class="label">bridge_ws_client</text>
<text x="150" y="330" text-anchor="middle" class="small">WS client bridge</text>
<!-- Bridge -->
<rect class="box" x="300" y="100" width="220" height="220"/>
<text x="410" y="128" text-anchor="middle" class="title">Bridge ESP32</text>
<text x="410" y="148" text-anchor="middle" class="small">espnow-sender</text>
<rect x="320" y="165" width="180" height="36" fill="#fff" stroke="#888" rx="4"/>
<text x="410" y="188" text-anchor="middle" class="label">WebSocket server /ws</text>
<rect x="320" y="210" width="180" height="36" fill="#fef9e7" stroke="#d4ac0d" rx="4"/>
<text x="410" y="233" text-anchor="middle" class="label">ESP-NOW relay</text>
<text x="410" y="275" text-anchor="middle" class="small">max 20 peers (LRU)</text>
<!-- Drivers -->
<rect class="box" x="560" y="60" width="220" height="300"/>
<text x="670" y="88" text-anchor="middle" class="title">led-driver × N</text>
<text x="670" y="108" text-anchor="middle" class="small">ESP32 LED strips</text>
<rect x="580" y="140" width="180" height="32" fill="#eafaf1" stroke="#27ae60" rx="4"/>
<text x="670" y="161" text-anchor="middle" class="label">boot ANNOUNCE</text>
<rect x="580" y="182" width="180" height="32" fill="#fff" stroke="#888" rx="4"/>
<text x="670" y="203" text-anchor="middle" class="label">store GROUPS</text>
<rect x="580" y="224" width="180" height="32" fill="#fff" stroke="#888" rx="4"/>
<text x="670" y="245" text-anchor="middle" class="label">apply CMD / GROUP_CMD</text>
<text x="670" y="320" text-anchor="middle" class="small">binary only on air</text>
<!-- Arrows -->
<path class="line" d="M 260 278 L 298 200"/>
<text x="268" y="235" class="small">binary WS</text>
<path class="line" d="M 520 230 L 558 200"/>
<text x="528" y="218" class="small">ESP-NOW</text>
<path class="line dashed" d="M 520 260 L 558 280"/>
<text x="528" y="278" class="small">broadcast</text>
<path class="line dashed" d="M 558 160 L 520 175"/>
<text x="530" y="158" class="small">ANNOUNCE</text>
<text x="410" y="400" text-anchor="middle" class="small">d250 bytes per ESP-NOW frame · no JSON on wire</text>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -2,7 +2,7 @@
<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>
<text x="16" y="28" fill="#888" font-family="sans-serif" font-size="11">Zones</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"/>
@@ -13,7 +13,7 @@
<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>
<text x="470" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Zones</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"/>

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -1,26 +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>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 340" width="300" height="340" role="img" aria-labelledby="mobile-menu-title">
<title id="mobile-menu-title">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>
<text x="22" y="30" fill="#eee" font-family="sans-serif" font-size="12">Menu</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>
<text x="86" y="30" fill="#ccc" font-family="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>
<text x="142" y="30" fill="#fff" font-family="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="84" fill="#888" font-family="sans-serif" font-size="10">Dropdown (same actions as desktop header)</text>
<g font-family="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="156">Zones</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>
<text x="24" y="268" fill="#aaa" font-family="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>
<text x="36" y="298" fill="#ddd" font-family="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>
<text x="124" y="298" fill="#ddd" font-family="sans-serif" font-size="11">preset</text>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -1,23 +1,18 @@
{
"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
"v": "1",
"dv": {
"ff:ff:ff:ff:ff:ff": {
"p": {
"2": {
"p": "on",
"c": ["#FFFFFF"],
"a": true
}
},
"sv": true,
"st": 0
},
"s": ["2", 0],
"g": ["5", "18"],
"sg": false,
"sv": true
}
}
}

View File

@@ -1,7 +0,0 @@
# espnow-sender
Minimal MicroPython project for receiving JSON over Microdot WebSocket.
- WebSocket endpoint: `/ws`
- Entry point: `main.py`
- Message template: `msg.json`

View File

@@ -1,120 +0,0 @@
import asyncio
import json
from microdot import Microdot
from microdot.websocket import WebSocketError, with_websocket
import espnow
import network
from util import format_mac, parse_mac
app = Microdot()
_esp = None
_known_peers = set()
_ws_clients = set()
def _init_espnow():
global _esp
sta = network.WLAN(network.STA_IF)
sta.active(True)
_esp = espnow.ESPNow()
_esp.active(True)
def _validate_envelope(obj):
if obj.get("v") != "1":
raise ValueError("message.v must be '1'")
devices = obj["devices"]
for address in devices.keys():
parse_mac(address)
return obj
def _send_espnow(address, payload):
if _esp is None:
raise ValueError("espnow is not initialized")
mac = parse_mac(address)
msg = json.dumps(payload, separators=(",", ":")).encode("utf-8")
if mac not in _known_peers:
_esp.add_peer(mac)
_known_peers.add(mac)
_esp.send(mac, msg)
return mac, len(msg)
async def _broadcast_ws(obj):
text = json.dumps(obj)
dead = []
for client in list(_ws_clients):
try:
await client.send(text)
except Exception:
dead.append(client)
for client in dead:
_ws_clients.discard(client)
async def _espnow_receive_loop():
while True:
host, msg = _esp.recv(0)
if not host:
await asyncio.sleep(0.01)
continue
await _broadcast_ws(
{
"from": format_mac(host),
"payload": msg.decode("utf-8"),
}
)
@app.route("/ws")
@with_websocket
async def ws(request, ws):
_ws_clients.add(ws)
while True:
try:
raw = await ws.receive()
except WebSocketError:
break
if not raw:
break
try:
parsed = json.loads(raw)
env = _validate_envelope(parsed)
sent = []
for address, payload in env["devices"].items():
mac, payload_size = _send_espnow(address, payload)
sent.append(
{
"address": format_mac(mac),
"bytes": payload_size,
}
)
except (ValueError, TypeError) as e:
await ws.send(json.dumps({"ok": False, "error": str(e)}))
continue
await ws.send(
json.dumps(
{
"ok": True,
"sent": sent,
}
)
)
_ws_clients.discard(ws)
async def main(port=80):
_init_espnow()
asyncio.create_task(_espnow_receive_loop())
await app.start_server(host="0.0.0.0", port=port)
if __name__ == "__main__":
asyncio.run(main(port=80))

View File

@@ -1,24 +0,0 @@
{
"v": "1",
"devices": {
"ff:ff:ff:ff:ff:ff": {
"presets": {
"preset_id": {
"pattern": "on",
"colors": ["#FF0000"],
"delay": 100,
"brightness": 255,
"auto": true
}
},
"select": {
"preset": "preset_id",
"step": 0
},
"save": true,
"default": "preset_id",
"b": 255
}
}
}

View File

@@ -0,0 +1,91 @@
"""HTTP settings API for the ESP-NOW bridge (AP IP, password, channel)."""
import json
from settings import WIFI_CHANNEL_DEFAULT
_SETTINGS_KEYS = frozenset(
{"name", "ap_ip", "ap_password", "wifi_channel", "ws_port", "max_peers"}
)
def _parse_ipv4(value):
parts = str(value).strip().split(".")
if len(parts) != 4:
raise ValueError("ap_ip must be dotted IPv4")
out = []
for p in parts:
n = int(p)
if n < 0 or n > 255:
raise ValueError("ap_ip octet out of range")
out.append(n)
return ".".join(str(x) for x in out)
def public_settings(settings):
return {
"name": settings.get("name", ""),
"ap_ip": settings.get("ap_ip", "192.168.4.1"),
"ap_password_set": bool(str(settings.get("ap_password") or "").strip()),
"wifi_channel": settings.get("wifi_channel", WIFI_CHANNEL_DEFAULT),
"ws_port": settings.get("ws_port", 80),
"max_peers": settings.get("max_peers", 20),
}
def apply_settings_update(settings, data):
if not isinstance(data, dict):
raise ValueError("body must be a JSON object")
reboot_required = False
if "name" in data:
name = str(data["name"] or "").strip()
if not name:
raise ValueError("name is required")
if len(name) > 32:
raise ValueError("name too long")
settings["name"] = name
reboot_required = True
if "ap_ip" in data:
settings["ap_ip"] = _parse_ipv4(data["ap_ip"])
reboot_required = True
if "ap_password" in data:
pw = str(data["ap_password"] or "")
if pw and len(pw) < 8:
raise ValueError("ap_password must be at least 8 characters or empty")
settings["ap_password"] = pw
reboot_required = True
if "wifi_channel" in data:
ch = int(data["wifi_channel"])
if ch < 1 or ch > 11:
raise ValueError("wifi_channel must be 111")
settings["wifi_channel"] = ch
reboot_required = True
if "ws_port" in data:
port = int(data["ws_port"])
if port < 1 or port > 65535:
raise ValueError("ws_port out of range")
settings["ws_port"] = port
if "max_peers" in data:
settings["max_peers"] = max(1, min(20, int(data["max_peers"])))
return reboot_required
def register_bridge_routes(app, settings):
@app.get("/settings")
async def get_bridge_settings(request):
return json.dumps(public_settings(settings)), 200, {"Content-Type": "application/json"}
@app.put("/settings")
async def put_bridge_settings(request):
try:
data = request.json
reboot_required = apply_settings_update(settings, data)
settings.save()
body = public_settings(settings)
body["message"] = "Settings saved"
body["reboot_required"] = reboot_required
return json.dumps(body), 200, {"Content-Type": "application/json"}
except ValueError as err:
return json.dumps({"error": str(err)}), 400, {"Content-Type": "application/json"}
except Exception as err:
return json.dumps({"error": str(err)}), 500, {"Content-Type": "application/json"}

133
espnow-sender/src/main.py Normal file
View File

@@ -0,0 +1,133 @@
import asyncio
import json
from microdot import Microdot
from microdot.websocket import WebSocketError, with_websocket
import aioespnow
import machine
from settings import Settings
from espnow_wire import BROADCAST_MAC, pack_ws_uplink
from peer_table import PeerTable, load_max_peers
from downlink_router import is_devices_envelope, route_envelope
from wifi_ap import init_bridge_network
from util import print_bridge_ip
from bridge_http import register_bridge_routes
from machine import UART, Pin
wdt = machine.WDT(timeout=10000)
wdt.feed()
machine.freq(160000000)
settings = Settings()
print(settings)
uart = UART(1, baudrate=921600, tx=Pin(2), rx=Pin(3))
app = Microdot()
register_bridge_routes(app, settings)
init_bridge_network(settings)
print_bridge_ip(settings.get("ws_port", 80))
esp = aioespnow.AIOESPNow()
esp.active(True)
esp.add_peer(BROADCAST_MAC)
peer_table = PeerTable(load_max_peers())
clients = set()
def _note_uplink_peer(host, msg):
if host and len(host) == 6:
name = None
if msg and msg[0:1] == b"{":
try:
data = json.loads(msg)
if isinstance(data, dict):
name = data.get("name")
except (ValueError, TypeError):
pass
peer_table.touch(host, name, esp)
@app.route("/ws")
@with_websocket
async def ws(request, ws):
clients.add(ws)
try:
while True:
try:
raw = await ws.receive()
except WebSocketError as err:
print(err)
break
if not raw:
break
if isinstance(raw, str):
raw = raw.encode("utf-8")
try:
if is_devices_envelope(raw):
await route_envelope(esp, peer_table, raw)
else:
await esp.asend(BROADCAST_MAC, raw)
print(raw)
print("ws tx", len(raw), "B")
except Exception as err:
print(err)
break
finally:
clients.discard(ws)
async def _espnow_receive_loop():
async for host, msg in esp:
if not host or not msg:
continue
_note_uplink_peer(host, msg)
print("espnow rx", len(msg), "B")
frame = pack_ws_uplink(host, msg)
dead = []
for client in list(clients):
try:
await client.send(frame)
except Exception:
dead.append(client)
for client in dead:
clients.discard(client)
uart.write(msg)
async def _serial_receive_loop():
while True:
if uart.any():
raw = uart.read()
print(raw)
try:
if is_devices_envelope(raw):
await route_envelope(esp, peer_table, raw)
else:
await esp.asend(BROADCAST_MAC, raw)
print(raw)
print("ws tx", len(raw), "B")
except Exception as err:
print(err)
break
await asyncio.sleep(0)
async def _wdt_feed_loop():
while True:
await asyncio.sleep(1)
wdt.feed()
async def main():
asyncio.create_task(_wdt_feed_loop())
asyncio.create_task(_espnow_receive_loop())
asyncio.create_task(_serial_receive_loop())
await app.start_server(host="0.0.0.0", port=80)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,90 @@
"""LRU table of ESP-NOW peer MACs seen on uplink."""
from espnow_wire import BROADCAST_MAC
try:
from settings import Settings
except ImportError:
Settings = None
# ESP32 counts the broadcast peer toward the ~20 peer limit.
_RESERVED_FOR_BROADCAST = 1
class PeerTable:
def __init__(self, max_peers=20):
limit = max(1, int(max_peers) - _RESERVED_FOR_BROADCAST)
self._max = limit
self._order = []
self._names = {}
def _evict_lru(self, esp):
if not self._order:
return
old = self._order.pop(0)
self._names.pop(old, None)
if esp is not None:
try:
esp.del_peer(old)
except OSError:
pass
def touch(self, mac_bytes, name=None, esp=None):
"""Note a peer from uplink (LRU). Pass ``esp`` so evictions free ESP-NOW slots."""
if not mac_bytes or len(mac_bytes) != 6:
return
if mac_bytes == BROADCAST_MAC:
return
if mac_bytes in self._order:
self._order.remove(mac_bytes)
elif len(self._order) >= self._max:
self._evict_lru(esp)
self._order.append(mac_bytes)
if name:
self._names[mac_bytes] = str(name)
if esp is not None:
try:
esp.add_peer(mac_bytes)
except OSError:
pass
def ensure_peer(self, esp, mac_bytes):
"""Register ``mac_bytes`` on ESP-NOW, evicting LRU peers when the table is full."""
if not mac_bytes or len(mac_bytes) != 6:
return False
if mac_bytes == BROADCAST_MAC:
try:
esp.add_peer(mac_bytes)
except OSError:
pass
return True
if mac_bytes in self._order:
self._order.remove(mac_bytes)
self._order.append(mac_bytes)
else:
while len(self._order) >= self._max:
self._evict_lru(esp)
self._order.append(mac_bytes)
# Uplink touch() only updates LRU; always add_peer before unicast send.
try:
esp.add_peer(mac_bytes)
except OSError as err:
print("add_peer failed", err)
return False
return True
def peers(self):
return list(self._order)
def is_broadcast_mac(self, mac_bytes):
return mac_bytes == BROADCAST_MAC
def load_max_peers():
if Settings is None:
return 20
try:
s = Settings()
return int(s.get("max_peers", 20))
except Exception:
return 20

48
espnow-sender/src/util.py Normal file
View File

@@ -0,0 +1,48 @@
def parse_mac(value):
raw = value.strip().lower().replace(":", "").replace("-", "")
if len(raw) != 12:
raise ValueError("address must be 12 hex chars or aa:bb:cc:dd:ee:ff")
try:
return bytes.fromhex(raw)
except ValueError:
raise ValueError("address contains non-hex characters")
def format_mac(mac_bytes):
return ":".join("{:02x}".format(b) for b in mac_bytes)
def print_bridge_ip(ws_port=80):
import network
try:
port = int(ws_port)
except (TypeError, ValueError):
port = 80
ips = []
try:
sta = network.WLAN(network.STA_IF)
if sta.active():
ip = sta.ifconfig()[0]
if ip and ip != "0.0.0.0":
ips.append(("STA", ip))
except Exception:
pass
try:
ap = network.WLAN(network.AP_IF)
if ap.active():
ip = ap.ifconfig()[0]
if ip and ip != "0.0.0.0":
ips.append(("AP", ip))
except Exception:
pass
if not ips:
print("bridge IP: (AP not up)")
return
ips.sort(key=lambda x: 0 if x[0] == "AP" else 1)
_label, ip = ips[0]
print("bridge IP (AP):", ip)
print("bridge_ws_url: ws://%s:%s/ws" % (ip, port))

View File

@@ -1,12 +0,0 @@
def parse_mac(value):
raw = value.strip().lower().replace(":", "").replace("-", "")
if len(raw) != 12:
raise ValueError("address must be 12 hex chars or aa:bb:cc:dd:ee:ff")
try:
return bytes.fromhex(raw)
except ValueError:
raise ValueError("address contains non-hex characters")
def format_mac(mac_bytes):
return ":".join("{:02x}".format(b) for b in mac_bytes)

View File

@@ -1,14 +0,0 @@
class Loader:
def __init__(self, pkg, dir):
if dir == ".":
dir = ""
else:
dir = dir.replace("/", ".") + "."
if pkg and pkg != "__main__":
dir = pkg + "." + dir
self.p = dir
def load(self, name):
name = name.replace(".", "_")
return __import__(self.p + name, None, None, (name,)).render

View File

@@ -1,21 +0,0 @@
# (c) 2014-2020 Paul Sokolovsky. MIT license.
try:
from uos import stat, remove
except:
from os import stat, remove
from . import source
class Loader(source.Loader):
def load(self, name):
o_path = self.pkg_path + self.compiled_path(name)
i_path = self.pkg_path + self.dir + "/" + name
try:
o_stat = stat(o_path)
i_stat = stat(i_path)
if i_stat[8] > o_stat[8]:
# input file is newer, remove output to force recompile
remove(o_path)
finally:
return super().load(name)

View File

@@ -1,188 +0,0 @@
# (c) 2014-2019 Paul Sokolovsky. MIT license.
from . import compiled
class Compiler:
START_CHAR = "{"
STMNT = "%"
STMNT_END = "%}"
EXPR = "{"
EXPR_END = "}}"
def __init__(self, file_in, file_out, indent=0, seq=0, loader=None):
self.file_in = file_in
self.file_out = file_out
self.loader = loader
self.seq = seq
self._indent = indent
self.stack = []
self.in_literal = False
self.flushed_header = False
self.args = "*a, **d"
def indent(self, adjust=0):
if not self.flushed_header:
self.flushed_header = True
self.indent()
self.file_out.write("def render%s(%s):\n" % (str(self.seq) if self.seq else "", self.args))
self.stack.append("def")
self.file_out.write(" " * (len(self.stack) + self._indent + adjust))
def literal(self, s):
if not s:
return
if not self.in_literal:
self.indent()
self.file_out.write('yield """')
self.in_literal = True
self.file_out.write(s.replace('"', '\\"'))
def close_literal(self):
if self.in_literal:
self.file_out.write('"""\n')
self.in_literal = False
def render_expr(self, e):
self.indent()
self.file_out.write('yield str(' + e + ')\n')
def parse_statement(self, stmt):
tokens = stmt.split(None, 1)
if tokens[0] == "args":
if len(tokens) > 1:
self.args = tokens[1]
else:
self.args = ""
elif tokens[0] == "set":
self.indent()
self.file_out.write(stmt[3:].strip() + "\n")
elif tokens[0] == "include":
if not self.flushed_header:
# If there was no other output, we still need a header now
self.indent()
tokens = tokens[1].split(None, 1)
args = ""
if len(tokens) > 1:
args = tokens[1]
if tokens[0][0] == "{":
self.indent()
# "1" as fromlist param is uPy hack
self.file_out.write('_ = __import__(%s.replace(".", "_"), None, None, 1)\n' % tokens[0][2:-2])
self.indent()
self.file_out.write("yield from _.render(%s)\n" % args)
return
with self.loader.input_open(tokens[0][1:-1]) as inc:
self.seq += 1
c = Compiler(inc, self.file_out, len(self.stack) + self._indent, self.seq)
inc_id = self.seq
self.seq = c.compile()
self.indent()
self.file_out.write("yield from render%d(%s)\n" % (inc_id, args))
elif len(tokens) > 1:
if tokens[0] == "elif":
assert self.stack[-1] == "if"
self.indent(-1)
self.file_out.write(stmt + ":\n")
else:
self.indent()
self.file_out.write(stmt + ":\n")
self.stack.append(tokens[0])
else:
if stmt.startswith("end"):
assert self.stack[-1] == stmt[3:]
self.stack.pop(-1)
elif stmt == "else":
assert self.stack[-1] == "if"
self.indent(-1)
self.file_out.write("else:\n")
else:
assert False
def parse_line(self, l):
while l:
start = l.find(self.START_CHAR)
if start == -1:
self.literal(l)
return
self.literal(l[:start])
self.close_literal()
sel = l[start + 1]
#print("*%s=%s=" % (sel, EXPR))
if sel == self.STMNT:
end = l.find(self.STMNT_END)
assert end > 0
stmt = l[start + len(self.START_CHAR + self.STMNT):end].strip()
self.parse_statement(stmt)
end += len(self.STMNT_END)
l = l[end:]
if not self.in_literal and l == "\n":
break
elif sel == self.EXPR:
# print("EXPR")
end = l.find(self.EXPR_END)
assert end > 0
expr = l[start + len(self.START_CHAR + self.EXPR):end].strip()
self.render_expr(expr)
end += len(self.EXPR_END)
l = l[end:]
else:
self.literal(l[start])
l = l[start + 1:]
def header(self):
self.file_out.write("# Autogenerated file\n")
def compile(self):
self.header()
for l in self.file_in:
self.parse_line(l)
self.close_literal()
return self.seq
class Loader(compiled.Loader):
def __init__(self, pkg, dir):
super().__init__(pkg, dir)
self.dir = dir
if pkg == "__main__":
# if pkg isn't really a package, don't bother to use it
# it means we're running from "filesystem directory", not
# from a package.
pkg = None
self.pkg_path = ""
if pkg:
p = __import__(pkg)
if isinstance(p.__path__, str):
# uPy
self.pkg_path = p.__path__
else:
# CPy
self.pkg_path = p.__path__[0]
self.pkg_path += "/"
def input_open(self, template):
path = self.pkg_path + self.dir + "/" + template
return open(path)
def compiled_path(self, template):
return self.dir + "/" + template.replace(".", "_") + ".py"
def load(self, name):
try:
return super().load(name)
except (OSError, ImportError):
pass
compiled_path = self.pkg_path + self.compiled_path(name)
f_in = self.input_open(name)
f_out = open(compiled_path, "w")
c = Compiler(f_in, f_out, loader=self)
c.compile()
f_in.close()
f_out.close()
return super().load(name)

View File

@@ -1,3 +1,5 @@
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_endpoints_pytest.py"]
python_files = ["test_*.py"]
# ``tests/models/`` is a package name clash with ``src/models``; run via tests/models/run_all.py
norecursedirs = ["models"]

View File

@@ -0,0 +1,419 @@
#!/usr/bin/env python3
"""Add Winter profile: 6-light 2x3 grid, presets, and sequences."""
from __future__ import annotations
import json
from copy import deepcopy
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DB = ROOT / "db"
PROFILE_ID = "3"
PALETTE_ID = "14"
ZONE_PRESETS_ID = "11"
ZONE_SEQUENCES_ID = "12"
# 2x3 grid device MACs (placeholders — assign real devices in the UI)
DEVICE_MACS = [
"a0b100000001", # r0c0 top-left
"a0b100000002", # r0c1
"a0b100000003", # r0c2
"a0b100000004", # r1c0 bottom-left
"a0b100000005", # r1c1
"a0b100000006", # r1c2
]
GROUP_CELL = {
"a0b100000001": "6",
"a0b100000002": "7",
"a0b100000003": "8",
"a0b100000004": "9",
"a0b100000005": "10",
"a0b100000006": "11",
}
GROUP_TOP_ROW = "12"
GROUP_BOTTOM_ROW = "13"
GROUP_COL_LEFT = "14"
GROUP_COL_MID = "15"
GROUP_COL_RIGHT = "16"
GROUP_ALL = "17"
PRESET_OFF = "78"
PRESET_TWINKLE = "79"
PRESET_ICICLES = "80"
PRESET_BLIZZARD = "81"
PRESET_RIME = "82"
PRESET_AURORA = "83"
PRESET_STARFALL = "84"
PRESET_SPARKLE = "85"
PRESET_COOL_WHITE = "86"
PRESET_CHASE_ICE = "87"
SEQ_CASCADE = "12"
SEQ_ROWS = "13"
SEQ_COLUMNS = "14"
SEQ_BLIZZARD_ALL = "15"
SEQ_ROTATION = "16"
def load_json(name: str) -> dict:
path = DB / f"{name}.json"
return json.loads(path.read_text(encoding="utf-8"))
def save_json(name: str, data: dict) -> None:
path = DB / f"{name}.json"
path.write_text(json.dumps(data, separators=(",", ":")), encoding="utf-8")
def preset_skeleton(name: str, pattern: str, colors: list, **extra) -> dict:
doc = {
"name": name,
"pattern": pattern,
"colors": colors,
"brightness": 220,
"delay": 80,
"auto": True,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": PROFILE_ID,
"background": "#0A1520",
"manual_beat_n": 1,
}
doc.update(extra)
if "palette_refs" not in doc and pattern not in ("on", "off"):
doc["palette_refs"] = [None] * len(colors)
return doc
def seq_doc(
name: str,
lanes: list,
lanes_group_ids: list,
*,
loop: bool = True,
simulated_bpm: int = 90,
) -> dict:
steps = [step for lane in lanes for step in lane]
return {
"name": name,
"profile_id": PROFILE_ID,
"group_ids": [GROUP_ALL],
"lanes": lanes,
"lanes_group_ids": lanes_group_ids,
"advance_mode": "beats",
"steps": steps,
"step_duration_ms": 3000,
"simulated_bpm": simulated_bpm,
"sequence_transition": 500,
"loop": loop,
}
def main() -> None:
profiles = load_json("profile")
palettes = load_json("palette")
groups = load_json("group")
devices = load_json("device")
zones = load_json("zone")
sequences = load_json("sequence")
presets = load_json("preset")
labels = [
("winter top-left", 0),
("winter top-centre", 1),
("winter top-right", 2),
("winter bottom-left", 3),
("winter bottom-centre", 4),
("winter bottom-right", 5),
]
profiles[PROFILE_ID] = {
"name": "Winter",
"type": "zones",
"zones": [ZONE_PRESETS_ID, ZONE_SEQUENCES_ID],
"scenes": [],
"palette_id": PALETTE_ID,
}
palettes[PALETTE_ID] = [
"#E8F4FF",
"#9ECFFF",
"#5080C8",
"#FFFFFF",
"#B0DCFF",
"#0A1520",
"#FF8020",
"#071018",
]
for mac, (label, _idx) in zip(DEVICE_MACS, labels):
devices[mac] = {
"id": mac,
"name": label,
"type": "led",
"transport": "wifi",
"address": "",
"default_pattern": None,
"zones": [],
"output_brightness": 255,
"wifi_color_order": "rgb",
"wifi_startup_mode": "default",
}
def group_row(gid: str, name: str, macs: list) -> None:
groups[gid] = {
"name": name,
"devices": macs,
"profile_id": PROFILE_ID,
"wifi_color_order": "rgb",
"wifi_startup_mode": "default",
"output_brightness": 255,
"pattern": "on",
"colors": ["000000", "E8F4FF"],
"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,
}
for mac, gid in zip(DEVICE_MACS, GROUP_CELL.values()):
group_row(gid, labels[DEVICE_MACS.index(mac)][0], [mac])
group_row(GROUP_TOP_ROW, "winter top row", DEVICE_MACS[:3])
group_row(GROUP_BOTTOM_ROW, "winter bottom row", DEVICE_MACS[3:])
group_row(GROUP_COL_LEFT, "winter left column", [DEVICE_MACS[0], DEVICE_MACS[3]])
group_row(GROUP_COL_MID, "winter centre column", [DEVICE_MACS[1], DEVICE_MACS[4]])
group_row(GROUP_COL_RIGHT, "winter right column", [DEVICE_MACS[2], DEVICE_MACS[5]])
group_row(GROUP_ALL, "winter grid (all)", list(DEVICE_MACS))
presets[PRESET_OFF] = preset_skeleton("winter off", "off", [], brightness=0, delay=100)
presets[PRESET_TWINKLE] = preset_skeleton(
"winter twinkle",
"twinkle",
["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"],
n1=150,
n2=20,
n4=10,
delay=100,
)
presets[PRESET_ICICLES] = preset_skeleton(
"winter icicles",
"icicles",
["#F0F8FF", "#9ECFFF", "#FFFFFF"],
n1=14,
n2=11,
n3=1,
delay=80,
)
presets[PRESET_BLIZZARD] = preset_skeleton(
"winter blizzard",
"blizzard",
["#FFFFFF", "#CDE8FF", "#AACCF5"],
n1=110,
n2=2,
n3=140,
delay=45,
)
presets[PRESET_RIME] = preset_skeleton(
"winter rime",
"rime",
["#E8F4FF", "#FFFFFF", "#B8DCF8"],
n1=40,
n2=18,
n3=4,
delay=120,
)
presets[PRESET_AURORA] = preset_skeleton(
"winter aurora",
"aurora",
["#183050", "#5090C8", "#C8E8FF"],
n1=22,
n2=210,
n6=1,
delay=90,
)
presets[PRESET_STARFALL] = preset_skeleton(
"winter starfall",
"particles",
["#FFFFFF", "#C8E8FF", "#FFF8E0"],
n1=16,
n2=2,
n3=12,
n6=1,
delay=55,
)
presets[PRESET_SPARKLE] = preset_skeleton(
"winter ice sparkle",
"sparkle",
["#E8F4FF", "#B0DCFF", "#FFFFFF"],
n1=70,
n2=165,
n3=1,
n6=1,
delay=50,
)
presets[PRESET_COOL_WHITE] = preset_skeleton(
"winter cool white",
"on",
["#E6F2FF"],
brightness=200,
delay=100,
)
presets[PRESET_CHASE_ICE] = preset_skeleton(
"winter ice chase",
"chase",
["#E8F4FF", "#5080C8"],
auto=False,
n1=20,
n2=20,
n3=15,
n4=15,
delay=120,
background="#071018",
)
grid_presets = [
[PRESET_ICICLES, PRESET_TWINKLE, PRESET_BLIZZARD],
[PRESET_RIME, PRESET_AURORA, PRESET_STARFALL],
]
flat = [p for row in grid_presets for p in row]
zones[ZONE_PRESETS_ID] = {
"name": "Winter grid",
"names": [],
"group_ids": [GROUP_ALL],
"preset_group_ids": {},
"presets": grid_presets,
"presets_flat": flat,
"default_preset": PRESET_TWINKLE,
"brightness": 200,
"sequence_ids": [],
"content_kind": "presets",
}
sequences[SEQ_CASCADE] = seq_doc(
"Winter cell cascade",
[
[{"preset_id": PRESET_ICICLES, "beats": 6}],
[{"preset_id": PRESET_SPARKLE, "beats": 6}],
[{"preset_id": PRESET_BLIZZARD, "beats": 6}],
[{"preset_id": PRESET_RIME, "beats": 6}],
[{"preset_id": PRESET_AURORA, "beats": 6}],
[{"preset_id": PRESET_STARFALL, "beats": 6}],
],
[
[GROUP_CELL[DEVICE_MACS[0]]],
[GROUP_CELL[DEVICE_MACS[1]]],
[GROUP_CELL[DEVICE_MACS[2]]],
[GROUP_CELL[DEVICE_MACS[3]]],
[GROUP_CELL[DEVICE_MACS[4]]],
[GROUP_CELL[DEVICE_MACS[5]]],
],
simulated_bpm=85,
)
sequences[SEQ_ROWS] = seq_doc(
"Winter row waves",
[
[
{"preset_id": PRESET_BLIZZARD, "beats": 8},
{"preset_id": PRESET_ICICLES, "beats": 8},
],
[
{"preset_id": PRESET_AURORA, "beats": 8},
{"preset_id": PRESET_RIME, "beats": 8},
],
],
[[GROUP_TOP_ROW], [GROUP_BOTTOM_ROW]],
simulated_bpm=80,
)
sequences[SEQ_COLUMNS] = seq_doc(
"Winter column chase",
[
[{"preset_id": PRESET_CHASE_ICE, "beats": 12}],
[{"preset_id": PRESET_TWINKLE, "beats": 12}],
[{"preset_id": PRESET_STARFALL, "beats": 12}],
],
[[GROUP_COL_LEFT], [GROUP_COL_MID], [GROUP_COL_RIGHT]],
simulated_bpm=95,
)
sequences[SEQ_BLIZZARD_ALL] = seq_doc(
"Winter full blizzard",
[[{"preset_id": PRESET_BLIZZARD, "beats": 16}]],
[[GROUP_ALL]],
simulated_bpm=75,
)
sequences[SEQ_ROTATION] = seq_doc(
"Winter showcase",
[
[
{"preset_id": PRESET_ICICLES, "beats": 8},
{"preset_id": PRESET_BLIZZARD, "beats": 8},
{"preset_id": PRESET_RIME, "beats": 8},
{"preset_id": PRESET_AURORA, "beats": 8},
{"preset_id": PRESET_STARFALL, "beats": 8},
{"preset_id": PRESET_TWINKLE, "beats": 8},
]
],
[[GROUP_ALL]],
simulated_bpm=72,
)
zones[ZONE_SEQUENCES_ID] = {
"name": "Winter sequences",
"names": [],
"group_ids": [GROUP_ALL],
"preset_group_ids": {},
"presets": [],
"presets_flat": [],
"default_preset": None,
"brightness": 200,
"sequence_ids": [
SEQ_CASCADE,
SEQ_ROWS,
SEQ_COLUMNS,
SEQ_BLIZZARD_ALL,
SEQ_ROTATION,
],
"content_kind": "sequences",
}
save_json("profile", profiles)
save_json("palette", palettes)
save_json("group", groups)
save_json("device", devices)
save_json("zone", zones)
save_json("sequence", sequences)
save_json("preset", presets)
print("Winter profile created:")
print(f" profile {PROFILE_ID}, palette {PALETTE_ID}")
print(f" zones {ZONE_PRESETS_ID} (presets 2x3), {ZONE_SEQUENCES_ID} (sequences)")
print(f" devices {', '.join(DEVICE_MACS)}")
print(f" groups {GROUP_CELL} + rows/cols/all")
print(f" presets {PRESET_OFF}-{PRESET_CHASE_ICE}")
print(f" sequences {SEQ_CASCADE}-{SEQ_ROTATION}")
if __name__ == "__main__":
main()

View File

@@ -13,4 +13,8 @@ if [ -n "${pids}" ]; then
fi
cd "$ROOT_DIR/src"
exec python main.py
exec env LED_CONTROLLER_LIVE_RELOAD=1 python -m uvicorn fastapi_app:app \
--host 0.0.0.0 --port "$PORT" --reload --reload-dir . \
--reload-include '**/*.html' --reload-include '**/*.css' --reload-include '**/*.js' \
--reload-exclude '**/db/**' --reload-exclude '**/settings.json' \
--reload-exclude '**/settings.json.*'

View File

@@ -10,6 +10,18 @@ if [ ! -f "scripts/led-controller.service" ]; then
echo "Run this script from the repo root."
exit 1
fi
export PIPENV_VENV_IN_PROJECT="${PIPENV_VENV_IN_PROJECT:-1}"
if command -v pipenv >/dev/null 2>&1; then
PY="$(command -v python3)"
if [ -z "$PY" ]; then
echo "python3 not found; install python3." >&2
exit 1
fi
echo "Ensuring Pipenv deps with $PY (venv in project: .venv when PIPENV_VENV_IN_PROJECT=1)…"
# --skip-lock: install from Pipfile only (avoids lock/Python hash mismatches on device).
pipenv install --quiet --skip-lock --python "$PY"
pipenv --venv > scripts/.led-controller-venv
fi
chmod +x scripts/start.sh
sudo cp "scripts/led-controller.service" "$UNIT_PATH"
sudo systemctl daemon-reload

View File

@@ -1,7 +1,8 @@
[Unit]
Description=LED Controller web server
After=network-online.target
Wants=network-online.target
# Use network.target only. Ordering after network-online.target can block `systemctl start`
# until wait-online finishes; WiFi/DHCP delays then look like a hung start job.
After=network.target
[Service]
Type=simple
@@ -12,6 +13,8 @@ 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
# pipenv/first bind can be slow; avoid misleading "activating" forever if misconfigured
TimeoutStartSec=120
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""Migrate Microdot controllers to native FastAPI (no compat layer)."""
from __future__ import annotations
import re
import sys
from pathlib import Path
CONTROLLERS = Path(__file__).resolve().parents[1] / "src" / "controllers"
IMPORT_BLOCK = """from fastapi import APIRouter, Request
from http_responses import J, html_response, plain, read_json, send_file
from http_session import with_session
"""
_JSON_HEADERS = re.compile(
r"return json\.dumps\(([\s\S]*?)\),\s*(\d+)\s*,\s*\{\s*"
r"['\"]Content-Type['\"]\s*:\s*['\"]application/json['\"]\s*,?\s*\}",
re.MULTILINE,
)
_JSON_PLAIN = re.compile(
r"^(\s*)return json\.dumps\((.+)\),\s*(\d+)\s*$",
re.MULTILINE,
)
_HTML_LINE = re.compile(
r"^(\s*)return ([^,\n]+),\s*(\d+),\s*\{['\"]Content-Type['\"]: ['\"]text/html['\"]\}\s*$",
re.MULTILINE,
)
_PLAIN_LINE = re.compile(
r"^(\s*)return ([^,\n]+),\s*(\d+),\s*\{['\"]Content-Type['\"]: ['\"]text/plain[^'\"]*['\"]\}\s*$",
re.MULTILINE,
)
_PAREN_JSON = re.compile(
r"return \(\s*json\.dumps\(([\s\S]*?)\)\s*,\s*(\d+)\s*,\s*"
r"\{['\"]Content-Type['\"]: ['\"]application/json['\"]\}\s*,?\s*\)",
re.MULTILINE,
)
_PAREN_JSON_NOHDR = re.compile(
r"return \(\s*json\.dumps\(([\s\S]*?)\)\s*,\s*(\d+)\s*,?\s*\)",
re.MULTILINE,
)
_PAREN_HTML = re.compile(
r"return \(\s*([^,]+?)\s*,\s*(\d+)\s*,\s*"
r"\{['\"]Content-Type['\"]: ['\"]text/html['\"]\}\s*,?\s*\)",
re.DOTALL,
)
def _insert_imports(text: str) -> str:
if "from fastapi import APIRouter" in text:
return text
fut = re.search(r"^from __future__ import annotations\n", text, re.M)
if fut:
return text[: fut.end()] + "\n" + IMPORT_BLOCK + text[fut.end() :]
doc = re.match(r'("""[\s\S]*?"""\n+|\'\'\'[\s\S]*?\'\'\'\n+)', text)
if doc:
return text[: doc.end()] + "\n" + IMPORT_BLOCK + text[doc.end() :]
return IMPORT_BLOCK + text
def _strip_microdot(text: str) -> str:
text = re.sub(r"from microdot import Microdot(?:, send_file)?\n", "", text)
text = re.sub(r"from microdot\.session import with_session\n", "", text)
text = re.sub(r"from microdot import send_file\n", "", text)
text = text.replace("controller = Microdot()", "router = APIRouter()")
text = text.replace("@controller.", "@router.")
return text
def _convert_paths(text: str) -> str:
def fix(m: re.Match) -> str:
method, path = m.group(1), m.group(2)
if path == "":
path = "/"
path = re.sub(r"<(\w+)>", r"{\1}", path)
return f'@router.{method}("{path}")'
return re.sub(
r"@router\.(get|post|put|delete|patch)\(['\"]([^'\"]*)['\"]\)",
fix,
text,
)
def _convert_request_access(text: str) -> str:
text = text.replace("request.json or {}", "await read_json(request)")
text = re.sub(r"(?<![.\w])request\.json(?!\w)", "await read_json(request)", text)
text = text.replace("request.args.get", "request.query_params.get")
return text
def _convert_request_annotations(text: str) -> str:
text = re.sub(r"async def (\w+)\(request,", r"async def \1(request: Request,", text)
text = re.sub(r"async def (\w+)\(request\)", r"async def \1(request: Request)", text)
return text
def _convert_returns(text: str) -> str:
text = _PAREN_JSON.sub(r"return J(\1, \2)", text)
text = _PAREN_JSON_NOHDR.sub(r"return J(\1, \2)", text)
text = _PAREN_HTML.sub(r"return html_response(\1, \2)", text)
text = _JSON_HEADERS.sub(r"return J(\1, \2)", text)
text = _JSON_PLAIN.sub(r"\1return J(\2, \3)", text)
text = _HTML_LINE.sub(r"\1return html_response(\2, \3)", text)
text = _PLAIN_LINE.sub(r"\1return plain(\2, \3)", text)
text = re.sub(
r'^(\s*)return "([^"]+)",\s*(\d+)\s*$',
r'\1return plain("\2", \3)',
text,
flags=re.MULTILINE,
)
return text
def convert_file(path: Path) -> str:
text = path.read_text(encoding="utf-8")
if "Microdot" not in text:
return text
text = _strip_microdot(text)
text = _insert_imports(text)
text = _convert_paths(text)
text = _convert_request_access(text)
text = _convert_request_annotations(text)
text = _convert_returns(text)
return text
def main() -> int:
for path in sorted(CONTROLLERS.glob("*.py")):
if path.name == "__init__.py":
continue
path.write_text(convert_file(path), encoding="utf-8")
print(path.name)
return 0
if __name__ == "__main__":
sys.exit(main())

24
scripts/mpremote_send_ch5.sh Executable file
View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
# Upload and run a device-side ESP-NOW sender script.
# Default channel is 5 and default destination is broadcast.
#
# Usage:
# scripts/mpremote_send_ch5.sh [port] [dest_mac_hex] [payload_hex]
#
# Examples:
# scripts/mpremote_send_ch5.sh /dev/ttyACM0
# scripts/mpremote_send_ch5.sh /dev/ttyACM0 ffffffffffff 4c0501000000
PORT="${1:-/dev/ttyACM0}"
DEST_HEX="${2:-ffffffffffff}"
PAYLOAD_HEX="${3:-4c0501000000}"
CHANNEL=5
DEVICE_SCRIPT="send_ch5.py"
mpremote connect "${PORT}" fs cp "scripts/mpremote_send_ch5_device.py" ":${DEVICE_SCRIPT}"
mpremote connect "${PORT}" exec "
import ${DEVICE_SCRIPT%.*}
${DEVICE_SCRIPT%.*}.send_once('${DEST_HEX}', '${PAYLOAD_HEX}', ${CHANNEL})
"

View File

@@ -0,0 +1,42 @@
"""Device-side ESP-NOW sender (MicroPython, channel 5)."""
import espnow
import network
import ubinascii
CHANNEL = 5
DEST_HEX = "ffffffffffff"
PAYLOAD_HEX = "4c0501000000"
def _set_channel(channel):
sta = network.WLAN(network.STA_IF)
sta.active(True)
sta.config(pm=network.WLAN.PM_NONE)
sta.config(channel=channel)
def _add_peer(esp, dest, channel):
try:
esp.add_peer(dest, channel=channel)
except TypeError:
esp.add_peer(dest)
except OSError:
pass
def send_once(dest_hex=DEST_HEX, payload_hex=PAYLOAD_HEX, channel=CHANNEL):
dest = ubinascii.unhexlify(dest_hex)
pkt = ubinascii.unhexlify(payload_hex)
_set_channel(channel)
e = espnow.ESPNow()
e.active(True)
_add_peer(e, dest, channel)
ok = e.send(dest, pkt, True)
print("sent", ok, "ch", channel, "dest", dest_hex, "len", len(pkt))
return ok
if __name__ == "__main__":
send_once()

253
scripts/pi-eth-lan-router.sh Executable file
View File

@@ -0,0 +1,253 @@
#!/usr/bin/env bash
# Configure Raspberry Pi OS: Wi-Fi client on IF_WAN (default wlan0), Ethernet IF_LAN
# (default eth0) toward an external AP. Static LAN IP, DHCP via dnsmasq, NAT masquerade.
#
# Usage:
# sudo ./pi-eth-lan-router.sh install
# sudo ./pi-eth-lan-router.sh remove
#
# Environment overrides (optional):
# IF_WAN=wlan0 IF_LAN=eth0 LAN_IP=192.168.4.1 LAN_PREFIX=24 \
# DHCP_START=192.168.4.100 DHCP_END=192.168.4.200 \
# DNSMASQ_DNS=1.1.1.1,8.8.8.8 \
# sudo ./pi-eth-lan-router.sh install
set -euo pipefail
IF_WAN="${IF_WAN:-wlan0}"
IF_LAN="${IF_LAN:-eth0}"
LAN_IP="${LAN_IP:-192.168.4.1}"
LAN_PREFIX="${LAN_PREFIX:-24}"
DHCP_START="${DHCP_START:-192.168.4.100}"
DHCP_END="${DHCP_END:-192.168.4.200}"
# Comma-separated DNS for DHCP clients (Pi does not need to run a resolver).
DNSMASQ_DNS="${DNSMASQ_DNS:-1.1.1.1,8.8.8.8}"
NM_CON_NAME="pi-eth-lan-router"
MARK_BEGIN="# BEGIN pi-eth-lan-router (scripts/pi-eth-lan-router.sh)"
MARK_END="# END pi-eth-lan-router"
SYSCTL_FILE="/etc/sysctl.d/99-pi-eth-lan-router.conf"
DNSMASQ_SNIPPET="/etc/dnsmasq.d/pi-eth-lan-router.conf"
NFT_SNIPPET="/etc/nftables.d/50-pi-eth-lan-router.nft"
NFT_INCLUDE='include "/etc/nftables.d/50-pi-eth-lan-router.nft"'
NFTABLES_CONF="/etc/nftables.conf"
DHCPCD_CONF="/etc/dhcpcd.conf"
die() { echo "error: $*" >&2; exit 1; }
log() { echo "$*"; }
need_root() {
[[ "${EUID:-0}" -eq 0 ]] || die "run as root (sudo)"
}
have_cmd() { command -v "$1" >/dev/null 2>&1; }
apt_install() {
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq dnsmasq nftables
}
write_sysctl() {
cat >"$SYSCTL_FILE" <<EOF
# Managed by scripts/pi-eth-lan-router.sh
net.ipv4.ip_forward=1
EOF
sysctl --system -q 2>/dev/null || sysctl -p "$SYSCTL_FILE" || true
}
remove_sysctl() {
rm -f "$SYSCTL_FILE"
sysctl --system -q 2>/dev/null || true
}
write_dnsmasq() {
local mask="255.255.255.0"
if [[ "$LAN_PREFIX" != "24" ]]; then
die "only LAN_PREFIX=24 is supported by this script (extend dnsmasq netmask manually)"
fi
cat >"$DNSMASQ_SNIPPET" <<EOF
# Managed by scripts/pi-eth-lan-router.sh
interface=$IF_LAN
bind-interfaces
dhcp-range=$DHCP_START,$DHCP_END,$mask,24h
dhcp-option=option:router,$LAN_IP
dhcp-option=option:dns-server,$DNSMASQ_DNS
EOF
}
remove_dnsmasq() {
rm -f "$DNSMASQ_SNIPPET"
}
write_nft() {
mkdir -p /etc/nftables.d
cat >"$NFT_SNIPPET" <<EOF
# Managed by scripts/pi-eth-lan-router.sh
table ip pi_eth_wlan_nat {
chain postrouting {
type nat hook postrouting priority 100; policy accept;
oifname "$IF_WAN" masquerade
}
}
EOF
if [[ -f "$NFTABLES_CONF" ]] && ! grep -qF '50-pi-eth-lan-router.nft' "$NFTABLES_CONF" 2>/dev/null; then
printf '\n# pi-eth-lan-router\n%s\n' "$NFT_INCLUDE" >>"$NFTABLES_CONF"
elif [[ ! -f "$NFTABLES_CONF" ]]; then
log "warning: $NFTABLES_CONF missing; NAT was not added for boot persistence. Install/configure nftables, or add: $NFT_INCLUDE"
fi
}
remove_nft() {
rm -f "$NFT_SNIPPET"
if [[ -f "$NFTABLES_CONF" ]]; then
sed -i '/# pi-eth-lan-router/d;/50-pi-eth-lan-router\.nft/d' "$NFTABLES_CONF" || true
fi
nft delete table ip pi_eth_wlan_nat 2>/dev/null || true
}
apply_nft() {
if have_cmd nft; then
nft delete table ip pi_eth_wlan_nat 2>/dev/null || true
nft -f "$NFT_SNIPPET"
fi
}
configure_nm_eth() {
have_cmd nmcli || return 1
systemctl is-active --quiet NetworkManager 2>/dev/null || return 1
if nmcli -t -f NAME con show --active 2>/dev/null | grep -qxF "$NM_CON_NAME"; then
nmcli con down "$NM_CON_NAME" || true
fi
if nmcli -t -f NAME con show 2>/dev/null | grep -qxF "$NM_CON_NAME"; then
nmcli con mod "$NM_CON_NAME" \
connection.interface-name "$IF_LAN" \
ipv4.method manual \
ipv4.addresses "${LAN_IP}/${LAN_PREFIX}" \
ipv4.gateway "" \
ipv4.dns "" \
ipv4.never-default yes \
ipv6.method ignore
else
nmcli con add type ethernet con-name "$NM_CON_NAME" ifname "$IF_LAN" \
ipv4.method manual \
ipv4.addresses "${LAN_IP}/${LAN_PREFIX}" \
ipv4.gateway "" \
ipv4.dns "" \
ipv4.never-default yes \
ipv6.method ignore
fi
if ! nmcli con up "$NM_CON_NAME"; then
log "warning: could not activate '$NM_CON_NAME' (is $IF_LAN connected?); profile saved for next boot."
fi
return 0
}
remove_nm_eth() {
have_cmd nmcli || return 0
if nmcli -t -f NAME con show 2>/dev/null | grep -qxF "$NM_CON_NAME"; then
nmcli con delete "$NM_CON_NAME" || true
fi
}
configure_dhcpcd_eth() {
[[ -f "$DHCPCD_CONF" ]] || return 1
if grep -qF "$MARK_BEGIN" "$DHCPCD_CONF" 2>/dev/null; then
sed -i "/$MARK_BEGIN/,/$MARK_END/d" "$DHCPCD_CONF" || true
fi
{
echo "$MARK_BEGIN"
echo "interface $IF_LAN"
echo "static ip_address=${LAN_IP}/${LAN_PREFIX}"
echo "nohook wpa_supplicant"
echo "$MARK_END"
} >>"$DHCPCD_CONF"
systemctl restart dhcpcd 2>/dev/null || true
return 0
}
remove_dhcpcd_block() {
[[ -f "$DHCPCD_CONF" ]] || return 0
if grep -qF "$MARK_BEGIN" "$DHCPCD_CONF" 2>/dev/null; then
sed -i "/$MARK_BEGIN/,/$MARK_END/d" "$DHCPCD_CONF" || true
systemctl restart dhcpcd 2>/dev/null || true
fi
}
configure_eth_static() {
if configure_nm_eth; then
log "configured $IF_LAN via NetworkManager profile '$NM_CON_NAME'"
return 0
fi
if configure_dhcpcd_eth; then
log "configured $IF_LAN via dhcpcd ($DHCPCD_CONF)"
return 0
fi
die "neither NetworkManager (active) nor $DHCPCD_CONF found; set $IF_LAN to ${LAN_IP}/${LAN_PREFIX} manually"
}
remove_eth_static() {
remove_nm_eth
remove_dhcpcd_block
}
do_install() {
need_root
log "installing packages (dnsmasq, nftables)…"
apt_install
log "writing sysctl, dnsmasq, nftables snippets…"
write_sysctl
write_dnsmasq
write_nft
log "setting static IP on $IF_LAN"
configure_eth_static
log "restarting dnsmasq…"
systemctl enable dnsmasq
systemctl restart dnsmasq
log "loading NAT rules and enabling nftables…"
apply_nft
systemctl enable nftables 2>/dev/null || true
systemctl restart nftables 2>/dev/null || true
log "done. Connect $IF_LAN to the external AP (DHCP off on the AP)."
log "Join Wi-Fi on $IF_WAN to the uplink network and complete any captive portal on the Pi."
}
do_remove() {
need_root
remove_eth_static
remove_dnsmasq
systemctl restart dnsmasq 2>/dev/null || true
remove_nft
systemctl restart nftables 2>/dev/null || true
remove_sysctl
sysctl -w net.ipv4.ip_forward=0 2>/dev/null || true
log "removed pi-eth-lan-router configuration snippets and NM profile '$NM_CON_NAME' (if present)."
}
usage() {
cat <<EOF
Usage: sudo $0 install|remove
WAN (Wi-Fi client): $IF_WAN
LAN (Ethernet to AP): $IF_LAN
LAN address: ${LAN_IP}/${LAN_PREFIX}
DHCP range: $DHCP_START $DHCP_END
Override with environment variables (see script header).
EOF
}
case "${1:-}" in
install) do_install ;;
remove) do_remove ;;
*) usage; exit 1 ;;
esac

View File

@@ -1,5 +1,38 @@
#!/usr/bin/env bash
# Start the LED controller web server (port 80 by default).
cd "$(dirname "$0")/.."
# Avoid `pipenv run` on the hot path — it re-resolves the env every time and is slow on a Pi.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
export PORT="${PORT:-80}"
pipenv run run
export PIPENV_VENV_IN_PROJECT="${PIPENV_VENV_IN_PROJECT:-1}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CACHE="$SCRIPT_DIR/.led-controller-venv"
PYTHON=""
if [ -x "$ROOT/.venv/bin/python" ]; then
PYTHON="$ROOT/.venv/bin/python"
elif [ -f "$CACHE" ]; then
_v="$(tr -d '\r\n' < "$CACHE")"
if [ -n "$_v" ] && [ -x "$_v/bin/python" ]; then
PYTHON="$_v/bin/python"
fi
fi
if [ -z "$PYTHON" ] && command -v pipenv >/dev/null 2>&1; then
_v="$(cd "$ROOT" && pipenv --venv 2>/dev/null || true)"
if [ -n "${_v:-}" ] && [ -x "$_v/bin/python" ]; then
PYTHON="$_v/bin/python"
printf '%s\n' "$_v" > "$CACHE" || true
fi
fi
if [ -z "$PYTHON" ]; then
echo 'led-controller: no venv resolved; using pipenv run (slow). Run: cd '"$ROOT"' && PIPENV_VENV_IN_PROJECT=1 pipenv install --skip-lock --python "$(command -v python3)"' >&2
exec pipenv run run
fi
cd "$ROOT/src"
exec "$PYTHON" -u -m uvicorn fastapi_app:app --host 0.0.0.0 --port "$PORT"

320
src/app_factory.py Normal file
View File

@@ -0,0 +1,320 @@
"""Application factory: FastAPI routes and shared runtime startup."""
from __future__ import annotations
import asyncio
import hashlib
import os
import secrets
from typing import Any, Optional
from fastapi import FastAPI
from fastapi.responses import PlainTextResponse
from fastapi.staticfiles import StaticFiles
from settings import WIFI_CHANNEL_DEFAULT, get_settings
import controllers.preset as preset
import controllers.profile as profile
import controllers.group as group
import controllers.sequence as sequence
import controllers.zone as zone
import controllers.palette as palette
import controllers.scene as scene
import controllers.pattern as pattern
import controllers.settings as settings_controller
import controllers.device as device_controller
import controllers.led_tool as led_tool_controller
import controllers.wifi_bridge as wifi_bridge_controller
from http_responses import send_file, send_html_file
from http_session import SessionMiddleware
from models.transport import (
BridgeSerialTransport,
BridgeWsTransport,
get_bridge,
set_bridge,
)
from models.device import Device
from models.bridge_serial_client import init_bridge_serial_client
from models.bridge_ws_client import init_bridge_client
from util.espnow_registry import handle_bridge_uplink
from util.bridge_runtime import set_bridge_uplink_handler
from util.audio_detector import AudioBeatDetector
from util.wifi_driver_runtime import start_wifi_driver_runtime, stop_wifi_driver_runtime
_SRC_DIR = os.path.dirname(os.path.abspath(__file__))
def live_reload_enabled() -> bool:
v = os.environ.get("LED_CONTROLLER_LIVE_RELOAD", "").strip().lower()
return v not in ("", "0", "false", "no")
_dev_build_id: Optional[str] = None
def dev_build_id() -> Optional[str]:
global _dev_build_id
if not live_reload_enabled():
return None
if _dev_build_id is None:
_dev_build_id = secrets.token_hex(12)
return _dev_build_id
def dev_client_revision() -> Optional[str]:
"""Revision of static/template assets (changes when UI files are saved)."""
if not live_reload_enabled():
return None
base = _SRC_DIR
parts: list[str] = []
for sub in ("static", "templates"):
root = os.path.join(base, sub)
if not os.path.isdir(root):
continue
for dirpath, _, files in os.walk(root):
for name in sorted(files):
if not name.endswith((".js", ".css", ".html")):
continue
path = os.path.join(dirpath, name)
try:
st = os.stat(path)
except OSError:
continue
parts.append(f"{path}:{st.st_mtime_ns}:{st.st_size}")
if not parts:
return "0"
digest = hashlib.sha256("\n".join(parts).encode("utf-8")).hexdigest()
return digest[:16]
def mount_controller_routers(app: FastAPI) -> None:
"""Register all controller API routers."""
app.include_router(preset.router, prefix="/presets", tags=["presets"])
app.include_router(profile.router, prefix="/profiles", tags=["profiles"])
app.include_router(group.router, prefix="/groups", tags=["groups"])
app.include_router(sequence.router, prefix="/sequences", tags=["sequences"])
app.include_router(zone.router, prefix="/zones", tags=["zones"])
app.include_router(palette.router, prefix="/palettes", tags=["palettes"])
app.include_router(scene.router, prefix="/scenes", tags=["scenes"])
app.include_router(pattern.router, prefix="/patterns", tags=["patterns"])
app.include_router(settings_controller.router, prefix="/settings", tags=["settings"])
app.include_router(
wifi_bridge_controller.router, prefix="/settings/wifi", tags=["wifi"]
)
app.include_router(device_controller.router, prefix="/devices", tags=["devices"])
app.include_router(led_tool_controller.router, prefix="/led-tool", tags=["led-tool"])
def mount_static_routes(app: FastAPI, *, inject_live_reload: bool = False) -> None:
"""Index page, favicon, and static assets."""
build_id = dev_build_id() if inject_live_reload else None
live_tag = '<script src="/static/dev-live-reload.js" defer></script>'
@app.get("/")
async def index():
if build_id:
return send_html_file("templates/index.html", inject=live_tag)
return send_file("templates/index.html")
@app.get("/favicon.ico")
async def favicon():
return PlainTextResponse("", status_code=204)
static_dir = os.path.join(_SRC_DIR, "static")
if os.path.isdir(static_dir):
app.mount("/static", StaticFiles(directory=static_dir), name="static")
class AppRuntime:
"""Holds long-lived services started with the HTTP server."""
def __init__(self):
self.settings = get_settings()
self.audio_detector = AudioBeatDetector()
self.bridge = None
async def startup(self, *, test_mode: bool = False) -> None:
set_bridge_uplink_handler(handle_bridge_uplink)
if test_mode:
return
self.bridge = get_bridge(self.settings)
set_bridge(self.bridge)
bridge_mode = str(self.settings.get("bridge_transport") or "wifi").strip().lower()
if bridge_mode == "wifi":
ws_url = str(self.settings.get("bridge_ws_url") or "").strip()
if ws_url:
try:
ch = int(self.settings.get("wifi_channel", WIFI_CHANNEL_DEFAULT))
except (TypeError, ValueError):
ch = WIFI_CHANNEL_DEFAULT
ws_client = init_bridge_client(ws_url, wifi_channel=ch)
ws_client.set_uplink_handler(handle_bridge_uplink)
ws_client.start()
set_bridge(BridgeWsTransport())
elif bridge_mode == "serial":
serial_port = str(self.settings.get("bridge_serial_port") or "").strip()
if serial_port:
baud = 115200
for prof in self.settings.get("bridges") or []:
if not isinstance(prof, dict):
continue
if str(prof.get("transport") or "").strip().lower() != "serial":
continue
if str(prof.get("serial_port") or "").strip() != serial_port:
continue
try:
baud = int(prof.get("serial_baudrate") or baud)
except (TypeError, ValueError):
pass
break
else:
try:
baud = int(self.settings.get("bridge_serial_baudrate") or baud)
except (TypeError, ValueError):
pass
serial_client = init_bridge_serial_client(serial_port, baudrate=baud)
serial_client.set_uplink_handler(handle_bridge_uplink)
serial_client.start()
set_bridge(BridgeSerialTransport())
try:
from util import audio_detector as audio_detector_module
audio_detector_module.set_shared_beat_detector(self.audio_detector)
except Exception as e:
print(f"[startup] audio detector shared registration skipped: {e!r}")
try:
from util.audio_run_persist import coerce_audio_device, read_audio_run_state
persisted = read_audio_run_state()
if persisted.get("enabled"):
sel = persisted.get("device_select") or persisted.get("device")
dev = coerce_audio_device(sel)
self.audio_detector.start(device=dev)
print("[startup] audio beat detector started from saved run state")
except Exception as e:
print(f"[startup] audio auto-start skipped: {e!r}")
from util import beat_driver_route
beat_driver_route.set_beat_route_main_loop(asyncio.get_running_loop())
from util import beat_status_broadcaster as beat_sse
from util import sequence_playback as seq_pb
loop = asyncio.get_running_loop()
beat_sse.configure(
loop=loop,
status_builder=lambda: audio_status_payload(
self.audio_detector, self.settings
),
)
seq_pb.ensure_beat_consumer_started()
Device()
if not test_mode:
await start_wifi_driver_runtime(self.settings)
async def shutdown(self) -> None:
try:
await stop_wifi_driver_runtime()
except Exception:
pass
try:
self.audio_detector.stop()
except Exception:
pass
try:
from util import beat_status_broadcaster as beat_sse
await beat_sse.shutdown()
except Exception:
pass
try:
from util import sequence_playback as seq_pb
seq_pb.stop()
t = getattr(seq_pb, "_background_beat_task", None)
if t is not None and not t.done():
t.cancel()
except Exception:
pass
def audio_status_payload(
audio_detector: AudioBeatDetector, settings: Any
) -> dict:
from util import beat_driver_route
from util import sequence_playback
from util.audio_run_persist import read_audio_run_state
st = audio_detector.status()
st["sequence"] = sequence_playback.playback_status()
st["manual_beat_stride"] = beat_driver_route.manual_beat_stride_status()
seq = st.get("sequence")
running = bool(st.get("running"))
beat_readout = ""
if isinstance(seq, dict) and str(seq.get("beat_readout") or "").strip():
beat_readout = str(seq.get("beat_readout") or "").strip()
if not beat_readout:
tail = sequence_playback.last_completed_beat_readout()
if tail:
beat_readout = tail
if not beat_readout and st.get("running"):
mb = st.get("manual_beat_stride")
if isinstance(mb, dict) and mb.get("active"):
try:
n = int(mb.get("stride_n") or 1)
except (TypeError, ValueError):
n = 1
n = max(1, min(64, n))
try:
bi = int(mb.get("beat_in_stride") or 1)
except (TypeError, ValueError):
bi = 1
pos = min(n, max(1, bi))
beat_readout = f"{pos}/{n}"
else:
try:
bs = int(st.get("beat_seq") or 0)
except (TypeError, ValueError):
bs = 0
if bs > 0:
beat_readout = str(bs)
st["beat_readout"] = beat_readout
st["beat_phase_ms"] = int(settings.get("audio_beat_phase_ms") or 0)
try:
st["input_volume"] = int(settings.get("audio_input_volume") or 100)
except (TypeError, ValueError):
st["input_volume"] = 100
st["input_volume"] = max(0, min(200, st["input_volume"]))
seq_wait = str(settings.get("sequence_switch_wait") or "beat").strip().lower()
if seq_wait not in ("beat", "downbeat"):
seq_wait = "beat"
st["sequence_switch_wait_saved"] = seq_wait
from util.sequence_playback import effective_sequence_switch_wait
st["sequence_switch_wait"] = effective_sequence_switch_wait()
st["audio_run"] = read_audio_run_state()
from util.bpm_limits import clamp_bpm
sim_bpm = int(clamp_bpm(settings.get("audio_simulated_bpm")))
st["audio_simulated_bpm"] = sim_bpm
st["sequence_pending"] = sequence_playback.pending_play_status()
from util import audio_detector as audio_detector_module
st["bpm_simulated"] = not audio_detector_module.shared_beat_detector_timing_sequences()
if running and st.get("bpm") is not None:
st["bpm"] = float(clamp_bpm(st["bpm"]))
if not running:
st["bpm"] = float(sim_bpm)
st["simulated_beat_tick"] = sequence_playback.simulated_beat_tick()
if not running:
phase = sequence_playback.simulated_beat_phase_snapshot()
st["bar_beat"] = phase.get("bar_beat")
st["is_downbeat"] = bool(phase.get("is_downbeat"))
st["bar_phase_readout"] = str(phase.get("bar_phase_readout") or "")
st["phase_confidence"] = 0.0
return st

View File

@@ -1,16 +1,18 @@
from microdot import Microdot
from fastapi import APIRouter, Request
from http_responses import J, html_response, plain, read_json, send_file
from http_session import with_session
from models.device import (
Device,
derive_device_mac,
normalize_mac,
validate_device_transport,
validate_device_type,
)
from models.transport import get_current_sender
from models.wifi_ws_clients import (
normalize_tcp_peer_ip,
send_json_line_to_ip,
tcp_client_connected,
)
from models.group import Group
from models.transport import get_current_bridge
from settings import get_settings
from util.brightness_combine import effective_brightness_for_mac
from util.driver_patterns import driver_patterns_dir
from util.espnow_message import build_message
import asyncio
@@ -47,27 +49,38 @@ def _compact_v1_json(*, presets=None, select=None, save=False):
body["save"] = True
if select is not None:
body["select"] = select
return json.dumps(body, separators=(",", ":"))
return J(body, separators=(",", ":"))
# Seconds after identify blink before selecting built-in ``off`` (tests may monkeypatch).
IDENTIFY_OFF_DELAY_S = 2.0
controller = Microdot()
def _validate_output_brightness(value):
if value is None:
return None
try:
b = int(value)
except (TypeError, ValueError):
raise ValueError("output_brightness must be an integer 0255")
if b < 0 or b > 255:
raise ValueError("output_brightness must be between 0 and 255")
return b
def _brightness_save_message_json(b_val: int) -> str:
b_val = max(0, min(255, int(b_val)))
return json.dumps({"v": "1", "b": b_val, "save": True}, separators=(",", ":"))
router = APIRouter()
devices = Device()
_group_registry = Group()
_pi_settings = get_settings()
def _device_live_connected(dev_dict):
"""
Wi-Fi: whether the controller has an outbound WebSocket to this device's IP.
ESP-NOW: None (no Wi-Fi session on the Pi for that transport).
"""
tr = (dev_dict.get("transport") or "espnow").strip().lower()
if tr != "wifi":
return None
ip = normalize_tcp_peer_ip(dev_dict.get("address") or "")
if not ip:
return False
return tcp_client_connected(ip)
"""ESP-NOW has no live session flag on the Pi."""
return None
def _device_json_with_live_status(dev_dict):
@@ -131,67 +144,182 @@ def _http_post_pattern_source(ip, filename, code_text, reload_patterns=True, tim
return b" 2" in first_line
async def _identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name):
async def _identify_send_off_after_delay(bridge, dev_id):
try:
await asyncio.sleep(IDENTIFY_OFF_DELAY_S)
off_msg = build_message(select={name: ["off"]})
if transport == "wifi":
await send_json_line_to_ip(wifi_ip, off_msg)
else:
await sender.send(off_msg, addr=dev_id)
await bridge.send(
{"v": "1", "select": ["off"]},
addr=dev_id,
)
except Exception:
pass
@controller.get("")
async def list_devices(request):
async def _identify_send_off_after_delay_broadcast(bridge, group_ids=None):
try:
await asyncio.sleep(IDENTIFY_OFF_DELAY_S)
body = {"v": "1", "select": ["off"]}
if group_ids:
body["groups"] = [str(g) for g in group_ids if str(g).strip()]
await bridge.send(body)
except Exception:
pass
async def send_identify_to_device(dev_id: str) -> tuple[int, str]:
"""
Send the same identify blink as ``POST /devices/<id>/identify``.
Returns ``(http_status, "")`` on success, or ``(status, error_message)`` on failure
(status matches the single-device route).
"""
dev = devices.read(dev_id)
if not dev:
return 404, "Device not found"
bridge = get_current_bridge()
if not bridge:
return 503, "Transport not configured"
try:
ok = await bridge.send(
{
"v": "1",
"presets": {_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
"select": [_IDENTIFY_PRESET_KEY],
},
addr=dev_id,
)
if not ok:
return 503, "Send failed"
asyncio.create_task(
_identify_send_off_after_delay(bridge, dev_id)
)
except Exception as e:
return 503, str(e)
return 200, ""
async def send_identify_to_group_devices(
macs: list[str],
*,
group_ids: list[str] | None = None,
) -> tuple[int, list[dict]]:
"""
Identify all drivers in ``group_ids`` via broadcast; members filter on ``groups``.
``macs`` is only used for the API ``sent`` count (group member list), not for addressing.
"""
from util.driver_delivery import deliver_json_messages
errors: list[dict] = []
bridge = get_current_bridge()
if not bridge:
return 0, [{"mac": "*", "error": "Transport not configured"}]
body = {
"v": "1",
"presets": {_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
"select": [_IDENTIFY_PRESET_KEY],
}
gids = [str(g).strip() for g in (group_ids or []) if str(g).strip()]
if gids:
body["groups"] = gids
try:
deliveries, _chunks = await deliver_json_messages(
bridge,
[json.dumps(body, separators=(",", ":"))],
None,
devices,
delay_s=0,
)
except Exception as e:
return 0, errors + [{"mac": "*", "error": str(e)}]
if deliveries < 1:
return 0, errors + [{"mac": "*", "error": "Send failed"}]
asyncio.create_task(_identify_send_off_after_delay_broadcast(bridge, gids))
seen: set[str] = set()
for raw in macs:
m = normalize_mac(str(raw))
if m and m not in seen:
seen.add(m)
return len(seen), errors
@router.get("/")
async def list_devices(request: Request):
"""List all devices (includes ``connected`` for live Wi-Fi WebSocket presence)."""
devices_data = {}
for dev_id in devices.list():
d = devices.read(dev_id)
if d:
devices_data[dev_id] = _device_json_with_live_status(d)
return json.dumps(devices_data), 200, {"Content-Type": "application/json"}
return J(devices_data, 200)
@router.post("/resolve-brightness")
async def resolve_brightness_batch(request: Request):
"""
POST JSON ``{ \"macs\": [\"..\"], \"zone_brightness\": optional 0255 }``.
Returns ``{ \"values\": { mac: combined_int } }`` — global × group(s) × device × zone (optional).
"""
try:
data = await read_json(request)
except Exception:
data = {}
macs = data.get("macs")
if not isinstance(macs, list):
return J({"error": "macs must be an array"}, 400)
zb = None
if isinstance(data, dict) and data.get("zone_brightness") is not None:
try:
zb = _validate_output_brightness(data.get("zone_brightness"))
except ValueError as e:
return J({"error": str(e)}, 400)
values = {}
for raw in macs:
m = normalize_mac(str(raw))
if not m:
continue
values[m] = effective_brightness_for_mac(
_pi_settings,
_group_registry,
devices,
m,
zone_brightness=zb,
)
return J({"values": values}, 200)
@controller.get("/<id>")
async def get_device(request, id):
@router.get("/{id}")
async def get_device(request: Request, id):
"""Get a device by ID (includes ``connected`` for live Wi-Fi WebSocket presence)."""
dev = devices.read(id)
if dev:
return json.dumps(_device_json_with_live_status(dev)), 200, {
"Content-Type": "application/json",
}
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
return J(_device_json_with_live_status(dev), 200)
return J({"error": "Device not found"}, 404)
@controller.post("")
async def create_device(request):
@router.post("/")
async def create_device(request: Request):
"""Create a new device."""
try:
data = request.json or {}
data = await read_json(request)
name = data.get("name", "").strip()
if not name:
return json.dumps({"error": "name is required"}), 400, {
"Content-Type": "application/json",
}
return J({"error": "name is required"}, 400)
try:
device_type = validate_device_type(data.get("type", "led"))
transport = validate_device_transport(data.get("transport", "espnow"))
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {
"Content-Type": "application/json",
}
return J({"error": str(e)}, 400)
address = data.get("address")
mac = data.get("mac")
if derive_device_mac(mac=mac, address=address, transport=transport) is None:
return json.dumps(
{
return J({
"error": "mac is required (12 hex digits); for Wi-Fi include mac plus IP in address"
}
), 400, {"Content-Type": "application/json"}
}, 400)
default_pattern = data.get("default_pattern")
zl = data.get("zones")
if isinstance(zl, list):
@@ -208,20 +336,20 @@ async def create_device(request):
transport=transport,
)
dev = devices.read(dev_id)
return json.dumps({dev_id: dev}), 201, {"Content-Type": "application/json"}
return J({dev_id: dev}, 201)
except ValueError as e:
msg = str(e)
code = 409 if "already exists" in msg.lower() else 400
return json.dumps({"error": msg}), code, {"Content-Type": "application/json"}
return J({"error": msg}, code)
except Exception as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
return J({"error": str(e)}, 400)
@controller.put("/<id>")
async def update_device(request, id):
@router.put("/{id}")
async def update_device(request: Request, id):
"""Update a device."""
try:
raw = request.json or {}
raw = await read_json(request)
data = dict(raw)
data.pop("id", None)
data.pop("addresses", None)
@@ -229,9 +357,7 @@ async def update_device(request, id):
if "name" in data:
n = (data.get("name") or "").strip()
if not n:
return json.dumps({"error": "name cannot be empty"}), 400, {
"Content-Type": "application/json",
}
return J({"error": "name cannot be empty"}, 400)
data["name"] = n
if "type" in data:
data["type"] = validate_device_type(data.get("type"))
@@ -239,155 +365,164 @@ async def update_device(request, id):
data["transport"] = validate_device_transport(data.get("transport"))
if "zones" in data and isinstance(data["zones"], list):
data["zones"] = [str(t) for t in data["zones"]]
if "output_brightness" in data:
data["output_brightness"] = _validate_output_brightness(data.get("output_brightness"))
prev_doc = devices.read(id)
if devices.update(id, data):
return json.dumps(devices.read(id)), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
if prev_doc and "name" in data:
on = str(prev_doc.get("name") or "").strip()
nn = str(data.get("name") or "").strip()
if on and nn and on != nn:
from util.beat_driver_route import remap_beat_route_device_name
remap_beat_route_device_name(on, nn)
return J(devices.read(id), 200)
return J({"error": "Device not found"}, 404)
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
return J({"error": str(e)}, 400)
except Exception as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
return J({"error": str(e)}, 400)
@controller.delete("/<id>")
async def delete_device(request, id):
@router.delete("/{id}")
async def delete_device(request: Request, id):
"""Delete a device."""
if devices.delete(id):
return (
json.dumps({"message": "Device deleted successfully"}),
200,
{"Content-Type": "application/json"},
)
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
return J({"message": "Device deleted successfully"}, 200)
return J({"error": "Device not found"}, 404)
@controller.post("/<id>/identify")
async def identify_device(request, id):
@router.post("/groups")
async def update_device_groups(request: Request):
"""Push current group membership to all ESP-NOW drivers in the registry."""
_ = request
from util.espnow_registry import push_groups_all_espnow_devices
result = await push_groups_all_espnow_devices()
status = 200 if result.get("ok") else 503
if not result.get("total"):
return J({"ok": False, "error": "No ESP-NOW devices in registry"}, 400)
return J(result, status)
@router.post("/ping")
async def ping_devices(request: Request):
"""
Broadcast ESP-NOW PING_REQ; collect PING_RSP until timeout (default 3 s).
JSON body: ``{"timeout_s": 3.0}`` (optional).
"""
from util.espnow_ping import run_ping
timeout_s = 3.0
try:
body = await read_json(request)
if isinstance(body, dict) and body.get("timeout_s") is not None:
timeout_s = float(body["timeout_s"])
except (TypeError, ValueError):
return J({"error": "Invalid timeout_s"}, 400)
timeout_s = max(0.5, min(30.0, timeout_s))
result = await run_ping(timeout_s=timeout_s)
status = 200 if result.get("ok") else 503
return J(result, status)
@router.post("/{id}/identify")
async def identify_device(request: Request, id):
"""
One v1 JSON object: ``presets.__identify`` (``d``=50 ms → 10 Hz blink) plus ``select`` for
this device name — same combined shape as profile sends the driver already accepts over TCP
/ ESP-NOW. No ``save``. After ``IDENTIFY_OFF_DELAY_S``, a background task selects ``off``.
"""
dev = devices.read(id)
if not dev:
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
sender = get_current_sender()
if not sender:
return json.dumps({"error": "Transport not configured"}), 503, {
"Content-Type": "application/json",
}
name = str(dev.get("name") or "").strip()
if not name:
return json.dumps({"error": "Device must have a name to identify"}), 400, {
"Content-Type": "application/json",
}
transport = dev.get("transport") or "espnow"
wifi_ip = None
if transport == "wifi":
wifi_ip = dev.get("address")
if not wifi_ip:
return json.dumps({"error": "Device has no IP address"}), 400, {
"Content-Type": "application/json",
}
try:
msg = _compact_v1_json(
presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
select={name: [_IDENTIFY_PRESET_KEY]},
)
if transport == "wifi":
ok = await send_json_line_to_ip(wifi_ip, msg)
if not ok:
return json.dumps({"error": "Wi-Fi driver not connected"}), 503, {
"Content-Type": "application/json",
}
else:
await sender.send(msg, addr=id)
asyncio.create_task(
_identify_send_off_after_delay(sender, transport, wifi_ip, id, name)
)
except Exception as e:
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
return json.dumps({"message": "Identify sent"}), 200, {
"Content-Type": "application/json",
}
status, err = await send_identify_to_device(id)
if status == 200:
return J({"message": "Identify sent"}, 200)
return J({"error": err}, status)
@controller.post("/<id>/patterns/push")
async def push_patterns_ota(request, id):
@router.post("/{id}/brightness")
async def push_device_output_brightness(request: Request, id):
"""
Push all local pattern files directly to a Wi-Fi LED driver over HTTP upload.
Push combined brightness to the driver: global × group(s) × device × optional ``zone_brightness``
in JSON body — single ``b`` (``v``/``b``/``save``). WiFi or ESPNOW.
"""
dev = devices.read(id)
if not dev:
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
if (dev.get("transport") or "").lower() != "wifi":
return json.dumps({"error": "Pattern OTA push is only supported for Wi-Fi devices"}), 400, {
"Content-Type": "application/json",
}
wifi_ip = str(dev.get("address") or "").strip()
if not wifi_ip:
return json.dumps({"error": "Device has no IP address"}), 400, {
"Content-Type": "application/json",
}
base_dir = driver_patterns_dir()
try:
names = sorted(os.listdir(base_dir))
except OSError as e:
return json.dumps({"error": str(e)}), 500, {
"Content-Type": "application/json",
}
files = [n for n in names if _safe_pattern_filename(n) and n != "__init__.py"]
if not files:
return json.dumps({"error": "No pattern files found"}), 404, {
"Content-Type": "application/json",
}
sent = []
failed = []
total = len(files)
for idx, filename in enumerate(files):
path = os.path.join(base_dir, filename)
return J({"error": "Device not found"}, 404)
body = await read_json(request)
zb = None
if isinstance(body, dict) and body.get("zone_brightness") is not None:
try:
with open(path, "r") as f:
code = f.read()
except OSError:
failed.append(filename)
continue
reload_patterns = idx == (total - 1)
ok = _http_post_pattern_source(
wifi_ip,
filename,
code,
reload_patterns=reload_patterns,
timeout_s=10.0,
)
if ok:
sent.append(filename)
else:
failed.append(filename)
zb = _validate_output_brightness(body.get("zone_brightness"))
except ValueError as e:
return J({"error": str(e)}, 400)
b_val = effective_brightness_for_mac(
_pi_settings,
_group_registry,
devices,
id,
zone_brightness=zb,
)
if not sent:
return json.dumps({"error": "Wi-Fi driver did not accept pattern uploads", "failed": failed}), 503, {
"Content-Type": "application/json",
}
bridge = get_current_bridge()
if not bridge:
return J({"error": "Transport not configured"}, 503)
try:
ok = await bridge.send({"v": "1", "b": b_val, "save": True}, addr=id)
if not ok:
return J({"error": "Send failed"}, 503)
except Exception as e:
return J({"error": str(e)}, 503)
return json.dumps({
"message": "Pattern files uploaded",
"sent_count": len(sent),
"sent": sent,
"failed": failed,
}), 200, {
"Content-Type": "application/json",
}
return J({"message": "brightness sent", "brightness": b_val}, 200)
@router.post("/{id}/driver-config")
async def push_driver_config(request: Request, id):
"""
Push ``device_config`` to an ESP-NOW LED driver.
Body JSON: optional ``name``, ``num_leds``, ``color_order``, ``startup_mode`` (default|last|off).
"""
dev = devices.read(id)
if not dev:
return J({"error": "Device not found"}, 404)
bridge = get_current_bridge()
if not bridge:
return J({"error": "Transport not configured"}, 503)
body = await read_json(request)
dc = {}
if isinstance(body.get("name"), str) and body["name"].strip():
dc["name"] = body["name"].strip()
if "num_leds" in body:
try:
n = int(body["num_leds"])
if 1 <= n <= 2048:
dc["num_leds"] = n
except (TypeError, ValueError):
pass
if isinstance(body.get("color_order"), str):
co = body["color_order"].strip().lower()
if co in ("rgb", "rbg", "grb", "gbr", "brg", "bgr"):
dc["color_order"] = co
if isinstance(body.get("startup_mode"), str):
sm = body["startup_mode"].strip().lower()
if sm in ("default", "last", "off"):
dc["startup_mode"] = sm
if not dc:
return J({
"error": "Provide at least one of name, num_leds, color_order, startup_mode"
}, 400)
ok = await bridge.send({"v": "1", "device_config": dc, "save": True}, addr=id)
if not ok:
return J({"error": "Send failed"}, 503)
return J({"message": "driver-config sent"}, 200)
@router.post("/{id}/patterns/push")
async def push_patterns_ota(request: Request, id):
"""
Pattern OTA over HTTP is not available for ESP-NOW drivers.
"""
dev = devices.read(id)
if not dev:
return J({"error": "Device not found"}, 404)
return J({"error": "Pattern OTA push is not supported for ESP-NOW devices"}, 400)

View File

@@ -1,50 +1,344 @@
from microdot import Microdot
from fastapi import APIRouter, Request
from http_responses import J, html_response, plain, read_json, send_file
from http_session import with_session
import asyncio
from models.group import Group
from models.device import Device
from models.transport import get_current_bridge
from util.espnow_registry import push_groups_for_group_devices
from settings import get_settings
from util.brightness_combine import effective_brightness_for_mac
import json
controller = Microdot()
router = APIRouter()
groups = Group()
devices = Device()
_pi_settings = get_settings()
@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."""
def _group_doc_visible_for_profile(doc, profile_id):
if not isinstance(doc, dict):
return False
scoped = doc.get("profile_id")
if scoped is None:
scoped = doc.get("profileId")
if scoped is None or str(scoped).strip() == "":
return True
if not profile_id:
return False
return str(scoped).strip() == str(profile_id).strip()
def _filtered_groups_dict(session):
from controllers.zone import get_current_profile_id
pid = get_current_profile_id(session)
out = {}
for gid, doc in groups.items():
if not isinstance(doc, dict):
continue
if _group_doc_visible_for_profile(doc, pid):
out[str(gid)] = doc
return out
@router.get("/")
@with_session
async def list_groups(request: Request, session):
"""List groups visible for the current profile (shared + profile-scoped)."""
return J(_filtered_groups_dict(session), 200)
@router.get("/{id}")
@with_session
async def get_group(request: Request, session, id):
"""Get a specific group by ID (404 if scoped to another profile)."""
group = groups.read(id)
if group:
return json.dumps(group), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Group not found"}), 404
if not group or not isinstance(group, dict):
return J({"error": "Group not found"}, 404)
from controllers.zone import get_current_profile_id
@controller.post('')
async def create_group(request):
"""Create a new group."""
if not _group_doc_visible_for_profile(group, get_current_profile_id(session)):
return J({"error": "Group not found"}, 404)
return J(group, 200)
def _sanitize_group_bridge_id_write(data):
"""Per-group bridge assignment is disabled; ignore writes."""
if isinstance(data, dict) and "bridge_id" in data:
data["bridge_id"] = None
def _sanitize_group_profile_id_write(data, session):
"""Allow ``profile_id`` only for the active profile, or null to share across profiles."""
if not isinstance(data, dict):
return
from controllers.zone import get_current_profile_id
cur = get_current_profile_id(session)
if "profile_id" not in data and "profileId" not in data:
return
raw = data.get("profile_id")
if raw is None and "profileId" in data:
raw = data.get("profileId")
if raw is None or raw == "":
data.pop("profileId", None)
data["profile_id"] = None
return
if not cur or str(raw).strip() != str(cur).strip():
data.pop("profileId", None)
data.pop("profile_id", None)
@router.post("/")
@with_session
async def create_group(request: Request, session):
"""Create a new group (omit ``profile_id`` for shared; or ``profile_scoped``: true for this profile only)."""
try:
data = request.json or {}
data = dict(await read_json(request))
name = data.get("name", "")
profile_scoped = bool(data.pop("profile_scoped", False))
_sanitize_group_profile_id_write(data, session)
_sanitize_group_bridge_id_write(data)
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
if profile_scoped:
from controllers.zone import get_current_profile_id
@controller.put('/<id>')
async def update_group(request, id):
cur = get_current_profile_id(session)
if cur:
groups.update(group_id, {"profile_id": str(cur)})
g = groups.read(group_id)
if g:
await push_groups_for_group_devices(g)
return J(groups.read(group_id), 201)
except Exception as e:
return J({"error": str(e)}, 400)
@router.put("/{id}")
@with_session
async def update_group(request: Request, session, id):
"""Update an existing group."""
try:
data = request.json
data = await read_json(request)
if not isinstance(data, dict):
return J({"error": "Invalid JSON"}, 400)
data = dict(data)
_sanitize_group_profile_id_write(data, session)
_sanitize_group_bridge_id_write(data)
if groups.update(id, data):
return json.dumps(groups.read(id)), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Group not found"}), 404
g = groups.read(id)
if g:
await push_groups_for_group_devices(g)
return J(g, 200)
return J({"error": "Group not found"}, 404)
except Exception as e:
return json.dumps({"error": str(e)}), 400
return J({"error": str(e)}, 400)
@router.delete("/{id}")
@with_session
async def delete_group(request: Request, session, id):
"""Delete a group (not allowed for another profile's scoped group)."""
g = groups.read(id)
if not g or not isinstance(g, dict):
return J({"error": "Group not found"}, 404)
from controllers.zone import get_current_profile_id
@controller.delete('/<id>')
async def delete_group(request, id):
"""Delete a group."""
if not _group_doc_visible_for_profile(g, get_current_profile_id(session)):
return J({"error": "Group not found"}, 404)
macs = list(g.get("devices") or []) if isinstance(g, dict) else []
if groups.delete(id):
return json.dumps({"message": "Group deleted successfully"}), 200
return json.dumps({"error": "Group not found"}), 404
await push_groups_for_group_devices({"devices": macs})
return J({"message": "Group deleted successfully"}, 200)
return J({"error": "Group not found"}, 404)
def _group_driver_config_payload(doc):
"""Build ``device_config`` dict from stored group WiFi defaults (non-empty only)."""
dc = {}
if not isinstance(doc, dict):
return dc
nm = doc.get("wifi_driver_display_name")
if isinstance(nm, str) and nm.strip():
dc["name"] = nm.strip()
nled = doc.get("wifi_driver_num_leds")
if nled is not None:
try:
n = int(nled)
if 1 <= n <= 2048:
dc["num_leds"] = n
except (TypeError, ValueError):
pass
co = doc.get("wifi_color_order")
if isinstance(co, str):
c = co.strip().lower()
if c in ("rgb", "rbg", "grb", "gbr", "brg", "bgr"):
dc["color_order"] = c
sm = doc.get("wifi_startup_mode")
if isinstance(sm, str):
s = sm.strip().lower()
if s in ("default", "last", "off"):
dc["startup_mode"] = s
return dc
def _read_group_for_session(session, id):
g = groups.read(id)
if not g or not isinstance(g, dict):
return None
from controllers.zone import get_current_profile_id
if not _group_doc_visible_for_profile(g, get_current_profile_id(session)):
return None
return g
@router.post("/{id}/driver-config")
@with_session
async def push_group_driver_config(request: Request, session, id):
"""
Push group driver defaults to every ESP-NOW device listed in the group.
Uses stored ``wifi_*`` fields on the group; optional JSON body may override for this send only.
"""
gdoc = _read_group_for_session(session, id)
if not gdoc:
return J({"error": "Group not found"}, 404)
body = await read_json(request)
merged = dict(gdoc)
if isinstance(body, dict):
for k in (
"wifi_driver_display_name",
"wifi_driver_num_leds",
"wifi_color_order",
"wifi_startup_mode",
):
if k in body:
merged[k] = body[k]
dc = _group_driver_config_payload(merged)
if not dc:
return J(
{
"error": "No driver defaults on this group (set display name, LEDs, colour order, or power-on pattern)"
},
400,
)
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
sent = 0
errors = []
bridge = get_current_bridge()
if not bridge:
return J({"error": "Transport not configured"}, 503)
payload = {"v": "1", "device_config": dc, "save": True}
for mac in mac_list:
m = str(mac).strip().lower().replace(":", "").replace("-", "")
if len(m) != 12:
continue
dev = devices.read(m)
if not dev:
errors.append({"mac": m, "error": "not in registry"})
continue
try:
if await bridge.send(payload, addr=m):
sent += 1
else:
errors.append({"mac": m, "error": "send failed"})
except Exception as e:
errors.append({"mac": m, "error": str(e)})
return J({"message": "driver-config sent", "sent": sent, "errors": errors}, 200)
def _brightness_save_message_json(b_val: int) -> str:
b_val = max(0, min(255, int(b_val)))
return json.dumps({"v": "1", "b": b_val, "save": True}, separators=(",", ":"))
@router.post("/{id}/brightness")
@with_session
async def push_group_output_brightness(request: Request, session, id):
"""
Push combined brightness (global × group(s) × device) to each member — one ``b`` per device.
"""
gdoc = _read_group_for_session(session, id)
if not gdoc:
return J({"error": "Group not found"}, 404)
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
sent = 0
errors = []
bridge = get_current_bridge()
async def _push_brightness_one(m: str, dev: dict) -> tuple[str, bool, str | None]:
b_val = effective_brightness_for_mac(
_pi_settings,
groups,
devices,
m,
zone_brightness=None,
)
if not bridge:
return m, False, "transport not configured"
try:
ok = await bridge.send({"v": "1", "b": b_val, "save": True}, addr=m)
return m, bool(ok), None if ok else "send failed"
except Exception as e:
return m, False, str(e)
tasks: list = []
for mac in mac_list:
m = str(mac).strip().lower().replace(":", "").replace("-", "")
if len(m) != 12:
continue
dev = devices.read(m)
if not dev:
errors.append({"mac": m, "error": "not in registry"})
continue
tasks.append(_push_brightness_one(m, dev))
if tasks:
results = await asyncio.gather(*tasks, return_exceptions=True)
for r in results:
if isinstance(r, Exception):
errors.append({"mac": "*", "error": str(r)})
continue
m, ok, err = r
if ok:
sent += 1
elif err:
errors.append({"mac": m, "error": err})
return J({"message": "brightness sent", "sent": sent, "errors": errors}, 200)
@router.post("/{id}/identify")
@with_session
async def identify_group_devices(request: Request, session, id):
"""
Run the same identify blink as ``POST /devices/<id>/identify`` for every registry member
in parallel so all drivers in the group blink together.
"""
_ = request
gdoc = _read_group_for_session(session, id)
if not gdoc:
return J({"error": "Group not found"}, 404)
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
if not mac_list:
return J({"error": "Group has no devices"}, 400)
from controllers.device import send_identify_to_group_devices
normalized: list[str] = []
errors: list[dict] = []
for mac in mac_list:
m = str(mac).strip().lower().replace(":", "").replace("-", "")
if len(m) != 12:
errors.append({"mac": str(mac), "error": "invalid MAC"})
continue
normalized.append(m)
if not normalized:
return J({"message": "identify group done", "sent": 0, "errors": errors}, 200)
sent, batch_errors = await send_identify_to_group_devices(
normalized, group_ids=[str(id)]
)
errors.extend(batch_errors)
return J({"message": "identify group done", "sent": sent, "errors": errors}, 200)

View File

@@ -1,22 +1,45 @@
from fastapi import APIRouter, Request
from http_responses import J, html_response, plain, read_json, send_file
from http_session import with_session
import json
import os
import subprocess
import sys
from microdot import Microdot
from serial.tools import list_ports
controller = Microdot()
router = APIRouter()
_STATIC_ALLOWED = frozenset(
{"settings_editor.html", "settings_editor.js", "web_serial.js"}
)
def _repo_root() -> str:
return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
def _led_tool_static_dir() -> str:
return os.path.join(_repo_root(), "led-tool", "static")
def _led_cli_path() -> str:
return os.path.join(_repo_root(), "led-tool", "cli.py")
def _filter_host_serial_ports(ports: list) -> list:
mod_path = os.path.join(_repo_root(), "led-tool", "host_ports.py")
if not os.path.isfile(mod_path):
return ports
import importlib.util
spec = importlib.util.spec_from_file_location("led_tool_host_ports", mod_path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod.filter_port_dicts(ports)
def _build_led_cli_command(port: str, payload: dict):
cmd = [sys.executable, _led_cli_path(), "--port", port]
@@ -54,31 +77,17 @@ def _run_led_cli_command(cmd, cli_path: str, timeout_s=180):
cwd=os.path.dirname(cli_path),
)
except subprocess.TimeoutExpired:
return (
json.dumps({"error": "led-tool command timed out after 180 seconds"}),
504,
{"Content-Type": "application/json"},
)
return J({"error": "led-tool command timed out after 180 seconds"}, 504)
except Exception as exc:
return (
json.dumps({"error": str(exc)}),
500,
{"Content-Type": "application/json"},
)
return J({"error": str(exc)}, 500)
return (
json.dumps(
{
return J({
"ok": result.returncode == 0,
"returncode": result.returncode,
"stdout": result.stdout,
"stderr": result.stderr,
"command": cmd,
}
),
200,
{"Content-Type": "application/json"},
)
}, 200)
def _extract_settings_from_stdout(stdout: str):
@@ -92,98 +101,88 @@ def _extract_settings_from_stdout(stdout: str):
return None
@controller.get("/ports")
async def list_serial_ports(request):
ports = []
for info in list_ports.comports():
ports.append(
@router.get("/editor")
async def settings_editor_page(request: Request):
"""led-tool settings UI (Web Serial + host serial via led-cli)."""
path = os.path.join(_led_tool_static_dir(), "settings_editor.html")
if not os.path.isfile(path):
return J({"error": "led-tool/static/settings_editor.html not found"}, 404)
return send_file(path)
@router.get("/static/<path:filename>")
async def led_tool_static(request: Request, filename):
if filename not in _STATIC_ALLOWED:
return plain("Not found", 404)
path = os.path.join(_led_tool_static_dir(), filename)
if not os.path.isfile(path):
return plain("Not found", 404)
return send_file(path)
@router.get("/ports")
async def list_serial_ports(request: Request):
ports = _filter_host_serial_ports(
[
{
"device": info.device,
"description": info.description,
"hwid": info.hwid,
}
)
return (
json.dumps(
{
for info in list_ports.comports()
]
)
return J({
"ports": ports,
"led_cli_exists": os.path.exists(_led_cli_path()),
}
),
200,
{"Content-Type": "application/json"},
)
}, 200)
@controller.post("/settings")
async def apply_settings(request):
data = request.json or {}
@router.post("/settings")
async def apply_settings(request: Request):
data = await read_json(request)
port = str(data.get("port") or "").strip()
if not port:
return (
json.dumps({"error": "port is required"}),
400,
{"Content-Type": "application/json"},
)
return J({"error": "port is required"}, 400)
cli_path = _led_cli_path()
if not os.path.exists(cli_path):
return (
json.dumps({"error": "led-tool/cli.py not found"}),
500,
{"Content-Type": "application/json"},
)
return J({"error": "led-tool/cli.py not found"}, 500)
cmd = _build_led_cli_command(port, data) + ["--follow"]
return _run_led_cli_command(cmd, cli_path, timeout_s=None)
@controller.post("/reset")
@controller.post("/reset/")
async def reset_device(request):
data = request.json or {}
@router.post("/reset")
@router.post("/reset/")
async def reset_device(request: Request):
data = await read_json(request)
port = str(data.get("port") or "").strip()
if not port:
return (
json.dumps({"error": "port is required"}),
400,
{"Content-Type": "application/json"},
)
return J({"error": "port is required"}, 400)
cli_path = _led_cli_path()
if not os.path.exists(cli_path):
return (
json.dumps({"error": "led-tool/cli.py not found"}),
500,
{"Content-Type": "application/json"},
)
return J({"error": "led-tool/cli.py not found"}, 500)
cmd = [sys.executable, cli_path, "--port", port, "--reset", "--follow"]
return _run_led_cli_command(cmd, cli_path, timeout_s=None)
@controller.get("/settings")
async def read_settings(request):
port = str(request.args.get("port") or "").strip()
@router.get("/settings")
async def read_settings(request: Request):
port = str(request.query_params.get("port") or "").strip()
if not port:
return (
json.dumps({"error": "port is required"}),
400,
{"Content-Type": "application/json"},
)
return J({"error": "port is required"}, 400)
cli_path = _led_cli_path()
if not os.path.exists(cli_path):
return (
json.dumps({"error": "led-tool/cli.py not found"}),
500,
{"Content-Type": "application/json"},
)
return J({"error": "led-tool/cli.py not found"}, 500)
cmd = [sys.executable, cli_path, "--port", port, "--show"]
body, status, headers = _run_led_cli_command(cmd, cli_path)
if status != 200:
return body, status, headers
data = json.loads(body)
result = _run_led_cli_command(cmd, cli_path)
if result.status_code != 200:
return result
data = json.loads(result.body.decode())
data["settings"] = _extract_settings_from_stdout(data.get("stdout") or "")
return json.dumps(data), status, headers
return J(data, 200)

View File

@@ -1,58 +1,58 @@
from microdot import Microdot
from fastapi import APIRouter, Request
from http_responses import J, html_response, plain, read_json, send_file
from http_session import with_session
from models.pallet import Palette
import json
controller = Microdot()
router = APIRouter()
palettes = Palette()
@controller.get('')
async def list_palettes(request):
@router.get("/")
async def list_palettes(request: 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'}
return J(data, 200)
@controller.get('/<id>')
async def get_palette(request, id):
@router.get("/{id}")
async def get_palette(request: 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):
return J({"colors": palette or [], "id": str(id)}, 200)
return J({"error": "Palette not found"}, 404)
@router.post("/")
async def create_palette(request: Request):
"""Create a new palette."""
try:
data = request.json or {}
data = await read_json(request)
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'}
return J({"id": str(palette_id), "colors": created_colors}, 201)
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.put('/<id>')
async def update_palette(request, id):
return J({"error": str(e)}, 400)
@router.put("/{id}")
async def update_palette(request: Request, id):
"""Update an existing palette."""
try:
data = request.json or {}
data = await read_json(request)
# 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
return J({"id": str(id), "colors": colors}, 200)
return J({"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):
return J({"error": str(e)}, 400)
@router.delete("/{id}")
async def delete_palette(request: 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
return J({"message": "Palette deleted successfully"}, 200)
return J({"error": "Palette not found"}, 404)

View File

@@ -1,4 +1,7 @@
from microdot import Microdot
from fastapi import APIRouter, Request
from http_responses import J, html_response, plain, read_json, send_file
from http_session import with_session
from models.pattern import Pattern
from models.device import Device
from util.driver_patterns import (
@@ -12,7 +15,7 @@ import os
import socket
from urllib.parse import quote
controller = Microdot()
router = APIRouter()
patterns = Pattern()
@@ -147,26 +150,24 @@ def build_runtime_pattern_map():
result[name] = {}
return result
@controller.get('/definitions')
async def get_pattern_definitions(request):
@router.get("/definitions")
async def get_pattern_definitions(request: Request):
"""Get definitions for patterns currently available on the driver."""
definitions = build_runtime_pattern_map()
return json.dumps(definitions), 200, {'Content-Type': 'application/json'}
return J(definitions, 200)
@controller.get('/ota/manifest')
async def ota_manifest(request):
@router.get("/ota/manifest")
async def ota_manifest(request: Request):
"""Manifest of driver pattern source files for OTA pulls."""
base_dir = driver_patterns_dir()
host = request.headers.get("Host", "")
if not host:
return json.dumps({"error": "Missing Host header"}), 400, {
"Content-Type": "application/json"
}
return J({"error": "Missing Host header"}, 400)
try:
names = sorted(os.listdir(base_dir))
except OSError as e:
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
return J({"error": str(e)}, 500)
files = []
for name in names:
@@ -177,97 +178,69 @@ async def ota_manifest(request):
"url": "http://%s/patterns/ota/file/%s" % (host, name),
})
return json.dumps({"files": files}), 200, {"Content-Type": "application/json"}
return J({"files": files}, 200)
@controller.get('/ota/file/<name>')
async def ota_pattern_file(request, name):
@router.get("/ota/file/{name}")
async def ota_pattern_file(request: Request, name):
"""Serve one driver pattern source file for OTA pulls."""
fname = normalize_pattern_py_filename(name)
if not fname or not _safe_pattern_filename(fname) or fname == "__init__.py":
return json.dumps({"error": "Invalid filename"}), 400, {
"Content-Type": "application/json"
}
return J({"error": "Invalid filename"}, 400)
if is_firmware_builtin_pattern_module(fname):
return json.dumps(
{
return J({
"error": "on and off are built into the driver firmware; there is no module file to serve.",
}
), 400, {
"Content-Type": "application/json"
}
}, 400)
base = driver_patterns_dir()
path = os.path.join(base, fname)
try:
with open(path, "r") as f:
content = f.read()
except OSError:
return json.dumps(
{
return J({
"error": "Pattern file not found",
"path": path,
"hint": "Ensure led-driver is present or set LED_CONTROLLER_PATTERNS_DIR.",
}
), 404, {
"Content-Type": "application/json"
}
return content, 200, {"Content-Type": "text/plain; charset=utf-8"}
@controller.post('/<name>/send')
async def send_pattern_to_device(request, name):
}, 404)
return plain(content, 200)
@router.post("/{name}/send")
async def send_pattern_to_device(request: Request, name):
"""Push one pattern source file directly to Wi-Fi driver(s) over HTTP."""
if not isinstance(name, str):
return json.dumps({"error": "Invalid pattern name"}), 400, {
"Content-Type": "application/json"
}
return J({"error": "Invalid pattern name"}, 400)
filename = normalize_pattern_py_filename(name)
if not filename or not _safe_pattern_filename(filename) or filename == "__init__.py":
return json.dumps({"error": "Invalid pattern filename"}), 400, {
"Content-Type": "application/json"
}
return J({"error": "Invalid pattern filename"}, 400)
if is_firmware_builtin_pattern_module(filename):
return json.dumps(
{
return J({
"error": "on and off are built into the driver firmware; send does not apply.",
}
), 400, {
"Content-Type": "application/json"
}
}, 400)
devices = Device()
body = request.json or {}
body = await read_json(request)
requested_device_id = str(body.get("device_id") or "").strip()
base = driver_patterns_dir()
path = os.path.join(base, filename)
if not os.path.exists(path):
return json.dumps(
{
return J({
"error": "Pattern file not found",
"path": path,
"hint": "Ensure led-driver is present or set LED_CONTROLLER_PATTERNS_DIR.",
}
), 404, {
"Content-Type": "application/json"
}
}, 404)
try:
with open(path, "r") as f:
source = f.read()
except OSError as e:
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
return J({"error": str(e)}, 500)
target_ids = []
if requested_device_id:
dev = devices.read(requested_device_id)
if not dev:
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json"
}
return J({"error": "Device not found"}, 404)
if (dev.get("transport") or "").lower() != "wifi":
return json.dumps({"error": "Pattern send is only supported for Wi-Fi devices"}), 400, {
"Content-Type": "application/json"
}
return J({"error": "Pattern send is only supported for Wi-Fi devices"}, 400)
target_ids = [requested_device_id]
else:
for did in devices.list():
@@ -275,9 +248,7 @@ async def send_pattern_to_device(request, name):
if (dev.get("transport") or "").lower() == "wifi":
target_ids.append(str(did))
if not target_ids:
return json.dumps({"error": "No Wi-Fi devices found"}), 404, {
"Content-Type": "application/json"
}
return J({"error": "No Wi-Fi devices found"}, 404)
sent_ids = []
for did in target_ids:
@@ -290,16 +261,12 @@ async def send_pattern_to_device(request, name):
sent_ids.append(did)
if not sent_ids:
return json.dumps({"error": "No Wi-Fi drivers accepted pattern upload"}), 503, {
"Content-Type": "application/json"
}
return json.dumps({"message": "Pattern sent", "pattern": filename, "device_ids": sent_ids, "sent_count": len(sent_ids)}), 200, {
"Content-Type": "application/json"
}
return J({"error": "No Wi-Fi drivers accepted pattern upload"}, 503)
return J({"message": "Pattern sent", "pattern": filename, "device_ids": sent_ids, "sent_count": len(sent_ids)}, 200)
@controller.post('/upload')
async def upload_pattern_file(request):
@router.post("/upload")
async def upload_pattern_file(request: Request):
"""
Upload a pattern source file to led-controller local storage.
@@ -310,56 +277,44 @@ async def upload_pattern_file(request):
"overwrite": true | false # optional, default true
}
"""
data = request.json or {}
data = await read_json(request)
raw_name = data.get("name") or data.get("filename")
code = data.get("code")
overwrite = data.get("overwrite", True)
overwrite = bool(overwrite)
if not isinstance(raw_name, str) or not raw_name.strip():
return json.dumps({"error": "name is required"}), 400, {
"Content-Type": "application/json"
}
return J({"error": "name is required"}, 400)
filename = raw_name.strip()
if not filename.endswith(".py"):
filename += ".py"
if not _safe_pattern_filename(filename) or filename == "__init__.py":
return json.dumps({"error": "invalid pattern filename"}), 400, {
"Content-Type": "application/json"
}
return J({"error": "invalid pattern filename"}, 400)
if is_firmware_builtin_pattern_module(filename):
return json.dumps(
{"error": "on and off are built into the driver firmware; use a different pattern name."}
), 400, {
"Content-Type": "application/json"
}
return J({"error": "on and off are built into the driver firmware; use a different pattern name."}, 400)
if not isinstance(code, str) or not code.strip():
return json.dumps({"error": "code is required"}), 400, {
"Content-Type": "application/json"
}
return J({"error": "code is required"}, 400)
path = os.path.join(driver_patterns_dir(), filename)
exists = os.path.exists(path)
if exists and not overwrite:
return json.dumps({"error": "pattern file already exists", "name": filename}), 409, {
"Content-Type": "application/json"
}
return J({"error": "pattern file already exists", "name": filename}, 409)
try:
with open(path, "w") as f:
f.write(code)
except OSError as e:
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
return J({"error": str(e)}, 500)
return json.dumps({
return J({
"message": "Pattern uploaded",
"name": filename,
"overwrote": bool(exists),
}), 201, {"Content-Type": "application/json"}
}, 201)
@controller.post('/driver')
async def create_driver_pattern(request):
@router.post("/driver")
async def create_driver_pattern(request: Request):
"""
Create a driver pattern: save ``.py`` under led-driver/src/patterns and
metadata in db/pattern.json (Pattern model).
@@ -367,36 +322,30 @@ async def create_driver_pattern(request):
Body JSON:
name, code (required),
min_delay, max_delay, max_colors (optional numbers),
has_background (optional bool),
supports_manual (optional bool, default true if omitted in db),
n1..n8 (optional string labels),
overwrite (optional, default true).
"""
data = request.json or {}
data = await read_json(request)
key = _normalize_pattern_key(data.get("name") or "")
if not _valid_pattern_key(key):
return json.dumps({
return J({
"error": "name must be a valid Python identifier (e.g. sparkle, my_pattern)",
}), 400, {"Content-Type": "application/json"}
}, 400)
if is_firmware_builtin_pattern_module(key):
return json.dumps(
{"error": "on and off are built into the driver firmware; use a different pattern name."}
), 400, {
"Content-Type": "application/json"
}
return J({"error": "on and off are built into the driver firmware; use a different pattern name."}, 400)
code = data.get("code")
if not isinstance(code, str) or not code.strip():
return json.dumps({"error": "code is required (upload a .py file or paste source)"}), 400, {
"Content-Type": "application/json"
}
return J({"error": "code is required (upload a .py file or paste source)"}, 400)
overwrite = bool(data.get("overwrite", True))
filename = key + ".py"
py_path = os.path.join(driver_patterns_dir(), filename)
if os.path.exists(py_path) and not overwrite:
return json.dumps({"error": "pattern file already exists", "name": filename}), 409, {
"Content-Type": "application/json"
}
return J({"error": "pattern file already exists", "name": filename}, 409)
meta = {}
for fld in ("min_delay", "max_delay", "max_colors"):
@@ -405,9 +354,13 @@ async def create_driver_pattern(request):
try:
meta[fld] = int(data[fld])
except (TypeError, ValueError):
return json.dumps({"error": "%s must be an integer" % fld}), 400, {
"Content-Type": "application/json"
}
return J({"error": "%s must be an integer" % fld}, 400)
if "has_background" in data:
meta["has_background"] = bool(data.get("has_background"))
if "supports_manual" in data:
meta["supports_manual"] = bool(data.get("supports_manual"))
for i in range(1, 9):
nk = "n%d" % i
@@ -424,41 +377,39 @@ async def create_driver_pattern(request):
with open(py_path, "w") as f:
f.write(code)
except OSError as e:
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
return J({"error": str(e)}, 500)
if patterns.read(key):
patterns.update(key, meta)
else:
patterns.create(key, meta)
return json.dumps({
return J({
"message": "Pattern created",
"name": key,
"file": filename,
"metadata": patterns.read(key),
}), 201, {"Content-Type": "application/json"}
}, 201)
@controller.get('')
async def list_patterns(request):
@router.get("/")
async def list_patterns(request: Request):
"""List patterns for UI (DB metadata + local driver additions)."""
return json.dumps(build_runtime_pattern_map()), 200, {'Content-Type': 'application/json'}
return J(build_runtime_pattern_map(), 200)
@controller.get('/<id>')
async def get_pattern(request, id):
@router.get("/{id}")
async def get_pattern(request: 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):
return J(pattern, 200)
return J({"error": "Pattern not found"}, 404)
@router.post("/")
async def create_pattern(request: Request):
"""Create a new pattern."""
try:
payload = request.json or {}
payload = await read_json(request)
name = payload.get("name", "")
pattern_data = payload.get("data", {})
@@ -475,26 +426,22 @@ async def create_pattern(request):
extra.pop("data", None)
if extra:
patterns.update(pattern_id, extra)
return json.dumps(patterns.read(pattern_id)), 201, {'Content-Type': 'application/json'}
return J(patterns.read(pattern_id), 201)
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.put('/<id>')
async def update_pattern(request, id):
return J({"error": str(e)}, 400)
@router.put("/{id}")
async def update_pattern(request: Request, id):
"""Update an existing pattern."""
try:
data = request.json
data = await read_json(request)
if patterns.update(id, data):
return json.dumps(patterns.read(id)), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Pattern not found"}), 404
return J(patterns.read(id), 200)
return J({"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):
return J({"error": str(e)}, 400)
@router.delete("/{id}")
async def delete_pattern(request: 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
return J({"message": "Pattern deleted successfully"}, 200)
return J({"error": "Pattern not found"}, 404)

View File

@@ -1,17 +1,36 @@
from microdot import Microdot
from microdot.session import with_session
from fastapi import APIRouter, Request
from http_responses import J, html_response, plain, read_json, send_file
from http_session import with_session
from models.preset import Preset
from models.profile import Profile
from models.pallet import Palette
from models.device import Device, normalize_mac
from models.transport import get_current_sender
from util.driver_delivery import deliver_json_messages, deliver_preset_broadcast_then_per_device
from models.transport import get_current_bridge
from util.driver_delivery import (
build_preset_json_chunks,
deliver_json_messages,
)
from util.espnow_message import build_message, build_preset_dict
from util.profile_bundle import export_preset_bundle, import_preset_bundle
import json
controller = Microdot()
router = APIRouter()
presets = Preset()
profiles = Profile()
def _palette_colors_for_profile(profile_id):
prof = profiles.read(str(profile_id))
if not isinstance(prof, dict):
return None
pid = prof.get("palette_id") or prof.get("paletteId")
if not pid:
return None
cols = Palette().read(str(pid))
return cols if isinstance(cols, list) else None
def get_current_profile_id(session=None):
"""Get the current active profile ID from session or fallback to first."""
profile_list = profiles.list()
@@ -24,41 +43,75 @@ def get_current_profile_id(session=None):
return profile_list[0]
return None
@controller.get('')
@router.get("/")
@with_session
async def list_presets(request, session):
async def list_presets(request: 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'}
return J({}, 200)
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'}
return J(scoped, 200)
@controller.get('/<preset_id>')
@router.get("/{preset_id}/export")
@with_session
async def get_preset(request, session, preset_id):
async def export_preset(request: Request, session, preset_id):
"""Export one preset as a JSON bundle."""
current_profile_id = get_current_profile_id(session)
preset = presets.read(preset_id)
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
return J({"error": "Preset not found"}, 404)
try:
bundle = export_preset_bundle(preset_id, presets)
return J(bundle, 200)
except ValueError as e:
return J({"error": str(e)}, 404)
@router.post("/import")
@with_session
async def import_preset(request: Request, session):
"""Import a preset bundle into the current profile."""
try:
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return J({"error": "No profile available"}, 404)
body = await read_json(request)
bundle = body.get("bundle") if isinstance(body, dict) else body
if not isinstance(bundle, dict):
return J({"error": "Expected JSON bundle"}, 400)
new_id, preset_data = import_preset_bundle(bundle, presets, current_profile_id)
return J({new_id: preset_data}, 201)
except ValueError as e:
return J({"error": str(e)}, 400)
except Exception as e:
return J({"error": str(e)}, 400)
@router.get("/{preset_id}")
@with_session
async def get_preset(request: 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('')
return J(preset, 200)
return J({"error": "Preset not found"}, 404)
@router.post("/")
@with_session
async def create_preset(request, session):
async def create_preset(request: Request, session):
"""Create a new preset for the current profile."""
try:
try:
data = request.json or {}
data = await read_json(request)
except Exception:
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
return J({"error": "Invalid JSON"}, 400)
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return json.dumps({"error": "No profile available"}), 404
return J({"error": "No profile available"}, 404)
preset_id = presets.create(current_profile_id)
if not isinstance(data, dict):
data = {}
@@ -66,65 +119,46 @@ async def create_preset(request, session):
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
return J({preset_id: preset_data}, 201)
return J({"error": "Failed to create preset"}, 400)
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.put('/<preset_id>')
return J({"error": str(e)}, 400)
@router.put("/{preset_id}")
@with_session
async def update_preset(request, session, preset_id):
async def update_preset(request: 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
return J({"error": "Preset not found"}, 404)
try:
data = request.json or {}
data = await read_json(request)
except Exception:
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
return J({"error": "Invalid JSON"}, 400)
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
return J(presets.read(preset_id), 200)
return J({"error": "Preset not found"}, 404)
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.delete('/<preset_id>')
return J({"error": str(e)}, 400)
@router.delete("/{preset_id}")
@with_session
async def delete_preset(request, *args, **kwargs):
async def delete_preset(request: Request, session, preset_id):
"""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
return J({"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')
return J({"message": "Preset deleted successfully"}, 200)
return J({"error": "Preset not found"}, 404)
@router.post("/send")
@with_session
async def send_presets(request, session):
async def send_presets(request: Request, session):
"""
Send one or more presets to LED drivers (serial/ESP-NOW and/or TCP Wi-Fi clients).
@@ -139,13 +173,12 @@ async def send_presets(request, session):
Optional "destination_mac" / "to": single MAC when targets is omitted.
"""
try:
data = request.json or {}
data = await read_json(request)
except Exception:
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
return J({"error": "Invalid JSON"}, 400)
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'}
return J({"error": "preset_ids must be a non-empty list"}, 400)
save_flag = data.get('save', True)
save_flag = bool(save_flag)
default_id = data.get('default')
@@ -153,6 +186,7 @@ async def send_presets(request, session):
# Build API-compliant preset map keyed by preset ID, include name
current_profile_id = get_current_profile_id(session)
palette_colors = _palette_colors_for_profile(current_profile_id)
presets_by_name = {}
for pid in preset_ids:
preset_data = presets.read(str(pid))
@@ -161,53 +195,27 @@ async def send_presets(request, session):
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 = build_preset_dict(preset_data, palette_colors)
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'}
return J({"error": "No matching presets found"}, 404)
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'}
bridge = get_current_bridge()
if not bridge:
return J({"error": "Transport not configured"}, 503)
MAX_BYTES = 240
send_delay_s = 0.1
entries = list(presets_by_name.items())
total_presets = len(entries)
batch = {}
chunk_messages = []
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
else:
chunk_messages.append(
build_message(
presets=dict(batch),
save=False,
default=None,
)
)
batch = {name: preset_obj}
if batch:
chunk_messages.append(
build_message(
presets=dict(batch),
save=save_flag,
default=default_id,
)
)
total_presets = len(presets_by_name)
chunk_messages = build_preset_json_chunks(
presets_by_name,
save=save_flag,
default=str(default_id) if default_id is not None else None,
)
target_list = None
raw_targets = data.get("targets")
@@ -224,37 +232,67 @@ async def send_presets(request, session):
dm = normalize_mac(str(destination_mac))
target_list = [dm] if dm else None
group_ids = data.get("group_ids") or data.get("groups")
if isinstance(group_ids, list):
group_ids = [str(g).strip() for g in group_ids if str(g).strip()]
else:
group_ids = None
unicast = bool(data.get("unicast")) or bool(destination_mac)
try:
if target_list:
deliveries = await deliver_preset_broadcast_then_per_device(
sender,
chunk_messages,
target_list,
Device(),
str(default_id) if default_id is not None else None,
delay_s=send_delay_s,
)
if unicast and target_list:
deliveries = 0
for msg in chunk_messages:
d, _chunks = await deliver_json_messages(
bridge, [msg],
target_list,
Device(),
delay_s=send_delay_s,
unicast=True,
)
deliveries += d
if default_id is not None:
def_msg = json.dumps(
{"v": "1", "default": str(default_id), "save": True},
separators=(",", ":"),
)
d, _chunks = await deliver_json_messages(
bridge,
[def_msg],
target_list,
Device(),
delay_s=send_delay_s,
unicast=True,
)
deliveries += d
else:
wire_messages = []
for msg in chunk_messages:
body = json.loads(msg)
if group_ids:
body["groups"] = list(group_ids)
wire_messages.append(json.dumps(body, separators=(",", ":")))
deliveries, _chunks = await deliver_json_messages(
sender,
chunk_messages,
bridge,
wire_messages,
None,
Device(),
delay_s=send_delay_s,
)
except Exception:
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
return J({"error": "Send failed"}, 503)
return json.dumps({
return J({
"message": "Presets sent",
"presets_sent": total_presets,
"messages_sent": deliveries,
}), 200, {'Content-Type': 'application/json'}
}, 200)
@controller.post('/push')
@router.post("/push")
@with_session
async def push_driver_messages(request, session):
async def push_driver_messages(request: Request, session):
"""
Deliver one or more raw v1 JSON objects to devices (ESP-NOW and/or TCP).
@@ -263,15 +301,15 @@ async def push_driver_messages(request, session):
or a single {"payload": {...}, "targets": [...]}.
"""
try:
data = request.json or {}
data = await read_json(request)
except Exception:
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
return J({"error": "Invalid JSON"}, 400)
seq = data.get("sequence")
if not seq and data.get("payload") is not None:
seq = [data["payload"]]
if not isinstance(seq, list) or not seq:
return json.dumps({"error": "sequence or payload required"}), 400, {'Content-Type': 'application/json'}
return J({"error": "sequence or payload required"}, 400)
raw_targets = data.get("targets")
target_list = None
@@ -285,18 +323,37 @@ async def push_driver_messages(request, session):
if not target_list:
target_list = None
sender = get_current_sender()
if not sender:
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
bridge = get_current_bridge()
if not bridge:
return J({"error": "Transport not configured"}, 503)
messages = []
for item in seq:
if isinstance(item, dict):
messages.append(json.dumps(item))
elif isinstance(item, str):
messages.append(item)
else:
return json.dumps({"error": "sequence items must be objects or strings"}), 400, {'Content-Type': 'application/json'}
i = 0
while i < len(seq):
item = seq[i]
if not isinstance(item, dict):
if isinstance(item, str):
messages.append(item)
i += 1
continue
return J({"error": "sequence items must be objects or strings"}, 400)
nxt = seq[i + 1] if i + 1 < len(seq) else None
if (
isinstance(nxt, dict)
and "presets" in item
and "select" not in item
and "select" in nxt
and "presets" not in nxt
):
combined = dict(item)
combined["select"] = nxt["select"]
combined_str = json.dumps(combined, separators=(",", ":"))
if len(combined_str.encode("utf-8")) <= 248:
messages.append(combined_str)
i += 2
continue
messages.append(json.dumps(item, separators=(",", ":")))
i += 1
delay_s = data.get("delay_s", 0.05)
try:
@@ -304,19 +361,33 @@ async def push_driver_messages(request, session):
except (TypeError, ValueError):
delay_s = 0.05
unicast = bool(data.get("unicast"))
try:
deliveries, _chunks = await deliver_json_messages(
sender,
bridge,
messages,
target_list,
Device(),
delay_s=delay_s,
unicast=unicast,
)
except Exception:
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
return J({"error": "Send failed"}, 503)
return json.dumps({
try:
from util import sequence_playback as seq_pb
from util.beat_driver_route import sync_beat_route_from_push_sequence
preserve = bool(seq_pb.playback_status().get("active"))
sync_beat_route_from_push_sequence(
seq, target_macs=target_list, preserve_parallel_lane_routes=preserve
)
except Exception:
pass
return J({
"message": "Delivered",
"deliveries": deliveries,
}), 200, {'Content-Type': 'application/json'}
}, 200)

View File

@@ -1,18 +1,23 @@
from microdot import Microdot
from microdot.session import with_session
from fastapi import APIRouter, Request
from http_responses import J, html_response, plain, read_json, send_file
from http_session import with_session
from models.profile import Profile
from models.zone import Zone
from models.preset import Preset
from models.sequence import Sequence
from util.profile_bundle import export_profile_bundle, import_profile_bundle
import json
controller = Microdot()
router = APIRouter()
profiles = Profile()
zones = Zone()
presets = Preset()
sequences = Sequence()
@controller.get('')
@router.get("/")
@with_session
async def list_profiles(request, session):
async def list_profiles(request: Request, session):
"""List all profiles with current profile info."""
profile_list = profiles.list()
current_id = session.get('current_profile')
@@ -32,14 +37,14 @@ async def list_profiles(request, session):
if profile_data:
profiles_data[profile_id] = profile_data
return json.dumps({
return J({
"profiles": profiles_data,
"current_profile_id": current_id
}), 200, {'Content-Type': 'application/json'}
}, 200)
@controller.get('/current')
@router.get("/current")
@with_session
async def get_current_profile(request, session):
async def get_current_profile(request: Request, session):
"""Get the current profile ID from session (or fallback)."""
profile_list = profiles.list()
current_id = session.get('current_profile')
@@ -51,203 +56,81 @@ async def get_current_profile(request, session):
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>')
return J({"id": current_id, "profile": profile}, 200)
return J({"error": "No profile available"}, 404)
@router.post("/import")
@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)
async def import_profile(request: Request, session):
"""Import a profile bundle (optionally apply as current profile)."""
try:
body = await read_json(request)
bundle = body.get("bundle") if isinstance(body, dict) else body
if not isinstance(bundle, dict):
return J({"error": "Expected JSON bundle"}, 400)
name = body.get("name") if isinstance(body, dict) else None
apply_raw = body.get("apply", True) if isinstance(body, dict) else True
if isinstance(apply_raw, str):
apply = apply_raw.strip().lower() in ("1", "true", "yes", "on")
else:
apply = bool(apply_raw)
profile = profiles.read(id)
if profile:
return json.dumps(profile), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Profile not found"}), 404
new_profile_id, profile_data = import_profile_bundle(
bundle,
profiles,
zones,
presets,
sequences,
profiles._palette_model,
name=str(name).strip() if name else None,
)
if apply:
session['current_profile'] = str(new_profile_id)
session.save()
return J({new_profile_id: profile_data, "id": new_profile_id}, 201)
except ValueError as e:
return J({"error": str(e)}, 400)
except Exception as e:
return J({"error": str(e)}, 400)
@controller.post('/<id>/apply')
@router.get("/{id}/export")
async def export_profile(request: Request, id):
"""Export profile, zones, presets, sequences, and palette as a JSON bundle."""
try:
bundle = export_profile_bundle(
str(id),
profiles,
zones,
presets,
sequences,
profiles._palette_model,
)
return J(bundle, 200)
except ValueError as e:
return J({"error": str(e)}, 404)
except Exception as e:
return J({"error": str(e)}, 400)
@router.post("/{id}/apply")
@with_session
async def apply_profile(request, session, id):
async def apply_profile(request: Request, session, id):
"""Apply a profile by saving it to session."""
if not profiles.read(id):
return json.dumps({"error": "Profile not found"}), 404
return J({"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'}
return J({"message": "Profile applied", "id": str(id)}, 200)
@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_zone", False)
if isinstance(seed_raw, str):
seed_dj_zone = seed_raw.strip().lower() in ("1", "true", "yes", "on")
else:
seed_dj_zone = bool(seed_raw)
# Request-only flag: do not persist on profile records.
data.pop("seed_dj_zone", 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 zone 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": "Colour Cycle",
"pattern": "colour_cycle",
"colors": ["#FF0000", "#00FF00", "#0000FF"],
"brightness": 255,
"delay": 100,
"auto": True,
"n1": 1,
},
{
"name": "transition",
"pattern": "transition",
"colors": ["#FF0000", "#00FF00", "#0000FF"],
"brightness": 255,
"delay": 500,
"auto": True,
},
{
"name": "flicker",
"pattern": "flicker",
"colors": ["#FFB84D"],
"brightness": 255,
"delay": 80,
"auto": True,
"n1": 30,
},
{
"name": "flame",
"pattern": "flame",
"colors": [],
"brightness": 255,
"delay": 50,
"auto": True,
"n1": 35,
"n2": 2600,
"n3": 0,
"n4": 0,
},
{
"name": "twinkle",
"pattern": "twinkle",
"colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"],
"brightness": 255,
"delay": 55,
"auto": True,
"n1": 72,
"n2": 140,
"n3": 2,
"n4": 6,
},
]
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 = zones.create(name="default", names=["1"], presets=[default_preset_ids])
zones.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("zones", []) if isinstance(profile.get("zones", []), list) else []
profile_tabs.append(str(default_tab_id))
if seed_dj_zone:
# Seed a DJ-focused zone 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 = zones.create(name="dj", names=["dj"], presets=[seeded_preset_ids])
zones.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, {"zones": 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):
@router.post("/{id}/clone")
async def clone_profile(request: 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 {}
return J({"error": "Profile not found"}, 404)
data = await read_json(request)
source_name = source.get("name") or f"Profile {id}"
new_name = data.get("name") or source_name
profile_type = source.get("type", "zones")
@@ -347,16 +230,190 @@ async def clone_profile(request, id):
zones.save()
profiles.save()
return json.dumps({new_profile_id: new_profile_data}), 201, {'Content-Type': 'application/json'}
return J({new_profile_id: new_profile_data}, 201)
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.put('/current')
return J({"error": str(e)}, 400)
@router.get("/{id}")
@with_session
async def update_current_profile(request, session):
async def get_profile(request: 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 J(profile, 200)
return J({"error": "Profile not found"}, 404)
@router.post("/")
async def create_profile(request: Request):
"""Create a new profile."""
try:
data = dict(await read_json(request))
name = data.get("name", "")
seed_raw = data.get("seed_dj_zone", False)
if isinstance(seed_raw, str):
seed_dj_zone = seed_raw.strip().lower() in ("1", "true", "yes", "on")
else:
seed_dj_zone = bool(seed_raw)
# Request-only flag: do not persist on profile records.
data.pop("seed_dj_zone", 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 zone 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": "colour_cycle",
"colors": [],
"brightness": 255,
"delay": 100,
"auto": True,
"n1": 2,
"mode": 1,
},
{
"name": "Colour Cycle",
"pattern": "colour_cycle",
"colors": ["#FF0000", "#00FF00", "#0000FF"],
"brightness": 255,
"delay": 100,
"auto": True,
"n1": 1,
},
{
"name": "transition",
"pattern": "transition",
"colors": ["#FF0000", "#00FF00", "#0000FF"],
"brightness": 255,
"delay": 500,
"auto": True,
},
{
"name": "flicker",
"pattern": "flicker",
"colors": ["#FFB84D"],
"brightness": 255,
"delay": 80,
"auto": True,
"n1": 30,
},
{
"name": "flame",
"pattern": "flame",
"colors": [],
"brightness": 255,
"delay": 50,
"auto": True,
"n1": 35,
"n2": 2600,
"n3": 0,
"n4": 0,
},
{
"name": "twinkle",
"pattern": "twinkle",
"colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"],
"brightness": 255,
"delay": 55,
"auto": True,
"n1": 72,
"n2": 140,
"n3": 2,
"n4": 6,
},
]
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 = zones.create(name="default", names=["1"], presets=[default_preset_ids])
zones.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("zones", []) if isinstance(profile.get("zones", []), list) else []
profile_tabs.append(str(default_tab_id))
if seed_dj_zone:
# Seed a DJ-focused zone with three starter presets.
seeded_preset_ids = []
preset_defs = [
{
"name": "DJ Rainbow",
"pattern": "colour_cycle",
"colors": [],
"brightness": 220,
"delay": 60,
"n1": 12,
"mode": 1,
},
{
"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 = zones.create(name="dj", names=["dj"], presets=[seeded_preset_ids])
zones.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, {"zones": profile_tabs})
profile_data = profiles.read(profile_id)
return J({profile_id: profile_data}, 201)
except Exception as e:
return J({"error": str(e)}, 400)
@router.put("/current")
@with_session
async def update_current_profile(request: Request, session):
"""Update the current profile using session (or fallback)."""
try:
data = request.json or {}
data = await read_json(request)
profile_list = profiles.list()
current_id = session.get('current_profile')
if not current_id and profile_list:
@@ -364,27 +421,25 @@ async def update_current_profile(request, session):
session['current_profile'] = str(current_id)
session.save()
if not current_id:
return json.dumps({"error": "No profile available"}), 404
return J({"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
return J(profiles.read(current_id), 200)
return J({"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):
return J({"error": str(e)}, 400)
@router.put("/{id}")
async def update_profile(request: Request, id):
"""Update an existing profile."""
try:
data = request.json
data = await read_json(request)
if profiles.update(id, data):
return json.dumps(profiles.read(id)), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Profile not found"}), 404
return J(profiles.read(id), 200)
return J({"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):
return J({"error": str(e)}, 400)
@router.delete("/{id}")
async def delete_profile(request: 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
return J({"message": "Profile deleted successfully"}, 200)
return J({"error": "Profile not found"}, 404)

View File

@@ -1,49 +1,49 @@
from microdot import Microdot
from fastapi import APIRouter, Request
from http_responses import J, html_response, plain, read_json, send_file
from http_session import with_session
from models.scene import Scene
import json
controller = Microdot()
router = APIRouter()
scenes = Scene()
@controller.get('')
async def list_scenes(request):
@router.get("/")
async def list_scenes(request: Request):
"""List all scenes."""
return json.dumps(scenes), 200, {'Content-Type': 'application/json'}
return J(scenes, 200)
@controller.get('/<id>')
async def get_scene(request, id):
@router.get("/{id}")
async def get_scene(request: 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):
return J(scene, 200)
return J({"error": "Scene not found"}, 404)
@router.post("/")
async def create_scene(request: Request):
"""Create a new scene."""
try:
data = request.json
data = await read_json(request)
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
return J(scenes.read(scene_id), 201)
return J({"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):
return J({"error": str(e)}, 400)
@router.put("/{id}")
async def update_scene(request: Request, id):
"""Update an existing scene."""
try:
data = request.json
data = await read_json(request)
if scenes.update(id, data):
return json.dumps(scenes.read(id)), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Scene not found"}), 404
return J(scenes.read(id), 200)
return J({"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):
return J({"error": str(e)}, 400)
@router.delete("/{id}")
async def delete_scene(request: 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
return J({"message": "Scene deleted successfully"}, 200)
return J({"error": "Scene not found"}, 404)

View File

@@ -1,51 +1,242 @@
from microdot import Microdot
from models.squence import Sequence
from fastapi import APIRouter, Request
from http_responses import J, html_response, plain, read_json, send_file
from http_session import with_session
from models.sequence import Sequence
from models.profile import Profile
from models.transport import get_current_bridge
from models.preset import Preset
from util.profile_bundle import export_sequence_bundle, import_sequence_bundle
import json
controller = Microdot()
router = APIRouter()
sequences = Sequence()
profiles = Profile()
presets = Preset()
@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
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.post('')
async def create_sequence(request):
"""Create a new sequence."""
@router.get("/")
@with_session
async def list_sequences(request: Request, session):
"""List sequences for the current profile."""
sequences.load()
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return J({}, 200)
scoped = {
sid: sdata
for sid, sdata in sequences.items()
if isinstance(sdata, dict)
and str(sdata.get("profile_id")) == str(current_profile_id)
}
return J(scoped, 200)
@router.get("/{id}/export")
@with_session
async def export_sequence(request: Request, session, id):
"""Export a sequence and referenced presets as a JSON bundle."""
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return J({"error": "No profile available"}, 404)
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'}
bundle = export_sequence_bundle(
id,
sequences,
presets,
profile_id=current_profile_id,
)
return J(bundle, 200)
except ValueError as e:
return J({"error": str(e)}, 404)
@router.post("/import")
@with_session
async def import_sequence(request: Request, session):
"""Import a sequence bundle into the current profile."""
try:
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return J({"error": "No profile available"}, 404)
body = await read_json(request)
bundle = body.get("bundle") if isinstance(body, dict) else body
if not isinstance(bundle, dict):
return J({"error": "Expected JSON bundle"}, 400)
new_id, seq_data = import_sequence_bundle(bundle, sequences, presets, current_profile_id)
return J({new_id: seq_data}, 201)
except ValueError as e:
return J({"error": str(e)}, 400)
except Exception as e:
return json.dumps({"error": str(e)}), 400
return J({"error": str(e)}, 400)
@controller.put('/<id>')
async def update_sequence(request, id):
"""Update an existing sequence."""
@router.get("/{id}")
@with_session
async def get_sequence(request: Request, session, id):
"""Get a specific sequence by ID (current profile only)."""
sequences.load()
current_profile_id = get_current_profile_id(session)
seq = sequences.read(id)
if (
seq
and current_profile_id
and str(seq.get("profile_id")) == str(current_profile_id)
):
return J(seq, 200)
return J({"error": "Sequence not found"}, 404)
@router.post("/")
@with_session
async def create_sequence(request: Request, session):
"""Create a new sequence for the current profile."""
try:
data = request.json
try:
data = await read_json(request)
except Exception:
return J({"error": "Invalid JSON"}, 400)
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return J({"error": "No profile available"}, 404)
sequence_id = sequences.create(current_profile_id)
if not isinstance(data, dict):
data = {}
data = dict(data)
data["profile_id"] = str(current_profile_id)
if sequences.update(sequence_id, data):
seq_data = sequences.read(sequence_id)
return J({sequence_id: seq_data}, 201)
return J({"error": "Failed to create sequence"}, 400)
except Exception as e:
return J({"error": str(e)}, 400)
@router.put("/{id}")
@with_session
async def update_sequence(request: Request, session, id):
"""Update an existing sequence (current profile only)."""
try:
current_profile_id = get_current_profile_id(session)
seq = sequences.read(id)
if not seq or str(seq.get("profile_id")) != str(current_profile_id):
return J({"error": "Sequence not found"}, 404)
data = await read_json(request)
if not isinstance(data, dict):
return J({"error": "Invalid JSON"}, 400)
data = dict(data)
data["profile_id"] = str(current_profile_id)
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
try:
from util.sequence_playback import stop_if_playing_sequence
@controller.delete('/<id>')
async def delete_sequence(request, id):
"""Delete a sequence."""
stop_if_playing_sequence(str(id))
except Exception:
pass
return J(sequences.read(id), 200)
return J({"error": "Sequence not found"}, 404)
except Exception as e:
return J({"error": str(e)}, 400)
@router.delete("/{id}")
@with_session
async def delete_sequence(request: Request, session, id):
"""Delete a sequence (current profile only)."""
current_profile_id = get_current_profile_id(session)
seq = sequences.read(id)
if not seq or str(seq.get("profile_id")) != str(current_profile_id):
return J({"error": "Sequence not found"}, 404)
try:
from util.sequence_playback import stop_if_playing_sequence
stop_if_playing_sequence(str(id))
except Exception:
pass
if sequences.delete(id):
return json.dumps({"message": "Sequence deleted successfully"}), 200
return json.dumps({"error": "Sequence not found"}), 404
return J({"message": "Sequence deleted successfully"}, 200)
return J({"error": "Sequence not found"}, 404)
@router.post("/sync-phase")
@with_session
async def sync_sequence_beat_phase(request: Request, session):
"""Align beat counters while a sequence is playing (body: {\"mode\": \"step\"|\"pass\"})."""
_ = session
try:
data = await read_json(request)
except Exception:
data = {}
if not isinstance(data, dict):
data = {}
mode = data.get("mode") or data.get("align") or "step"
try:
from util.sequence_playback import sync_beat_phase
if not await sync_beat_phase(str(mode)):
return J({"error": "No sequence is playing"}, 409)
from util.audio_detector import anchor_shared_bar_phase
anchor_shared_bar_phase()
return J({"ok": True, "mode": str(mode).strip().lower()}, 200)
except Exception as e:
return J({"error": str(e)}, 500)
@router.post("/stop")
@with_session
async def stop_sequence_playback(request: Request, session):
"""Stop server-driven zone sequence playback."""
_ = request
try:
from util.sequence_playback import stop_playback
await stop_playback(clear_devices=True)
return J({"ok": True}, 200)
except Exception as e:
return J({"error": str(e)}, 500)
@router.post("/{id}/play")
@with_session
async def play_sequence(request: Request, session, id):
"""Start server-driven playback for a sequence in a zone (body: {\"zone_id\": \"...\"})."""
if not get_current_bridge():
return J({"error": "Transport not configured"}, 503)
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return J({"error": "No profile available"}, 404)
try:
data = await read_json(request)
except Exception:
data = {}
if not isinstance(data, dict):
data = {}
zone_id = data.get("zone_id") or data.get("zoneId")
if zone_id is None or str(zone_id).strip() == "":
return J({"error": "zone_id required"}, 400)
zone_id = str(zone_id).strip()
try:
from util.sequence_playback import start
play_opts = data if isinstance(data, dict) else None
await start(zone_id, str(id), str(current_profile_id), play_opts)
from util.sequence_playback import pending_play_status
body = {"ok": True, **pending_play_status()}
return J(body, 200)
except ValueError as e:
return J({"error": str(e)}, 400)
except RuntimeError as e:
return J({"error": str(e)}, 503)
except Exception as e:
return J({"error": str(e)}, 500)

View File

@@ -1,19 +1,25 @@
from microdot import Microdot, send_file
from settings import Settings
from fastapi import APIRouter, Request
from http_responses import J, html_response, plain, read_json, send_file
from http_session import with_session
import asyncio
import json
controller = Microdot()
settings = Settings()
@controller.get('')
async def get_settings(request):
from settings import get_settings
router = APIRouter()
settings = get_settings()
@router.get("/")
async def get_settings(request: 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'}
return J(settings, 200)
@controller.get('/wifi/ap')
async def get_ap_config(request):
@router.get("/wifi/ap")
async def get_ap_config(request: Request):
"""Get saved AP configuration (Pi: no in-device AP)."""
config = {
'saved_ssid': settings.get('wifi_ap_ssid'),
@@ -21,40 +27,37 @@ async def get_ap_config(request):
'saved_channel': settings.get('wifi_ap_channel'),
'active': False,
}
return json.dumps(config), 200, {'Content-Type': 'application/json'}
return J(config, 200)
@controller.post('/wifi/ap')
async def configure_ap(request):
@router.post("/wifi/ap")
async def configure_ap(request: Request):
"""Save AP configuration to settings (Pi: no in-device AP)."""
try:
data = request.json
data = await read_json(request)
ssid = data.get('ssid')
password = data.get('password', '')
channel = data.get('channel')
if not ssid:
return json.dumps({"error": "SSID is required"}), 400
return J({"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
return J({"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({
return J({
"message": "AP settings saved",
"ssid": ssid,
"channel": channel
}), 200, {'Content-Type': 'application/json'}
"channel": channel,
}, 200)
except Exception as e:
return json.dumps({"error": str(e)}), 500
return J({"error": str(e)}, 500)
def _validate_wifi_channel(value):
"""Return int 111 or raise ValueError."""
ch = int(value)
@@ -63,25 +66,71 @@ def _validate_wifi_channel(value):
return ch
@controller.put('/settings')
async def update_settings(request):
def _validate_global_brightness(value):
"""Return int 0255 or raise ValueError."""
v = int(value)
if v < 0 or v > 255:
raise ValueError("global_brightness must be between 0 and 255")
return v
def _validate_sequence_switch_wait(value):
s = str(value).strip().lower()
if s not in ("beat", "downbeat"):
raise ValueError("sequence_switch_wait must be beat or downbeat")
return s
def _validate_audio_beat_phase_ms(value):
v = int(value)
if v < 0 or v > 500:
raise ValueError("audio_beat_phase_ms must be between 0 and 500")
return v
def _validate_audio_input_volume(value):
v = int(value)
if v < 0 or v > 200:
raise ValueError("audio_input_volume must be between 0 and 200")
return v
def _validate_audio_simulated_bpm(value):
from util.bpm_limits import clamp_bpm
return int(clamp_bpm(value))
@router.put("/")
async def update_settings(request: Request):
"""Update general settings."""
try:
data = request.json
data = await read_json(request)
global_brightness_changed = False
for key, value in data.items():
if key == 'wifi_channel' and value is not None:
settings[key] = _validate_wifi_channel(value)
elif key == 'global_brightness' and value is not None:
settings[key] = _validate_global_brightness(value)
global_brightness_changed = True
elif key == 'sequence_switch_wait' and value is not None:
settings[key] = _validate_sequence_switch_wait(value)
elif key == 'audio_beat_phase_ms' and value is not None:
settings[key] = _validate_audio_beat_phase_ms(value)
elif key == 'audio_input_volume' and value is not None:
settings[key] = _validate_audio_input_volume(value)
elif key == 'audio_simulated_bpm' and value is not None:
settings[key] = _validate_audio_simulated_bpm(value)
else:
settings[key] = value
settings.save()
return json.dumps({"message": "Settings updated successfully"}), 200, {'Content-Type': 'application/json'}
return J({"message": "Settings updated successfully"}, 200)
except ValueError as e:
return json.dumps({"error": str(e)}), 400
return J({"error": str(e)}, 400)
except Exception as e:
return json.dumps({"error": str(e)}), 500
@controller.get('/page')
async def settings_page(request):
return J({"error": str(e)}, 500)
@router.get("/page")
async def settings_page(request: Request):
"""Serve the settings page."""
return send_file('templates/settings.html')

View File

@@ -0,0 +1,244 @@
"""Pi WiFi and saved ESP-NOW bridge profiles."""
from __future__ import annotations
from fastapi import APIRouter, Request
from http_responses import J, html_response, plain, read_json, send_file
from http_session import with_session
import json
import secrets
from settings import get_settings
from util.bridge_profiles import find_bridge_profile, normalise_bridges
from util.bridge_runtime import (
active_bridge_profile_id,
bridge_connected,
bridge_serial_connected,
bridge_ws_connected,
connect_bridge_profile,
connect_bridge_serial,
connect_bridge_wifi,
)
from util.pi_wifi import list_wifi_interfaces, nmcli_available, scan_wifi
router = APIRouter()
def _bridge_transport(settings) -> str:
mode = str(settings.get("bridge_transport") or "wifi").strip().lower()
return mode if mode in ("wifi", "serial") else "wifi"
def _bridges_payload(settings) -> dict:
return {
"ok": True,
"wifi_interface": settings.get("wifi_interface") or "",
"bridge_ws_url": settings.get("bridge_ws_url") or "",
"bridge_connected": bridge_connected(),
"bridge_wifi_connected": bridge_ws_connected(),
"bridge_serial_connected": bridge_serial_connected(),
"bridge_transport": _bridge_transport(settings),
"active_bridge_id": active_bridge_profile_id(settings) or "",
"bridge_serial_port": settings.get("bridge_serial_port") or "",
"bridge_serial_baudrate": int(settings.get("bridge_serial_baudrate") or 921600),
"bridges": normalise_bridges(settings.get("bridges")),
}
@router.get("/interfaces")
async def wifi_interfaces(request: Request):
_ = request
if not nmcli_available():
return J({"ok": False, "error": "nmcli not found (install NetworkManager)"}, 503)
return J({"ok": True, "interfaces": list_wifi_interfaces()}, 200)
@router.get("/scan")
async def wifi_scan(request: Request):
device = (request.query_params.get("device") or "").strip()
if not device:
return J({"error": "device query param required"}, 400)
if not nmcli_available():
return J({"ok": False, "error": "nmcli not found"}, 503)
try:
networks = await scan_wifi(device)
return J({"ok": True, "device": device, "networks": networks}, 200)
except Exception as e:
return J({"ok": False, "error": str(e)}, 500)
@router.get("/bridges")
async def get_bridges(request: Request):
_ = request
settings = get_settings()
return J(_bridges_payload(settings), 200)
@router.put("/bridges")
async def put_bridges(request: Request):
try:
data = await read_json(request)
settings = get_settings()
if "wifi_interface" in data:
settings["wifi_interface"] = str(data.get("wifi_interface") or "").strip()
if "bridge_transport" in data:
mode = str(data.get("bridge_transport") or "").strip().lower()
if mode in ("wifi", "serial"):
settings["bridge_transport"] = mode
if "bridge_ws_url" in data:
settings["bridge_ws_url"] = str(data.get("bridge_ws_url") or "").strip()
if "bridge_serial_port" in data:
settings["bridge_serial_port"] = str(data.get("bridge_serial_port") or "").strip()
if "bridge_serial_baudrate" in data:
settings["bridge_serial_baudrate"] = int(data.get("bridge_serial_baudrate") or 921600)
if "bridges" in data:
settings["bridges"] = normalise_bridges(data.get("bridges"))
settings.save()
return J({"ok": True, "message": "Bridge profiles saved"}, 200)
except Exception as e:
return J({"ok": False, "error": str(e)}, 400)
@router.delete("/bridges/{bridge_id}")
async def delete_bridge_profile(request: Request, bridge_id):
_ = request
settings = get_settings()
bid = str(bridge_id or "").strip()
bridges = normalise_bridges(settings.get("bridges"))
kept = [b for b in bridges if str(b.get("id") or "") != bid]
if len(kept) == len(bridges):
return J({"ok": False, "error": "Bridge profile not found"}, 404)
settings["bridges"] = kept
settings.save()
payload = _bridges_payload(settings)
payload["message"] = "Bridge profile deleted"
return J(payload, 200)
@router.post("/bridges/{bridge_id}/connect")
async def connect_saved_bridge(request: Request, bridge_id):
_ = request
settings = get_settings()
profile = find_bridge_profile(settings, bridge_id)
if not profile:
return J({"error": "Bridge profile not found"}, 404)
try:
ok, err = await connect_bridge_profile(profile, settings)
if not ok:
return J({"ok": False, "error": err or "Connect failed"}, 400)
payload = _bridges_payload(settings)
payload["message"] = f"Connected to {profile.get('label')}"
return J(payload, 200)
except Exception as e:
return J({"ok": False, "error": str(e)}, 500)
@router.post("/connect")
async def wifi_connect_bridge(request: Request):
"""Join a bridge AP and open its WebSocket."""
try:
data = await read_json(request)
settings = get_settings()
device = str(data.get("device") or settings.get("wifi_interface") or "").strip()
ssid = str(data.get("ssid") or "").strip()
password = str(data.get("password") or "")
ap_ip = str(data.get("ap_ip") or "192.168.4.1").strip()
try:
ws_port = int(data.get("ws_port") or 80)
except (TypeError, ValueError):
ws_port = 80
label = str(data.get("label") or ssid).strip() or ssid
save_profile = bool(data.get("save_profile", True))
if not device:
return J({"error": "WiFi interface (device) is required"}, 400)
if not ssid:
return J({"error": "ssid is required"}, 400)
settings["wifi_interface"] = device
bridges = normalise_bridges(settings.get("bridges"))
profile_id = None
if save_profile:
profile_id = secrets.token_hex(6)
bridges = [
b
for b in bridges
if not (b.get("transport") == "wifi" and b.get("ssid") == ssid)
]
bridges.append(
{
"id": profile_id,
"label": label,
"transport": "wifi",
"ssid": ssid,
"password": password,
"ap_ip": ap_ip,
"ws_port": ws_port,
}
)
settings["bridges"] = bridges
settings.save()
profile = {
"transport": "wifi",
"ssid": ssid,
"password": password,
"ap_ip": ap_ip,
"ws_port": ws_port,
"wifi_interface": device,
}
ok, err = await connect_bridge_wifi(profile, settings)
if not ok:
return J({"ok": False, "error": err or "Connect failed"}, 400)
payload = _bridges_payload(settings)
payload["profile_id"] = profile_id
payload["message"] = f"Connected to {ssid}"
return J(payload, 200)
except Exception as e:
return J({"ok": False, "error": str(e)}, 500)
@router.post("/serial/connect")
async def serial_connect_bridge(request: Request):
try:
data = await read_json(request)
port = str(data.get("port") or data.get("serial_port") or "").strip()
save_profile = bool(data.get("save_profile", True))
label = str(data.get("label") or port).strip() or port
try:
baud = int(data.get("baudrate") or data.get("serial_baudrate") or 921600)
except (TypeError, ValueError):
baud = 921600
if not port:
return J({"error": "port is required"}, 400)
settings = get_settings()
bridges = normalise_bridges(settings.get("bridges"))
profile_id = None
if save_profile:
profile_id = secrets.token_hex(6)
bridges = [
b
for b in bridges
if not (b.get("transport") == "serial" and b.get("serial_port") == port)
]
bridges.append(
{
"id": profile_id,
"label": label,
"transport": "serial",
"serial_port": port,
"serial_baudrate": baud,
}
)
settings["bridges"] = bridges
settings.save()
profile = {"transport": "serial", "serial_port": port, "serial_baudrate": baud}
ok, err = await connect_bridge_serial(profile, settings)
if not ok:
return J({"ok": False, "error": err}, 500)
payload = _bridges_payload(settings)
payload["profile_id"] = profile_id
payload["message"] = f"Connected on {port}"
return J(payload, 200)
except Exception as e:
return J({"ok": False, "error": str(e)}, 500)

View File

@@ -1,10 +1,12 @@
from microdot import Microdot, send_file
from microdot.session import with_session
from fastapi import APIRouter, Request
from http_responses import J, J_cookie, html_response, plain, read_json, send_file
from http_session import with_session
from models.zone import Zone
from models.profile import Profile
import json
controller = Microdot()
router = APIRouter()
zones = Zone()
profiles = Profile()
@@ -69,11 +71,7 @@ def _render_zones_list_fragment(request, session):
"""Render zone strip HTML for HTMX / JS."""
profile_id = get_current_profile_id(session)
if not profile_id:
return (
'<div class="zones-list">No profile selected</div>',
200,
{"Content-Type": "text/html"},
)
return html_response('<div class="zones-list">No profile selected</div>', 200)
zone_order = get_profile_zone_order(profile_id)
current_zone_id = get_current_zone_id(request, session)
@@ -96,9 +94,7 @@ def _render_zones_list_fragment(request, session):
+ "</button>"
)
html += "</div>"
return html, 200, {"Content-Type": "text/html"}
return html_response(html, 200)
def _render_zone_content_fragment(request, session, id):
if id == "current":
current_zone_id = get_current_zone_id(request, session)
@@ -106,18 +102,13 @@ def _render_zone_content_fragment(request, session, id):
accept_header = request.headers.get("Accept", "")
wants_html = "text/html" in accept_header
if wants_html:
return (
'<div class="error">No current zone set</div>',
404,
{"Content-Type": "text/html"},
)
return json.dumps({"error": "No current zone set"}), 404
return html_response('<div class="error">No current zone set</div>', 404)
return J({"error": "No current zone set"}, 404)
id = current_zone_id
z = zones.read(id)
if not z:
return '<div>Zone not found</div>', 404, {"Content-Type": "text/html"}
return html_response('<div>Zone not found</div>', 404)
session["current_zone"] = str(id)
session.save()
@@ -133,18 +124,17 @@ def _render_zone_content_fragment(request, session, id):
"</div>"
"</div>"
)
return html, 200, {"Content-Type": "text/html"}
@controller.get("/<id>/content-fragment")
return html_response(html, 200)
@router.get("/{id}/content-fragment")
@with_session
async def zone_content_fragment(request, session, id):
async def zone_content_fragment(request: Request, session, id):
return _render_zone_content_fragment(request, session, id)
@controller.get("")
@router.get("/")
@with_session
async def list_zones(request, session):
async def list_zones(request: Request, session):
zones.load()
profile_id = get_current_profile_id(session)
current_zone_id = get_current_zone_id(request, session)
zone_order = get_profile_zone_order(profile_id) if profile_id else []
@@ -155,92 +145,66 @@ async def list_zones(request, session):
if zdata:
zones_data[zid] = zdata
return (
json.dumps(
{
return J({
"zones": zones_data,
"zone_order": zone_order,
"current_zone_id": current_zone_id,
"profile_id": profile_id,
}
),
200,
{"Content-Type": "application/json"},
)
}, 200)
@controller.get("/current")
@router.get("/current")
@with_session
async def get_current_zone(request, session):
async def get_current_zone(request: Request, session):
current_zone_id = get_current_zone_id(request, session)
if not current_zone_id:
return (
json.dumps({"error": "No current zone set", "zone": None, "zone_id": None}),
404,
)
return J({"error": "No current zone set", "zone": None, "zone_id": None}, 404)
z = zones.read(current_zone_id)
if z:
return (
json.dumps({"zone": z, "zone_id": current_zone_id}),
200,
{"Content-Type": "application/json"},
)
return (
json.dumps({"error": "Zone not found", "zone": None, "zone_id": None}),
404,
)
return J({"zone": z, "zone_id": current_zone_id}, 200)
return J({"error": "Zone not found", "zone": None, "zone_id": None}, 404)
@controller.post("/<id>/set-current")
async def set_current_zone(request, id):
@router.post("/{id}/set-current")
async def set_current_zone(request: Request, id):
z = zones.read(id)
if not z:
return json.dumps({"error": "Zone not found"}), 404
response_data = json.dumps({"message": "Current zone set", "zone_id": id})
return (
response_data,
200,
{
"Content-Type": "application/json",
"Set-Cookie": (
f"current_zone={id}; Path=/; Max-Age=31536000; SameSite=Lax"
),
},
return J({"error": "Zone not found"}, 404)
return J_cookie(
{"message": "Current zone set", "zone_id": id},
name="current_zone",
value=str(id),
max_age=31536000,
)
@controller.get("/<id>")
async def get_zone(request, id):
@router.get("/{id}")
async def get_zone(request: Request, id):
zones.load()
z = zones.read(id)
if z:
return json.dumps(z), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Zone not found"}), 404
@controller.put("/<id>")
async def update_zone(request, id):
return J(z, 200)
return J({"error": "Zone not found"}, 404)
@router.put("/{id}")
async def update_zone(request: Request, id):
try:
data = request.json
data = await read_json(request)
if zones.update(id, data):
return json.dumps(zones.read(id)), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Zone not found"}), 404
return J(zones.read(id), 200)
return J({"error": "Zone not found"}, 404)
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.delete("/<id>")
return J({"error": str(e)}, 400)
@router.delete("/{id}")
@with_session
async def delete_zone(request, session, id):
async def delete_zone(request: Request, session, id):
try:
if id == "current":
current_zone_id = get_current_zone_id(request, session)
if current_zone_id:
id = current_zone_id
else:
return json.dumps({"error": "No current zone to delete"}), 404
return J({"error": "No current zone to delete"}, 404)
if zones.delete(id):
profile_id = get_current_profile_id(session)
if profile_id:
@@ -254,23 +218,15 @@ async def delete_zone(request, session, id):
current_zone_id = get_current_zone_id(request, session)
if current_zone_id == id:
response_data = json.dumps({"message": "Zone deleted successfully"})
return (
response_data,
200,
{
"Content-Type": "application/json",
"Set-Cookie": (
"current_zone=; Path=/; Max-Age=0; SameSite=Lax"
),
},
return J_cookie(
{"message": "Zone deleted successfully"},
name="current_zone",
value="",
max_age=0,
)
return json.dumps({"message": "Zone deleted successfully"}), 200, {
"Content-Type": "application/json"
}
return json.dumps({"error": "Zone not found"}), 404
return J({"message": "Zone deleted successfully"}, 200)
return J({"error": "Zone not found"}, 404)
except Exception as e:
import sys
@@ -278,30 +234,42 @@ async def delete_zone(request, session, id):
sys.print_exception(e)
except Exception:
pass
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
return J({"error": str(e)}, 500)
@controller.post("")
@router.post("/")
@with_session
async def create_zone(request, session):
async def create_zone(request: Request, session):
try:
if request.form:
name = request.form.get("name", "").strip()
ids_str = request.form.get("ids", "1").strip()
ct = (request.headers.get("content-type") or "").split(";")[0].strip().lower()
if ct in ("application/x-www-form-urlencoded", "multipart/form-data"):
form = await request.form()
name = (form.get("name") or "").strip()
ids_str = (form.get("ids") or "1").strip()
names = [i.strip() for i in ids_str.split(",") if i.strip()]
preset_ids = None
group_ids = []
content_kind = None
else:
data = request.json or {}
data = await read_json(request)
name = data.get("name", "")
names = data.get("names")
if names is None:
names = data.get("ids")
preset_ids = data.get("presets", None)
group_ids = data.get("group_ids")
if group_ids is None:
group_ids = []
if isinstance(group_ids, list):
group_ids = [str(x) for x in group_ids if x is not None]
else:
group_ids = []
raw_kind = data.get("content_kind")
content_kind = raw_kind if raw_kind in ("presets", "sequences") else None
if not name:
return json.dumps({"error": "Zone name cannot be empty"}), 400
zid = zones.create(name, names, preset_ids)
return J({"error": "Zone name cannot be empty"}, 400)
zid = zones.create(name, names, preset_ids, group_ids, content_kind)
profile_id = get_current_profile_id(session)
if profile_id:
@@ -314,26 +282,29 @@ async def create_zone(request, session):
profiles.update(profile_id, profile)
zdata = zones.read(zid)
return json.dumps({zid: zdata}), 201, {"Content-Type": "application/json"}
return J({zid: zdata}, 201)
except Exception as e:
import sys
sys.print_exception(e)
return json.dumps({"error": str(e)}), 400
@controller.post("/<id>/clone")
return J({"error": str(e)}, 400)
@router.post("/{id}/clone")
@with_session
async def clone_zone(request, session, id):
async def clone_zone(request: Request, session, id):
try:
source = zones.read(id)
if not source:
return json.dumps({"error": "Zone not found"}), 404
data = request.json or {}
return J({"error": "Zone not found"}, 404)
data = await read_json(request)
source_name = source.get("name") or f"Zone {id}"
new_name = data.get("name") or f"{source_name} Copy"
clone_id = zones.create(new_name, source.get("names"), source.get("presets"))
clone_id = zones.create(
new_name,
source.get("names"),
source.get("presets"),
source.get("group_ids"),
source.get("content_kind"),
)
extra = {k: v for k, v in source.items() if k not in ("name", "names", "presets")}
if extra:
zones.update(clone_id, extra)
@@ -349,7 +320,7 @@ async def clone_zone(request, session, id):
profiles.update(profile_id, profile)
zdata = zones.read(clone_id)
return json.dumps({clone_id: zdata}), 201, {"Content-Type": "application/json"}
return J({clone_id: zdata}, 201)
except Exception as e:
import sys
@@ -357,5 +328,4 @@ async def clone_zone(request, session, id):
sys.print_exception(e)
except Exception:
pass
return json.dumps({"error": str(e)}), 400
return J({"error": str(e)}, 400)

281
src/fastapi_app.py Normal file
View File

@@ -0,0 +1,281 @@
"""FastAPI application entrypoint."""
from __future__ import annotations
import json
import logging
import os
from contextlib import asynccontextmanager
from typing import Optional
import asyncio
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse
from app_factory import (
AppRuntime,
audio_status_payload,
dev_build_id,
dev_client_revision,
live_reload_enabled,
mount_controller_routers,
mount_static_routes,
)
from http_session import SessionMiddleware
from models.transport import get_current_bridge
_runtime: Optional[AppRuntime] = None
_test_mode = False
class _SuppressDevAccessLogFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
return "/__dev/" not in record.getMessage()
logging.getLogger("uvicorn.access").addFilter(_SuppressDevAccessLogFilter())
def _bridge():
return get_current_bridge()
def _notify_audio_status_sse() -> None:
try:
from util.beat_status_broadcaster import request_status_broadcast
request_status_broadcast()
except Exception:
pass
@asynccontextmanager
async def _lifespan(app: FastAPI):
global _runtime
_runtime = AppRuntime()
await _runtime.startup(test_mode=_test_mode)
if live_reload_enabled() and not _test_mode:
print(
"[dev] LED_CONTROLLER_LIVE_RELOAD: browser refreshes when uvicorn reloads"
)
yield
await _runtime.shutdown()
def create_application(*, test_mode: bool = False) -> FastAPI:
global _test_mode
_test_mode = test_mode
api = FastAPI(title="LED Controller", lifespan=_lifespan)
api.add_middleware(SessionMiddleware)
mount_controller_routers(api)
mount_static_routes(api, inject_live_reload=live_reload_enabled())
@api.get("/__dev/build-id", response_class=PlainTextResponse)
async def dev_build_id_route():
bid = dev_build_id()
if not bid:
return PlainTextResponse("", status_code=404)
return PlainTextResponse(
bid,
headers={"Cache-Control": "no-store"},
)
@api.get("/__dev/client-rev", response_class=PlainTextResponse)
async def dev_client_rev_route():
rev = dev_client_revision()
if not rev:
return PlainTextResponse("", status_code=404)
return PlainTextResponse(
rev,
headers={"Cache-Control": "no-store"},
)
@api.get("/api/audio/devices")
async def audio_devices():
if _runtime is None:
return JSONResponse({"error": "not ready"}, status_code=503)
try:
return {
"devices": _runtime.audio_detector.list_input_devices(),
"diagnostics": _runtime.audio_detector.diagnostics(),
}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@api.post("/api/audio/start")
async def audio_start(payload: dict | None = None):
if _runtime is None:
return JSONResponse({"ok": False, "error": "not ready"}, status_code=503)
body = payload if isinstance(payload, dict) else {}
device = body.get("device", None)
if device in ("", None):
device = None
device_select = str(body.get("device_select") or "").strip()
if not device_select and device not in ("", None):
device_select = str(device).strip()
try:
from util.pulse_audio_devices import resolve_capture_device
device = resolve_capture_device(device)
_runtime.audio_detector.start(device=device)
from util.audio_run_persist import write_audio_run_state
write_audio_run_state(
enabled=True,
device=device,
device_override=str(body.get("device_override") or ""),
device_select=device_select,
)
_notify_audio_status_sse()
return {"ok": True, "status": _runtime.audio_detector.status()}
except Exception as e:
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
@api.put("/api/audio/device")
async def audio_set_device(payload: dict | None = None):
if _runtime is None:
return JSONResponse({"ok": False, "error": "not ready"}, status_code=503)
body = payload if isinstance(payload, dict) else {}
device_select = str(body.get("device_select") or "").strip()
device_override = str(body.get("device_override") or "").strip()
raw = device_override if device_override else device_select
device = raw if raw else None
from util.audio_run_persist import read_audio_run_state, write_audio_run_state
prev = read_audio_run_state()
write_audio_run_state(
enabled=bool(prev.get("enabled")),
device=device if raw else None,
device_override=device_override,
device_select=device_select,
)
return {"ok": True, "audio_run": read_audio_run_state()}
@api.post("/api/audio/stop")
async def audio_stop():
if _runtime is None:
return JSONResponse({"ok": False, "error": "not ready"}, status_code=503)
_runtime.audio_detector.stop()
from util.audio_run_persist import write_audio_run_state
write_audio_run_state(enabled=False)
_notify_audio_status_sse()
return {"ok": True, "status": _runtime.audio_detector.status()}
@api.post("/api/audio/reset")
async def audio_reset():
if _runtime is None:
return JSONResponse({"ok": False, "error": "not ready"}, status_code=503)
ok = _runtime.audio_detector.reset_tracking()
if not ok:
return JSONResponse(
{"ok": False, "error": "Audio detector is not running"},
status_code=409,
)
_notify_audio_status_sse()
return {"ok": True, "status": _runtime.audio_detector.status()}
@api.post("/api/audio/anchor-bar")
async def audio_anchor_bar():
if _runtime is None:
return JSONResponse({"ok": False, "error": "not ready"}, status_code=503)
ok = _runtime.audio_detector.anchor_bar_phase()
if not ok:
return JSONResponse(
{"ok": False, "error": "Audio detector is not running"},
status_code=409,
)
_notify_audio_status_sse()
return {"ok": True, "status": _runtime.audio_detector.status()}
@api.get("/api/audio/status")
async def audio_status():
if _runtime is None:
return JSONResponse({"error": "not ready"}, status_code=503)
return {"status": audio_status_payload(_runtime.audio_detector, _runtime.settings)}
@api.get("/api/audio/events")
async def audio_events(request: Request):
if _runtime is None:
return JSONResponse({"error": "not ready"}, status_code=503)
from util.beat_status_broadcaster import (
initial_sse_line,
register_sse_client,
unregister_sse_client,
)
async def stream():
queue: asyncio.Queue[str] = asyncio.Queue(maxsize=8)
await register_sse_client(queue)
try:
yield await initial_sse_line()
while True:
if await request.is_disconnected():
break
try:
line = await asyncio.wait_for(queue.get(), timeout=30.0)
except asyncio.TimeoutError:
yield ": keepalive\n\n"
continue
yield line
finally:
await unregister_sse_client(queue)
return StreamingResponse(
stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@api.websocket("/ws")
async def ws_endpoint(websocket: WebSocket):
from util.device_status_broadcaster import (
broadcast_device_tcp_snapshot_to,
register_device_status_ws,
unregister_device_status_ws,
)
await websocket.accept()
await register_device_status_ws(websocket)
await broadcast_device_tcp_snapshot_to(websocket)
bridge = _bridge()
try:
while True:
data = await websocket.receive()
if data.get("type") == "websocket.disconnect":
break
if "bytes" in data and data["bytes"] is not None:
await bridge.send(bytes(data["bytes"]))
continue
text = data.get("text")
if text is None:
continue
try:
parsed = json.loads(text)
addr = parsed.pop("to", None)
await bridge.send(parsed, addr=addr)
except json.JSONDecodeError:
pass
except Exception:
try:
await websocket.send_text(json.dumps({"error": "Send failed"}))
except Exception:
pass
except WebSocketDisconnect:
pass
except Exception:
pass
finally:
await unregister_device_status_ws(websocket)
return api
app = create_application()

84
src/http_responses.py Normal file
View File

@@ -0,0 +1,84 @@
"""Response helpers for FastAPI controllers."""
from __future__ import annotations
import os
from typing import Any
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
_SRC_DIR = os.path.dirname(os.path.abspath(__file__))
def J(
data: Any,
status_code: int = 200,
*,
headers: dict[str, str] | None = None,
) -> Response:
"""JSON response (accepts dict or JSON string)."""
if isinstance(data, str):
return Response(
content=data,
status_code=status_code,
media_type="application/json",
headers=headers,
)
return JSONResponse(content=data, status_code=status_code, headers=headers)
async def read_json(request) -> dict:
try:
body = await request.json()
except Exception:
return {}
return body if isinstance(body, dict) else {}
def send_file(relative_path: str) -> FileResponse:
path = os.path.join(_SRC_DIR, relative_path)
return FileResponse(path)
def send_html_file(relative_path: str, *, inject: str | None = None) -> HTMLResponse:
path = os.path.join(_SRC_DIR, relative_path)
with open(path, encoding="utf-8") as f:
html = f.read()
if inject and "</body>" in html:
html = html.replace("</body>", inject + "\n</body>", 1)
return HTMLResponse(content=html)
def html_response(content: str, status_code: int = 200) -> HTMLResponse:
return HTMLResponse(content=content, status_code=status_code)
def plain(content: str, status_code: int = 200) -> Response:
return Response(
content=content,
status_code=status_code,
media_type="text/plain; charset=utf-8",
)
def empty(status_code: int = 204) -> Response:
return Response(status_code=status_code)
def J_cookie(
data: Any,
status_code: int = 200,
*,
name: str,
value: str,
max_age: int | None = None,
path: str = "/",
samesite: str = "lax",
) -> Response:
resp = J(data, status_code)
kwargs: dict[str, Any] = {"path": path, "samesite": samesite}
if max_age is not None:
kwargs["max_age"] = max_age
resp.set_cookie(name, value, **kwargs)
return resp

117
src/http_session.py Normal file
View File

@@ -0,0 +1,117 @@
"""Signed-cookie sessions for the web UI."""
from __future__ import annotations
import inspect
from typing import Any, Callable
import jwt
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
from settings import get_settings
_COOKIE = "session"
_ALGORITHM = "HS256"
class SessionDict(dict):
"""Session mapping with ``save()`` / ``delete()`` for cookie persistence."""
def __init__(self, request: Request, data: dict | None = None):
super().__init__(data or {})
self._request = request
self._save = False
self._delete = False
def save(self) -> None:
self._save = True
self._delete = False
def delete(self) -> None:
self._delete = True
self._save = False
def _secret_key() -> str:
return str(
get_settings().get(
"session_secret_key",
"led-controller-secret-key-change-in-production",
)
)
def encode_session(payload: dict) -> str:
return jwt.encode(payload, _secret_key(), algorithm=_ALGORITHM)
def decode_session(token: str) -> dict:
try:
data = jwt.decode(token, _secret_key(), algorithms=[_ALGORITHM])
return data if isinstance(data, dict) else {}
except jwt.PyJWTError:
return {}
def get_session(request: Request) -> SessionDict:
session = getattr(request.state, "session", None)
if session is None:
cookie = request.cookies.get(_COOKIE)
data = decode_session(cookie) if cookie else {}
session = SessionDict(request, data)
request.state.session = session
return session
def with_session(handler: Callable) -> Callable:
sig = inspect.signature(handler)
public_params = [
p
for name, p in sig.parameters.items()
if name not in ("request", "session")
]
if "request" in sig.parameters:
req_param = sig.parameters["request"].replace(annotation=Request)
else:
req_param = inspect.Parameter(
"request", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=Request
)
wrapper_sig = inspect.Signature([req_param, *public_params])
async def wrapper(request: Request, *args: Any, **kwargs: Any):
session = get_session(request)
return await handler(request, session, *args, **kwargs)
wrapper.__name__ = handler.__name__
wrapper.__doc__ = handler.__doc__
wrapper.__module__ = handler.__module__
wrapper.__qualname__ = handler.__qualname__
wrapper.__signature__ = wrapper_sig # type: ignore[attr-defined]
return wrapper
def _apply_session_cookie(request: Request, response: Response) -> Response:
session: SessionDict | None = getattr(request.state, "session", None)
if session is None:
return response
if session._delete:
response.delete_cookie(_COOKIE, path="/", httponly=True)
elif session._save:
response.set_cookie(
_COOKIE,
encode_session(dict(session)),
path="/",
httponly=True,
)
return response
class SessionMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
cookie = request.cookies.get(_COOKIE)
data = decode_session(cookie) if cookie else {}
request.state.session = SessionDict(request, data)
response = await call_next(request)
return _apply_session_cookie(request, response)

View File

@@ -1,413 +0,0 @@
import asyncio
import errno
import json
import os
import signal
import socket
import threading
import traceback
from microdot import Microdot, send_file
from microdot.websocket import with_websocket
from microdot.session import Session
from settings import Settings
import controllers.preset as preset
import controllers.profile as profile
import controllers.group as group
import controllers.sequence as sequence
import controllers.zone as zone
import controllers.palette as palette
import controllers.scene as scene
import controllers.pattern as pattern
import controllers.settings as settings_controller
import controllers.device as device_controller
import controllers.led_tool as led_tool_controller
from models.transport import get_sender, set_sender, get_current_sender
from models.device import Device, normalize_mac
from models import wifi_ws_clients as tcp_client_registry
from util.device_status_broadcaster import (
broadcast_device_tcp_snapshot_to,
broadcast_device_tcp_status,
register_device_status_ws,
unregister_device_status_ws,
)
_tcp_device_lock = threading.Lock()
DISCOVERY_UDP_PORT = 8766
def _register_udp_device_sync(
device_name: str, peer_ip: str, mac, device_type=None
) -> None:
with _tcp_device_lock:
try:
d = Device()
did, persisted = d.upsert_wifi_tcp_client(
device_name, peer_ip, mac, device_type=device_type
)
if did and persisted:
print(
f"UDP device registered: mac={did} name={device_name!r} ip={peer_ip!r}"
)
except Exception as e:
print(f"UDP device registry failed: {e}")
traceback.print_exception(type(e), e, e.__traceback__)
async def _handle_udp_discovery(sock, udp_holder=None) -> None:
while True:
try:
data, addr = await asyncio.get_running_loop().sock_recvfrom(sock, 2048)
except asyncio.CancelledError:
raise
except OSError as e:
if udp_holder and udp_holder.get("closing"):
break
print(f"[UDP] recv failed: {e!r}")
continue
except Exception as e:
print(f"[UDP] recv failed: {e!r}")
continue
peer_ip = addr[0] if addr else ""
line = data.split(b"\n", 1)[0].strip()
if line:
try:
parsed = json.loads(line.decode("utf-8"))
if isinstance(parsed, dict):
dns = str(parsed.get("device_name") or "").strip()
mac = parsed.get("mac") or parsed.get("device_mac") or parsed.get(
"sta_mac"
)
device_type = parsed.get("type") or parsed.get("device_type")
if dns and normalize_mac(mac):
_register_udp_device_sync(dns, peer_ip, mac, device_type)
if str(parsed.get("v") or "") == "1":
tcp_client_registry.ensure_driver_connection(peer_ip)
except (UnicodeError, ValueError, TypeError):
pass
try:
await asyncio.get_running_loop().sock_sendto(sock, data, addr)
except Exception as e:
print(f"[UDP] echo send failed: {e!r}")
def _prime_wifi_outbound_driver_connections() -> None:
"""
For each WiFi device in the registry with a usable IPv4, start (or keep) the
outbound WebSocket task. The client loop reconnects automatically if the link
drops. Presets are not pushed automatically; use Send Presets / profile apply.
"""
n = 0
try:
dev = Device()
for mac_key, doc in list(dev.items()):
if not isinstance(doc, dict):
continue
if doc.get("transport") != "wifi":
continue
ip = _ipv4_address(str(doc.get("address") or ""))
if not ip:
continue
tcp_client_registry.ensure_driver_connection(ip)
n += 1
except Exception as e:
print(f"[startup] Wi-Fi driver connection prime failed: {e!r}")
traceback.print_exception(type(e), e, e.__traceback__)
return
if n:
print(f"[startup] primed outbound WebSocket for {n} Wi-Fi driver(s)")
def _ipv4_address(addr: str) -> str | None:
"""Return dotted IPv4 string or None (hostnames skipped for UDP nudge)."""
s = (addr or "").strip()
if not s:
return None
parts = s.split(".")
if len(parts) != 4:
return None
try:
nums = [int(p) for p in parts]
except ValueError:
return None
if not all(0 <= n <= 255 for n in nums):
return None
return s
async def _periodic_wifi_driver_hello_loop(settings, udp_holder) -> None:
"""
While a registered Wi-Fi driver has no outbound WebSocket, send a short JSON hello on
UDP discovery port so the device can announce itself and we can reconnect.
"""
try:
interval = float(settings.get("wifi_driver_hello_interval_s", 10.0))
except (TypeError, ValueError):
interval = 10.0
if interval <= 0:
return
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setblocking(False)
loop = asyncio.get_running_loop()
try:
while True:
await asyncio.sleep(interval)
if udp_holder.get("closing"):
break
try:
dev = Device()
except Exception as e:
print(f"[hello] device list failed: {e!r}")
continue
for _mac_key, doc in list(dev.items()):
if not isinstance(doc, dict):
continue
if doc.get("transport") != "wifi":
continue
ip = _ipv4_address(str(doc.get("address") or ""))
if not ip:
continue
if tcp_client_registry.tcp_client_connected(ip):
continue
name = (doc.get("name") or "").strip()
mac = normalize_mac(doc.get("id") or _mac_key)
if not name or not mac:
continue
line = (
json.dumps(
{"m": "hello", "device_name": name, "mac": mac},
separators=(",", ":"),
)
+ "\n"
)
try:
await loop.sock_sendto(
sock, line.encode("utf-8"), (ip, DISCOVERY_UDP_PORT)
)
except OSError as e:
print(f"[hello] UDP to {ip!r} failed: {e!r}")
finally:
try:
sock.close()
except OSError:
pass
async def _run_udp_discovery_server(udp_holder=None) -> None:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setblocking(False)
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
except (AttributeError, OSError):
pass
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
except (AttributeError, OSError):
pass
sock.bind(("0.0.0.0", DISCOVERY_UDP_PORT))
if udp_holder is not None:
udp_holder["sock"] = sock
print(f"UDP discovery listening on 0.0.0.0:{DISCOVERY_UDP_PORT}")
try:
await _handle_udp_discovery(sock, udp_holder)
finally:
if udp_holder is not None:
udp_holder.pop("sock", None)
try:
sock.close()
except Exception:
pass
async def _send_bridge_wifi_channel(settings, sender):
"""Tell the serial ESP32 bridge to set STA channel (settings wifi_channel); not forwarded as ESP-NOW."""
try:
ch = int(settings.get("wifi_channel", 6))
except (TypeError, ValueError):
ch = 6
ch = max(1, min(11, ch))
payload = json.dumps({"m": "bridge", "ch": ch}, separators=(",", ":"))
try:
await sender.send(payload, addr="ffffffffffff")
print(f"[startup] bridge Wi-Fi channel -> {ch}")
except Exception as e:
print(f"[startup] bridge channel message failed: {e}")
async def main(port=80):
settings = Settings()
print(settings)
print("Starting")
# 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'),
('/zones', zone, 'zone'),
('/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(zone.controller, '/zones')
app.mount(palette.controller, '/palettes')
app.mount(scene.controller, '/scenes')
app.mount(pattern.controller, '/patterns')
app.mount(settings_controller.controller, '/settings')
app.mount(device_controller.controller, '/devices')
app.mount(led_tool_controller.controller, '/led-tool')
tcp_client_registry.set_settings(settings)
tcp_client_registry.set_tcp_status_broadcaster(broadcast_device_tcp_status)
# 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):
await register_device_status_ws(ws)
await broadcast_device_tcp_snapshot_to(ws)
try:
while True:
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
finally:
await unregister_device_status_ws(ws)
# Touch Device singleton early so db/device.json exists before first UDP hello.
Device()
await _send_bridge_wifi_channel(settings, sender)
_prime_wifi_outbound_driver_connections()
udp_holder = {"closing": False}
loop = asyncio.get_running_loop()
def _graceful_shutdown(*_args):
print("[server] shutting down...")
udp_holder["closing"] = True
u = udp_holder.get("sock")
if u is not None:
try:
u.close()
except OSError:
pass
tcp_client_registry.cancel_all_driver_tasks()
if getattr(app, "server", None) is not None:
app.shutdown()
shutdown_handlers_registered = False
try:
try:
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, _graceful_shutdown)
shutdown_handlers_registered = True
except (NotImplementedError, RuntimeError):
pass
# Await HTTP + UDP discovery; bind failures (e.g. port 80 in use) surface here.
try:
await asyncio.gather(
app.start_server(host="0.0.0.0", port=port),
_run_udp_discovery_server(udp_holder),
_periodic_wifi_driver_hello_loop(settings, udp_holder),
)
except OSError as e:
if e.errno == errno.EADDRINUSE:
print(
f"[server] bind failed (address already in use): {e!s}\n"
f"[server] HTTP is configured for port {port} (env PORT). "
f"Stop the other process or use a free port, e.g. PORT=8080 pipenv run run"
)
raise
finally:
srv = getattr(app, "server", None)
if srv is not None:
try:
srv.close()
await srv.wait_closed()
except Exception:
pass
try:
app.server = None
except Exception:
pass
if shutdown_handlers_registered:
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.remove_signal_handler(sig)
except (NotImplementedError, OSError, ValueError):
pass
if __name__ == "__main__":
import os
port = int(os.environ.get("PORT", 80))
asyncio.run(main(port=port))

View File

@@ -0,0 +1,199 @@
"""Persistent USB/serial client to the ESP-NOW bridge."""
from __future__ import annotations
import asyncio
import json
from typing import Awaitable, Callable, Optional, Union
import serial
import serial_asyncio
from util.bridge_serial_frame import feed_serial_buffer, pack_serial_frame
from util.espnow_wire import parse_ws_frame
UplinkHandler = Callable[[bytes, bytes], Awaitable[None]]
class BridgeSerialClient:
def __init__(
self,
port: str,
*,
baudrate: int = 921600,
reconnect_delay_s: float = 2.0,
):
self._port = str(port or "").strip()
self._baudrate = int(baudrate)
self._reconnect_delay_s = reconnect_delay_s
self._reader: Optional[asyncio.StreamReader] = None
self._writer: Optional[asyncio.StreamWriter] = None
self._send_lock = asyncio.Lock()
self._uplink_handler: Optional[UplinkHandler] = None
self._task: Optional[asyncio.Task] = None
self._read_task: Optional[asyncio.Task] = None
self._connected = asyncio.Event()
self._disconnect_event = asyncio.Event()
self._stop = False
self._read_buf = bytearray()
self._bad_frame_count = 0
def set_uplink_handler(self, handler: Optional[UplinkHandler]) -> None:
self._uplink_handler = handler
def _signal_disconnect(self) -> None:
self._connected.clear()
self._disconnect_event.set()
async def _close_serial(self) -> None:
reader = self._reader
writer = self._writer
self._reader = None
self._writer = None
if writer is not None:
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
async def _read_loop(self) -> None:
try:
while not self._disconnect_event.is_set() and not self._stop:
reader = self._reader
if reader is None:
break
try:
chunk = await reader.read(4096)
except (serial.SerialException, OSError, asyncio.IncompleteReadError) as e:
print(f"[bridge-serial] read error: {e!r}")
break
if not chunk:
await asyncio.sleep(0.01)
continue
frames = feed_serial_buffer(self._read_buf, chunk)
handler = self._uplink_handler
if handler is None:
continue
for frame in frames:
try:
peer, pkt, _bcast = parse_ws_frame(frame)
except ValueError:
self._bad_frame_count += 1
if self._bad_frame_count <= 3:
print(
f"[bridge-serial] ignored frame ({len(frame)} B), "
f"expected ws uplink header"
)
continue
self._bad_frame_count = 0
await handler(peer, pkt)
except asyncio.CancelledError:
raise
finally:
self._signal_disconnect()
async def run_forever(self) -> None:
while not self._stop:
try:
await self._connect_once()
except asyncio.CancelledError:
raise
except Exception as e:
print(f"[bridge-serial] connection error: {e!r}")
self._signal_disconnect()
self._disconnect_event.clear()
await self._close_serial()
if self._stop:
break
print("[bridge-serial] disconnected, reconnecting...")
await asyncio.sleep(self._reconnect_delay_s)
async def _connect_once(self) -> None:
if not self._port:
raise serial.SerialException("serial port not configured")
print(f"[bridge-serial] opening {self._port!r} @ {self._baudrate}")
self._read_buf.clear()
self._disconnect_event.clear()
reader, writer = await serial_asyncio.open_serial_connection(
url=self._port,
baudrate=self._baudrate,
exclusive=True,
)
self._reader = reader
self._writer = writer
self._connected.set()
self._read_task = asyncio.create_task(self._read_loop())
print("[bridge-serial] connected")
try:
await self._disconnect_event.wait()
finally:
read_task = self._read_task
self._read_task = None
if read_task is not None:
read_task.cancel()
try:
await read_task
except asyncio.CancelledError:
pass
await self._close_serial()
async def wait_connected(self, timeout: float = 30.0) -> bool:
try:
await asyncio.wait_for(self._connected.wait(), timeout=timeout)
writer = self._writer
return writer is not None and not writer.is_closing()
except asyncio.TimeoutError:
return False
async def send_packet(self, packet: Union[bytes, str, dict]) -> bool:
if isinstance(packet, dict):
packet = json.dumps(packet, separators=(",", ":"))
if isinstance(packet, str):
packet = packet.encode("utf-8")
if not await self.wait_connected(timeout=30.0):
return False
writer = self._writer
if writer is None or writer.is_closing():
return False
frame = pack_serial_frame(bytes(packet))
async with self._send_lock:
try:
writer = self._writer
if writer is None or writer.is_closing():
return False
writer.write(frame)
await writer.drain()
return True
except (serial.SerialException, OSError, ConnectionError) as e:
print(f"[bridge-serial] send failed: {e!r}")
self._signal_disconnect()
return False
def start(self) -> asyncio.Task:
self._stop = False
if self._task is None or self._task.done():
self._task = asyncio.create_task(self.run_forever())
return self._task
def stop(self) -> None:
self._stop = True
self._signal_disconnect()
task = self._task
if task is not None and not task.done():
task.cancel()
_client: Optional[BridgeSerialClient] = None
def get_bridge_serial_client() -> Optional[BridgeSerialClient]:
return _client
def init_bridge_serial_client(port: str, *, baudrate: int = 921600) -> BridgeSerialClient:
global _client
if _client is not None:
_client.stop()
_client = BridgeSerialClient(port, baudrate=baudrate)
return _client

View File

@@ -0,0 +1,170 @@
"""Persistent WebSocket client to the ESP-NOW bridge."""
from __future__ import annotations
import asyncio
import json
from typing import Awaitable, Callable, Optional, Union
import websockets
from websockets.exceptions import ConnectionClosed
from settings import WIFI_CHANNEL_DEFAULT
from util.espnow_wire import parse_ws_frame
UplinkHandler = Callable[[bytes, bytes], Awaitable[None]]
class BridgeWsClient:
def __init__(
self, url: str, *, wifi_channel: int = WIFI_CHANNEL_DEFAULT, reconnect_delay_s: float = 2.0
):
self._url = url.strip()
self._wifi_channel = wifi_channel
self._reconnect_delay_s = reconnect_delay_s
self._ws: Optional[websockets.WebSocketClientProtocol] = None
self._send_lock = asyncio.Lock()
self._uplink_handler: Optional[UplinkHandler] = None
self._task: Optional[asyncio.Task] = None
self._connected = asyncio.Event()
self._disconnect_event = asyncio.Event()
self._stop = False
def set_uplink_handler(self, handler: Optional[UplinkHandler]) -> None:
self._uplink_handler = handler
def _signal_disconnect(self) -> None:
self._connected.clear()
self._disconnect_event.set()
async def _close_ws(self) -> None:
ws = self._ws
self._ws = None
if ws is not None:
try:
await ws.close()
except Exception:
pass
async def run_forever(self) -> None:
while not self._stop:
try:
await self._connect_once()
except asyncio.CancelledError:
raise
except Exception as e:
print(f"[bridge] connection error: {e!r}")
self._signal_disconnect()
self._disconnect_event.clear()
await self._close_ws()
if self._stop:
break
print("[bridge] disconnected, reconnecting...")
await asyncio.sleep(self._reconnect_delay_s)
async def _reader_loop(self) -> None:
ws = self._ws
if ws is None:
return
try:
async for message in ws:
if self._uplink_handler is None:
continue
if isinstance(message, str):
message = message.encode("utf-8")
if not message:
continue
try:
peer, pkt, _bcast = parse_ws_frame(message)
except ValueError:
continue
await self._uplink_handler(peer, pkt)
except ConnectionClosed:
pass
finally:
self._signal_disconnect()
async def _connect_once(self) -> None:
print(f"[bridge] connecting to {self._url} (channel {self._wifi_channel} on bridge)")
async with websockets.connect(self._url, ping_interval=20, ping_timeout=20) as ws:
self._ws = ws
self._connected.set()
self._disconnect_event.clear()
print("[bridge] connected")
reader = asyncio.create_task(self._reader_loop())
try:
while not self._disconnect_event.is_set():
await asyncio.sleep(0.5)
finally:
reader.cancel()
try:
await reader
except asyncio.CancelledError:
pass
except Exception:
pass
async def wait_connected(self, timeout: float = 30.0) -> bool:
try:
await asyncio.wait_for(self._connected.wait(), timeout=timeout)
return True
except asyncio.TimeoutError:
return False
async def send_packet(self, packet: Union[bytes, str, dict]) -> bool:
if isinstance(packet, dict):
packet = json.dumps(packet, separators=(",", ":"))
if isinstance(packet, str):
packet = packet.encode("utf-8")
if not await self.wait_connected(timeout=30.0):
return False
ws = self._ws
if ws is None:
return False
async with self._send_lock:
try:
await ws.send(packet)
return True
except (ConnectionClosed, OSError) as e:
print(f"[bridge] send failed: {e!r}")
self._signal_disconnect()
await self._close_ws()
return False
async def send_espnow(
self,
packet: bytes,
*,
peer_mac: Optional[bytes] = None,
broadcast: bool = False,
) -> bool:
del peer_mac, broadcast
return await self.send_packet(packet)
def start(self) -> asyncio.Task:
self._stop = False
if self._task is None or self._task.done():
self._task = asyncio.create_task(self.run_forever())
return self._task
def stop(self) -> None:
self._stop = True
self._signal_disconnect()
task = self._task
if task is not None and not task.done():
task.cancel()
_client: Optional[BridgeWsClient] = None
def get_bridge_client() -> Optional[BridgeWsClient]:
return _client
def init_bridge_client(url: str, *, wifi_channel: int = WIFI_CHANNEL_DEFAULT) -> BridgeWsClient:
global _client
if _client is not None:
_client.stop()
_client = BridgeWsClient(url, wifi_channel=wifi_channel)
return _client

View File

@@ -233,6 +233,68 @@ class Device(Model):
def list(self):
return list(self.keys())
def upsert_espnow_announced(
self,
mac,
device_name,
*,
device_type="led",
num_leds=None,
color_order=None,
startup_mode=None,
brightness=None,
):
"""
Register or update an ESP-NOW device from a binary ANNOUNCE.
Returns ``(mac_hex | None, persisted)``.
"""
mac_hex = normalize_mac(mac)
if not mac_hex:
return None, False
name = (device_name or "").strip()
if not name:
return None, False
resolved_type = validate_device_type(device_type)
meta = {}
if num_leds is not None:
meta["num_leds"] = int(num_leds)
if color_order is not None:
meta["color_order"] = str(color_order)
if startup_mode is not None:
meta["startup_mode"] = str(startup_mode)
if brightness is not None:
meta["brightness"] = int(brightness)
if mac_hex in self:
prev = self[mac_hex]
merged = dict(prev)
merged["name"] = name
merged["type"] = resolved_type
merged["transport"] = "espnow"
merged["address"] = mac_hex
merged["id"] = mac_hex
merged.update({k: v for k, v in meta.items() if v is not None})
if merged == prev:
return mac_hex, False
self[mac_hex] = merged
self.save()
return mac_hex, True
row = {
"id": mac_hex,
"name": name,
"type": resolved_type,
"transport": "espnow",
"address": mac_hex,
"default_pattern": None,
"zones": [],
}
row.update({k: v for k, v in meta.items() if v is not None})
self[mac_hex] = row
self.save()
return mac_hex, True
def upsert_wifi_tcp_client(self, device_name, peer_ip, mac, device_type=None):
"""
Register or update a Wi-Fi client by **MAC** (storage id). Updates **name**,

View File

@@ -1,14 +1,75 @@
from models.model import Model
class Group(Model):
"""Device groups (members + optional WiFi driver defaults); also pattern fields for sequences.
Omit ``profile_id`` (or set it null) for a **shared** group: every profile can attach it to
zones and sequences. Set ``profile_id`` to a profile id to show the group only when that
profile is active (still one global record in ``group.json``).
"""
def __init__(self):
super().__init__()
def load(self):
super().load()
changed = False
for gid, doc in list(self.items()):
if not isinstance(doc, dict):
continue
if self._migrate_record(doc):
changed = True
if changed:
self.save()
def _migrate_record(self, doc):
changed = False
raw_dev = doc.get("devices")
if raw_dev is None:
doc["devices"] = []
changed = True
elif isinstance(raw_dev, list):
norm = []
for x in raw_dev:
if x is None:
continue
s = str(x).strip().lower().replace(":", "").replace("-", "")
if len(s) == 12 and all(c in "0123456789abcdef" for c in s):
norm.append(s)
else:
norm.append(str(x).strip())
if norm != raw_dev:
doc["devices"] = norm
changed = True
for key in (
"wifi_driver_display_name",
"wifi_driver_num_leds",
"wifi_color_order",
"wifi_startup_mode",
):
if key not in doc:
doc[key] = None
changed = True
if "output_brightness" not in doc:
doc["output_brightness"] = 255
changed = True
if "bridge_id" not in doc:
doc["bridge_id"] = None
changed = True
return changed
def create(self, name=""):
next_id = self.get_next_id()
self[next_id] = {
"name": name,
"devices": [],
"wifi_driver_display_name": None,
"wifi_driver_num_leds": None,
"wifi_color_order": None,
"wifi_startup_mode": None,
"output_brightness": 255,
"bridge_id": None,
"pattern": "on",
"colors": ["000000", "FF0000"],
"brightness": 100,
@@ -22,7 +83,7 @@ class Group(Model):
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0
"n8": 0,
}
self.save()
return next_id

View File

@@ -15,6 +15,9 @@ class Preset(Model):
if default_profile_id is not None:
preset_data["profile_id"] = str(default_profile_id)
changed = True
if isinstance(preset_data, dict) and "group_ids" in preset_data:
preset_data.pop("group_ids", None)
changed = True
if changed:
self.save()
except Exception:
@@ -26,6 +29,7 @@ class Preset(Model):
"name": "",
"pattern": "",
"colors": [],
"background": "#000000",
"brightness": 0,
"delay": 0,
"n1": 0,
@@ -36,6 +40,7 @@ class Preset(Model):
"n6": 0,
"n7": 0,
"n8": 0,
"manual_beat_n": 1,
"profile_id": str(profile_id) if profile_id is not None else None,
}
self.save()

148
src/models/sequence.py Normal file
View File

@@ -0,0 +1,148 @@
from models.model import Model
class Sequence(Model):
def load(self):
super().load()
self._migrate_after_load()
def _migrate_after_load(self):
try:
from models.profile import Profile
profiles = Profile()
profile_list = profiles.list()
default_profile_id = profile_list[0] if profile_list else None
except Exception:
default_profile_id = None
changed = False
for _sid, doc in list(self.items()):
if not isinstance(doc, dict):
continue
if not isinstance(doc.get("steps"), list):
presets = doc.get("presets")
if isinstance(presets, list) and presets:
doc["steps"] = [
{"preset_id": str(p), "group_ids": []} for p in presets
]
else:
doc["steps"] = []
changed = True
if "step_duration_ms" not in doc:
dur = doc.get("sequence_duration")
doc["step_duration_ms"] = (
int(dur) if isinstance(dur, (int, float)) else 3000
)
changed = True
if "loop" not in doc:
doc["loop"] = bool(doc.get("sequence_loop", False))
changed = True
if "name" not in doc:
doc["name"] = str(doc.get("group_name") or "")
changed = True
if "profile_id" not in doc and default_profile_id is not None:
doc["profile_id"] = str(default_profile_id)
changed = True
if not isinstance(doc.get("lanes"), list):
steps = doc.get("steps")
if isinstance(steps, list) and steps:
doc["lanes"] = [list(steps)]
else:
doc["lanes"] = [[]]
changed = True
if "group_ids" not in doc or not isinstance(doc.get("group_ids"), list):
doc["group_ids"] = []
changed = True
if doc.get("advance_mode") != "beats":
doc["advance_mode"] = "beats"
changed = True
if "sequence_transition" not in doc:
doc["sequence_transition"] = 500
changed = True
# Ensure each step has beats (beat-based advance); default 1
for lane in doc.get("lanes") or []:
if not isinstance(lane, list):
continue
for step in lane:
if not isinstance(step, dict):
continue
if "beats" not in step:
step["beats"] = 1
changed = True
# Per-lane group ids (parallel to ``lanes``)
lanes_list = [x for x in (doc.get("lanes") or []) if isinstance(x, list)]
n_lanes = len(lanes_list)
lg = doc.get("lanes_group_ids")
if n_lanes and (not isinstance(lg, list) or len(lg) != n_lanes):
shared = doc.get("group_ids") if isinstance(doc.get("group_ids"), list) else []
shared_s = [str(x).strip() for x in shared if x is not None and str(x).strip()]
if n_lanes == 1 and lanes_list[0]:
first = lanes_list[0][0] if isinstance(lanes_list[0][0], dict) else {}
step_g = (
first.get("group_ids")
if isinstance(first.get("group_ids"), list)
else []
)
step_s = [
str(x).strip() for x in step_g if x is not None and str(x).strip()
]
doc["lanes_group_ids"] = [step_s if step_s else list(shared_s)]
else:
doc["lanes_group_ids"] = [list(shared_s) for _ in range(n_lanes)]
changed = True
if changed:
self.save()
def create(self, profile_id=None):
next_id = self.get_next_id()
self[next_id] = {
"name": "",
"profile_id": str(profile_id) if profile_id is not None else None,
"group_ids": [],
"lanes": [[]],
"lanes_group_ids": [[]],
"advance_mode": "beats",
"steps": [],
"step_duration_ms": 3000,
"sequence_transition": 500,
"loop": True,
}
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 not isinstance(data, dict):
return False
data = dict(data)
steps = data.get("steps")
lanes = data.get("lanes")
if isinstance(steps, list) and steps:
lanes_ok = (
isinstance(lanes, list)
and lanes
and any(isinstance(x, list) and len(x) > 0 for x in lanes)
)
if not lanes_ok:
data["lanes"] = [list(steps)]
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())

View File

@@ -1,44 +0,0 @@
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())

View File

@@ -1,68 +1,171 @@
import asyncio
"""Transport to LED drivers via ESP-NOW bridge WebSocket or USB serial."""
import json
from typing import Any, Dict, List, Optional, Union
from models.bridge_serial_client import get_bridge_serial_client
from models.bridge_ws_client import get_bridge_client
from util.bridge_envelope import (
BROADCAST_HEX,
BROADCAST_MAC,
build_devices_envelope,
format_mac_key,
is_broadcast_mac,
normalize_mac_key,
)
from util.espnow_wire import WIRE_MAGIC
# 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)
self._write_lock = asyncio.Lock()
class NullBridge:
"""No bridge configured."""
async def send(self, data, addr=None):
mac = _parse_mac(addr) if addr is not None else self._default_addr
payload = _encode_payload(data)
async with self._write_lock:
await _to_thread(self._serial.write, mac + payload)
return True
return False
_current_sender = None
class BridgeWsTransport:
"""Send v1 JSON or devices envelope via bridge WebSocket."""
async def send(self, data: Union[bytes, str, Dict[str, Any]], addr=None) -> bool:
client = get_bridge_client()
if client is None:
return False
if isinstance(data, dict):
if data.get("v") == "1" and ("devices" in data or "dv" in data):
from util.v1_wire import compact_envelope
return await client.send_packet(compact_envelope(data))
packet = json.dumps(data, separators=(",", ":")).encode("utf-8")
elif isinstance(data, str):
packet = data.encode("utf-8")
elif isinstance(data, (bytes, bytearray)):
packet = bytes(data)
else:
return False
if not packet:
return False
if packet[0] == WIRE_MAGIC:
return await client.send_packet(packet)
if packet[0:1] != b"{":
return False
mac_key = _addr_to_envelope_key(addr)
if mac_key is None:
return await client.send_packet(packet)
try:
body = json.loads(packet.decode("utf-8"))
except (UnicodeError, ValueError, TypeError):
return False
if not isinstance(body, dict) or body.get("v") != "1":
return False
envelope = build_devices_envelope({mac_key: body})
return await client.send_packet(envelope)
async def send_envelope(self, envelope: Dict[str, Any]) -> bool:
client = get_bridge_client()
if client is None:
return False
return await client.send_packet(envelope)
def set_sender(sender):
global _current_sender
_current_sender = sender
class BridgeSerialTransport:
"""Send v1 JSON or devices envelope via bridge USB/serial."""
async def send(self, data: Union[bytes, str, Dict[str, Any]], addr=None) -> bool:
client = get_bridge_serial_client()
if client is None:
return False
if isinstance(data, dict):
if data.get("v") == "1" and ("devices" in data or "dv" in data):
from util.v1_wire import compact_envelope
return await client.send_packet(compact_envelope(data))
packet = json.dumps(data, separators=(",", ":")).encode("utf-8")
elif isinstance(data, str):
packet = data.encode("utf-8")
elif isinstance(data, (bytes, bytearray)):
packet = bytes(data)
else:
return False
if not packet:
return False
if packet[0] == WIRE_MAGIC:
return await client.send_packet(packet)
if packet[0:1] != b"{":
return False
mac_key = _addr_to_envelope_key(addr)
if mac_key is None:
return await client.send_packet(packet)
try:
body = json.loads(packet.decode("utf-8"))
except (UnicodeError, ValueError, TypeError):
return False
if not isinstance(body, dict) or body.get("v") != "1":
return False
envelope = build_devices_envelope({mac_key: body})
return await client.send_packet(envelope)
async def send_envelope(self, envelope: Dict[str, Any]) -> bool:
client = get_bridge_serial_client()
if client is None:
return False
return await client.send_packet(envelope)
def get_current_sender():
return _current_sender
def _addr_to_envelope_key(addr) -> Optional[str]:
if addr is None:
return BROADCAST_MAC
s = str(addr).strip().lower()
if is_broadcast_mac(s):
return BROADCAST_MAC
h = normalize_mac_key(s)
if h:
try:
return format_mac_key(h)
except ValueError:
return None
return None
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)
_current_bridge = None
def set_bridge(bridge):
global _current_bridge
_current_bridge = bridge
def get_current_bridge():
return _current_bridge
def get_bridge(settings):
mode = str(settings.get("bridge_transport") or "wifi").strip().lower()
if mode == "wifi":
url = str(settings.get("bridge_ws_url") or "").strip()
if not url:
print("[startup] bridge WiFi disabled (set bridge_ws_url in settings.json)")
return NullBridge()
print(f"[startup] ESP-NOW via bridge WebSocket {url!r}")
return BridgeWsTransport()
port = str(settings.get("bridge_serial_port") or "").strip()
if not port:
print(
"[startup] bridge serial disabled (set bridge_serial_port in settings.json)"
)
return NullBridge()
print(f"[startup] ESP-NOW via bridge USB serial {port!r}")
return BridgeSerialTransport()

View File

@@ -13,7 +13,6 @@ from websockets.exceptions import ConnectionClosed
_connections: dict[str, object] = {}
_send_locks: dict[str, asyncio.Lock] = {}
_tasks: dict[str, asyncio.Task] = {}
_unreachable_counts: dict[str, int] = {}
_settings = None
_tcp_status_broadcast = None
@@ -84,12 +83,41 @@ def prune_stale_tcp_writers() -> None:
_schedule_status_broadcast(ip, False)
def _global_brightness_message_text() -> str | None:
"""v1 JSON line for saved zone UI brightness; works with shipping driver firmware (applies ``b`` in RAM)."""
global _settings
if _settings is None:
return None
try:
b = int(_settings.get("global_brightness", 255))
except (TypeError, ValueError):
b = 255
b = max(0, min(255, b))
return json.dumps({"v": "1", "b": b})
async def sync_global_brightness_to_driver(ip: str) -> bool:
"""Push Pi-stored global brightness to one Wi-Fi driver over the outbound WebSocket."""
text = _global_brightness_message_text()
if not text:
return False
return await send_json_line_to_ip(ip, text)
async def broadcast_global_brightness_to_tcp_drivers() -> None:
"""Push saved global brightness to every connected Wi-Fi driver."""
text = _global_brightness_message_text()
if not text:
return
for ip in list_connected_ips():
await send_json_line_to_ip(ip, text)
def _register_ws(ip: str, ws) -> None:
key = normalize_tcp_peer_ip(ip)
if not key:
return
_connections[key] = ws
_unreachable_counts.pop(key, None)
if key not in _send_locks:
_send_locks[key] = asyncio.Lock()
_schedule_status_broadcast(key, True)
@@ -155,9 +183,9 @@ async def send_json_line_to_ip(ip: str, json_str: str) -> bool:
async def _recv_forward_loop(ip: str, ws) -> None:
from models.transport import get_current_sender
from models.transport import get_current_bridge
sender = get_current_sender()
bridge = get_current_bridge()
async for message in ws:
if isinstance(message, bytes):
try:
@@ -171,13 +199,13 @@ async def _recv_forward_loop(ip: str, ws) -> None:
if not text:
continue
print(f"[WS] recv {ip}: {text}")
if not sender:
if not bridge:
continue
try:
parsed = json.loads(text)
except json.JSONDecodeError:
try:
await sender.send(text)
await bridge.send(text)
except Exception:
pass
continue
@@ -185,16 +213,37 @@ async def _recv_forward_loop(ip: str, ws) -> None:
addr = parsed.pop("to", None)
payload = json.dumps(parsed) if parsed else "{}"
try:
await sender.send(payload, addr=addr)
await bridge.send(payload, addr=addr)
except Exception as e:
print(f"[WS] forward to bridge failed: {e}")
else:
try:
await sender.send(text)
await bridge.send(text)
except Exception:
pass
def _stagger_delay_s_for_ip(ip: str) -> float:
"""0 .. wifi_driver_connect_stagger_max_s based on last IPv4 octet (deterministic spread)."""
global _settings
if _settings is None:
return 0.0
try:
max_s = float(_settings.get("wifi_driver_connect_stagger_max_s", 2.5))
except (TypeError, ValueError):
max_s = 2.5
if max_s <= 0:
return 0.0
parts = str(ip).strip().split(".")
if len(parts) != 4:
return 0.0
try:
last = int(parts[3]) % 256
except ValueError:
return 0.0
return (last / 255.0) * max_s
async def _driver_connection_loop(ip: str) -> None:
global _settings
if _settings is None:
@@ -204,48 +253,63 @@ async def _driver_connection_loop(ip: str) -> None:
if not path.startswith("/"):
path = "/" + path
uri = f"ws://{ip}:{port}{path}"
retry_interval_s = 2.0
retry_window_s = 30.0
deadline = asyncio.get_running_loop().time() + retry_window_s
try:
while True:
now = asyncio.get_running_loop().time()
if now >= deadline:
print(
f"[WS] driver {ip} still unreachable after {int(retry_window_s)}s; "
"stopping retries until next hello"
)
break
retry_interval_s = float(_settings.get("wifi_driver_connect_retry_interval_s", 2.0))
except (TypeError, ValueError):
retry_interval_s = 2.0
retry_interval_s = max(0.2, retry_interval_s)
try:
max_boot_attempts = int(_settings.get("wifi_driver_initial_connect_attempts", 4))
except (TypeError, ValueError):
max_boot_attempts = 4
max_boot_attempts = max(1, max_boot_attempts)
try:
open_timeout = float(_settings.get("wifi_driver_ws_open_timeout", 45.0))
except (TypeError, ValueError):
open_timeout = 45.0
open_timeout = max(5.0, open_timeout)
stagger = _stagger_delay_s_for_ip(ip)
if stagger > 0:
await asyncio.sleep(stagger)
try:
for attempt in range(1, max_boot_attempts + 1):
try:
print(f"[WS] connecting to {uri!r}")
print(f"[WS] connecting to {uri!r} (attempt {attempt}/{max_boot_attempts})")
async with websockets.connect(
uri,
ping_interval=20,
ping_timeout=15,
open_timeout=30,
open_timeout=open_timeout,
) as ws:
_register_ws(ip, ws)
try:
await _recv_forward_loop(ip, ws)
finally:
unregister_tcp_writer(ip, ws)
return
except asyncio.CancelledError:
raise
except ConnectionClosed as e:
print(f"[WS] driver {ip} closed: {e}")
unregister_tcp_writer(ip, None)
return
except Exception as e:
if _benign_ws_connect_failure(e):
n = _unreachable_counts.get(ip, 0) + 1
_unreachable_counts[ip] = n
if n == 1 or (n % 30) == 0:
print(f"[WS] driver {ip} unreachable, retry in 2s: {e} (x{n})")
print(
f"[WS] driver {ip} unreachable (attempt {attempt}/{max_boot_attempts}): {e}"
)
else:
print(f"[WS] driver {ip} session error: {e!r}")
traceback.print_exception(type(e), e, e.__traceback__)
_unreachable_counts.pop(ip, None)
unregister_tcp_writer(ip, None)
await asyncio.sleep(retry_interval_s)
if attempt < max_boot_attempts:
await asyncio.sleep(retry_interval_s)
print(
f"[WS] driver {ip} still unreachable after {max_boot_attempts} attempt(s); "
"waiting for next UDP hello"
)
except asyncio.CancelledError:
unregister_tcp_writer(ip, None)
raise
@@ -254,10 +318,12 @@ async def _driver_connection_loop(ip: str) -> None:
def ensure_driver_connection(peer_ip: str) -> None:
"""Start (or keep) a background task that maintains ``ws://<ip>:port/ws``."""
"""Dial ``ws://<ip>:port/ws`` up to wifi_driver_initial_connect_attempts times (UDP hello only)."""
key = normalize_tcp_peer_ip(peer_ip)
if not key:
return
if tcp_client_connected(key):
return
t = _tasks.get(key)
if t is not None and not t.done():
return
@@ -278,4 +344,3 @@ def cancel_all_driver_tasks() -> None:
_schedule_status_broadcast(ip, False)
_connections.clear()
_send_locks.clear()
_unreachable_counts.clear()

View File

@@ -19,7 +19,11 @@ def _maybe_migrate_tab_json_to_zone():
class Zone(Model):
"""Preset layout row (stored in ``db/zone.json``); legacy storage was ``tab.json`` / Tab."""
"""Preset layout row (stored in ``db/zone.json``); legacy storage was ``tab.json`` / Tab.
Optional legacy ``content_kind`` (``\"presets\"`` / ``\"sequences\"``) is kept for older data;
zones may hold both preset tiles and ``sequence_ids``.
"""
def __init__(self):
if not getattr(Zone, "_migration_checked", False):
@@ -27,14 +31,93 @@ class Zone(Model):
Zone._migration_checked = True
super().__init__()
def create(self, name="", names=None, presets=None):
def load(self):
super().load()
changed = False
for zid, doc in list(self.items()):
if not isinstance(doc, dict):
continue
if "group_ids" not in doc:
doc["group_ids"] = []
changed = True
if "preset_group_ids" not in doc or not isinstance(doc.get("preset_group_ids"), dict):
doc["preset_group_ids"] = {}
changed = True
if "sequence_ids" not in doc or not isinstance(doc.get("sequence_ids"), list):
doc["sequence_ids"] = []
changed = True
if not self._normalized_content_kind(doc):
doc["content_kind"] = self._infer_content_kind(doc)
changed = True
if changed:
self.save()
@staticmethod
def _normalized_content_kind(doc):
if not isinstance(doc, dict):
return None
kind = doc.get("content_kind")
return kind if kind in ("presets", "sequences") else None
@staticmethod
def _preset_ids_in_doc(doc):
if not isinstance(doc, dict):
return []
flat = doc.get("presets_flat")
if isinstance(flat, list):
return [str(x) for x in flat if x is not None and str(x).strip()]
presets = doc.get("presets")
if not isinstance(presets, list) or not presets:
return []
if isinstance(presets[0], str):
return [str(x) for x in presets if x is not None and str(x).strip()]
if isinstance(presets[0], list):
out = []
for row in presets:
if isinstance(row, list):
out.extend(str(x) for x in row if x is not None and str(x).strip())
return out
return []
@classmethod
def _infer_content_kind(cls, doc):
kind = cls._normalized_content_kind(doc)
if kind:
return kind
seq_ids = [
str(x).strip()
for x in (doc.get("sequence_ids") or [])
if x is not None and str(x).strip()
]
preset_ids = cls._preset_ids_in_doc(doc)
if seq_ids and not preset_ids:
return "sequences"
return "presets"
def _enforce_content_kind_invariants(self, doc):
"""No-op: presets and sequences may coexist on one zone."""
_ = doc
def create(self, name="", names=None, presets=None, group_ids=None, content_kind=None):
next_id = self.get_next_id()
self[next_id] = {
gid_list = []
if isinstance(group_ids, list):
gid_list = [str(x).strip() for x in group_ids if x is not None and str(x).strip()]
doc = {
"name": name,
"names": names if names else [],
"group_ids": gid_list,
"preset_group_ids": {},
"presets": presets if presets else [],
"default_preset": None,
"brightness": 255,
}
if content_kind in ("presets", "sequences"):
doc["content_kind"] = content_kind
if "sequence_ids" not in doc:
doc["sequence_ids"] = []
self._enforce_content_kind_invariants(doc)
self[next_id] = doc
self.save()
return next_id
@@ -46,7 +129,8 @@ class Zone(Model):
id_str = str(id)
if id_str not in self:
return False
self[id_str].update(data)
patch = dict(data) if isinstance(data, dict) else {}
self[id_str].update(patch)
self.save()
return True

View File

@@ -2,6 +2,8 @@ import json
import os
import binascii
WIFI_CHANNEL_DEFAULT = 5
def _settings_path():
"""Path to settings.json in project root (writable without root)."""
@@ -12,11 +14,15 @@ def _settings_path():
return "settings.json"
_settings_singleton: "Settings | None" = None
class Settings(dict):
SETTINGS_FILE = None # Set in __init__ from _settings_path()
def __init__(self):
def __init__(self, *, quiet: bool = False):
super().__init__()
self._quiet = quiet
if Settings.SETTINGS_FILE is None:
Settings.SETTINGS_FILE = _settings_path()
self.load() # Load settings from file during initialization
@@ -47,23 +53,65 @@ class Settings(dict):
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
self['wifi_channel'] = WIFI_CHANNEL_DEFAULT
# WebSocket URL of ESP-NOW bridge (Pi is client), e.g. ws://192.168.4.1/ws
if 'bridge_ws_url' not in self:
self['bridge_ws_url'] = ''
if 'wifi_interface' not in self:
self['wifi_interface'] = ''
if 'bridges' not in self:
self['bridges'] = []
if 'bridge_transport' not in self:
self['bridge_transport'] = 'serial'
if 'bridge_serial_port' not in self:
self['bridge_serial_port'] = ''
if 'bridge_serial_baudrate' not in self:
self['bridge_serial_baudrate'] = 115200
# Wi-Fi LED drivers: controller opens WebSocket to device (firmware serves /ws)
if 'wifi_driver_ws_port' not in self:
self['wifi_driver_ws_port'] = 80
if 'wifi_driver_ws_path' not in self:
self['wifi_driver_ws_path'] = '/ws'
# Seconds between UDP discovery nudges when a Wi-Fi driver WebSocket is
# down (0 disables). Helps drivers that reconnect after seeing traffic on 8766.
if 'wifi_driver_hello_interval_s' not in self:
self['wifi_driver_hello_interval_s'] = 10.0
if 'wifi_driver_connect_retry_window_s' not in self:
self['wifi_driver_connect_retry_window_s'] = 120.0
if 'wifi_driver_connect_stagger_max_s' not in self:
self['wifi_driver_connect_stagger_max_s'] = 2.5
if 'wifi_driver_ws_open_timeout' not in self:
self['wifi_driver_ws_open_timeout'] = 45.0
if 'wifi_driver_connect_retry_interval_s' not in self:
self['wifi_driver_connect_retry_interval_s'] = 2.0
# Zone UI global brightness (0255); shared across browsers/devices.
if 'global_brightness' not in self:
self['global_brightness'] = 255
# Sequence tile start: wait for beat or downbeat (server-owned).
if 'sequence_switch_wait' not in self:
self['sequence_switch_wait'] = 'beat'
elif str(self.get('sequence_switch_wait', '')).strip().lower() == 'phrase':
self['sequence_switch_wait'] = 'beat'
# Beat flash alignment delay (ms); applied by all UI clients polling audio status.
if 'audio_beat_phase_ms' not in self:
self['audio_beat_phase_ms'] = 0
# Input gain for beat detection (percent, 0200).
if 'audio_input_volume' not in self:
self['audio_input_volume'] = 100
# BPM used for sequences when the audio detector is not running.
from util.bpm_limits import clamp_bpm
if 'audio_simulated_bpm' not in self:
self['audio_simulated_bpm'] = int(clamp_bpm(120))
else:
self['audio_simulated_bpm'] = int(clamp_bpm(self['audio_simulated_bpm']))
def save(self):
try:
j = json.dumps(self)
j = json.dumps(self, indent=2, sort_keys=True)
with open(self.SETTINGS_FILE, 'w') as file:
file.write(j)
print("Settings saved successfully.")
file.write("\n")
if not getattr(self, "_quiet", False):
print("Settings saved successfully.")
except Exception as e:
print(f"Error saving settings: {e}")
@@ -74,9 +122,11 @@ class Settings(dict):
loaded_settings = json.load(file)
self.update(loaded_settings)
loaded_from_file = True
print("Settings loaded successfully.")
if not getattr(self, "_quiet", False):
print("Settings loaded successfully.")
except Exception as e:
print(f"Error loading settings")
if not getattr(self, "_quiet", False):
print(f"Error loading settings: {e}")
self.clear()
finally:
# Ensure defaults are set even if file exists but is missing keys
@@ -84,3 +134,18 @@ class Settings(dict):
# Only save if file didn't exist or was invalid
if not loaded_from_file:
self.save()
def get_settings() -> Settings:
"""Process-wide settings instance (avoid re-reading settings.json on every request)."""
global _settings_singleton
if _settings_singleton is None:
_settings_singleton = Settings()
return _settings_singleton
def reload_settings() -> Settings:
"""Re-read settings.json (e.g. after external file edit)."""
global _settings_singleton
_settings_singleton = Settings(quiet=True)
return _settings_singleton

View File

@@ -872,7 +872,7 @@ class LightingController {
this.selectTab(this.state.zone_order[0]);
} else {
this.currentTab = null;
document.getElementById('zone-content').innerHTML = '<p>No tabs available. Create a new zone to get started.</p>';
document.getElementById('zone-content').innerHTML = '<p>No zones available. Create a new zone to get started.</p>';
}
}
} catch (error) {
@@ -1010,7 +1010,7 @@ class LightingController {
this.state.lights = {};
this.state.zone_order = [];
this.renderTabs();
document.getElementById('zone-content').innerHTML = '<p>No tabs available. Create a new zone to get started.</p>';
document.getElementById('zone-content').innerHTML = '<p>No zones available. Create a new zone to get started.</p>';
this.updateCurrentProfileDisplay();
}
} else {
@@ -1382,7 +1382,7 @@ class LightingController {
const presetNames = Object.keys(this.state.presets);
if (presetNames.length === 0) {
presetsList.innerHTML = '<p style="text-align: center; color: #888;">No presets found. Create one to get started.</p>';
presetsList.innerHTML = '<p style="text-align: center; color: #888;">No presets found.</p>';
} else {
presetNames.forEach(presetName => {
const preset = this.state.presets[presetName];

936
src/static/audio.js Normal file
View File

@@ -0,0 +1,936 @@
(() => {
let beatEventSource = null;
let beatEventsReconnectTimer = null;
let audioDetectorRunning = false;
let lastBeatSeq = 0;
let lastSimulatedBeatTick = 0;
/** Prior poll had server zone sequence playback active (`status.sequence.active === true`). */
let prevZoneSequencePlaybackActive = false;
/**
* After sequence playback ends/stops while audio keeps running, keep header # idle until the
* next beat bumps `beat_seq` (avoids the stuck final cumulative value vs sequence readout).
*/
let headerBeatStickyIdleAfterSeq = false;
/** @type {Set<ReturnType<typeof setTimeout>>} */
const pendingBeatPhaseTimers = new Set();
let cachedBeatPhaseMs = 0;
/** @type {{ device: string|number|null, device_override: string, device_select: string }} */
let cachedAudioRun = { device: null, device_override: "", device_select: "" };
/** True after client starts sequence playback until server reports stop. */
let clientSequenceUiActive = false;
/** Last pass readout (e.g. ``6/6``) kept visible briefly after playback ends. */
let stickySequenceBeatReadout = "";
function el(id) {
return document.getElementById(id);
}
/** @param {Record<string, unknown>} status */
function resolveBeatReadoutText(status) {
let text = String((status && status.beat_readout) || "").trim();
if (text) return text;
const seq = /** @type {Record<string, unknown>|undefined} */ (
status && status.sequence
);
if (seq && seq.active) {
text = String(seq.beat_readout || "").trim();
if (text) return text;
}
if (stickySequenceBeatReadout) {
return stickySequenceBeatReadout;
}
return "";
}
/** @param {Record<string, unknown>} status */
function updateBeatReadoutDisplays(status) {
const text = resolveBeatReadoutText(status);
for (const id of ["audio-top-beat-readout", "audio-modal-beat-readout"]) {
const n = el(id);
if (n) n.textContent = text;
}
}
function updateBpmDisplay(bpm, simulated = false) {
const text = Number.isFinite(bpm) ? bpm.toFixed(1) : "--";
for (const id of ["audio-bpm-value", "audio-top-bpm-value"]) {
const node = el(id);
if (node) node.textContent = text;
}
for (const id of ["audio-top-indicator", "audio-modal-beat-sync"]) {
const node = el(id);
if (node) node.classList.toggle("audio-simulated", !!simulated);
}
}
/** Zone sequence playback (server); only when `active === true` is beat X/Y meaningful. */
function sequencePlaybackActiveFromStatus(status) {
const seq = /** @type {Record<string, unknown>|undefined} */ (
status && status.sequence
);
return !!(seq && seq.active);
}
/** Sequence playing or waiting on beat/downbeat before start (simulated beats still run). */
function sequenceBeatUiActiveFromStatus(status) {
if (sequencePlaybackActiveFromStatus(status)) return true;
const pending = /** @type {Record<string, unknown>|undefined} */ (
status && status.sequence_pending
);
return !!(pending && pending.pending);
}
function resolveSeqUiActive(status) {
return sequenceBeatUiActiveFromStatus(status) || clientSequenceUiActive;
}
/** @param {Record<string, unknown>} status */
function updateTopIndicatorFromStatus(status) {
const running = !!(status && status.running);
const bpmSimulated = !!(status && status.bpm_simulated);
const seqUiActive = resolveSeqUiActive(status);
const show = running || seqUiActive || bpmSimulated;
setTopBpmVisible(show);
if (!show || running) return;
const simBpm =
status && status.audio_simulated_bpm != null
? Number(status.audio_simulated_bpm)
: getSimulatedBpmPercent();
updateBpmDisplay(Number.isFinite(simBpm) ? simBpm : null, true);
}
/** @param {Record<string, unknown>} status */
function shouldKeepStatusPolling(status) {
return (
!!(status && status.running) ||
resolveSeqUiActive(status) ||
!!(status && status.bpm_simulated)
);
}
function updateHitTypeDisplay(hitType, confidence) {
const node = el("audio-hit-type-value");
if (!node) return;
const label = String(hitType || "unknown").toLowerCase();
const conf = Number.isFinite(confidence) ? ` (${confidence.toFixed(2)})` : "";
node.textContent = `${label}${conf}`;
}
/** @param {Record<string, unknown>} status */
function updateBarPhaseDisplay(status) {
const readout = String((status && status.bar_phase_readout) || "").trim();
const phaseConf = Number((status && status.phase_confidence) || 0);
const downbeat = !!(status && status.is_downbeat);
const simulated = !!(status && status.bpm_simulated);
const showPhase = !!(status && status.running) || simulated;
let text = readout || "--";
if (readout && Number.isFinite(phaseConf) && phaseConf > 0) {
text = `${text} (${Math.round(phaseConf * 100)}%)`;
}
for (const id of ["audio-bar-phase-value", "audio-top-bar-phase"]) {
const node = el(id);
if (!node) continue;
node.textContent = showPhase ? text : "";
node.classList.toggle("is-downbeat", downbeat && !!readout && showPhase);
}
}
function setTopBpmVisible(on) {
const top = el("audio-top-indicator");
if (!top) return;
top.classList.toggle("audio-running", !!on);
}
function closeBeatEvents() {
if (beatEventsReconnectTimer != null) {
clearTimeout(beatEventsReconnectTimer);
beatEventsReconnectTimer = null;
}
if (beatEventSource) {
beatEventSource.close();
beatEventSource = null;
}
}
function scheduleBeatEventsReconnect() {
if (beatEventsReconnectTimer != null) return;
beatEventsReconnectTimer = setTimeout(() => {
beatEventsReconnectTimer = null;
void fetchAudioStatusOnce()
.then((status) => {
applyAudioStatus(status);
if (shouldKeepStatusPolling(status)) ensureBeatEvents();
})
.catch((e) => {
console.warn("audio status reconnect fetch failed", e);
});
}, 2000);
}
function ensureBeatEvents() {
if (beatEventSource) return;
const es = new EventSource("/api/audio/events");
beatEventSource = es;
es.onmessage = (ev) => {
try {
const data = JSON.parse(String(ev.data || ""));
if (data && data.status) applyAudioStatus(data.status);
} catch (e) {
console.warn("audio beat event parse failed", e);
}
};
es.onerror = () => {
closeBeatEvents();
scheduleBeatEventsReconnect();
};
}
function setResetDetectorEnabled(on) {
const btn = el("audio-reset-btn");
if (btn) btn.disabled = !on;
}
async function resetAudioTracking() {
try {
const res = await fetch("/api/audio/reset", {
method: "POST",
headers: { Accept: "application/json" },
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
console.warn("audio reset failed", data.error || res.status);
return;
}
await pollStatus();
} catch (e) {
console.warn("audio reset failed", e);
}
}
function beatSyncButtonTitle(zoneSeqActive) {
if (!audioDetectorRunning) return "Start beat detection";
if (zoneSeqActive) return "Sync step to music (S)";
return "Beat detection running";
}
function updateSequenceSyncControls(zoneSeqActive) {
const disabled = audioDetectorRunning && !zoneSeqActive;
const title = beatSyncButtonTitle(zoneSeqActive);
for (const id of ["audio-top-beat-sync", "audio-modal-beat-sync"]) {
const btn = el(id);
if (!btn) continue;
btn.disabled = disabled;
btn.title = title;
}
}
async function handleTopBpmButtonClick() {
if (!audioDetectorRunning) {
try {
await startAudio();
} catch (e) {
console.error("audio start failed", e);
alert("Failed to start audio input. Check mic permissions.");
}
return;
}
try {
await syncSequenceBeatPhase("step");
} catch (e) {
console.warn("sequence beat sync failed", e);
}
}
async function syncSequenceBeatPhase(mode) {
const res = await fetch("/sequences/sync-phase", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ mode: mode || "step" }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `Sync failed (${res.status})`);
}
await pollStatus();
}
function isTypingTarget(target) {
if (!target || typeof target !== "object") return false;
const tag = String(target.tagName || "").toLowerCase();
return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable;
}
function flashBeatSyncButton(btn, simulated = false) {
if (!btn) return;
btn.classList.add(simulated ? "flash-simulated" : "flash");
setTimeout(() => btn.classList.remove(simulated ? "flash-simulated" : "flash"), 90);
}
function flashBeat(simulated = false) {
const top = el("audio-top-indicator");
const topSync = el("audio-top-beat-sync");
if (
topSync &&
top &&
(top.classList.contains("audio-running") || simulated)
) {
flashBeatSyncButton(topSync, simulated);
}
const modalSync = el("audio-modal-beat-sync");
if (modalSync && (audioDetectorRunning || simulated)) {
flashBeatSyncButton(modalSync, simulated);
}
}
function gainPercentToDb(pct) {
const gain = Math.max(0.001, pct / 100);
return 20 * Math.log10(gain);
}
function formatGainReadout(pct) {
const db = gainPercentToDb(pct);
const dbText = db >= 0 ? `+${db.toFixed(2)}` : db.toFixed(2);
return `${pct}% (${dbText} dB)`;
}
function updateInputLevelDisplay(level) {
const pct = Number.isFinite(level) ? Math.round(Math.min(1, Math.max(0, level)) * 100) : 0;
const bar = el("audio-input-level-bar");
const meter = el("audio-modal")?.querySelector(".audio-input-level-meter");
if (bar) bar.style.width = `${pct}%`;
if (meter) meter.setAttribute("aria-valuenow", String(pct));
}
function clearBeatPhaseTimers() {
pendingBeatPhaseTimers.forEach((t) => clearTimeout(t));
pendingBeatPhaseTimers.clear();
}
function getBeatPhaseDelayMs() {
return Math.min(500, Math.max(0, cachedBeatPhaseMs));
}
function getInputVolumePercent() {
const inp = el("audio-input-volume");
if (!inp) return 100;
const n = parseInt(String(inp.value).trim(), 10);
if (!Number.isFinite(n)) return 100;
return Math.min(200, Math.max(0, n));
}
function updateInputVolumeReadout() {
const readout = el("audio-input-volume-readout");
const slider = el("audio-input-volume");
const pct = getInputVolumePercent();
if (readout) readout.textContent = formatGainReadout(pct);
if (slider) {
slider.style.setProperty("--audio-volume-pct", `${(pct / 200) * 100}%`);
}
}
const SIMULATED_BPM_MIN = 60;
const SIMULATED_BPM_MAX = 200;
function clampSimulatedBpm(n) {
if (!Number.isFinite(n)) return 120;
return Math.min(SIMULATED_BPM_MAX, Math.max(SIMULATED_BPM_MIN, Math.round(n)));
}
function clampLiveBpm(n) {
if (!Number.isFinite(n)) return null;
return Math.min(SIMULATED_BPM_MAX, Math.max(SIMULATED_BPM_MIN, n));
}
function getSimulatedBpmPercent() {
const inp = el("audio-simulated-bpm");
if (!inp) return 120;
return clampSimulatedBpm(parseInt(String(inp.value).trim(), 10));
}
async function persistSimulatedBpm() {
const bpm = getSimulatedBpmPercent();
try {
await fetch("/settings", {
method: "PUT",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ audio_simulated_bpm: bpm }),
});
} catch (e) {
console.warn("simulated bpm save failed", e);
}
}
async function persistInputVolume() {
const vol = getInputVolumePercent();
updateInputVolumeReadout();
try {
await fetch("/settings", {
method: "PUT",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ audio_input_volume: vol }),
});
} catch (e) {
console.warn("input volume save failed", e);
}
}
function scheduleBeatPhaseFire(seq, delayMs, simulated = false) {
let tid = null;
const run = () => {
if (tid != null) pendingBeatPhaseTimers.delete(tid);
flashBeat(simulated);
try {
window.dispatchEvent(
new CustomEvent("ledControllerAudioBeat", { detail: { beatSeq: seq } }),
);
} catch (e) {
/* ignore */
}
};
if (delayMs <= 0) {
run();
return;
}
tid = setTimeout(run, delayMs);
pendingBeatPhaseTimers.add(tid);
}
/** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */
async function stopAudioOnly() {
audioDetectorRunning = false;
setResetDetectorEnabled(false);
clearBeatPhaseTimers();
lastBeatSeq = 0;
lastSimulatedBeatTick = 0;
prevZoneSequencePlaybackActive = false;
headerBeatStickyIdleAfterSeq = false;
updateBeatReadoutDisplays({});
updateInputLevelDisplay(0);
setTopBpmVisible(true);
updateBpmDisplay(getSimulatedBpmPercent(), true);
try {
await fetch("/api/audio/stop", { method: "POST" });
} catch (e) {
console.warn("audio stop failed", e);
}
ensureBeatEvents();
await pollStatus();
}
/** User-initiated stop (run intent cleared on server). */
async function stopAudio() {
await stopAudioOnly();
}
async function fetchAudioStatusOnce() {
const res = await fetch("/api/audio/status", { cache: "no-store" });
const data = await res.json();
return data?.status || {};
}
async function pollStatus() {
try {
const status = await fetchAudioStatusOnce();
applyAudioStatus(status);
} catch (e) {
console.warn("audio status fetch failed", e);
}
}
/** @param {Record<string, unknown>} status */
function applyAudioStatus(status) {
try {
if (status.error && String(status.error).trim()) {
const node = el("audio-hit-type-value");
if (node) {
node.textContent = String(status.error).trim().slice(0, 120);
}
updateBeatReadoutDisplays({});
audioDetectorRunning = !!status.running;
updateInputLevelDisplay(0);
updateTopIndicatorFromStatus(status);
setResetDetectorEnabled(!!status.running);
if (!shouldKeepStatusPolling(status)) closeBeatEvents();
return;
}
audioDetectorRunning = !!status.running;
const zoneSeqActive = sequencePlaybackActiveFromStatus(status);
const seqUiActive = resolveSeqUiActive(status);
const bpmSimulated = !!status.bpm_simulated;
if (sequenceBeatUiActiveFromStatus(status)) {
clientSequenceUiActive = false;
}
updateTopIndicatorFromStatus(status);
setResetDetectorEnabled(!!status.running);
updateSequenceSyncControls(zoneSeqActive || clientSequenceUiActive);
const displayBpm =
bpmSimulated && status.audio_simulated_bpm != null
? clampSimulatedBpm(Number(status.audio_simulated_bpm))
: status.bpm != null
? clampLiveBpm(Number(status.bpm))
: null;
updateBpmDisplay(
Number.isFinite(displayBpm) ? displayBpm : null,
bpmSimulated,
);
updateHitTypeDisplay(status.beat_type, Number(status.beat_type_confidence));
updateBarPhaseDisplay(status);
updateInputLevelDisplay(
status.running ? Number(status.input_level) : 0,
);
applyServerAudioUiFields(status);
if (typeof window.setSequenceSwitchSimulatedMode === "function") {
window.setSequenceSwitchSimulatedMode(bpmSimulated);
}
if (typeof window.applySequenceSwitchWaitFromServer === "function") {
window.applySequenceSwitchWaitFromServer(status.sequence_switch_wait);
}
/*
* `status.beat_seq` is cumulative since Audio Start — used only for flash / sticky idle
* after sequence ends. Preset and sequence loop counts come from `manual_beat_stride` /
* `sequence` on each poll.
*/
const beatSeq = Number(status.beat_seq || 0);
const simTick = Number(status.simulated_beat_tick || 0);
const endedSeq = prevZoneSequencePlaybackActive && !zoneSeqActive;
const startedSeq = !prevZoneSequencePlaybackActive && zoneSeqActive;
prevZoneSequencePlaybackActive = zoneSeqActive;
if (startedSeq) {
headerBeatStickyIdleAfterSeq = false;
stickySequenceBeatReadout = "";
if (bpmSimulated) {
lastSimulatedBeatTick = Math.max(0, simTick - 1);
}
}
if (zoneSeqActive) {
const liveReadout = String((status.beat_readout || "") || "").trim()
|| String((status.sequence && status.sequence.beat_readout) || "").trim();
if (liveReadout) {
stickySequenceBeatReadout = liveReadout;
}
}
if (endedSeq) {
clientSequenceUiActive = false;
headerBeatStickyIdleAfterSeq = true;
clearBeatPhaseTimers();
lastBeatSeq = beatSeq;
lastSimulatedBeatTick = simTick;
if (!stickySequenceBeatReadout) {
const tail = String((status.beat_readout || "") || "").trim();
if (tail) stickySequenceBeatReadout = tail;
}
}
if (bpmSimulated && simTick > lastSimulatedBeatTick) {
lastSimulatedBeatTick = simTick;
scheduleBeatPhaseFire(simTick, getBeatPhaseDelayMs(), true);
} else if (!zoneSeqActive && headerBeatStickyIdleAfterSeq) {
if (beatSeq > lastBeatSeq) {
lastBeatSeq = beatSeq;
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs(), false);
headerBeatStickyIdleAfterSeq = false;
}
} else if (!bpmSimulated && beatSeq > lastBeatSeq) {
lastBeatSeq = beatSeq;
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs(), false);
}
updateBeatReadoutDisplays(status);
if (shouldKeepStatusPolling(status)) {
ensureBeatEvents();
} else {
closeBeatEvents();
}
} catch (e) {
console.warn("audio status apply failed", e);
}
}
/** Ignore server device sync briefly after the user picks from the dropdown. */
let deviceSelectLockUntil = 0;
/** Suppress change handler while rebuilding or programmatically setting the select. */
let suppressDeviceSelectEvents = false;
/** Last explicit UI choice (dropdown); not overwritten by server poll. */
let uiDeviceSelectId = "";
function lockDeviceSelect(ms = 10000) {
deviceSelectLockUntil = Date.now() + ms;
}
function preferredSavedDeviceId() {
return cachedAudioRun.device_select ? String(cachedAudioRun.device_select) : "";
}
function optionIdForSavedDevice(select, savedId) {
const saved = savedId == null ? "" : String(savedId);
if (!saved || !select) return "";
if (selectHasDeviceOptionId(select, saved)) return saved;
if (!/^-?\d+$/.test(saved)) return "";
for (const opt of select.options) {
if (String(opt.dataset.sdIndex ?? "") === saved) return opt.value;
}
return "";
}
function restoreDeviceSelectAfterRefresh(select, defaultId, restoreId = "") {
const picked = restoreId || getSelectedDeviceId();
if (picked && selectHasDeviceOptionId(select, picked)) {
setSelectedDeviceId(picked);
return;
}
const saved = preferredSavedDeviceId();
const savedId = optionIdForSavedDevice(select, saved) || saved;
if (savedId && selectHasDeviceOptionId(select, savedId)) {
setSelectedDeviceId(savedId);
return;
}
if (defaultId && selectHasDeviceOptionId(select, defaultId)) {
setSelectedDeviceId(defaultId);
return;
}
setSelectedDeviceId("");
}
function getSelectedDeviceId() {
return String(el("audio-device-select")?.value ?? "");
}
function selectHasDeviceOptionId(select, deviceId) {
const id = deviceId == null ? "" : String(deviceId);
return [...select.options].some((opt) => opt.value === id);
}
function audioRunPreferredDeviceId(run) {
return run.device_select ? String(run.device_select) : "";
}
function setSelectedDeviceId(deviceId, { force = false } = {}) {
const id = deviceId == null ? "" : String(deviceId);
const select = el("audio-device-select");
if (!select) return false;
if (id !== "" && !selectHasDeviceOptionId(select, id)) {
if (!force) return false;
}
suppressDeviceSelectEvents = true;
try {
select.value = id;
uiDeviceSelectId = id;
} finally {
suppressDeviceSelectEvents = false;
}
return true;
}
function readDeviceForm() {
return { override: "", selected: getSelectedDeviceId() };
}
async function persistDeviceSelection(deviceId) {
const selected = deviceId != null ? String(deviceId) : getSelectedDeviceId();
uiDeviceSelectId = selected;
cachedAudioRun.device_select = selected;
try {
const res = await fetch("/api/audio/device", {
method: "PUT",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ device_select: selected, device_override: "" }),
});
const data = await res.json().catch(() => ({}));
if (data?.audio_run && typeof data.audio_run === "object") {
const saved = data.audio_run.device_select
? String(data.audio_run.device_select)
: "";
if (saved === selected) {
cachedAudioRun.device_select = saved;
}
}
} catch (e) {
console.warn("device selection save failed", e);
}
}
async function startAudio(deviceId) {
const selected =
deviceId != null && deviceId !== undefined
? String(deviceId)
: uiDeviceSelectId || getSelectedDeviceId();
lockDeviceSelect();
uiDeviceSelectId = selected;
cachedAudioRun.device_select = selected;
await stopAudioOnly();
await persistDeviceSelection(selected);
const rawDevice = selected;
const numeric = rawDevice !== "" && /^-?\d+$/.test(rawDevice) ? Number(rawDevice) : rawDevice;
const body = {
device: rawDevice === "" ? null : numeric,
device_override: "",
device_select: selected,
};
const res = await fetch("/api/audio/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "Failed to start audio detector");
}
cachedAudioRun.device_select = selected;
setSelectedDeviceId(selected);
updateBpmDisplay(null);
updateHitTypeDisplay("unknown", NaN);
ensureBeatEvents();
await pollStatus();
}
async function refreshDevices() {
const select = el("audio-device-select");
if (!select) return;
const res = await fetch("/api/audio/devices");
const data = await res.json();
// Re-read after fetch so a pick during the request is not overwritten by a stale value.
const restoreId = getSelectedDeviceId();
const inputs = Array.isArray(data?.devices) ? data.devices.slice() : [];
select.innerHTML = "";
const defaultOpt = document.createElement("option");
defaultOpt.value = "";
defaultOpt.textContent = "System default input";
select.appendChild(defaultOpt);
let defaultId = "";
inputs.forEach((d, idx) => {
const opt = document.createElement("option");
opt.value = String(d.id);
const text = d.display_name || d.name || `Input ${idx + 1}`;
opt.textContent = text;
const title = d.label || d.name || "";
if (title && title !== text) opt.title = title;
if (d.sounddevice_index != null && d.sounddevice_index !== "") {
opt.dataset.sdIndex = String(d.sounddevice_index);
}
select.appendChild(opt);
if (d.is_default) defaultId = String(d.id);
});
suppressDeviceSelectEvents = true;
try {
restoreDeviceSelectAfterRefresh(select, defaultId, restoreId);
} finally {
suppressDeviceSelectEvents = false;
}
}
function bind() {
const modal = el("audio-modal");
const openBtn = el("audio-btn");
const closeBtn = el("audio-close-btn");
const startBtn = el("audio-start-btn");
const stopBtn = el("audio-stop-btn");
const resetBtn = el("audio-reset-btn");
const refreshBtn = el("audio-refresh-btn");
if (!modal || !openBtn) return;
openBtn.addEventListener("click", async () => {
modal.classList.add("active");
try {
await refreshDevices();
} catch (e) {
console.warn("audio device refresh failed", e);
}
await loadServerAudioUiFields();
setResetDetectorEnabled(audioDetectorRunning);
});
if (closeBtn) {
closeBtn.addEventListener("click", () => {
modal.classList.remove("active");
});
}
if (startBtn) {
startBtn.addEventListener("click", async () => {
const picked = getSelectedDeviceId();
try {
await startAudio(picked);
} catch (e) {
console.error("audio start failed", e);
alert("Failed to start audio input. Check mic permissions.");
}
});
}
if (stopBtn) {
stopBtn.addEventListener("click", async () => {
await stopAudio();
});
}
if (resetBtn) {
resetBtn.addEventListener("click", () => resetAudioTracking());
}
if (refreshBtn) {
refreshBtn.addEventListener("click", async () => {
try {
await refreshDevices();
} catch (e) {
console.error("refresh devices failed", e);
}
});
}
const deviceSelect = el("audio-device-select");
if (deviceSelect) {
deviceSelect.addEventListener("change", async () => {
if (suppressDeviceSelectEvents) return;
const picked = getSelectedDeviceId();
uiDeviceSelectId = picked;
lockDeviceSelect();
cachedAudioRun.device_select = picked;
await persistDeviceSelection(picked);
});
}
const volInp = el("audio-input-volume");
if (volInp) {
volInp.addEventListener("input", () => {
updateInputVolumeReadout();
void persistInputVolume();
});
volInp.addEventListener("change", () => {
updateInputVolumeReadout();
void persistInputVolume();
});
updateInputVolumeReadout();
}
const simBpmInp = el("audio-simulated-bpm");
if (simBpmInp) {
const onSimBpmChange = () => {
void persistSimulatedBpm();
if (!audioDetectorRunning) {
updateBpmDisplay(getSimulatedBpmPercent(), true);
}
};
simBpmInp.addEventListener("input", onSimBpmChange);
simBpmInp.addEventListener("change", onSimBpmChange);
}
for (const id of ["audio-top-beat-sync", "audio-modal-beat-sync"]) {
const btn = el(id);
if (btn) {
btn.addEventListener("click", () => {
void handleTopBpmButtonClick();
});
}
}
document.addEventListener("keydown", (ev) => {
if (ev.defaultPrevented || ev.repeat || isTypingTarget(ev.target)) return;
const k = String(ev.key || "").toLowerCase();
if (k !== "s") return;
ev.preventDefault();
const mode = ev.shiftKey ? "pass" : "step";
void syncSequenceBeatPhase(mode).catch((e) => console.warn("sequence beat sync failed", e));
});
}
async function resumeBeatEventsIfNeeded() {
try {
const status = await fetchAudioStatusOnce();
audioDetectorRunning = !!status.running;
updateTopIndicatorFromStatus(status);
if (shouldKeepStatusPolling(status)) {
lastBeatSeq = Number(status.beat_seq || 0);
lastSimulatedBeatTick = Number(status.simulated_beat_tick || 0);
prevZoneSequencePlaybackActive = sequencePlaybackActiveFromStatus(status);
applyAudioStatus(status);
ensureBeatEvents();
} else {
updateSequenceSyncControls(
sequencePlaybackActiveFromStatus(status) || clientSequenceUiActive,
);
}
} catch (e) {
console.warn("audio resume status check failed", e);
}
}
/** Apply server-owned audio UI fields from status (volume; device dropdown is user-owned). */
function applyServerAudioUiFields(status) {
if (!status || typeof status !== "object") return;
const run = status.audio_run;
if (run && typeof run === "object") {
cachedAudioRun = {
device: run.device ?? null,
device_override: run.device_override != null ? String(run.device_override) : "",
device_select: run.device_select ? String(run.device_select) : "",
};
}
if (status.beat_phase_ms != null) {
const ms = parseInt(String(status.beat_phase_ms), 10);
if (Number.isFinite(ms)) {
cachedBeatPhaseMs = Math.min(500, Math.max(0, ms));
}
}
const volInp = el("audio-input-volume");
if (
volInp &&
status.input_volume != null &&
document.activeElement !== volInp
) {
const vol = parseInt(String(status.input_volume), 10);
if (Number.isFinite(vol)) {
volInp.value = String(Math.min(200, Math.max(0, vol)));
updateInputVolumeReadout();
}
}
const simBpmInp = el("audio-simulated-bpm");
if (
simBpmInp &&
status.audio_simulated_bpm != null &&
document.activeElement !== simBpmInp
) {
const bpm = parseInt(String(status.audio_simulated_bpm), 10);
if (Number.isFinite(bpm)) {
simBpmInp.value = String(clampSimulatedBpm(bpm));
}
}
}
async function loadServerAudioUiFields() {
try {
const res = await fetch("/api/audio/status", { cache: "no-store" });
const data = await res.json();
const status = data?.status || {};
applyServerAudioUiFields(status);
const select = el("audio-device-select");
const saved = audioRunPreferredDeviceId(status.audio_run || {});
if (select && saved && selectHasDeviceOptionId(select, saved)) {
uiDeviceSelectId = saved;
setSelectedDeviceId(saved);
}
updateInputLevelDisplay(status.running ? Number(status.input_level) : 0);
updateTopIndicatorFromStatus(status);
if (typeof window.setSequenceSwitchSimulatedMode === "function") {
window.setSequenceSwitchSimulatedMode(!!status.bpm_simulated);
}
if (!status.running) {
lastSimulatedBeatTick = Number(status.simulated_beat_tick || 0);
applyAudioStatus(status);
}
} catch (e) {
console.warn("audio status load failed", e);
}
}
/** Called from sequences.js when server playback starts/stops. */
window.ledControllerSequencePlaybackChanged = (active) => {
clientSequenceUiActive = !!active;
updateSequenceSyncControls(!!active);
if (active) {
setTopBpmVisible(true);
if (!audioDetectorRunning) {
updateBpmDisplay(getSimulatedBpmPercent(), true);
}
ensureBeatEvents();
void pollStatus();
return;
}
void pollStatus();
};
document.addEventListener("DOMContentLoaded", async () => {
bind();
await loadServerAudioUiFields();
await resumeBeatEventsIfNeeded();
});
})();

48
src/static/bundle_io.js Normal file
View File

@@ -0,0 +1,48 @@
/** Download/upload JSON bundles for profile, preset, and sequence import/export. */
window.downloadJsonFile = function downloadJsonFile(filename, data) {
const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
const blob = new Blob([text], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || 'bundle.json';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
};
window.pickJsonFile = function pickJsonFile() {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json,.json';
input.style.display = 'none';
document.body.appendChild(input);
input.addEventListener('change', () => {
const file = input.files && input.files[0];
input.remove();
if (!file) {
resolve(null);
return;
}
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => resolve(null);
reader.readAsText(file);
});
input.click();
});
};
window.parseJsonFileText = function parseJsonFileText(text) {
if (text == null || text === '') {
return null;
}
try {
return JSON.parse(text);
} catch (e) {
return null;
}
};

View File

@@ -0,0 +1,43 @@
/* Reload when uvicorn restarts (build-id) or static/template files change (client-rev). */
(function () {
var prevBuild = null;
var prevRev = null;
function fetchText(url) {
return fetch(url, { cache: 'no-store', credentials: 'same-origin' })
.then(function (r) {
return r.ok ? r.text() : '';
})
.catch(function () {
return '';
});
}
function tick() {
Promise.all([
fetchText('/__dev/build-id'),
fetchText('/__dev/client-rev'),
]).then(function (parts) {
var buildId = (parts[0] || '').trim();
var clientRev = (parts[1] || '').trim();
if (!buildId && !clientRev) return;
if (prevBuild === null && prevRev === null) {
prevBuild = buildId;
prevRev = clientRev;
return;
}
var buildChanged = buildId && buildId !== prevBuild;
var revChanged = clientRev && clientRev !== prevRev;
if (buildChanged || revChanged) {
if (buildId) prevBuild = buildId;
if (clientRev) prevRev = clientRev;
window.location.reload();
}
});
}
setInterval(tick, 750);
tick();
})();

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