22 Commits

Author SHA1 Message Date
764d918d5b data: update local db fixtures and browser test expectations
Made-with: Cursor
2026-03-21 23:15:55 +13:00
edadb40cb6 docs: rewrite API reference for current HTTP and driver flows
Made-with: Cursor
2026-03-21 23:15:44 +13:00
9323719a85 feat(ui): add run/edit workflow and improve preset color editing
Made-with: Cursor
2026-03-21 23:15:31 +13:00
91de705647 feat(profiles): seed new profiles and refresh tabs on apply
Made-with: Cursor
2026-03-21 23:15:19 +13:00
3ee7b74152 fix(api): stabilize palette and preset endpoints
Made-with: Cursor
2026-03-21 23:15:08 +13:00
98bbdcbb3d chore: add dev watch command to Pipfile scripts
Made-with: Cursor
2026-03-21 23:15:00 +13:00
a2abd3e833 data: refresh db JSON fixtures
Made-with: Cursor
2026-03-21 20:17:33 +13:00
550217c443 ui: data-bwignore on AP password fields for password managers
Made-with: Cursor
2026-03-21 20:17:33 +13:00
2d2032e8b9 esp32: log startup and UART receive for debugging
Made-with: Cursor
2026-03-21 20:17:33 +13:00
81bf4dded5 docs: update msg.json example payload
Made-with: Cursor
2026-03-21 20:17:33 +13:00
a75e27e3d2 feat: device model, API, static UI, and endpoint tests
Made-with: Cursor
2026-03-21 20:17:33 +13:00
13538c39a6 tests: skip browser tests when no driver; try Firefox after Chrome
Made-with: Cursor
2026-03-21 20:17:33 +13:00
7b724e9ce1 tests: point model tests at db/ and align palette assertions
Made-with: Cursor
2026-03-21 20:17:33 +13:00
aaca5435e9 chore: gitignore local settings.json (session secret)
Made-with: Cursor
2026-03-21 20:17:33 +13:00
b64dacc1c3 Stop ignoring esp32; drop esp32 rules from .gitignore
Made-with: Cursor
2026-03-21 20:08:24 +13:00
8689bdb6ef Restore esp32 MicroPython sources (main, benchmark_peers)
Adjust .gitignore to ignore esp32/* except *.py so firmware .bin stays untracked.

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

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

Made-with: Cursor
2026-03-15 17:16:07 +13:00
61 changed files with 2223 additions and 1751 deletions

2
.gitignore vendored
View File

@@ -23,7 +23,7 @@ ENV/
Thumbs.db
# Project specific
settings.json
*.log
*.db
*.sqlite

6
.gitmodules vendored Normal file
View File

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

View File

@@ -12,6 +12,7 @@ watchfiles = "*"
requests = "*"
selenium = "*"
adafruit-ampy = "*"
microdot = "*"
[dev-packages]
@@ -21,4 +22,6 @@ python_version = "3.12"
[scripts]
web = "python /home/pi/led-controller/tests/web.py"
watch = "python -m watchfiles 'python tests/web.py' src tests"
install = "pipenv install"
install = "pipenv install"
run = "sh -c 'cd src && python main.py'"
dev = "watchfiles \"sh -c 'cd src && python main.py'\" src"

499
Pipfile.lock generated
View File

@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "c963cd52164ac13fda5e6f3c5975bc14db6cea03ad4973de02ad91a0ab10d2ea"
"sha256": "12b64c3bf5857d958f790f2416072408e2244631242ba2598210d89df330e184"
},
"pipfile-spec": 6,
"requires": {
@@ -32,14 +32,6 @@
"markers": "python_version >= '3.9'",
"version": "==4.12.1"
},
"async-generator": {
"hashes": [
"sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b",
"sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144"
],
"markers": "python_version >= '3.5'",
"version": "==1.10"
},
"attrs": {
"hashes": [
"sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11",
@@ -159,19 +151,19 @@
},
"bitstring": {
"hashes": [
"sha256:69d1587f0ac18dc7d93fc7e80d5f447161a33e57027e726dc18a0a8bacf1711a",
"sha256:a08bc09d3857216d4c0f412a1611056f1cc2b64fd254fb1e8a0afba7cfa1a95a"
"sha256:e682ac522bb63e041d16cbc9d0ca86a4f00194db16d0847c7efe066f836b2e37",
"sha256:feac49524fcf3ef27e6081e86f02b10d2adf6c3773bf22fbe0e7eea9534bc737"
],
"markers": "python_version >= '3.8'",
"version": "==4.3.1"
"version": "==4.4.0"
},
"certifi": {
"hashes": [
"sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c",
"sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"
"sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa",
"sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"
],
"markers": "python_version >= '3.7'",
"version": "==2026.1.4"
"version": "==2026.2.25"
},
"cffi": {
"hashes": [
@@ -265,122 +257,122 @@
},
"charset-normalizer": {
"hashes": [
"sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad",
"sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93",
"sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394",
"sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89",
"sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc",
"sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86",
"sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63",
"sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d",
"sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f",
"sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8",
"sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0",
"sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505",
"sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161",
"sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af",
"sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152",
"sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318",
"sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72",
"sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4",
"sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e",
"sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3",
"sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576",
"sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c",
"sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1",
"sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8",
"sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1",
"sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2",
"sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44",
"sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26",
"sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88",
"sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016",
"sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede",
"sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf",
"sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a",
"sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc",
"sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0",
"sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84",
"sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db",
"sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1",
"sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7",
"sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed",
"sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8",
"sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133",
"sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e",
"sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef",
"sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14",
"sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2",
"sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0",
"sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d",
"sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828",
"sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f",
"sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf",
"sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6",
"sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328",
"sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090",
"sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa",
"sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381",
"sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c",
"sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb",
"sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc",
"sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a",
"sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec",
"sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc",
"sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac",
"sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e",
"sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313",
"sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569",
"sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3",
"sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d",
"sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525",
"sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894",
"sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3",
"sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9",
"sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a",
"sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9",
"sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14",
"sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25",
"sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50",
"sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf",
"sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1",
"sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3",
"sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac",
"sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e",
"sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815",
"sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c",
"sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6",
"sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6",
"sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e",
"sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4",
"sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84",
"sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69",
"sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15",
"sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191",
"sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0",
"sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897",
"sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd",
"sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2",
"sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794",
"sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d",
"sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074",
"sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3",
"sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224",
"sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838",
"sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a",
"sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d",
"sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d",
"sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f",
"sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8",
"sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490",
"sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966",
"sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9",
"sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3",
"sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e",
"sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"
"sha256:014837af6fabf57121b6254fa8ade10dceabc3528b27b721a64bbc7b8b1d4eb4",
"sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66",
"sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54",
"sha256:02a9d1b01c1e12c27883b0c9349e0bcd9ae92e727ff1a277207e1a262b1cbf05",
"sha256:036c079aa08a6a592b82487f97c60b439428320ed1b2ea0b3912e99d30c77765",
"sha256:039215608ac7b358c4da0191d10fc76868567fbf276d54c14721bdedeb6de064",
"sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819",
"sha256:0a45e504f5e1be0bd385935a8e1507c442349ca36f511a47057a71c9d1d6ea9e",
"sha256:0b362bcd27819f9c07cbf23db4e0e8cd4b44c5ecd900c2ff907b2b92274a7412",
"sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc",
"sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e",
"sha256:10b473fc8dca1c3ad8559985794815f06ca3fc71942c969129070f2c3cdf7281",
"sha256:131716d6786ad5e3dc542f5cc6f397ba3339dc0fb87f87ac30e550e8987756af",
"sha256:14498a429321de554b140013142abe7608f9d8ccc04d7baf2ad60498374aefa2",
"sha256:149ec69866c3d6c2fb6f758dbc014ecb09f30b35a5ca90b6a8a2d4e54e18fdfe",
"sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8",
"sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262",
"sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac",
"sha256:1a374cc0b88aa710e8865dc1bd6edb3743c59f27830f0293ab101e4cf3ce9f85",
"sha256:1d1401945cb77787dbd3af2446ff2d75912327c4c3a1526ab7955ecf8600687c",
"sha256:1f2da5cbb9becfcd607757a169e38fb82aa5fd86fae6653dea716e7b613fe2cf",
"sha256:259cd1ca995ad525f638e131dbcc2353a586564c038fc548a3fe450a91882139",
"sha256:2820a98460c83663dd8ec015d9ddfd1e4879f12e06bb7d0500f044fb477d2770",
"sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d",
"sha256:2b970382e4a36bed897c19f310f31d7d13489c11b4f468ddfba42d41cddfb918",
"sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3",
"sha256:30987f4a8ed169983f93e1be8ffeea5214a779e27ed0b059835c7afe96550ad7",
"sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39",
"sha256:340810d34ef83af92148e96e3e44cb2d3f910d2bf95e5618a5c467d9f102231d",
"sha256:3f64c6bf8f32f9133b668c7f7a7cbdbc453412bc95ecdbd157f3b1e377a92990",
"sha256:4167a621a9a1a986c73777dbc15d4b5eac8ac5c10393374109a343d4013ec765",
"sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1",
"sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa",
"sha256:4b8551b6e6531e156db71193771c93bda78ffc4d1e6372517fe58ad3b91e4659",
"sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d",
"sha256:50bcbca6603c06a1dcc7b056ed45c37715fb5d2768feb3bcd37d2313c587a5b9",
"sha256:530beedcec9b6e027e7a4b6ce26eed36678aa39e17da85e6e03d7bd9e8e9d7c9",
"sha256:568e3c34b58422075a1b49575a6abc616d9751b4d61b23f712e12ebb78fe47b2",
"sha256:573ef5814c4b7c0d59a7710aa920eaaaef383bd71626aa420fba27b5cab92e8d",
"sha256:58ad8270cfa5d4bef1bc85bd387217e14ff154d6630e976c6f56f9a040757475",
"sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c",
"sha256:5bcb3227c3d9aaf73eaaab1db7ccd80a8995c509ee9941e2aae060ca6e4e5d81",
"sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67",
"sha256:5fea359734b140d0d6741189fea5478c6091b54ffc69d7ce119e0a05637d8c99",
"sha256:60d68e820af339df4ae8358c7a2e7596badeb61e544438e489035f9fbf3246a5",
"sha256:610f72c0ee565dfb8ae1241b666119582fdbfe7c0975c175be719f940e110694",
"sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf",
"sha256:65b3c403a5b6b8034b655e7385de4f72b7b244869a22b32d4030b99a60593eca",
"sha256:66dee73039277eb35380d1b82cccc69cc82b13a66f9f4a18da32d573acf02b7c",
"sha256:708c7acde173eedd4bfa4028484426ba689d2103b28588c513b9db2cd5ecde9c",
"sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636",
"sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f",
"sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02",
"sha256:75ee9c1cce2911581a70a3c0919d8bccf5b1cbc9b0e5171400ec736b4b569497",
"sha256:76a9d0de4d0eab387822e7b35d8f89367dd237c72e82ab42b9f7bf5e15ada00f",
"sha256:77be992288f720306ab4108fe5c74797de327f3248368dfc7e1a916d6ed9e5a2",
"sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d",
"sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873",
"sha256:82cc7c2ad42faec8b574351f8bc2a0c049043893853317bd9bb309f5aba6cb5a",
"sha256:8a28afb04baa55abf26df544e3e5c6534245d3daa5178bc4a8eeb48202060d0e",
"sha256:8b78d8a609a4b82c273257ee9d631ded7fac0d875bdcdccc109f3ee8328cfcb1",
"sha256:8ce11cd4d62d11166f2b441e30ace226c19a3899a7cf0796f668fba49a9fb123",
"sha256:8fff79bf5978c693c9b1a4d71e4a94fddfb5fe744eb062a318e15f4a2f63a550",
"sha256:92263f7eca2f4af326cd20de8d16728d2602f7cfea02e790dcde9d83c365d7cc",
"sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36",
"sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644",
"sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4",
"sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0",
"sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e",
"sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f",
"sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4",
"sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98",
"sha256:aa2f963b4da26daf46231d9b9e0e2c9408a751f8f0d0f44d2de56d3caf51d294",
"sha256:aa92ec1102eaff840ccd1021478af176a831f1bccb08e526ce844b7ddda85c22",
"sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23",
"sha256:ae8b03427410731469c4033934cf473426faff3e04b69d2dfb64a4281a3719f8",
"sha256:afca7f78067dd27c2b848f1b234623d26b87529296c6c5652168cc1954f2f3b2",
"sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362",
"sha256:b3e71afc578b98512bfe7bdb822dd6bc57d4b0093b4b6e5487c1e96ad4ace242",
"sha256:ba20bdf69bd127f66d0174d6f2a93e69045e0b4036dc1ca78e091bcc765830c4",
"sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95",
"sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d",
"sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94",
"sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6",
"sha256:c7e84e0c0005e3bdc1a9211cd4e62c78ba80bc37b2365ef4410cd2007a9047f2",
"sha256:cace89841c0599d736d3d74a27bc5821288bb47c5441923277afc6059d7fbcb4",
"sha256:cd2d0f0ec9aa977a27731a3209ebbcacebebaf41f902bd453a928bfd281cf7f8",
"sha256:d01de5e768328646e6a3fa9e562706f8f6641708c115c62588aef2b941a4f88e",
"sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a",
"sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce",
"sha256:d29dd9c016f2078b43d0c357511e87eee5b05108f3dd603423cb389b89813969",
"sha256:d31f0d1671e1534e395f9eb84a68e0fb670e1edb1fe819a9d7f564ae3bc4e53f",
"sha256:d4eb8ac7469b2a5d64b5b8c04f84d8bf3ad340f4514b98523805cbf46e3b3923",
"sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6",
"sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee",
"sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6",
"sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467",
"sha256:e09f671a54ce70b79a1fc1dc6da3072b7ef7251fadb894ed92d9aa8218465a5f",
"sha256:e22d1059b951e7ae7c20ef6b06afd10fb95e3c41bf3c4fbc874dba113321c193",
"sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7",
"sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9",
"sha256:e545b51da9f9af5c67815ca0eb40676c0f016d0b0381c86f20451e35696c5f95",
"sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763",
"sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7",
"sha256:ec56a2266f32bc06ed3c3e2a8f58417ce02f7e0356edc89786e52db13c593c98",
"sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60",
"sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade",
"sha256:ed98364e1c262cf5f9363c3eca8c2df37024f52a8fa1180a3610014f26eac51c",
"sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2",
"sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f",
"sha256:f8102ae93c0bc863b1d41ea0f4499c20a83229f52ed870850892df555187154a",
"sha256:fc1c64934b8faf7584924143eb9db4770bbdb16659626e1a1a4d9efbcb68d947",
"sha256:ff95a9283de8a457e6b12989de3f9f5193430f375d64297d323a615ea52cbdb3"
],
"markers": "python_version >= '3.7'",
"version": "==3.4.4"
"version": "==3.4.5"
},
"click": {
"hashes": [
@@ -392,66 +384,65 @@
},
"cryptography": {
"hashes": [
"sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa",
"sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc",
"sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da",
"sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255",
"sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2",
"sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485",
"sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0",
"sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d",
"sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616",
"sha256:44cc0675b27cadb71bdbb96099cca1fa051cd11d2ade09e5cd3a2edb929ed947",
"sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0",
"sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908",
"sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81",
"sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc",
"sha256:62217ba44bf81b30abaeda1488686a04a702a261e26f87db51ff61d9d3510abd",
"sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b",
"sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019",
"sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7",
"sha256:766330cce7416c92b5e90c3bb71b1b79521760cdcfc3a6a1a182d4c9fab23d2b",
"sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973",
"sha256:829c2b12bbc5428ab02d6b7f7e9bbfd53e33efd6672d21341f2177470171ad8b",
"sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5",
"sha256:8a15fb869670efa8f83cbffbc8753c1abf236883225aed74cd179b720ac9ec80",
"sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef",
"sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0",
"sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b",
"sha256:9b34d8ba84454641a6bf4d6762d15847ecbd85c1316c0a7984e6e4e9f748ec2e",
"sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c",
"sha256:9c2da296c8d3415b93e6053f5a728649a87a48ce084a9aaf51d6e46c87c7f2d2",
"sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af",
"sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4",
"sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab",
"sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82",
"sha256:be8c01a7d5a55f9a47d1888162b76c8f49d62b234d88f0ff91a9fbebe32ffbc3",
"sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59",
"sha256:c236a44acfb610e70f6b3e1c3ca20ff24459659231ef2f8c48e879e2d32b73da",
"sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061",
"sha256:c92010b58a51196a5f41c3795190203ac52edfd5dc3ff99149b4659eba9d2085",
"sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b",
"sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263",
"sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e",
"sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829",
"sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4",
"sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c",
"sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f",
"sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095",
"sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32",
"sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976",
"sha256:fdc3daab53b212472f1524d070735b2f0c214239df131903bae1d598016fa822"
"sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72",
"sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235",
"sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9",
"sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356",
"sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257",
"sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad",
"sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4",
"sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c",
"sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614",
"sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed",
"sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31",
"sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229",
"sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0",
"sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731",
"sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b",
"sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4",
"sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4",
"sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263",
"sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595",
"sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1",
"sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678",
"sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48",
"sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76",
"sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0",
"sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18",
"sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d",
"sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d",
"sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1",
"sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981",
"sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7",
"sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82",
"sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2",
"sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4",
"sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663",
"sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c",
"sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d",
"sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a",
"sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a",
"sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d",
"sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b",
"sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a",
"sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826",
"sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee",
"sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9",
"sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648",
"sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da",
"sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2",
"sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2",
"sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87"
],
"markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'",
"version": "==46.0.4"
"version": "==46.0.5"
},
"esptool": {
"hashes": [
"sha256:2ea9bcd7eb263d380a4fe0170856a10e4c65e3f38c757ebdc73584c8dd8322da"
"sha256:9c355b7d6331cc92979cc710ae5c41f59830d1ea29ec24c467c6005a092c06d6"
],
"index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==5.1.0"
"version": "==5.2.0"
},
"h11": {
"hashes": [
@@ -469,14 +460,6 @@
"markers": "python_version >= '3.8'",
"version": "==3.11"
},
"importlib-metadata": {
"hashes": [
"sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb",
"sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"
],
"markers": "python_version >= '3.9'",
"version": "==8.7.1"
},
"intelhex": {
"hashes": [
"sha256:87cc5225657524ec6361354be928adfd56bcf2a3dcc646c40f8f094c39c07db4",
@@ -500,23 +483,22 @@
"markers": "python_version >= '3.7'",
"version": "==0.1.2"
},
"microdot": {
"hashes": [
"sha256:363e3ebfc80b7e0415779848c9332e4e7fb7bd365ee54d3620abffe42ed82946",
"sha256:abfb82ca31cc430174e4761cc7356adc4bff00ea758d437c2b258883dc63f464"
],
"index": "pypi",
"version": "==2.6.0"
},
"mpremote": {
"hashes": [
"sha256:11d134c69b21b487dae3d03eed54c8ccbf84c916c8732a3e069a97cae47be3d4",
"sha256:6bb75774648091dad6833af4f86c5bf6505f8d7aec211380f9e6996c01d23cb5"
],
"index": "pypi",
"markers": "python_version >= '3.4'",
"version": "==1.27.0"
},
"mypy-extensions": {
"hashes": [
"sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505",
"sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"
],
"markers": "python_version >= '3.8'",
"version": "==1.1.0"
},
"outcome": {
"hashes": [
"sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8",
@@ -525,21 +507,13 @@
"markers": "python_version >= '3.7'",
"version": "==1.3.0.post0"
},
"packaging": {
"hashes": [
"sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4",
"sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"
],
"markers": "python_version >= '3.8'",
"version": "==26.0"
},
"platformdirs": {
"hashes": [
"sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda",
"sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31"
"sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934",
"sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868"
],
"markers": "python_version >= '3.10'",
"version": "==4.5.1"
"version": "==4.9.4"
},
"pycparser": {
"hashes": [
@@ -559,12 +533,11 @@
},
"pyjwt": {
"hashes": [
"sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623",
"sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469"
"sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c",
"sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==2.11.0"
"version": "==2.12.1"
},
"pyserial": {
"hashes": [
@@ -584,11 +557,11 @@
},
"python-dotenv": {
"hashes": [
"sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6",
"sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61"
"sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a",
"sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"
],
"markers": "python_version >= '3.9'",
"version": "==1.2.1"
"markers": "python_version >= '3.10'",
"version": "==1.2.2"
},
"pyyaml": {
"hashes": [
@@ -682,16 +655,15 @@
"sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==2.32.5"
},
"rich": {
"hashes": [
"sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69",
"sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8"
"sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d",
"sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b"
],
"markers": "python_full_version >= '3.8.0'",
"version": "==14.3.2"
"version": "==14.3.3"
},
"rich-click": {
"hashes": [
@@ -703,12 +675,11 @@
},
"selenium": {
"hashes": [
"sha256:a88f5905d88ad0b84991c2386ea39e2bbde6d6c334be38df5842318ba98eaa8c",
"sha256:c8823fc02e2c771d9ad9a0cf899cee7de1a57a6697e3d0b91f67566129f2b729"
"sha256:003e971f805231ad63e671783a2b91a299355d10cefb9de964c36ff3819115aa",
"sha256:b8ccde8d2e7642221ca64af184a92c19eee6accf2e27f20f30472f5efae18eb1"
],
"index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==4.40.0"
"version": "==4.41.0"
},
"sniffio": {
"hashes": [
@@ -725,20 +696,50 @@
],
"version": "==2.4.0"
},
"tibs": {
"hashes": [
"sha256:01ea5258bdf942d21560dc07d532082cd04f07cfef65fedd58ae84f7d0d2562a",
"sha256:0a7ce857ef05c59dc61abadc31c4b9b1e3c62f9e5fb29217988c308936aea71e",
"sha256:130bc68ff500fc8185677df7a97350b5d5339e6ba7e325bc3031337f6424ede7",
"sha256:173dfbecb2309edd9771f453580c88cf251e775613461566b23dbd756b3d54cb",
"sha256:1906729038b85c3b4c040aa28a456d85bc976d0c5007177350eb73374ffa0fd0",
"sha256:1b56583db148e5094d781c3d746815dbcbb6378c6f813c8ce291efd4ab21da8b",
"sha256:1d5521cc6768bfa6282a0c591ba06b079ab91b5c7d5696925ad2abac59779a54",
"sha256:1f95d5db62960205a1e9eba73ce67dc14e7366ae080cd4e5b6f005ebd90faf02",
"sha256:29480bf03e3372a5f9cc59ea0541f76f8efd696d4f0d214715e94247c342a037",
"sha256:2a618de62004d9217d2d2ab0f7f9bbdd098c12642dc01f07b3fb00f0b5f3131a",
"sha256:42725200f1b02687ed6e6a1c01e0ec150dc829d21d901ffc74cc0ac4d821f57f",
"sha256:477608f9b87e24a22ab6d50b81da04a5cb59bfa49598ff7ec5165035a18fb392",
"sha256:4b7510235379368b7523f624d46e0680f3706e3a3965877a6583cdcb598b8bac",
"sha256:501728d096e10d9a165aa526743d47418a6bbfd7b084fa47ecb22be7641d3edb",
"sha256:63255749f937c5e6fedcc7d54e7bd359aef711017e6855f373b0510a14ee2215",
"sha256:6a9feed5931b881809a950eca0e01e757113e2383a2af06a3e6982f110c869e2",
"sha256:76746f01b3db9dbd802f5e615f11f68df7a29ecef521b082dca53f3fa7d0084f",
"sha256:77103a9f1af72ac4cf5006828d0fb21578d19ce55fd990e9a1c8e46fd549561f",
"sha256:7d6592ed93c6748acd39df484c1ee24d40ee247c2a20ca38ba03363506fd24f3",
"sha256:847709c108800ad6a45efaf9a040628278956938a4897f7427a2587013dc3b98",
"sha256:859f05315ffb307d3474c505d694f3a547f00730a024c982f5f60316a5505b3c",
"sha256:a61d36155f8ab8642e1b6744e13822f72050fc7ec4f86ec6965295afa04949e2",
"sha256:a883ca13a922a66b2c1326a9c188123a574741a72510a4bf52fd6f97db191e44",
"sha256:ac0aa2aae38f7325c91c261ce1d18f769c4c7033c98d6ea3ea5534585cf16452",
"sha256:ace018a057459e3dccd06a4aae1c5c8cd57e352b263dcef534ae39bf3e03b5cf",
"sha256:ad61df93b50f875b277ab736c5d37b6bce56f9abce489a22f4e02d9daa2966e3",
"sha256:b9535dc7b7484904a58b51bd8e64da7efbf1d8466ff7e84ed1d78f4ddc561c99",
"sha256:d4f3ff613d486650816bc5516760c0382a2cc0ca8aeddd8914d011bc3b81d9a2",
"sha256:e13b9c7ff2604b0146772025e1ac6f85c8c625bf6ac73736ff671eaf357dda41",
"sha256:f5eea45851c960628a2bd29847765d55e19a687c5374456ad2c8cf6410eb1efa",
"sha256:f70bd250769381c73110d6f24feaf8b6fcd44f680b3cb28a20ea06db3d04fb6f"
],
"markers": "python_version >= '3.8'",
"version": "==0.5.7"
},
"trio": {
"hashes": [
"sha256:150f29ec923bcd51231e1d4c71c7006e65247d68759dd1c19af4ea815a25806b",
"sha256:4ab65984ef8370b79a76659ec87aa3a30c5c7c83ff250b4de88c29a8ab6123c5"
"sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b",
"sha256:a29b92b73f09d4b48ed249acd91073281a7f1063f09caba5dc70465b5c7aa970"
],
"markers": "python_version >= '3.10'",
"version": "==0.32.0"
},
"trio-typing": {
"hashes": [
"sha256:065ee684296d52a8ab0e2374666301aec36ee5747ac0e7a61f230250f8907ac3",
"sha256:6d0e7ec9d837a2fe03591031a172533fbf4a1a95baf369edebfc51d5a49f0264"
],
"version": "==0.10.0"
"version": "==0.33.0"
},
"trio-websocket": {
"hashes": [
@@ -748,20 +749,6 @@
"markers": "python_version >= '3.8'",
"version": "==0.12.2"
},
"types-certifi": {
"hashes": [
"sha256:72cf7798d165bc0b76e1c10dd1ea3097c7063c42c21d664523b928e88b554a4f",
"sha256:b2d1e325e69f71f7c78e5943d410e650b4707bb0ef32e4ddf3da37f54176e88a"
],
"version": "==2021.10.8.3"
},
"types-urllib3": {
"hashes": [
"sha256:229b7f577c951b8c1b92c1bc2b2fdb0b49847bd2af6d1cc2a2e3dd340f3bda8f",
"sha256:9683bbb7fb72e32bfe9d2be6e04875fbe1b3eeec3cbb4ea231435aa7fd6b4f0e"
],
"version": "==1.26.25.14"
},
"typing-extensions": {
"hashes": [
"sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466",
@@ -771,9 +758,6 @@
"version": "==4.15.0"
},
"urllib3": {
"extras": [
"socks"
],
"hashes": [
"sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed",
"sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"
@@ -894,7 +878,6 @@
"sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==1.1.1"
},
"websocket-client": {
@@ -912,14 +895,6 @@
],
"markers": "python_version >= '3.10'",
"version": "==1.3.2"
},
"zipp": {
"hashes": [
"sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e",
"sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"
],
"markers": "python_version >= '3.9'",
"version": "==3.23.0"
}
},
"develop": {}

View File

@@ -1,2 +1,6 @@
# led-controller
## Run on port 80 without root
Run once: `sudo scripts/setup-port80.sh`. Then start the app with: `pipenv run run`.

View File

@@ -1,2 +0,0 @@
#!/usr/bin/env sh
rm -f /home/pi/led-controller/.cursor/debug.log

1
db/device.json Normal file
View File

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

View File

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

View File

@@ -1,12 +1 @@
{
"1": [
"#FF0000",
"#00FF00",
"#0000FF",
"#FFFF00",
"#FF00FF",
"#00FFFF",
"#FFFFFF",
"#000000"
]
}
{"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"]}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,263 +1,297 @@
# LED Driver ESPNow API Documentation
# LED Controller API
This document describes the ESPNow message format for controlling LED driver devices.
This document covers:
## Message Format
1. **HTTP and WebSocket** exposed by the Raspberry Pi app (`src/main.py`) — profiles, presets, transport send, and related resources.
2. **LED driver JSON** — the compact message format sent over the serial→ESP-NOW bridge to devices (same logical API as ESP-NOW payloads).
All messages are JSON objects sent via ESPNow with the following structure:
Default listen address: `0.0.0.0`. Port defaults to **80**; override with the `PORT` environment variable (see `pipenv run run`).
All JSON APIs use `Content-Type: application/json` for bodies and responses unless noted.
---
## Session and scoping
Several routes use **`@with_session`**: the server stores a **current profile** in the session (cookie). Endpoints that scope data to “the current profile” (notably **`/presets`**) only return or mutate presets whose `profile_id` matches that session value.
Profiles are selected with **`POST /profiles/<id>/apply`**, which sets `current_profile` in the session.
---
## Static pages and assets
| Method | Path | Description |
|--------|------|-------------|
| GET | `/` | Main UI (`templates/index.html`) |
| GET | `/settings` | Settings page (`templates/settings.html`) |
| GET | `/favicon.ico` | Empty response (204) |
| GET | `/static/<path>` | Static files under `src/static/` |
---
## WebSocket: `/ws`
Connect to **`ws://<host>:<port>/ws`**.
- Send **JSON**: the object is forwarded to the transport (serial bridge → ESP-NOW) as JSON. Optional key **`to`**: 12-character hex MAC address; if present it is removed from the object and the payload is sent to that peer; otherwise the default destination is used.
- Send **non-JSON text**: forwarded as raw bytes with the default address.
- On send failure, the server may reply with `{"error": "Send failed"}`.
---
## HTTP API by resource
Below, `<id>` values are string identifiers used by the JSON stores (numeric strings in practice).
### Settings — `/settings`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/settings` | Full settings object (from `settings.json` / `Settings` model). |
| PUT | `/settings/settings` | Merge keys into settings and save. Returns `{"message": "Settings updated successfully"}`. |
| GET | `/settings/wifi/ap` | Saved WiFi AP fields: `saved_ssid`, `saved_password`, `saved_channel`, `active` (Pi: `active` is always false). |
| POST | `/settings/wifi/ap` | Body: `ssid` (required), `password`, `channel` (111). Persists AP-related settings. |
| GET | `/settings/page` | Serves `templates/settings.html` (same page as `GET /settings` from the root app, for convenience). |
### Profiles — `/profiles`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/profiles` | `{"profiles": {...}, "current_profile_id": "<id>"}`. Ensures a default current profile when possible. |
| GET | `/profiles/current` | `{"id": "...", "profile": {...}}` |
| GET | `/profiles/<id>` | Single profile. If `<id>` is `current`, same as `/profiles/current`. |
| POST | `/profiles` | Create profile. Body may include `name` and other fields. Returns `{ "<id>": { ... } }` with status 201. |
| POST | `/profiles/<id>/apply` | Sets session current profile to `<id>`. |
| POST | `/profiles/<id>/clone` | Clone profile (tabs, palettes, presets). Body may include `name`. |
| PUT | `/profiles/current` | Update the current profile (from session). |
| PUT | `/profiles/<id>` | Update profile by id. |
| DELETE | `/profiles/<id>` | Delete profile. |
### Presets — `/presets`
Scoped to **current profile** in session (see above).
| Method | Path | Description |
|--------|------|-------------|
| GET | `/presets` | Map of preset id → preset object for the current profile only. |
| GET | `/presets/<id>` | One preset, 404 if missing or wrong profile. |
| POST | `/presets` | Create preset; server assigns id and sets `profile_id`. Body fields stored on the preset. Returns `{ "<id>": { ... } }`, 201. |
| PUT | `/presets/<id>` | Update preset (must belong to current profile). |
| DELETE | `/presets/<id>` | Delete preset. |
| POST | `/presets/send` | Push presets to the LED driver over the configured transport (see below). |
**`POST /presets/send` body:**
```json
{
"preset_ids": ["1", "2"],
"save": true,
"default": "1",
"destination_mac": "aabbccddeeff"
}
```
- **`preset_ids`** (or **`ids`**): non-empty list of preset ids to include.
- **`save`**: if true, the outgoing message includes `"save": true` so the driver may persist presets (default true).
- **`default`**: optional preset id string; forwarded as top-level `"default"` in the driver message (startup selection on device).
- **`destination_mac`** (or **`to`**): optional 12-character hex MAC for unicast; omitted uses the transport default (e.g. broadcast).
Response on success includes `presets_sent`, `messages_sent` (chunking splits payloads so each JSON string stays ≤ 240 bytes).
### Tabs — `/tabs`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/tabs` | `tabs`, `tab_order`, `current_tab_id`, `profile_id` for the session-backed profile. |
| GET | `/tabs/current` | Current tab from cookie/session. |
| POST | `/tabs` | Create tab; optional JSON `name`, `names`, `presets`; can append to current profiles tab list. |
| GET | `/tabs/<id>` | Tab JSON. |
| PUT | `/tabs/<id>` | Update tab. |
| DELETE | `/tabs/<id>` | Delete tab; can delete `current` to remove the active tab; updates profile tab list. |
| POST | `/tabs/<id>/set-current` | Sets `current_tab` cookie. |
| POST | `/tabs/<id>/clone` | Clone tab into current profile. |
### Palettes — `/palettes`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/palettes` | Map of id → color list. |
| GET | `/palettes/<id>` | `{"colors": [...], "id": "<id>"}` |
| POST | `/palettes` | Body may include `colors`. Returns palette object with `id`, 201. |
| PUT | `/palettes/<id>` | Update colors (`name` ignored). |
| DELETE | `/palettes/<id>` | Delete palette. |
### Groups — `/groups`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/groups` | All groups. |
| GET | `/groups/<id>` | One group. |
| POST | `/groups` | Create; optional `name` and fields. |
| PUT | `/groups/<id>` | Update. |
| DELETE | `/groups/<id>` | Delete. |
### Scenes — `/scenes`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/scenes` | All scenes. |
| GET | `/scenes/<id>` | One scene. |
| POST | `/scenes` | Create (body JSON stored on scene). |
| PUT | `/scenes/<id>` | Update. |
| DELETE | `/scenes/<id>` | Delete. |
### Sequences — `/sequences`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/sequences` | All sequences. |
| GET | `/sequences/<id>` | One sequence. |
| POST | `/sequences` | Create; may use `group_name`, `presets` in body. |
| PUT | `/sequences/<id>` | Update. |
| DELETE | `/sequences/<id>` | Delete. |
### Patterns — `/patterns`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/patterns/definitions` | Contents of `pattern.json` (pattern metadata for the UI). |
| GET | `/patterns` | All pattern records. |
| GET | `/patterns/<id>` | One pattern. |
| POST | `/patterns` | Create (`name`, optional `data`). |
| PUT | `/patterns/<id>` | Update. |
| DELETE | `/patterns/<id>` | Delete. |
---
## LED driver message format (transport / ESP-NOW)
Messages are JSON objects. The Pi **`build_message()`** helper (`src/util/espnow_message.py`) produces the same shape sent over serial and forwarded by the ESP32 bridge.
### Top-level fields
```json
{
"v": "1",
"presets": { ... },
"select": { ... }
"presets": { },
"select": { },
"save": true,
"default": "preset_id",
"b": 255
}
```
### Version Field
- **`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).
- **`v`** (required): Message version, must be `"1"`. Messages with other versions are ignored.
### Preset object (wire / driver keys)
## Presets
On the wire, presets use **short keys** (saves space in the ≤240-byte chunks):
Presets define LED patterns with their configuration. Each preset has a name and contains pattern-specific settings.
| Key | Meaning | Notes |
|-----|---------|--------|
| `p` | Pattern id | `off`, `on`, `blink`, `rainbow`, `pulse`, `transition`, `chase`, `circle` |
| `c` | Colors | Array of `"#RRGGBB"` hex strings; converted to RGB on device |
| `d` | Delay ms | Default 100 |
| `b` | Preset brightness | 0255; combined with global `b` on the device |
| `a` | Auto | `true`: run continuously; `false`: one step/cycle per “beat” |
| `n1``n6` | Pattern parameters | See below |
### Preset Structure
The HTTP apps **`POST /presets/send`** path builds this from stored presets via **`build_preset_dict()`** (long names like `pattern` / `colors` in the DB are translated to `p` / `c` / …).
```json
{
"presets": {
"preset_name": {
"pattern": "pattern_type",
"colors": ["#RRGGBB", ...],
"delay": 100,
"brightness": 127,
"auto": true,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0
}
}
}
```
### Preset Fields
- **`pattern`** (required): Pattern type. Options:
- `"off"` - Turn off all LEDs
- `"on"` - Solid color
- `"blink"` - Blinking pattern
- `"rainbow"` - Rainbow color cycle
- `"pulse"` - Pulse/fade pattern
- `"transition"` - Color transition
- `"chase"` - Chasing pattern
- `"circle"` - Circle loading pattern
- **`colors`** (optional): Array of hex color strings (e.g., `"#FF0000"` for red). Default: `["#FFFFFF"]`
- Colors are automatically converted from hex to RGB and reordered based on device color order setting
- Supports multiple colors for patterns that use them
- **`delay`** (optional): Delay in milliseconds between pattern updates. Default: `100`
- **`brightness`** (optional): Brightness level (0-255). Default: `127`
- **`auto`** (optional): Auto mode flag. Default: `true`
- `true`: Pattern runs continuously
- `false`: Pattern advances one step per beat (manual mode)
- **`n1` through `n6`** (optional): Pattern-specific numeric parameters. Default: `0`
- See pattern-specific documentation below
### Pattern-Specific Parameters
### Pattern-specific parameters (`n1``n6`)
#### Rainbow
- **`n1`**: Step increment (how many color wheel positions to advance per update). Default: `1`
- **`n1`**: Step increment on the color wheel per update (default 1).
#### Pulse
- **`n1`**: Attack time in milliseconds (fade in)
- **`n2`**: Hold time in milliseconds (full brightness)
- **`n3`**: Decay time in milliseconds (fade out)
- **`delay`**: Delay time in milliseconds (off between pulses)
- **`n1`**: Attack (fade in) ms
- **`n2`**: Hold ms
- **`n3`**: Decay (fade out) ms
- **`d`**: Off time between pulses ms
#### Transition
- **`delay`**: Transition duration in milliseconds
- **`d`**: Transition duration ms
#### Chase
- **`n1`**: Number of LEDs with first color
- **`n2`**: Number of LEDs with second color
- **`n3`**: Movement amount on even steps (can be negative)
- **`n4`**: Movement amount on odd steps (can be negative)
- **`n1`**: LEDs with first color
- **`n2`**: LEDs with second color
- **`n3`**: Movement on even steps (may be negative)
- **`n4`**: Movement on odd steps (may be negative)
#### Circle
- **`n1`**: Head movement rate (LEDs per second)
- **`n2`**: Maximum length
- **`n3`**: Tail movement rate (LEDs per second)
- **`n4`**: Minimum length
- **`n1`**: Head speed (LEDs/s)
- **`n2`**: Max length
- **`n3`**: Tail speed (LEDs/s)
- **`n4`**: Min length
## Select Messages
Select messages control which preset is active on which device. The format uses a list to support step synchronization.
### Select Format
### Select messages
```json
{
"select": {
"device_name": ["preset_name"],
"device_name2": ["preset_name2", step_value]
"device_name": ["preset_id"],
"other_device": ["preset_id", 10]
}
}
```
### Select Fields
- One element: select preset; step behavior follows driver rules (reset on `off`, etc.).
- Two elements: explicit **step** for sync.
- **`select`**: Object mapping device names to selection lists
- **Key**: Device name (as configured in device settings)
- **Value**: List with one or two elements:
- `["preset_name"]` - Select preset (uses default step behavior)
- `["preset_name", step]` - Select preset with explicit step value (for synchronization)
### Beat and sync behavior
### Step Synchronization
- Sending **`select`** again with the **same** preset name acts as a **beat** (advances manual patterns / restarts generators per driver logic).
- Choosing **`off`** resets step as a sync point; then selecting a pattern aligns step 0 across devices unless a step is passed explicitly.
The step value allows precise synchronization across multiple devices:
- **Without step**: `["preset_name"]`
- If switching to different preset: step resets to 0
- If selecting "off" pattern: step resets to 0
- If selecting same preset (beat): step is preserved, pattern restarts
- **With step**: `["preset_name", 10]`
- Explicitly sets step to the specified value
- Useful for synchronizing multiple devices to the same step
### Beat Functionality
Calling `select()` again with the same preset name acts as a "beat" - it restarts the pattern generator:
- **Single-tick patterns** (rainbow, chase in manual mode): Advance one step per beat
- **Multi-tick patterns** (pulse in manual mode): Run through full cycle per beat
Example beat sequence:
```json
// Beat 1
{"select": {"device1": ["rainbow_preset"]}}
// Beat 2 (same preset = beat)
{"select": {"device1": ["rainbow_preset"]}}
// Beat 3
{"select": {"device1": ["rainbow_preset"]}}
```
## Synchronization
### Using "off" Pattern
Selecting the "off" pattern resets the step counter to 0, providing a synchronization point:
```json
{
"select": {
"device1": ["off"],
"device2": ["off"]
}
}
```
After all devices are "off", switching to a pattern ensures they all start from step 0:
```json
{
"select": {
"device1": ["rainbow_preset"],
"device2": ["rainbow_preset"]
}
}
```
### Using Step Parameter
For precise synchronization, use the step parameter:
```json
{
"select": {
"device1": ["rainbow_preset", 10],
"device2": ["rainbow_preset", 10],
"device3": ["rainbow_preset", 10]
}
}
```
All devices will start at step 10 and advance together on subsequent beats.
## Complete Example
### Example (compact preset map)
```json
{
"v": "1",
"save": true,
"presets": {
"red_blink": {
"pattern": "blink",
"colors": ["#FF0000"],
"delay": 200,
"brightness": 255,
"auto": true
},
"rainbow_manual": {
"pattern": "rainbow",
"delay": 100,
"n1": 2,
"auto": false
},
"pulse_slow": {
"pattern": "pulse",
"colors": ["#00FF00"],
"delay": 500,
"n1": 1000,
"n2": 500,
"n3": 1000,
"auto": false
"1": {
"name": "Red blink",
"p": "blink",
"c": ["#FF0000"],
"d": 200,
"b": 255,
"a": true,
"n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0
}
},
"select": {
"device1": ["red_blink"],
"device2": ["rainbow_manual", 0],
"device3": ["pulse_slow"]
"living-room": ["1"]
}
}
```
## Message Processing
---
1. **Version Check**: Messages with `v != "1"` are rejected
2. **Preset Processing**: Presets are created or updated (upsert behavior)
3. **Color Conversion**: Hex colors are converted to RGB tuples and reordered based on device color order
4. **Selection**: Devices select their assigned preset, optionally with step value
## Processing summary (driver)
## Best Practices
1. Reject if `v != "1"`.
2. Apply optional top-level **`b`** (global brightness).
3. For each entry in **`presets`**, normalize colors and upsert preset by id.
4. If this devices **`name`** appears in **`select`**, run selection (optional step).
5. If **`default`** is set, store startup preset id.
6. If **`save`** is set, persist presets.
1. **Always include version**: Set `"v": "1"` in all messages
2. **Use "off" for sync**: Select "off" pattern to synchronize devices before starting patterns
3. **Beats for manual mode**: Send select messages repeatedly with same preset name to advance manual patterns
4. **Step for precision**: Use step parameter when exact synchronization is required
5. **Color format**: Always use hex strings (`"#RRGGBB"`), conversion is automatic
---
## Error Handling
## Error handling (HTTP)
- Invalid version: Message is ignored
- Missing preset: Selection fails, device keeps current preset
- Invalid pattern: Selection fails, device keeps current preset
- Missing colors: Pattern uses default white color
- Invalid step: Step value is used as-is (may cause unexpected behavior)
Controllers typically return JSON with an **`error`** string and 4xx/5xx status codes. Invalid JSON bodies often yield `{"error": "Invalid JSON"}`.
---
## Notes
- Colors are automatically converted from hex strings to RGB tuples
- Color order reordering happens automatically based on device settings
- Step counter wraps around (0-255 for rainbow, unbounded for others)
- Manual mode patterns stop after one step/cycle, waiting for next beat
- Auto mode patterns run continuously until changed
- **Human-readable preset fields** (`pattern`, `colors`, `delay`, …) are fine in the **web app / database**; the **send path** converts them to **`p` / `c` / `d`** for the driver.
- For a copy of the older long-key reference, see **`led-driver/docs/API.md`** in this repo (conceptually the same behavior; wire format prefers short keys).

View File

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

112
esp32/benchmark_peers.py Normal file
View File

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

66
esp32/main.py Normal file
View File

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

View File

@@ -1,4 +0,0 @@
#!/usr/bin/env bash
# Install script - runs pipenv install
pipenv install "$@"

1
led-driver Submodule

Submodule led-driver added at 4c7646b2fe

1
led-tool Submodule

Submodule led-tool added at 3844aa9d6a

View File

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

View File

@@ -1,173 +0,0 @@
#!/usr/bin/env python3
"""
Local development web server - imports and runs main.py with port 5000
"""
import sys
import os
import asyncio
# Add src and lib to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'lib'))
# Import the main module
from src import main as main_module
# Override the port in the main function
async def run_local():
"""Run main with port 5000 for local development."""
from settings import Settings
import gc
# Mock MicroPython modules for local development
class MockMachine:
class WDT:
def __init__(self, timeout):
pass
def feed(self):
pass
import sys as sys_module
sys_module.modules['machine'] = MockMachine()
class MockESPNow:
def __init__(self):
self.active_value = False
self.peers = []
def active(self, value):
self.active_value = value
print(f"[MOCK] ESPNow active: {value}")
def add_peer(self, peer):
self.peers.append(peer)
print(f"[MOCK] Added peer: {peer.hex() if hasattr(peer, 'hex') else peer}")
async def asend(self, peer, data):
print(f"[MOCK] Would send to {peer.hex() if hasattr(peer, 'hex') else peer}: {data}")
class MockAIOESPNow:
def __init__(self):
pass
def active(self, value):
return MockESPNow()
def add_peer(self, peer):
pass
class MockNetwork:
class WLAN:
def __init__(self, interface):
self.interface = interface
def active(self, value):
print(f"[MOCK] WLAN({self.interface}) active: {value}")
STA_IF = 0
# Replace MicroPython modules with mocks
sys_module.modules['aioespnow'] = type('module', (), {'AIOESPNow': MockESPNow})()
sys_module.modules['network'] = MockNetwork()
# Mock gc if needed
if not hasattr(gc, 'collect'):
class MockGC:
def collect(self):
pass
gc = MockGC()
settings = Settings()
print("Starting LED Controller Web Server (Local Development)")
print("=" * 60)
# Mock network
import network
network.WLAN(network.STA_IF).active(True)
# Mock ESPNow
import aioespnow
e = aioespnow.AIOESPNow()
e.active(True)
e.add_peer(b"\xbb\xbb\xbb\xbb\xbb\xbb")
from microdot import Microdot, send_file
from microdot.websocket import with_websocket
from microdot.session import Session
import controllers.preset as preset
import controllers.profile as profile
import controllers.group as group
import controllers.sequence as sequence
import controllers.tab as tab
import controllers.palette as palette
import controllers.scene as scene
import controllers.pattern as pattern
import controllers.settings as settings_controller
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
app.mount(preset.controller, '/presets')
app.mount(profile.controller, '/profiles')
app.mount(group.controller, '/groups')
app.mount(sequence.controller, '/sequences')
app.mount(tab.controller, '/tabs')
app.mount(palette.controller, '/palettes')
app.mount(scene.controller, '/scenes')
app.mount(pattern.controller, '/patterns')
app.mount(settings_controller.controller, '/settings')
# Serve index.html at root
@app.route('/')
def index(request):
"""Serve the main web UI."""
return send_file('src/templates/index.html')
# Serve settings page
@app.route('/settings')
def settings_page(request):
"""Serve the settings page."""
return send_file('src/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:
return 'Not found', 404
return send_file('src/static/' + path)
@app.route('/ws')
@with_websocket
async def ws(request, ws):
while True:
data = await ws.receive()
if data:
await e.asend(b"\xbb\xbb\xbb\xbb\xbb\xbb", data)
print(data)
else:
break
# Use port 5000 for local development
port = 5000
print(f"Starting server on http://0.0.0.0:{port}")
print(f"Open http://localhost:{port} in your browser")
print("=" * 60)
try:
await app.start_server(host="0.0.0.0", port=port, debug=True)
except KeyboardInterrupt:
print("\nShutting down server...")
if __name__ == '__main__':
# Change to project root
os.chdir(os.path.dirname(os.path.abspath(__file__)))
# Override settings path for local development
import settings as settings_module
settings_module.Settings.SETTINGS_FILE = os.path.join(os.getcwd(), 'settings.json')
asyncio.run(run_local())

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

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

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

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

View File

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

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

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

5
scripts/start.sh Executable file
View File

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

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

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

View File

@@ -1,44 +0,0 @@
#!/usr/bin/env python3
import socket
import struct
import base64
import hashlib
# Connect to the WebSocket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(('192.168.4.1', 80))
# Send HTTP WebSocket upgrade request
key = base64.b64encode(b'test-nonce').decode('utf-8')
request = f'''GET /ws HTTP/1.1\r
Host: 192.168.4.1\r
Upgrade: websocket\r
Connection: Upgrade\r
Sec-WebSocket-Key: {key}\r
Sec-WebSocket-Version: 13\r
\r
'''
s.send(request.encode())
# Read upgrade response
response = s.recv(4096)
print(response.decode())
# Send WebSocket TEXT frame with empty JSON '{}'
payload = b'{}'
mask = b'\x12\x34\x56\x78'
payload_masked = bytes(p ^ mask[i % 4] for i, p in enumerate(payload))
frame = struct.pack('BB', 0x81, 0x80 | len(payload))
frame += mask
frame += payload_masked
s.send(frame)
print("Sent empty JSON to WebSocket")
s.close()

View File

@@ -1 +0,0 @@
{"session_secret_key": "06a2a6ac631cc8db0658373b37f7fe922a8a3ed3a27229e1adb5448cb368f2b7"}

View File

@@ -1,8 +1,6 @@
import settings
import util.wifi as wifi
# Boot script (ESP only; no-op on Pi)
import settings # noqa: F401
from settings import Settings
s = Settings()
name = s.get('name', 'led-controller')
wifi.ap(name, '')
# AP setup was here when running on ESP; Pi uses system networking.

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

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

View File

@@ -17,9 +17,9 @@ async def list_palettes(request):
@controller.get('/<id>')
async def get_palette(request, id):
"""Get a specific palette by ID."""
palette = palettes.read(id)
if palette:
return json.dumps({"colors": palette, "id": str(id)}), 200, {'Content-Type': 'application/json'}
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('')
@@ -30,11 +30,8 @@ async def create_palette(request):
colors = data.get("colors", None)
# Palette no longer needs a name; only colors are stored.
palette_id = palettes.create("", colors)
palette = palettes.read(palette_id) or {}
# Include the ID in the response payload so clients can link it.
palette_with_id = {"id": str(palette_id)}
palette_with_id.update(palette)
return json.dumps(palette_with_id), 201, {'Content-Type': 'application/json'}
created_colors = palettes.read(palette_id) or []
return json.dumps({"id": str(palette_id), "colors": created_colors}), 201, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 400
@@ -47,10 +44,8 @@ async def update_palette(request, id):
if "name" in data:
data.pop("name", None)
if palettes.update(id, data):
palette = palettes.read(id) or {}
palette_with_id = {"id": str(id)}
palette_with_id.update(palette)
return json.dumps(palette_with_id), 200, {'Content-Type': 'application/json'}
colors = palettes.read(id) or []
return json.dumps({"id": str(id), "colors": colors}), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Palette not found"}), 404
except Exception as e:
return json.dumps({"error": str(e)}), 400

View File

@@ -2,7 +2,7 @@ from microdot import Microdot
from microdot.session import with_session
from models.preset import Preset
from models.profile import Profile
from models.espnow import ESPNow
from models.transport import get_current_sender
from util.espnow_message import build_message, build_preset_dict
import asyncio
import json
@@ -36,11 +36,11 @@ async def list_presets(request, session):
}
return json.dumps(scoped), 200, {'Content-Type': 'application/json'}
@controller.get('/<id>')
@controller.get('/<preset_id>')
@with_session
async def get_preset(request, id, session):
async def get_preset(request, session, preset_id):
"""Get a specific preset by ID (current profile only)."""
preset = presets.read(id)
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'}
@@ -70,12 +70,12 @@ async def create_preset(request, session):
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.put('/<id>')
@controller.put('/<preset_id>')
@with_session
async def update_preset(request, id, session):
async def update_preset(request, session, preset_id):
"""Update an existing preset (current profile only)."""
try:
preset = presets.read(id)
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
@@ -87,21 +87,36 @@ async def update_preset(request, id, session):
data = {}
data = dict(data)
data["profile_id"] = str(current_profile_id)
if presets.update(id, data):
return json.dumps(presets.read(id)), 200, {'Content-Type': 'application/json'}
if presets.update(preset_id, data):
return json.dumps(presets.read(preset_id)), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Preset not found"}), 404
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.delete('/<id>')
@controller.delete('/<preset_id>')
@with_session
async def delete_preset(request, id, session):
async def delete_preset(request, *args, **kwargs):
"""Delete a preset (current profile only)."""
preset = presets.read(id)
# Be tolerant of wrapper/arg-order variations.
session = None
preset_id = None
if len(args) > 0:
session = args[0]
if len(args) > 1:
preset_id = args[1]
if 'session' in kwargs and kwargs.get('session') is not None:
session = kwargs.get('session')
if 'preset_id' in kwargs and kwargs.get('preset_id') is not None:
preset_id = kwargs.get('preset_id')
if 'id' in kwargs and kwargs.get('id') is not None and preset_id is None:
preset_id = kwargs.get('id')
if preset_id is None:
return json.dumps({"error": "Preset ID is required"}), 400
preset = presets.read(preset_id)
current_profile_id = get_current_profile_id(session)
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
return json.dumps({"error": "Preset not found"}), 404
if presets.delete(id):
if presets.delete(preset_id):
return json.dumps({"message": "Preset deleted successfully"}), 200
return json.dumps({"error": "Preset not found"}), 404
@@ -110,16 +125,13 @@ async def delete_preset(request, id, session):
@with_session
async def send_presets(request, session):
"""
Send one or more presets over ESPNow.
Send one or more presets to the LED driver (via serial transport).
Body JSON:
{"preset_ids": ["1", "2", ...]} or {"ids": ["1", "2", ...]}
The controller:
- looks up each preset in the Preset model
- converts them to API-compliant format
- splits into <= 240-byte ESPNow messages
- sends each message to all configured ESPNow peers.
The controller looks up each preset, converts to API format, chunks into
<= 240-byte messages, and sends them over the configured transport.
"""
try:
data = request.json or {}
@@ -132,6 +144,8 @@ async def send_presets(request, session):
save_flag = data.get('save', True)
save_flag = bool(save_flag)
default_id = data.get('default')
# Optional 12-char hex MAC to send to one device; omit for default (e.g. broadcast).
destination_mac = data.get('destination_mac') or data.get('to')
# Build API-compliant preset map keyed by preset ID, include name
current_profile_id = get_current_profile_id(session)
@@ -153,16 +167,17 @@ async def send_presets(request, session):
if default_id is not None and str(default_id) not in presets_by_name:
default_id = None
# Use shared ESPNow singleton
esp = ESPNow()
sender = get_current_sender()
if not sender:
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
async def send_chunk(chunk_presets):
# Include save flag so the led-driver can persist when desired.
msg = build_message(presets=chunk_presets, save=save_flag, default=default_id)
await esp.send(msg)
await sender.send(msg, addr=destination_mac)
MAX_BYTES = 240
SEND_DELAY_MS = 100
send_delay_s = 0.1
entries = list(presets_by_name.items())
total_presets = len(entries)
messages_sent = 0
@@ -182,8 +197,8 @@ async def send_presets(request, session):
try:
await send_chunk(batch)
except Exception:
return json.dumps({"error": "ESP-NOW send failed"}), 503, {'Content-Type': 'application/json'}
await asyncio.sleep_ms(SEND_DELAY_MS)
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
await asyncio.sleep(send_delay_s)
messages_sent += 1
batch = {name: preset_obj}
last_msg = build_message(presets=batch, save=save_flag, default=default_id)
@@ -192,12 +207,12 @@ async def send_presets(request, session):
try:
await send_chunk(batch)
except Exception:
return json.dumps({"error": "ESP-NOW send failed"}), 503, {'Content-Type': 'application/json'}
await asyncio.sleep_ms(SEND_DELAY_MS)
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
await asyncio.sleep(send_delay_s)
messages_sent += 1
return json.dumps({
"message": "Presets sent via ESPNow",
"message": "Presets sent",
"presets_sent": total_presets,
"messages_sent": messages_sent
}), 200, {'Content-Type': 'application/json'}

View File

@@ -81,11 +81,117 @@ async def apply_profile(request, session, id):
async def create_profile(request):
"""Create a new profile."""
try:
data = request.json or {}
data = dict(request.json or {})
name = data.get("name", "")
seed_raw = data.get("seed_dj_tab", False)
if isinstance(seed_raw, str):
seed_dj_tab = seed_raw.strip().lower() in ("1", "true", "yes", "on")
else:
seed_dj_tab = bool(seed_raw)
# Request-only flag: do not persist on profile records.
data.pop("seed_dj_tab", None)
profile_id = profiles.create(name)
# Avoid persisting request-only fields.
data.pop("name", None)
if data:
profiles.update(profile_id, data)
# New profiles always start with a default tab pre-populated with starter presets.
default_preset_ids = []
default_preset_defs = [
{
"name": "on",
"pattern": "on",
"colors": ["#FFFFFF"],
"brightness": 255,
"delay": 100,
"auto": True,
},
{
"name": "off",
"pattern": "off",
"colors": [],
"brightness": 0,
"delay": 100,
"auto": True,
},
{
"name": "rainbow",
"pattern": "rainbow",
"colors": [],
"brightness": 255,
"delay": 100,
"auto": True,
"n1": 2,
},
{
"name": "transition",
"pattern": "transition",
"colors": ["#FF0000", "#00FF00", "#0000FF"],
"brightness": 255,
"delay": 500,
"auto": True,
},
]
for preset_data in default_preset_defs:
pid = presets.create(profile_id)
presets.update(pid, preset_data)
default_preset_ids.append(str(pid))
default_tab_id = tabs.create(name="default", names=["1"], presets=[default_preset_ids])
tabs.update(default_tab_id, {
"presets_flat": default_preset_ids,
"default_preset": default_preset_ids[0] if default_preset_ids else None,
})
profile = profiles.read(profile_id) or {}
profile_tabs = profile.get("tabs", []) if isinstance(profile.get("tabs", []), list) else []
profile_tabs.append(str(default_tab_id))
if seed_dj_tab:
# Seed a DJ-focused tab with three starter presets.
seeded_preset_ids = []
preset_defs = [
{
"name": "DJ Rainbow",
"pattern": "rainbow",
"colors": [],
"brightness": 220,
"delay": 60,
"n1": 12,
},
{
"name": "DJ Single Color",
"pattern": "on",
"colors": ["#ff00ff"],
"brightness": 220,
"delay": 100,
},
{
"name": "DJ Transition",
"pattern": "transition",
"colors": ["#ff0000", "#00ff00", "#0000ff"],
"brightness": 220,
"delay": 250,
},
]
for preset_data in preset_defs:
pid = presets.create(profile_id)
presets.update(pid, preset_data)
seeded_preset_ids.append(str(pid))
dj_tab_id = tabs.create(name="dj", names=["dj"], presets=[seeded_preset_ids])
tabs.update(dj_tab_id, {
"presets_flat": seeded_preset_ids,
"default_preset": seeded_preset_ids[0] if seeded_preset_ids else None,
})
profile_tabs.append(str(dj_tab_id))
profiles.update(profile_id, {"tabs": profile_tabs})
profile_data = profiles.read(profile_id)
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
except Exception as e:

View File

@@ -1,6 +1,5 @@
from microdot import Microdot, send_file
from settings import Settings
import util.wifi as wifi
import json
controller = Microdot()
@@ -15,19 +14,18 @@ async def get_settings(request):
@controller.get('/wifi/ap')
async def get_ap_config(request):
"""Get Access Point configuration."""
config = wifi.get_ap_config()
if config:
# Also get saved settings
config['saved_ssid'] = settings.get('wifi_ap_ssid')
config['saved_password'] = settings.get('wifi_ap_password')
config['saved_channel'] = settings.get('wifi_ap_channel')
return json.dumps(config), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Failed to get AP config"}), 500
"""Get saved AP configuration (Pi: no in-device AP)."""
config = {
'saved_ssid': settings.get('wifi_ap_ssid'),
'saved_password': settings.get('wifi_ap_password'),
'saved_channel': settings.get('wifi_ap_channel'),
'active': False,
}
return json.dumps(config), 200, {'Content-Type': 'application/json'}
@controller.post('/wifi/ap')
async def configure_ap(request):
"""Configure Access Point."""
"""Save AP configuration to settings (Pi: no in-device AP)."""
try:
data = request.json
ssid = data.get('ssid')
@@ -43,18 +41,14 @@ async def configure_ap(request):
if channel < 1 or channel > 11:
return json.dumps({"error": "Channel must be between 1 and 11"}), 400
# Save to settings
settings['wifi_ap_ssid'] = ssid
settings['wifi_ap_password'] = password
if channel is not None:
settings['wifi_ap_channel'] = channel
settings.save()
# Configure AP
wifi.ap(ssid, password, channel)
return json.dumps({
"message": "AP configured successfully",
"message": "AP settings saved",
"ssid": ssid,
"channel": channel
}), 200, {'Content-Type': 'application/json'}

View File

@@ -1,14 +1,11 @@
import asyncio
import gc
import json
import machine
from machine import Pin
import os
from microdot import Microdot, send_file
from microdot.websocket import with_websocket
from microdot.session import Session
from settings import Settings
import aioespnow
import controllers.preset as preset
import controllers.profile as profile
import controllers.group as group
@@ -18,7 +15,7 @@ import controllers.palette as palette
import controllers.scene as scene
import controllers.pattern as pattern
import controllers.settings as settings_controller
from models.espnow import ESPNow
from models.transport import get_sender, set_sender
async def main(port=80):
@@ -26,8 +23,9 @@ async def main(port=80):
print(settings)
print("Starting")
# Initialize ESPNow singleton (config + peers)
esp = ESPNow()
# Initialize transport (serial to ESP32 bridge)
sender = get_sender(settings)
set_sender(sender)
app = Microdot()
@@ -58,7 +56,7 @@ async def main(port=80):
app.mount(pattern.controller, '/patterns')
app.mount(settings_controller.controller, '/settings')
# Serve index.html at root
# Serve index.html at root (cwd is src/ when run via pipenv run run)
@app.route('/')
def index(request):
"""Serve the main web UI."""
@@ -91,19 +89,25 @@ async def main(port=80):
data = await ws.receive()
print(data)
if data:
# Debug: log incoming WebSocket data
try:
parsed = json.loads(data)
print("WS received JSON:", parsed)
except Exception:
print("WS received raw:", data)
# Forward raw JSON payload over ESPNow to configured peers
try:
await esp.send(data)
# 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": "ESP-NOW send failed"}))
await ws.send(json.dumps({"error": "Send failed"}))
except Exception:
pass
else:
@@ -113,25 +117,11 @@ async def main(port=80):
server = asyncio.create_task(app.start_server(host="0.0.0.0", port=port))
#wdt = machine.WDT(timeout=10000)
#wdt.feed()
# Initialize heartbeat LED (XIAO ESP32S3 built-in LED on GPIO 21)
led = Pin(15, Pin.OUT)
led_state = False
while True:
gc.collect()
for i in range(60):
#wdt.feed()
# Heartbeat: toggle LED every 500 ms
led.value(not led.value())
await asyncio.sleep_ms(500)
await asyncio.sleep(30)
# cleanup before ending the application
if __name__ == "__main__":
asyncio.run(main())
import os
port = int(os.environ.get("PORT", 80))
asyncio.run(main(port=port))

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

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

View File

@@ -1,69 +0,0 @@
import network
import aioespnow
class ESPNow:
"""
Singleton ESPNow helper:
- Manages a single AIOESPNow instance
- Adds a single broadcast-like peer
- Exposes async send(data) to send to that peer.
"""
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if getattr(self, "_initialized", False):
return
# ESP-NOW requires a WiFi interface to be active (STA or AP). Activate STA
# so ESP-NOW has an interface to use; we don't need to connect to an AP.
try:
sta = network.WLAN(network.STA_IF)
sta.active(True)
except Exception as e:
print("ESPNow: STA active failed:", e)
self._esp = aioespnow.AIOESPNow()
self._esp.active(True)
try:
self._esp.add_peer(b"\xff\xff\xff\xff\xff\xff")
except Exception:
# Ignore add_peer failures (e.g. duplicate)
pass
self._initialized = True
async def send(self, data):
"""
Async send to the broadcast peer.
- data: bytes or str (JSON)
"""
if isinstance(data, str):
payload = data.encode()
else:
payload = data
# Debug: show what we're sending and its size
try:
preview = payload.decode('utf-8')
except Exception:
preview = str(payload)
if len(preview) > 200:
preview = preview[:200] + "...(truncated)"
print("ESPNow.send len=", len(payload), "payload=", preview)
try:
await self._esp.asend(b"\xff\xff\xff\xff\xff\xff", payload)
except Exception as e:
print("ESPNow.send error:", e)
raise

View File

@@ -1,5 +1,15 @@
import json
import os
import traceback
# DB directory: project root / db (writable without root)
def _db_dir():
try:
# src/models/model.py -> project root
base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
return os.path.join(base, "db")
except Exception:
return "db"
class Model(dict):
def __new__(cls, *args, **kwargs):
@@ -13,13 +23,13 @@ class Model(dict):
if hasattr(self, '_initialized'):
return
# Create /db directory if it doesn't exist (MicroPython compatible)
db_dir = _db_dir()
try:
os.mkdir("/db")
os.makedirs(db_dir, exist_ok=True)
except OSError:
pass # Directory already exists, which is fine
pass
self.class_name = self.__class__.__name__
self.file = f"/db/{self.class_name.lower()}.json"
self.file = os.path.join(db_dir, f"{self.class_name.lower()}.json")
super().__init__()
self.load() # Load settings from file during initialization
@@ -37,11 +47,11 @@ class Model(dict):
def save(self):
try:
# Ensure directory exists
db_dir = os.path.dirname(self.file)
try:
os.mkdir("/db")
os.makedirs(db_dir, exist_ok=True)
except OSError:
pass # Directory already exists
pass
j = json.dumps(self)
with open(self.file, 'w') as file:
file.write(j)
@@ -54,8 +64,7 @@ class Model(dict):
print(f"{self.class_name} saved successfully to {self.file}")
except Exception as e:
print(f"Error saving {self.class_name} to {self.file}: {e}")
import sys
sys.print_exception(e)
traceback.print_exception(type(e), e, e.__traceback__)
def load(self):
try:

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

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

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

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

View File

@@ -1,39 +0,0 @@
import network
import aioespnow
import asyncio
import json
from time import sleep
class P2P:
def __init__(self):
network.WLAN(network.STA_IF).active(True)
self.broadcast = bytes.fromhex("ffffffffffff")
self.e = aioespnow.AIOESPNow()
self.e.active(True)
try:
self.e.add_peer(self.broadcast)
except:
pass
async def send(self, data):
# Convert data to bytes if it's a string or dict
if isinstance(data, str):
payload = data.encode()
elif isinstance(data, dict):
payload = json.dumps(data).encode()
else:
payload = data # Assume it's already bytes
# Use asend for async sending - returns boolean indicating success
result = await self.e.asend(self.broadcast, payload)
return result
async def main():
p = P2P()
await p.send(json.dumps({"dj": {"p": "on", "colors": ["#ff0000"]}}))
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -2,11 +2,23 @@ import json
import os
import binascii
def _settings_path():
"""Path to settings.json in project root (writable without root)."""
try:
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
return os.path.join(base, "settings.json")
except Exception:
return "settings.json"
class Settings(dict):
SETTINGS_FILE = "/settings.json"
SETTINGS_FILE = None # Set in __init__ from _settings_path()
def __init__(self):
super().__init__()
if Settings.SETTINGS_FILE is None:
Settings.SETTINGS_FILE = _settings_path()
self.load() # Load settings from file during initialization
def generate_secret_key(self):

View File

@@ -4,7 +4,6 @@ document.addEventListener('DOMContentLoaded', () => {
const closeButton = document.getElementById('color-palette-close-btn');
const paletteContainer = document.getElementById('palette-container');
const paletteNewColor = document.getElementById('palette-new-color');
const paletteAddButton = document.getElementById('palette-add-color-btn');
const profileNameDisplay = document.getElementById('palette-current-profile-name');
if (!paletteButton || !paletteModal || !paletteContainer) {
@@ -177,8 +176,8 @@ document.addEventListener('DOMContentLoaded', () => {
if (closeButton) {
closeButton.addEventListener('click', closeModal);
}
if (paletteAddButton && paletteNewColor) {
paletteAddButton.addEventListener('click', async () => {
if (paletteNewColor) {
const addSelectedColor = async () => {
const color = paletteNewColor.value;
if (!color) {
return;
@@ -188,7 +187,9 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
await savePalette([...currentPalette, color]);
});
};
// Add when the picker closes (user confirms selection).
paletteNewColor.addEventListener('change', addSelectedColor);
}
paletteModal.addEventListener('click', (event) => {
if (event.target === paletteModal) {

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

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

View File

@@ -2,6 +2,72 @@
let espnowSocket = null;
let espnowSocketReady = false;
let espnowPendingMessages = [];
let currentProfileIdCache = null;
const getCurrentProfileId = async () => {
try {
const res = await fetch('/profiles/current', { headers: { Accept: 'application/json' } });
if (!res.ok) return currentProfileIdCache ? String(currentProfileIdCache) : null;
const data = await res.json();
const id = data && (data.id || (data.profile && data.profile.id));
currentProfileIdCache = id ? String(id) : null;
return currentProfileIdCache;
} catch (_) {
return currentProfileIdCache ? String(currentProfileIdCache) : null;
}
};
const filterPresetsForCurrentProfile = async (presetsObj) => {
const scoped = presetsObj && typeof presetsObj === 'object' ? presetsObj : {};
const currentProfileId = await getCurrentProfileId();
if (!currentProfileId) return scoped;
return Object.fromEntries(
Object.entries(scoped).filter(([, preset]) => {
if (!preset || typeof preset !== 'object') return false;
if (!('profile_id' in preset)) return true; // Legacy records
return String(preset.profile_id) === String(currentProfileId);
}),
);
};
const getCurrentProfileData = async () => {
try {
const res = await fetch('/profiles/current', { headers: { Accept: 'application/json' } });
if (!res.ok) return null;
return await res.json();
} catch (_) {
return null;
}
};
const getCurrentProfilePaletteColors = async () => {
const profileData = await getCurrentProfileData();
const profile = profileData && profileData.profile;
const paletteId = profile && (profile.palette_id || profile.paletteId);
if (!paletteId) return [];
try {
const res = await fetch(`/palettes/${paletteId}`, { headers: { Accept: 'application/json' } });
if (!res.ok) return [];
const pal = await res.json();
return Array.isArray(pal.colors) ? pal.colors : [];
} catch (_) {
return [];
}
};
const resolveColorsWithPaletteRefs = (colors, paletteRefs, paletteColors) => {
const baseColors = Array.isArray(colors) ? colors : [];
const refs = Array.isArray(paletteRefs) ? paletteRefs : [];
const pal = Array.isArray(paletteColors) ? paletteColors : [];
return baseColors.map((color, idx) => {
const refRaw = refs[idx];
const ref = Number.isInteger(refRaw) ? refRaw : parseInt(refRaw, 10);
if (Number.isInteger(ref) && ref >= 0 && ref < pal.length && pal[ref]) {
return pal[ref];
}
return color;
});
};
const getEspnowSocket = () => {
if (espnowSocket && (espnowSocket.readyState === WebSocket.OPEN || espnowSocket.readyState === WebSocket.CONNECTING)) {
@@ -105,7 +171,6 @@ document.addEventListener('DOMContentLoaded', () => {
const presetPatternInput = document.getElementById('preset-pattern-input');
const presetColorsContainer = document.getElementById('preset-colors-container');
const presetNewColorInput = document.getElementById('preset-new-color');
const presetAddColorButton = document.getElementById('preset-add-color-btn');
const presetBrightnessInput = document.getElementById('preset-brightness-input');
const presetDelayInput = document.getElementById('preset-delay-input');
const presetDefaultButton = document.getElementById('preset-default-btn');
@@ -123,6 +188,7 @@ document.addEventListener('DOMContentLoaded', () => {
let cachedPresets = {};
let cachedPatterns = {};
let currentPresetColors = []; // Track colors for the current preset
let currentPresetPaletteRefs = []; // Palette index refs per color (null for direct colors)
// Function to get max colors for current pattern
const getMaxColors = () => {
@@ -158,7 +224,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Hide/show the actions (color picker and buttons)
const colorActions = presetColorsContainer.nextElementSibling;
if (colorActions && (colorActions.querySelector('#preset-add-color-btn') || colorActions.querySelector('#preset-new-color'))) {
if (colorActions && colorActions.querySelector('#preset-new-color')) {
colorActions.style.display = shouldShow ? '' : 'none';
}
}
@@ -172,11 +238,24 @@ document.addEventListener('DOMContentLoaded', () => {
return parseInt(input.value, 10) || 0;
};
const renderPresetColors = (colors) => {
const renderPresetColors = (colors, paletteRefs) => {
if (!presetColorsContainer) return;
presetColorsContainer.innerHTML = '';
currentPresetColors = colors || [];
currentPresetColors = Array.isArray(colors) ? colors.slice() : [];
if (Array.isArray(paletteRefs)) {
currentPresetPaletteRefs = currentPresetColors.map((_, i) => {
const refRaw = paletteRefs[i];
const ref = Number.isInteger(refRaw) ? refRaw : parseInt(refRaw, 10);
return Number.isInteger(ref) ? ref : null;
});
} else {
currentPresetPaletteRefs = currentPresetColors.map((_, i) => {
const refRaw = currentPresetPaletteRefs[i];
const ref = Number.isInteger(refRaw) ? refRaw : parseInt(refRaw, 10);
return Number.isInteger(ref) ? ref : null;
});
}
// Get max colors for current pattern
const maxColors = getMaxColors();
@@ -185,7 +264,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (currentPresetColors.length === 0) {
const empty = document.createElement('p');
empty.className = 'muted-text';
empty.textContent = `No colors added. Click "Add Color" to add colors.${maxColorsText}`;
empty.textContent = `No colors added. Use the color picker to add colors.${maxColorsText}`;
presetColorsContainer.appendChild(empty);
return;
}
@@ -208,6 +287,8 @@ document.addEventListener('DOMContentLoaded', () => {
swatchWrapper.style.cssText = 'position: relative; display: inline-block;';
swatchWrapper.draggable = true;
swatchWrapper.dataset.colorIndex = index;
const refAtIndex = currentPresetPaletteRefs[index];
swatchWrapper.dataset.paletteRef = Number.isInteger(refAtIndex) ? String(refAtIndex) : '';
swatchWrapper.classList.add('draggable-color-swatch');
const swatch = document.createElement('div');
@@ -222,6 +303,31 @@ document.addEventListener('DOMContentLoaded', () => {
transition: opacity 0.2s, transform 0.2s;
`;
swatch.title = `${color} - Drag to reorder`;
if (Number.isInteger(refAtIndex)) {
const linkedBadge = document.createElement('span');
linkedBadge.textContent = 'P';
linkedBadge.title = `Linked to palette color #${refAtIndex + 1}`;
linkedBadge.style.cssText = `
position: absolute;
left: -6px;
top: -6px;
min-width: 18px;
height: 18px;
border-radius: 9px;
background: #3f51b5;
color: #fff;
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
z-index: 11;
border: 1px solid rgba(255,255,255,0.35);
box-shadow: 0 1px 3px rgba(0,0,0,0.35);
`;
swatchWrapper.appendChild(linkedBadge);
}
// Color picker overlay
const colorPicker = document.createElement('input');
@@ -239,7 +345,9 @@ document.addEventListener('DOMContentLoaded', () => {
`;
colorPicker.addEventListener('change', (e) => {
currentPresetColors[index] = e.target.value;
renderPresetColors(currentPresetColors);
// Manual picker edit breaks palette linkage for this slot.
currentPresetPaletteRefs[index] = null;
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
});
// Prevent color picker from interfering with drag
colorPicker.addEventListener('mousedown', (e) => {
@@ -271,7 +379,8 @@ document.addEventListener('DOMContentLoaded', () => {
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
currentPresetColors.splice(index, 1);
renderPresetColors(currentPresetColors);
currentPresetPaletteRefs.splice(index, 1);
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
});
// Prevent remove button from interfering with drag
removeBtn.addEventListener('mousedown', (e) => {
@@ -326,12 +435,18 @@ document.addEventListener('DOMContentLoaded', () => {
const colorPicker = el.querySelector('input[type="color"]');
return colorPicker ? colorPicker.value : null;
}).filter(color => color !== null);
const newRefOrder = colorElements.map((el) => {
const refRaw = el.dataset.paletteRef;
const ref = Number.isInteger(refRaw) ? refRaw : parseInt(refRaw, 10);
return Number.isInteger(ref) ? ref : null;
});
// Update current colors array
currentPresetColors = newColorOrder;
currentPresetPaletteRefs = newRefOrder;
// Re-render to update indices
renderPresetColors(currentPresetColors);
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
});
presetColorsContainer.appendChild(swatchContainer);
@@ -361,7 +476,8 @@ document.addEventListener('DOMContentLoaded', () => {
const patternName = preset.pattern || '';
presetPatternInput.value = patternName;
const colors = Array.isArray(preset.colors) ? preset.colors : [];
renderPresetColors(colors);
const paletteRefs = Array.isArray(preset.palette_refs) ? preset.palette_refs : [];
renderPresetColors(colors, paletteRefs);
presetBrightnessInput.value = preset.brightness || 0;
presetDelayInput.value = preset.delay || 0;
@@ -424,6 +540,7 @@ document.addEventListener('DOMContentLoaded', () => {
currentEditId = null;
currentEditTabId = null;
currentPresetColors = [];
currentPresetPaletteRefs = [];
setFormValues({
name: '',
pattern: '',
@@ -505,6 +622,7 @@ document.addEventListener('DOMContentLoaded', () => {
name: presetNameInput ? presetNameInput.value.trim() : '',
pattern: presetPatternInput ? presetPatternInput.value.trim() : '',
colors: currentPresetColors || [],
palette_refs: currentPresetPaletteRefs || [],
// Use canonical field names expected by the device / API
brightness: presetBrightnessInput ? parseInt(presetBrightnessInput.value, 10) || 0 : 0,
delay: presetDelayInput ? parseInt(presetDelayInput.value, 10) || 0 : 0,
@@ -633,9 +751,18 @@ document.addEventListener('DOMContentLoaded', () => {
const editButton = document.createElement('button');
editButton.className = 'btn btn-secondary btn-small';
editButton.textContent = 'Edit';
editButton.addEventListener('click', () => {
editButton.addEventListener('click', async () => {
currentEditId = presetId;
setFormValues(preset || {});
const paletteColors = await getCurrentProfilePaletteColors();
const presetForEditor = {
...(preset || {}),
colors: resolveColorsWithPaletteRefs(
(preset && preset.colors) || [],
(preset && preset.palette_refs) || [],
paletteColors,
),
};
setFormValues(presetForEditor);
openEditor();
});
@@ -698,7 +825,8 @@ document.addEventListener('DOMContentLoaded', () => {
throw new Error('Failed to load presets');
}
const presets = await response.json();
renderPresets(presets);
const filtered = await filterPresetsForCurrentProfile(presets);
renderPresets(filtered);
} catch (error) {
console.error('Load presets failed:', error);
presetsList.innerHTML = '';
@@ -757,7 +885,8 @@ document.addEventListener('DOMContentLoaded', () => {
if (!response.ok) {
throw new Error('Failed to load presets');
}
const allPresets = await response.json();
const allPresetsRaw = await response.json();
const allPresets = await filterPresetsForCurrentProfile(allPresetsRaw);
// Load only the current tab's presets so we can avoid duplicates within this tab.
let currentTabPresets = [];
@@ -946,12 +1075,12 @@ document.addEventListener('DOMContentLoaded', () => {
// Update color section visibility
updateColorSectionVisibility();
// Re-render colors to show updated max colors limit
renderPresetColors(currentPresetColors);
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
});
}
// Add Color button handler
if (presetAddColorButton && presetNewColorInput) {
presetAddColorButton.addEventListener('click', () => {
// Color picker auto-add handler
if (presetNewColorInput) {
const tryAddSelectedColor = () => {
const color = presetNewColorInput.value;
if (!color) return;
@@ -967,60 +1096,86 @@ document.addEventListener('DOMContentLoaded', () => {
}
currentPresetColors.push(color);
renderPresetColors(currentPresetColors);
});
currentPresetPaletteRefs.push(null);
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
};
// Add when the picker closes (user confirms selection).
presetNewColorInput.addEventListener('change', tryAddSelectedColor);
}
// Add from Palette button handler
if (presetAddFromPaletteButton) {
presetAddFromPaletteButton.addEventListener('click', () => {
const openButton = document.getElementById('color-palette-btn');
if (openButton) {
openButton.click();
}
const modal = document.getElementById('color-palette-modal');
const modalList = document.getElementById('palette-container');
if (modal) {
modal.classList.add('active');
}
if (!modalList) {
return;
}
presetAddFromPaletteButton.addEventListener('click', async () => {
try {
const paletteColors = await getCurrentProfilePaletteColors();
if (!Array.isArray(paletteColors) || paletteColors.length === 0) {
alert('No profile palette colors available.');
return;
}
const handlePick = (event) => {
const row = event.target.closest('[data-color]');
if (!row) {
return;
}
const picked = row.dataset.color;
if (!picked) {
return;
}
if (currentPresetColors.includes(picked)) {
alert('This color is already in the list.');
return;
}
const maxColors = getMaxColors();
if (currentPresetColors.length >= maxColors) {
alert(`This pattern allows a maximum of ${maxColors} color${maxColors !== 1 ? 's' : ''}.`);
if (modal) {
modal.classList.remove('active');
const modal = document.createElement('div');
modal.className = 'modal active';
modal.innerHTML = `
<div class="modal-content">
<h2>Pick Palette Color</h2>
<div id="pick-palette-list" class="profiles-list" style="max-height: 300px; overflow-y: auto;"></div>
<div class="modal-actions">
<button class="btn btn-secondary" id="pick-palette-close-btn">Close</button>
</div>
</div>
`;
document.body.appendChild(modal);
const list = modal.querySelector('#pick-palette-list');
paletteColors.forEach((color, idx) => {
const row = document.createElement('div');
row.className = 'profiles-row';
row.style.display = 'flex';
row.style.alignItems = 'center';
row.style.gap = '0.75rem';
row.dataset.paletteIndex = String(idx);
row.dataset.paletteColor = color;
row.innerHTML = `
<div style="width:28px;height:28px;border-radius:4px;border:1px solid #555;background:${color};"></div>
<span style="flex:1">${color}</span>
<button class="btn btn-primary btn-small" type="button">Use</button>
`;
list.appendChild(row);
});
const close = () => modal.remove();
modal.querySelector('#pick-palette-close-btn').addEventListener('click', close);
modal.addEventListener('click', (e) => {
if (e.target === modal) close();
});
list.addEventListener('click', (e) => {
const btn = e.target.closest('button');
if (!btn) return;
const row = e.target.closest('[data-palette-index]');
if (!row) return;
const color = row.dataset.paletteColor;
const ref = parseInt(row.dataset.paletteIndex, 10);
if (!color || !Number.isInteger(ref)) return;
if (currentPresetColors.includes(color) && currentPresetPaletteRefs.includes(ref)) {
alert('That palette color is already linked.');
return;
}
const maxColors = getMaxColors();
if (currentPresetColors.length >= maxColors) {
alert(`This pattern allows a maximum of ${maxColors} color${maxColors !== 1 ? 's' : ''}.`);
return;
}
modalList.removeEventListener('click', handlePick);
return;
}
currentPresetColors.push(picked);
renderPresetColors(currentPresetColors);
if (modal) {
modal.classList.remove('active');
}
modalList.removeEventListener('click', handlePick);
};
modalList.addEventListener('click', handlePick);
currentPresetColors.push(color);
currentPresetPaletteRefs.push(ref);
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
close();
});
} catch (err) {
console.error('Failed to add from palette:', err);
alert('Failed to load palette colors.');
}
});
}
const presetSendButton = document.getElementById('preset-send-btn');
@@ -1136,7 +1291,15 @@ document.addEventListener('DOMContentLoaded', () => {
currentEditId = presetId;
currentEditTabId = tabId || null;
await loadPatterns();
setFormValues(preset);
const paletteColors = await getCurrentProfilePaletteColors();
setFormValues({
...(preset || {}),
colors: resolveColorsWithPaletteRefs(
(preset && preset.colors) || [],
(preset && preset.palette_refs) || [],
paletteColors,
),
});
openEditor();
});
@@ -1175,11 +1338,13 @@ document.addEventListener('DOMContentLoaded', () => {
// Build an ESPNow preset message for a single preset and optionally include a select
// for the given device names, then send it via WebSocket.
// saveToDevice defaults to true.
const sendPresetViaEspNow = (presetId, preset, deviceNames, saveToDevice = true, setDefault = false) => {
const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice = true, setDefault = false) => {
try {
const colors = Array.isArray(preset.colors) && preset.colors.length
const baseColors = Array.isArray(preset.colors) && preset.colors.length
? preset.colors
: ['#FFFFFF'];
const paletteColors = await getCurrentProfilePaletteColors();
const colors = resolveColorsWithPaletteRefs(baseColors, preset.palette_refs, paletteColors);
const message = {
v: '1',
@@ -1261,97 +1426,29 @@ try {
// Store selected preset per tab
const selectedPresets = {};
// Run vs Edit for tab preset strip (in-memory only — each full page load starts in run mode)
let presetUiMode = 'run';
const getPresetUiMode = () => (presetUiMode === 'edit' ? 'edit' : 'run');
const setPresetUiMode = (mode) => {
presetUiMode = mode === 'edit' ? 'edit' : 'run';
};
const updateUiModeToggleButtons = () => {
const mode = getPresetUiMode();
// Label is the mode you switch *to* (opposite of current)
const label = mode === 'edit' ? 'Run mode' : 'Edit mode';
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
btn.textContent = label;
btn.setAttribute('aria-pressed', mode === 'edit' ? 'true' : 'false');
btn.classList.toggle('ui-mode-toggle--edit', mode === 'edit');
});
document.body.classList.toggle('preset-ui-edit', mode === 'edit');
document.body.classList.toggle('preset-ui-run', mode === 'run');
};
// Track if we're currently dragging a preset
let isDraggingPreset = false;
// Context menu for tab presets
let presetContextMenu = null;
let presetContextTarget = null;
const ensurePresetContextMenu = () => {
if (presetContextMenu) {
return presetContextMenu;
}
const menu = document.createElement('div');
menu.id = 'preset-context-menu';
menu.style.cssText = `
position: fixed;
z-index: 2000;
background: #2e2e2e;
border: 1px solid #4a4a4a;
border-radius: 4px;
box-shadow: 0 2px 6px rgba(0,0,0,0.6);
padding: 0.25rem 0;
min-width: 160px;
display: none;
`;
const addItem = (label, action) => {
const item = document.createElement('button');
item.type = 'button';
item.textContent = label;
item.dataset.action = action;
item.style.cssText = `
display: block;
width: 100%;
padding: 0.4rem 0.75rem;
background: transparent;
color: #eee;
border: none;
text-align: left;
cursor: pointer;
font-size: 0.9rem;
`;
item.addEventListener('mouseover', () => {
item.style.backgroundColor = '#3a3a3a';
});
item.addEventListener('mouseout', () => {
item.style.backgroundColor = 'transparent';
});
menu.appendChild(item);
};
addItem('Edit preset…', 'edit');
menu.addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-action]');
if (!btn || !presetContextTarget) {
return;
}
const { presetId } = presetContextTarget;
const action = btn.dataset.action;
hidePresetContextMenu();
if (action === 'edit') {
await editPresetFromTab(presetId);
}
});
document.body.appendChild(menu);
presetContextMenu = menu;
// Hide on outside click
document.addEventListener('click', (e) => {
if (!presetContextMenu) return;
if (e.target.closest('#preset-context-menu')) return;
hidePresetContextMenu();
});
return menu;
};
const showPresetContextMenu = (x, y, tabId, presetId, preset) => {
const menu = ensurePresetContextMenu();
presetContextTarget = { tabId, presetId, preset };
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
menu.style.display = 'block';
};
const hidePresetContextMenu = () => {
if (presetContextMenu) {
presetContextMenu.style.display = 'none';
}
presetContextTarget = null;
};
// Function to convert 2D grid to flat array (for backward compatibility)
const gridToArray = (presetsGrid) => {
@@ -1449,6 +1546,25 @@ const getDropTarget = (container, x, y) => {
return closest.element;
};
/**
* Move dragged tile onto the drop target's slot.
* When moving down the list (fromIdx < toIdx), insertBefore(dragging, dropTarget) lands one index
* too early; use the next element sibling so the item occupies the target slot.
*/
const insertDraggingOntoTarget = (presetsList, dragging, dropTarget) => {
const siblings = [...presetsList.querySelectorAll('.draggable-preset')];
const fromIdx = siblings.indexOf(dragging);
const toIdx = siblings.indexOf(dropTarget);
if (fromIdx === -1 || toIdx === -1) return;
if (fromIdx < toIdx) {
const next = dropTarget.nextElementSibling;
presetsList.insertBefore(dragging, next);
} else {
presetsList.insertBefore(dragging, dropTarget);
}
};
// Function to render presets for a specific tab in 2D grid
const renderTabPresets = async (tabId) => {
const presetsList = document.getElementById('presets-list-tab');
@@ -1482,47 +1598,74 @@ const renderTabPresets = async (tabId) => {
if (!presetsResponse.ok) {
throw new Error('Failed to load presets');
}
const allPresets = await presetsResponse.json();
const allPresetsRaw = await presetsResponse.json();
const allPresets = await filterPresetsForCurrentProfile(allPresetsRaw);
const paletteColors = await getCurrentProfilePaletteColors();
presetsList.innerHTML = '';
// Add drag and drop handlers to the container
presetsList.addEventListener('dragover', (e) => {
e.preventDefault();
const dragging = presetsList.querySelector('.dragging');
if (!dragging) return;
const dropTarget = getDropTarget(presetsList, e.clientX, e.clientY);
if (dropTarget && dropTarget !== dragging) {
// Insert before drop target so the dragged item takes that cell's position
presetsList.insertBefore(dragging, dropTarget);
}
});
presetsList.addEventListener('drop', async (e) => {
e.preventDefault();
const dragging = presetsList.querySelector('.dragging');
if (!dragging) return;
// Get new grid layout from DOM
const presetElements = [...presetsList.querySelectorAll('.draggable-preset')];
const presetIds = presetElements.map(el => el.dataset.presetId);
// Convert to 2D grid (3 columns)
const newGrid = arrayToGrid(presetIds, 3);
// Save new grid
try {
await savePresetGrid(tabId, newGrid);
// Re-render to ensure consistency
await renderTabPresets(tabId);
} catch (error) {
console.error('Failed to save preset grid:', error);
alert('Failed to save preset order. Please try again.');
// Re-render to restore original order
await renderTabPresets(tabId);
}
});
presetsList.dataset.reorderTabId = tabId;
// Drag-and-drop on the list (wire once — re-render would duplicate listeners otherwise)
if (!presetsList.dataset.dragWired) {
presetsList.dataset.dragWired = '1';
// dragenter + dropEffect tell the browser this zone accepts a move (avoids ⊘ cursor)
presetsList.addEventListener('dragenter', (e) => {
if (getPresetUiMode() !== 'edit') return;
e.preventDefault();
});
presetsList.addEventListener('dragover', (e) => {
if (getPresetUiMode() !== 'edit') return;
e.preventDefault();
try {
e.dataTransfer.dropEffect = 'move';
} catch (_) {}
const dragging = presetsList.querySelector('.dragging');
if (!dragging) return;
const dropTarget = getDropTarget(presetsList, e.clientX, e.clientY);
// Keep dragover side-effect free; commit placement only on drop.
if (!dropTarget || dropTarget === dragging) {
delete presetsList.dataset.dropTargetId;
return;
}
presetsList.dataset.dropTargetId = dropTarget.dataset.presetId || '';
});
presetsList.addEventListener('drop', async (e) => {
if (getPresetUiMode() !== 'edit') return;
e.preventDefault();
const dragging = presetsList.querySelector('.dragging');
if (!dragging) return;
const targetId = presetsList.dataset.dropTargetId;
if (targetId) {
const dropTarget = presetsList.querySelector(`.draggable-preset[data-preset-id="${targetId}"]:not(.dragging)`);
if (dropTarget) {
insertDraggingOntoTarget(presetsList, dragging, dropTarget);
}
}
delete presetsList.dataset.dropTargetId;
const saveId = presetsList.dataset.reorderTabId;
const presetElements = [...presetsList.querySelectorAll('.draggable-preset')];
const presetIds = presetElements.map((el) => el.dataset.presetId);
const newGrid = arrayToGrid(presetIds, 3);
try {
if (!saveId) {
console.warn('No tab id for preset reorder save');
return;
}
await savePresetGrid(saveId, newGrid);
await renderTabPresets(saveId);
} catch (error) {
console.error('Failed to save preset grid:', error);
alert('Failed to save preset order. Please try again.');
const fallbackId = presetsList.dataset.reorderTabId;
if (fallbackId) await renderTabPresets(fallbackId);
}
});
}
// Get the currently selected preset for this tab
const selectedPresetId = selectedPresets[tabId];
@@ -1543,7 +1686,11 @@ const renderTabPresets = async (tabId) => {
const preset = allPresets[presetId];
if (preset) {
const isSelected = presetId === selectedPresetId;
const wrapper = createPresetButton(presetId, preset, tabId, isSelected);
const displayPreset = {
...preset,
colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors),
};
const wrapper = createPresetButton(presetId, displayPreset, tabId, isSelected);
presetsList.appendChild(wrapper);
}
});
@@ -1555,15 +1702,22 @@ const renderTabPresets = async (tabId) => {
};
const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
const uiMode = getPresetUiMode();
const row = document.createElement('div');
const canDrag = uiMode === 'edit';
row.className = `preset-tile-row preset-tile-row--${uiMode}${canDrag ? ' draggable-preset' : ''}`;
row.draggable = canDrag;
row.dataset.presetId = presetId;
const button = document.createElement('button');
button.className = 'pattern-button draggable-preset';
button.draggable = true;
button.dataset.presetId = presetId;
button.type = 'button';
button.className = 'pattern-button preset-tile-main';
if (isSelected) {
button.classList.add('active');
}
const colors = Array.isArray(preset.colors) ? preset.colors.filter(c => c) : [];
const colors = Array.isArray(preset.colors) ? preset.colors.filter((c) => c) : [];
const isRainbow = (preset.pattern || '').toLowerCase() === 'rainbow';
const barColors = isRainbow
? ['#FF0000', '#FF7F00', '#FFFF00', '#00FF00', '#0000FF', '#4B0082', '#8F00FF']
@@ -1584,38 +1738,76 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
presetNameLabel.className = 'pattern-button-label';
button.appendChild(presetNameLabel);
button.addEventListener('click', (e) => {
button.addEventListener('click', () => {
if (isDraggingPreset) return;
const presetsList = document.getElementById('presets-list-tab');
if (presetsList) {
presetsList.querySelectorAll('.pattern-button').forEach(btn => btn.classList.remove('active'));
const presetsListEl = document.getElementById('presets-list-tab');
if (presetsListEl) {
presetsListEl.querySelectorAll('.pattern-button').forEach((btn) => btn.classList.remove('active'));
}
button.classList.add('active');
selectedPresets[tabId] = presetId;
const section = button.closest('.presets-section');
const section = row.closest('.presets-section');
sendSelectForCurrentTabDevices(presetId, section);
});
button.addEventListener('contextmenu', async (e) => {
e.preventDefault();
if (isDraggingPreset) return;
await editPresetFromTab(presetId, tabId, preset);
});
if (canDrag) {
row.addEventListener('dragstart', (e) => {
isDraggingPreset = true;
row.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', presetId);
});
button.addEventListener('dragstart', (e) => {
isDraggingPreset = true;
button.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', presetId);
});
row.addEventListener('dragend', () => {
row.classList.remove('dragging');
const presetsListEl = document.getElementById('presets-list-tab');
if (presetsListEl) {
delete presetsListEl.dataset.dropTargetId;
}
document.querySelectorAll('.draggable-preset').forEach((el) => el.classList.remove('drag-over'));
setTimeout(() => {
isDraggingPreset = false;
}, 100);
});
}
button.addEventListener('dragend', (e) => {
button.classList.remove('dragging');
document.querySelectorAll('.draggable-preset').forEach(el => el.classList.remove('drag-over'));
setTimeout(() => { isDraggingPreset = false; }, 100);
});
row.appendChild(button);
return button;
if (uiMode === 'edit') {
const actions = document.createElement('div');
actions.className = 'preset-tile-actions';
const editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'btn btn-secondary btn-small';
editBtn.textContent = 'Edit';
editBtn.title = 'Edit preset';
editBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (isDraggingPreset) return;
editPresetFromTab(presetId, tabId, preset);
});
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'btn btn-danger btn-small';
removeBtn.textContent = 'Remove';
removeBtn.title = 'Remove from this tab';
removeBtn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
if (isDraggingPreset) return;
if (!window.confirm('Remove this preset from this tab?')) return;
await removePresetFromTab(tabId, presetId);
});
actions.appendChild(editBtn);
actions.appendChild(removeBtn);
row.appendChild(actions);
}
return row;
};
const editPresetFromTab = async (presetId, tabId, existingPreset) => {
@@ -1731,3 +1923,20 @@ document.body.addEventListener('htmx:afterSwap', (event) => {
}
}
});
document.addEventListener('DOMContentLoaded', () => {
updateUiModeToggleButtons();
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
btn.addEventListener('click', () => {
const next = getPresetUiMode() === 'edit' ? 'run' : 'edit';
setPresetUiMode(next);
updateUiModeToggleButtons();
const mainMenu = document.getElementById('main-menu-dropdown');
if (mainMenu) mainMenu.classList.remove('open');
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
if (leftPanel) {
renderTabPresets(leftPanel.dataset.tabId);
}
});
});
});

View File

@@ -4,6 +4,7 @@ document.addEventListener("DOMContentLoaded", () => {
const profilesCloseButton = document.getElementById("profiles-close-btn");
const profilesList = document.getElementById("profiles-list");
const newProfileInput = document.getElementById("new-profile-name");
const newProfileSeedDjInput = document.getElementById("new-profile-seed-dj");
const createProfileButton = document.getElementById("create-profile-btn");
if (!profilesButton || !profilesModal || !profilesList) {
@@ -19,6 +20,18 @@ document.addEventListener("DOMContentLoaded", () => {
profilesModal.classList.remove("active");
};
const refreshTabsForActiveProfile = async () => {
// Clear stale current tab so tab controller falls back to first tab of applied profile.
document.cookie = "current_tab=; path=/; max-age=0";
if (window.tabsManager && typeof window.tabsManager.loadTabs === "function") {
await window.tabsManager.loadTabs();
}
if (window.tabsManager && typeof window.tabsManager.loadTabsModal === "function") {
await window.tabsManager.loadTabsModal();
}
};
const renderProfiles = (profiles, currentProfileId) => {
profilesList.innerHTML = "";
let entries = [];
@@ -66,7 +79,7 @@ document.addEventListener("DOMContentLoaded", () => {
throw new Error("Failed to apply profile");
}
await loadProfiles();
document.body.dispatchEvent(new Event("tabs-updated"));
await refreshTabsForActiveProfile();
} catch (error) {
console.error("Apply profile failed:", error);
alert("Failed to apply profile.");
@@ -115,22 +128,8 @@ document.addEventListener("DOMContentLoaded", () => {
headers: { Accept: "application/json" },
});
}
document.cookie = "current_tab=; path=/; max-age=0";
await loadProfiles();
if (typeof window.loadTabs === "function") {
await window.loadTabs();
}
if (typeof window.loadTabsModal === "function") {
await window.loadTabsModal();
}
const tabContent = document.getElementById("tab-content");
if (tabContent) {
tabContent.innerHTML = `
<div class="tab-content-placeholder">
Select a tab to get started
</div>
`;
}
await refreshTabsForActiveProfile();
} catch (error) {
console.error("Clone profile failed:", error);
alert("Failed to clone profile.");
@@ -210,7 +209,10 @@ document.addEventListener("DOMContentLoaded", () => {
const response = await fetch("/profiles", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
body: JSON.stringify({
name,
seed_dj_tab: !!(newProfileSeedDjInput && newProfileSeedDjInput.checked),
}),
});
if (!response.ok) {
throw new Error("Failed to create profile");
@@ -236,23 +238,11 @@ document.addEventListener("DOMContentLoaded", () => {
}
newProfileInput.value = "";
// Clear current tab and refresh the UI so the new profile starts empty.
document.cookie = "current_tab=; path=/; max-age=0";
if (newProfileSeedDjInput) {
newProfileSeedDjInput.checked = false;
}
await loadProfiles();
if (typeof window.loadTabs === "function") {
await window.loadTabs();
}
if (typeof window.loadTabsModal === "function") {
await window.loadTabsModal();
}
const tabContent = document.getElementById("tab-content");
if (tabContent) {
tabContent.innerHTML = `
<div class="tab-content-placeholder">
Select a tab to get started
</div>
`;
}
await refreshTabsForActiveProfile();
} catch (error) {
console.error("Create profile failed:", error);
alert("Failed to create profile.");

View File

@@ -77,6 +77,11 @@ header h1 {
background-color: #333;
}
/* Header/menu actions that should only appear in Edit mode */
body.preset-ui-run .edit-mode-only {
display: none !important;
}
.btn {
padding: 0.45rem 0.9rem;
border: none;
@@ -596,6 +601,52 @@ header h1 {
position: relative;
}
/* Preset tile: main button + optional edit/remove (Edit mode) */
.preset-tile-row {
display: flex;
flex-direction: row;
align-items: stretch;
min-width: 0;
min-height: 0;
}
.preset-tile-row--run .preset-tile-actions {
display: none;
}
.preset-tile-main {
flex: 1;
min-width: 0;
height: 5rem;
}
.preset-tile-actions {
display: flex;
flex-direction: column;
gap: 0.2rem;
justify-content: center;
flex-shrink: 0;
padding: 0.15rem 0 0.15rem 0.25rem;
}
.preset-tile-actions .btn {
flex: 1 1 0;
min-height: 0;
padding: 0.15rem 0.35rem;
font-size: 0.68rem;
line-height: 1.15;
white-space: nowrap;
}
.ui-mode-toggle--edit {
background-color: #4a3f8f;
border: 1px solid #7b6fd6;
}
.ui-mode-toggle--edit:hover {
background-color: #5a4f9f;
}
/* Preset select buttons inside the tab grid */
#presets-list-tab .pattern-button {
display: flex;

View File

@@ -1,6 +1,11 @@
// Tab management JavaScript
let currentTabId = null;
const isEditModeActive = () => {
const toggle = document.querySelector('.ui-mode-toggle');
return !!(toggle && toggle.getAttribute('aria-pressed') === 'true');
};
// Get current tab from cookie
function getCurrentTabFromCookie() {
const cookies = document.cookie.split(';');
@@ -38,10 +43,12 @@ async function loadTabs() {
// Load current tab content if available
if (currentTabId) {
loadTabContent(currentTabId);
await loadTabContent(currentTabId);
} else if (data.tab_order && data.tab_order.length > 0) {
// Set first tab as current if none is set
await setCurrentTab(data.tab_order[0]);
const firstTabId = data.tab_order[0];
await setCurrentTab(firstTabId);
await loadTabContent(firstTabId);
}
} catch (error) {
console.error('Failed to load tabs:', error);
@@ -62,6 +69,7 @@ function renderTabsList(tabs, tabOrder, currentTabId) {
return;
}
const editMode = isEditModeActive();
let html = '<div class="tabs-list">';
for (const tabId of tabOrder) {
const tab = tabs[tabId];
@@ -71,7 +79,7 @@ function renderTabsList(tabs, tabOrder, currentTabId) {
html += `
<button class="tab-button ${activeClass}"
data-tab-id="${tabId}"
title="Click to select, right-click to edit"
title="${editMode ? 'Click to select, right-click to edit' : 'Click to select'}"
onclick="selectTab('${tabId}')">
${tabName}
</button>
@@ -106,6 +114,7 @@ function renderTabsListModal(tabs, tabOrder, currentTabId) {
return;
}
const editMode = isEditModeActive();
entries.forEach(([tabId, tab]) => {
const row = document.createElement("div");
row.className = "profiles-row";
@@ -224,10 +233,12 @@ function renderTabsListModal(tabs, tabOrder, currentTabId) {
row.appendChild(label);
row.appendChild(applyButton);
row.appendChild(editButton);
row.appendChild(sendPresetsButton);
row.appendChild(cloneButton);
row.appendChild(deleteButton);
if (editMode) {
row.appendChild(editButton);
row.appendChild(cloneButton);
row.appendChild(deleteButton);
}
container.appendChild(row);
});
}
@@ -714,6 +725,9 @@ document.addEventListener('DOMContentLoaded', () => {
// Right-click on a tab button in the main header bar to edit that tab
document.addEventListener('contextmenu', async (event) => {
if (!isEditModeActive()) {
return;
}
const btn = event.target.closest('.tab-button');
if (!btn || !btn.dataset.tabId) {
return;
@@ -796,11 +810,22 @@ document.addEventListener('DOMContentLoaded', () => {
await sendProfilePresets();
});
}
// When run/edit mode toggles, refresh tabs UI so edit actions show/hide immediately.
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
btn.addEventListener('click', async () => {
await loadTabs();
if (tabsModal && tabsModal.classList.contains('active')) {
await loadTabsModal();
}
});
});
});
// Export for use in other scripts
window.tabsManager = {
loadTabs,
loadTabsModal,
selectTab,
createTab,
updateTab,

View File

@@ -15,24 +15,26 @@
</div>
</div>
<div class="header-actions">
<button class="btn btn-secondary" id="tabs-btn">Tabs</button>
<button class="btn btn-secondary" id="color-palette-btn">Color Palette</button>
<button class="btn btn-secondary" id="presets-btn">Presets</button>
<button class="btn btn-secondary edit-mode-only" id="tabs-btn">Tabs</button>
<button class="btn btn-secondary edit-mode-only" id="color-palette-btn">Color Palette</button>
<button class="btn btn-secondary edit-mode-only" id="presets-btn">Presets</button>
<button class="btn btn-secondary" id="send-profile-presets-btn">Send Presets</button>
<button class="btn btn-secondary" id="patterns-btn">Patterns</button>
<button class="btn btn-secondary" id="profiles-btn">Profiles</button>
<button class="btn btn-secondary edit-mode-only" id="patterns-btn">Patterns</button>
<button class="btn btn-secondary edit-mode-only" id="profiles-btn">Profiles</button>
<button class="btn btn-secondary" id="settings-btn">Settings</button>
<button class="btn btn-secondary" id="help-btn">Help</button>
<button type="button" class="btn btn-secondary ui-mode-toggle" id="ui-mode-toggle" aria-pressed="false" title="Switch preset strip mode — label is the mode you will switch to">Edit mode</button>
</div>
<div class="header-menu-mobile">
<button class="btn btn-secondary" id="main-menu-btn">Menu</button>
<div id="main-menu-dropdown" class="main-menu-dropdown">
<button type="button" data-target="tabs-btn">Tabs</button>
<button type="button" data-target="color-palette-btn">Color Palette</button>
<button type="button" data-target="presets-btn">Presets</button>
<button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
<button type="button" class="edit-mode-only" data-target="tabs-btn">Tabs</button>
<button type="button" class="edit-mode-only" data-target="color-palette-btn">Color Palette</button>
<button type="button" class="edit-mode-only" data-target="presets-btn">Presets</button>
<button type="button" data-target="send-profile-presets-btn">Send Presets</button>
<button type="button" data-target="patterns-btn">Patterns</button>
<button type="button" data-target="profiles-btn">Profiles</button>
<button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</button>
<button type="button" class="edit-mode-only" data-target="profiles-btn">Profiles</button>
<button type="button" data-target="settings-btn">Settings</button>
<button type="button" data-target="help-btn">Help</button>
</div>
@@ -92,6 +94,12 @@
<input type="text" id="new-profile-name" placeholder="Profile name">
<button class="btn btn-primary" id="create-profile-btn">Create</button>
</div>
<div class="form-group" style="margin-top: 0.5rem; margin-bottom: 0;">
<label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0;">
<input type="checkbox" id="new-profile-seed-dj">
DJ tab
</label>
</div>
<div id="profiles-list" class="profiles-list"></div>
<div class="modal-actions">
<button class="btn btn-secondary" id="profiles-close-btn">Close</button>
@@ -126,9 +134,8 @@
<label>Colors</label>
<div id="preset-colors-container" class="preset-colors-container"></div>
<div class="profiles-actions">
<input type="color" id="preset-new-color" value="#ffffff">
<button class="btn btn-secondary btn-small" id="preset-add-color-btn">Add Color</button>
<button class="btn btn-secondary btn-small" id="preset-add-from-palette-btn">Add from Palette</button>
<input type="color" id="preset-new-color" value="#ffffff" title="Choose color (auto-adds)">
<button class="btn btn-secondary btn-small" id="preset-add-from-palette-btn">From Palette</button>
</div>
<div class="profiles-actions">
<div class="preset-editor-field">
@@ -204,7 +211,6 @@
<div id="palette-container" class="profiles-list"></div>
<div class="profiles-actions">
<input type="color" id="palette-new-color" value="#ffffff">
<button class="btn btn-primary" id="palette-add-color-btn">Add Color</button>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" id="color-palette-close-btn">Close</button>
@@ -228,9 +234,10 @@
<h3>Presets in a tab</h3>
<ul>
<li><strong>Select preset</strong>: left-click a preset tile to select it and send a <code>select</code> message to all devices in the tab.</li>
<li><strong>Edit preset</strong>: right-click a preset tile and choose <strong>Edit preset…</strong>.</li>
<li><strong>Remove from tab</strong>: right-click a preset tile and choose <strong>Remove from this tab</strong> (the preset itself is not deleted, only its link from this tab).</li>
<li><strong>Reorder presets</strong>: drag preset tiles to change their order; the new layout is saved automatically.</li>
<li><strong>Run vs Edit mode</strong>: use the mode button in the menu (it shows the mode you will <em>switch to</em>). In <strong>Edit mode</strong>, each preset tile shows <strong>Edit</strong> and <strong>Remove</strong> on the right.</li>
<li><strong>Edit preset</strong>: switch to <strong>Edit mode</strong> (menu button) and use <strong>Edit</strong> on each tile.</li>
<li><strong>Remove from tab</strong>: in <strong>Edit mode</strong>, use <strong>Remove</strong> on the tile (the preset itself is not deleted, only its link from this tab).</li>
<li><strong>Reorder presets</strong>: in <strong>Edit mode</strong> only, drag tiles to change order; the layout saves automatically.</li>
</ul>
<h3>Presets, profiles & colors</h3>
@@ -287,7 +294,7 @@
<div class="form-group">
<label for="ap-password">AP Password</label>
<input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)">
<input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)" data-bwignore>
<small>Leave empty for open network (min 8 characters if set)</small>
</div>

View File

@@ -193,7 +193,7 @@
<div class="form-group">
<label for="ap-password">AP Password</label>
<input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)">
<input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)" data-bwignore>
<small>Leave empty for open network (min 8 characters if set)</small>
</div>

View File

@@ -1,7 +1,8 @@
"""
ESPNow message builder utility for LED driver communication.
Message builder for LED driver API communication.
This module provides utilities to build ESPNow messages according to the API specification.
Builds JSON messages according to the LED driver API specification
for sending presets and select commands over the transport (e.g. serial).
"""
import json
@@ -9,14 +10,14 @@ import json
def build_message(presets=None, select=None, save=False, default=None):
"""
Build an ESPNow message according to the API specification.
Build an API message (presets and/or select) as a JSON string.
Args:
presets: Dictionary mapping preset names to preset objects, or None
select: Dictionary mapping device names to select lists, or None
Returns:
JSON string ready to send via ESPNow
JSON string ready to send over the transport
Example:
message = build_message(

View File

@@ -1,42 +0,0 @@
import network
def ap(ssid, password, channel=None):
ap_if = network.WLAN(network.AP_IF)
ap_mac = ap_if.config('mac')
print(ssid)
ap_if.active(True)
if channel is not None:
ap_if.config(essid=ssid, password=password, channel=channel)
else:
ap_if.config(essid=ssid, password=password)
ap_if.active(False)
ap_if.active(True)
print(ap_if.ifconfig())
def get_mac():
ap_if = network.WLAN(network.AP_IF)
return ap_if.config('mac')
def get_ap_config():
"""Get current AP configuration."""
try:
ap_if = network.WLAN(network.AP_IF)
if ap_if.active():
config = ap_if.ifconfig()
return {
'ssid': ap_if.config('essid'),
'channel': ap_if.config('channel'),
'ip': config[0] if config else None,
'active': True
}
return {
'ssid': None,
'channel': None,
'ip': None,
'active': False
}
except Exception as e:
print(f"Error getting AP config: {e}")
return None

View File

@@ -12,6 +12,7 @@ from test_group import test_group
from test_sequence import test_sequence
from test_tab import test_tab
from test_palette import test_palette
from test_device import test_device
def run_all_tests():
"""Run all model tests."""
@@ -27,6 +28,7 @@ def run_all_tests():
("Sequence", test_sequence),
("Tab", test_tab),
("Palette", test_palette),
("Device", test_device),
]
passed = 0

View File

@@ -0,0 +1,64 @@
from models.device import Device
import os
def test_device():
"""Test Device model CRUD operations."""
db_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "db")
device_file = os.path.join(db_dir, "device.json")
if os.path.exists(device_file):
os.remove(device_file)
devices = Device()
print("Testing create device")
device_id = devices.create("Test Device", address="aa:bb:cc:dd:ee:ff", default_pattern="on", tabs=["1", "2"])
print(f"Created device with ID: {device_id}")
assert device_id is not None
assert device_id in devices
print("\nTesting read device")
device = devices.read(device_id)
print(f"Read: {device}")
assert device is not None
assert device["name"] == "Test Device"
assert device["address"] == "aabbccddeeff"
assert device["default_pattern"] == "on"
assert device["tabs"] == ["1", "2"]
print("\nTesting address normalization")
devices.update(device_id, {"address": "11:22:33:44:55:66"})
updated = devices.read(device_id)
assert updated["address"] == "112233445566"
print("\nTesting update device")
update_data = {
"name": "Updated Device",
"default_pattern": "rainbow",
"tabs": ["1", "2", "3"],
}
result = devices.update(device_id, update_data)
assert result is True
updated = devices.read(device_id)
assert updated["name"] == "Updated Device"
assert updated["default_pattern"] == "rainbow"
assert len(updated["tabs"]) == 3
print("\nTesting list devices")
device_list = devices.list()
print(f"Device list: {device_list}")
assert device_id in device_list
print("\nTesting delete device")
deleted = devices.delete(device_id)
assert deleted is True
assert device_id not in devices
print("\nTesting read after delete")
device = devices.read(device_id)
assert device is None
print("\nAll device tests passed!")
if __name__ == "__main__":
test_device()

View File

@@ -6,11 +6,13 @@ def test_model():
# Create a test model class
class TestModel(Model):
pass
# Clean up any existing test file
if os.path.exists("TestModel.json"):
os.remove("TestModel.json")
# Clean up any existing test file (model uses db/<classname>.json)
db_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "db")
testmodel_file = os.path.join(db_dir, "testmodel.json")
if os.path.exists(testmodel_file):
os.remove(testmodel_file)
model = TestModel()
print("Testing get_next_id with empty model")
@@ -43,9 +45,9 @@ def test_model():
assert hasattr(model2, 'set_defaults')
# Clean up
if os.path.exists("TestModel.json"):
os.remove("TestModel.json")
if os.path.exists(testmodel_file):
os.remove(testmodel_file)
print("\nAll model base class tests passed!")

View File

@@ -2,10 +2,14 @@ from models.pallet import Palette
import os
def test_palette():
"""Test Palette model CRUD operations."""
# Clean up any existing test file
if os.path.exists("Palette.json"):
os.remove("Palette.json")
"""Test Palette model CRUD operations.
Palette stores a list of colors per ID; read() returns that list (or unwraps from dict).
"""
# Clean up any existing test file (model uses db/palette.json from project root)
db_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "db")
palette_file = os.path.join(db_dir, "palette.json")
if os.path.exists(palette_file):
os.remove(palette_file)
palettes = Palette()
@@ -19,10 +23,12 @@ def test_palette():
print("\nTesting read palette")
palette = palettes.read(palette_id)
print(f"Read: {palette}")
# read() returns list of colors (name is not stored)
assert palette is not None
assert palette["name"] == "test_palette"
assert len(palette["colors"]) == 4
assert "#FF0000" in palette["colors"]
assert isinstance(palette, list) or (isinstance(palette, dict) and "colors" in palette)
colors_read = palette if isinstance(palette, list) else palette.get("colors", [])
assert len(colors_read) == 4
assert "#FF0000" in colors_read
print("\nTesting update palette")
update_data = {
@@ -32,9 +38,9 @@ def test_palette():
result = palettes.update(palette_id, update_data)
assert result is True
updated = palettes.read(palette_id)
assert updated["name"] == "updated_palette"
assert len(updated["colors"]) == 3
assert "#FF00FF" in updated["colors"]
updated_colors = updated if isinstance(updated, list) else (updated.get("colors") or [])
assert len(updated_colors) == 3
assert "#FF00FF" in updated_colors
print("\nTesting list palettes")
palette_list = palettes.list()
@@ -48,7 +54,8 @@ def test_palette():
print("\nTesting read after delete")
palette = palettes.read(palette_id)
assert palette is None
# read() returns [] when id is missing (value or [])
assert palette == [] or palette is None
print("\nAll palette tests passed!")

View File

@@ -2,10 +2,14 @@ from models.profile import Profile
import os
def test_profile():
"""Test Profile model CRUD operations."""
# Clean up any existing test file
if os.path.exists("Profile.json"):
os.remove("Profile.json")
"""Test Profile model CRUD operations.
Profile create() sets name, type, tabs (list of tab IDs), scenes, palette_id.
"""
# Clean up any existing test file (model uses db/profile.json from project root)
db_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "db")
profile_file = os.path.join(db_dir, "profile.json")
if os.path.exists(profile_file):
os.remove(profile_file)
profiles = Profile()
@@ -21,15 +25,13 @@ def test_profile():
assert profile is not None
assert profile["name"] == "test_profile"
assert "tabs" in profile
assert "palette" in profile
assert "tab_order" in profile
assert "palette_id" in profile
assert "type" in profile
print("\nTesting update profile")
update_data = {
"name": "updated_profile",
"tabs": {"tab1": {"names": ["1"], "presets": []}},
"palette": ["#FF0000", "#00FF00"],
"tab_order": ["tab1"]
"tabs": ["tab1"],
}
result = profiles.update(profile_id, update_data)
assert result is True

View File

@@ -2,6 +2,9 @@
"""
Browser automation tests using Selenium.
Tests run against the device at 192.168.4.1 in an actual browser.
On Pi OS Lite (no desktop) these tests are skipped unless headless Chromium
and chromedriver are installed (e.g. chromium-browser chromium-chromedriver).
"""
import sys
@@ -13,8 +16,8 @@ from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options as ChromeOptions
from selenium.webdriver.firefox.options import Options as FirefoxOptions
from selenium.webdriver.common.action_chains import ActionChains
from selenium.common.exceptions import TimeoutException, NoSuchElementException
@@ -33,24 +36,41 @@ class BrowserTest:
self.created_presets: List[str] = []
def setup(self):
"""Set up the browser driver."""
"""Set up the browser driver. Tries Chrome first, then Firefox."""
err_chrome, err_firefox = None, None
# Try Chrome first
try:
chrome_options = Options()
opts = ChromeOptions()
if self.headless:
chrome_options.add_argument('--headless')
chrome_options.add_argument('--no-sandbox')
chrome_options.add_argument('--disable-dev-shm-usage')
chrome_options.add_argument('--disable-gpu')
chrome_options.add_argument('--window-size=1920,1080')
self.driver = webdriver.Chrome(options=chrome_options)
opts.add_argument('--headless')
opts.add_argument('--no-sandbox')
opts.add_argument('--disable-dev-shm-usage')
opts.add_argument('--disable-gpu')
opts.add_argument('--window-size=1920,1080')
self.driver = webdriver.Chrome(options=opts)
self.driver.implicitly_wait(5)
print("✓ Browser started")
print("✓ Browser started (Chrome)")
return True
except Exception as e:
print(f"✗ Failed to start browser: {e}")
print(" Make sure Chrome and ChromeDriver are installed")
return False
err_chrome = e
# Fallback to Firefox
try:
opts = FirefoxOptions()
if self.headless:
opts.add_argument('--headless')
self.driver = webdriver.Firefox(options=opts)
self.driver.implicitly_wait(5)
print("✓ Browser started (Firefox)")
return True
except Exception as e:
err_firefox = e
print("✗ Failed to start browser.")
if err_chrome:
print(f" Chrome: {err_chrome}")
if err_firefox:
print(f" Firefox: {err_firefox}")
print(" On Raspberry Pi (aarch64), install: chromium-browser and chromium-chromedriver")
return False
def teardown(self):
"""Close the browser."""
@@ -209,46 +229,6 @@ class BrowserTest:
except Exception as e:
print(f" ⚠ Cleanup error: {e}")
def cleanup_test_data(self):
"""Clean up test data created during tests."""
try:
# Use requests to make API calls for cleanup
session = requests.Session()
# Delete created presets
for preset_id in self.created_presets:
try:
response = session.delete(f"{self.base_url}/presets/{preset_id}")
if response.status_code == 200:
print(f" ✓ Cleaned up preset: {preset_id}")
except Exception as e:
print(f" ⚠ Failed to cleanup preset {preset_id}: {e}")
# Delete created tabs
for tab_id in self.created_tabs:
try:
response = session.delete(f"{self.base_url}/tabs/{tab_id}")
if response.status_code == 200:
print(f" ✓ Cleaned up tab: {tab_id}")
except Exception as e:
print(f" ⚠ Failed to cleanup tab {tab_id}: {e}")
# Delete created profiles
for profile_id in self.created_profiles:
try:
response = session.delete(f"{self.base_url}/profiles/{profile_id}")
if response.status_code == 200:
print(f" ✓ Cleaned up profile: {profile_id}")
except Exception as e:
print(f" ⚠ Failed to cleanup profile {profile_id}: {e}")
# Clear the lists
self.created_tabs.clear()
self.created_profiles.clear()
self.created_presets.clear()
except Exception as e:
print(f" ⚠ Cleanup error: {e}")
def fill_input(self, by, value, text, timeout=10):
"""Fill an input field."""
try:
@@ -553,7 +533,7 @@ def test_mobile_tab_presets_two_columns():
container = bt.wait_for_element(By.ID, 'presets-list-tab', timeout=10)
assert container is not None, "presets-list-tab not found"
tiles = bt.driver.find_elements(By.CSS_SELECTOR, '#presets-list-tab .draggable-preset')
tiles = bt.driver.find_elements(By.CSS_SELECTOR, '#presets-list-tab .preset-tile-row')
# Need at least 2 presets to make this meaningful
assert len(tiles) >= 2, "Fewer than 2 presets found for tab"
@@ -902,14 +882,20 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
import traceback
traceback.print_exc()
# Test 5: Find presets in tab and test drag and drop
# Test 5: Find presets in tab and test drag and drop (Edit mode only)
total += 1
try:
# Wait for presets to load in the tab
presets_list_tab = browser.wait_for_element(By.ID, 'presets-list-tab', timeout=5)
if presets_list_tab:
time.sleep(1) # Wait for presets to render
# Reordering is only available in Edit mode (tiles get .draggable-preset)
mode_toggle = browser.wait_for_element(By.CSS_SELECTOR, '.ui-mode-toggle', timeout=5)
if mode_toggle and mode_toggle.get_attribute('aria-pressed') == 'false':
mode_toggle.click()
time.sleep(0.5)
# Find draggable preset elements - wait a bit more for rendering
time.sleep(1)
draggable_presets = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-tab .draggable-preset')
@@ -1005,11 +991,19 @@ def main():
print("LED Controller Browser Tests")
print(f"Testing against: {BASE_URL}")
print("=" * 60)
# On Pi OS Lite there is no browser by default; skip with exit 0 instead of failing
browser = BrowserTest(headless=True)
if not browser.setup():
print("\nSkipped (Pi OS Lite / no browser). Install chromium-browser and")
print("chromium-chromedriver to run browser tests, or run on Pi OS with desktop.")
sys.exit(0)
browser.teardown()
browser = BrowserTest(headless=False) # Set to True for headless mode
results = []
# Run browser tests
results.append(("Browser Connection", test_browser_connection(browser)))
results.append(("Tabs UI", test_tabs_ui(browser)))

View File

@@ -499,6 +499,7 @@ def test_static_files(client: TestClient) -> bool:
'/static/tabs.js',
'/static/presets.js',
'/static/profiles.js',
'/static/devices.js',
]
for file_path in static_files: