26 Commits

Author SHA1 Message Date
b2077c0199 Improve ESP-NOW messaging and tab defaults
- Use shared ESPNOW payload limit and message splitting
- Expand default tab names and add flash/build artifacts.

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 13:51:02 +13:00
00514f0525 Add in-app settings menu and fix settings API
Move WiFi and device name configuration into a modal menu, reuse existing settings endpoints, and harden settings serialization and startup for MicroPython.
2026-01-29 00:54:20 +13:00
cf1d831b5a Align controller backend and data with new presets
Update palettes, profiles, tabs, preset sending, and ESPNow message format to match the new preset defaults and driver short-field schema.
2026-01-29 00:04:23 +13:00
fd37183400 Update frontend for presets, tabs, and help
Align frontend with new preset ID usage and shortened driver fields, improve tab/preset interactions, and refine help and editor UI.
2026-01-28 23:27:50 +13:00
5fdeb57b74 Extend endpoint and browser tests for ESPNow and UI
Add coverage for /presets/send and updated tab/preset UI workflows in HTTP and Selenium tests.
2026-01-28 04:44:41 +13:00
1576383d09 Update tab UI, presets interactions, and help
Refine tab presets selection and editing, add per-tab removal, improve layout, and provide an in-app help modal.
2026-01-28 04:44:30 +13:00
8503315bef Add ESPNow preset send backend support
Implement ESPNow helper model, WebSocket forwarding, and /presets/send endpoint that chunks and broadcasts presets to devices.
2026-01-28 04:43:45 +13:00
928263fbd8 Remove Python cache files from version control
- Remove __pycache__ directories that should not be tracked
- These are now ignored via .gitignore
2026-01-27 13:05:22 +13:00
7e33f7db6a Add additional configuration and utility files
- Add install script and message configuration
- Add settings controller and templates
- Add ESP-NOW message utility
- Update API documentation
2026-01-27 13:05:09 +13:00
e74ef6d64f Update main application and dependencies
- Update main.py and run_web.py for local development
- Update microdot session handling
- Update wifi utility
2026-01-27 13:05:07 +13:00
3ed435824c Add Selenium dependency for browser tests 2026-01-27 13:05:04 +13:00
d7fabf58a4 Fix MicroPython compatibility issues in Model class
- Fix JSONDecodeError handling (use ValueError for MicroPython)
- Fix sys.print_exception argument issues
- Improve error handling in load() method
- Add proper file persistence with flush() and os.sync()
2026-01-27 13:05:02 +13:00
a7e921805a Update controllers to return JSON and fix parameter handling
- Fix decorator parameter order issues with @with_session
- Return JSON responses instead of HTML fragments
- Add proper error handling with JSON error responses
- Fix route parameter conflicts in delete and update endpoints
2026-01-27 13:05:01 +13:00
c56739c5fa Refactor UI to use JavaScript instead of HTMX
- Replace HTMX with plain JavaScript for tab management
- Consolidate tab UI into single button like profiles
- Add cookie-based current tab storage (client-side)
- Update profiles.js to work with new JSON response format
2026-01-27 13:05:00 +13:00
fd52e40d17 Add endpoint tests and consolidate test directory
- Add HTTP endpoint tests to mimic browser interactions
- Move old test files from test/ to tests/ directory
- Add comprehensive endpoint tests for tabs, profiles, presets, patterns
- Add README documenting test structure and how to run tests
2026-01-27 13:04:56 +13:00
f48c8789c7 Add browser automation tests for UI workflows
- Add Selenium-based browser tests for tabs, profiles, presets, and color palette
- Test drag and drop functionality for presets in tabs
- Include cleanup functionality to remove test data after tests
- Tests run against device at 192.168.4.1
2026-01-27 13:04:54 +13:00
70 changed files with 7006 additions and 1304 deletions

29
.gitignore vendored Normal file
View File

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

View File

@@ -9,6 +9,9 @@ pyserial = "*"
esptool = "*" esptool = "*"
pyjwt = "*" pyjwt = "*"
watchfiles = "*" watchfiles = "*"
requests = "*"
selenium = "*"
adafruit-ampy = "*"
[dev-packages] [dev-packages]
@@ -18,3 +21,4 @@ python_version = "3.12"
[scripts] [scripts]
web = "python /home/pi/led-controller/tests/web.py" web = "python /home/pi/led-controller/tests/web.py"
watch = "python -m watchfiles 'python tests/web.py' src tests" watch = "python -m watchfiles 'python tests/web.py' src tests"
install = "pipenv install"

449
Pipfile.lock generated
View File

@@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "24a0e63d49a769fb2bbc35d7d361aeb0c8563f2d65cbeb24acfae9e183d1c0ca" "sha256": "c963cd52164ac13fda5e6f3c5975bc14db6cea03ad4973de02ad91a0ab10d2ea"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@@ -16,6 +16,14 @@
] ]
}, },
"default": { "default": {
"adafruit-ampy": {
"hashes": [
"sha256:4a74812226e53c17d01eb828633424bc4f4fe76b9499a7b35eba6fc2532635b7",
"sha256:f4cba36f564096f2aafd173f7fbabb845365cc3bb3f41c37541edf98b58d3976"
],
"index": "pypi",
"version": "==1.1.0"
},
"anyio": { "anyio": {
"hashes": [ "hashes": [
"sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703",
@@ -24,6 +32,22 @@
"markers": "python_version >= '3.9'", "markers": "python_version >= '3.9'",
"version": "==4.12.1" "version": "==4.12.1"
}, },
"async-generator": {
"hashes": [
"sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b",
"sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144"
],
"markers": "python_version >= '3.5'",
"version": "==1.10"
},
"attrs": {
"hashes": [
"sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11",
"sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"
],
"markers": "python_version >= '3.9'",
"version": "==25.4.0"
},
"bitarray": { "bitarray": {
"hashes": [ "hashes": [
"sha256:004d518fa410e6da43386d20e07b576a41eb417ac67abf9f30fa75e125697199", "sha256:004d518fa410e6da43386d20e07b576a41eb417ac67abf9f30fa75e125697199",
@@ -141,6 +165,14 @@
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==4.3.1" "version": "==4.3.1"
}, },
"certifi": {
"hashes": [
"sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c",
"sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"
],
"markers": "python_version >= '3.7'",
"version": "==2026.1.4"
},
"cffi": { "cffi": {
"hashes": [ "hashes": [
"sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb",
@@ -231,6 +263,125 @@
"markers": "python_full_version >= '3.9' and platform_python_implementation != 'PyPy'", "markers": "python_full_version >= '3.9' and platform_python_implementation != 'PyPy'",
"version": "==2.0.0" "version": "==2.0.0"
}, },
"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"
],
"markers": "python_version >= '3.7'",
"version": "==3.4.4"
},
"click": { "click": {
"hashes": [ "hashes": [
"sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a",
@@ -241,71 +392,75 @@
}, },
"cryptography": { "cryptography": {
"hashes": [ "hashes": [
"sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", "sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa",
"sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", "sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc",
"sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", "sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da",
"sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", "sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255",
"sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", "sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2",
"sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", "sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485",
"sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", "sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0",
"sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", "sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d",
"sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", "sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616",
"sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b", "sha256:44cc0675b27cadb71bdbb96099cca1fa051cd11d2ade09e5cd3a2edb929ed947",
"sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", "sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0",
"sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", "sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908",
"sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", "sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81",
"sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", "sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc",
"sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", "sha256:62217ba44bf81b30abaeda1488686a04a702a261e26f87db51ff61d9d3510abd",
"sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", "sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b",
"sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", "sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019",
"sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", "sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7",
"sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", "sha256:766330cce7416c92b5e90c3bb71b1b79521760cdcfc3a6a1a182d4c9fab23d2b",
"sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", "sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973",
"sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", "sha256:829c2b12bbc5428ab02d6b7f7e9bbfd53e33efd6672d21341f2177470171ad8b",
"sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", "sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5",
"sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", "sha256:8a15fb869670efa8f83cbffbc8753c1abf236883225aed74cd179b720ac9ec80",
"sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", "sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef",
"sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", "sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0",
"sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", "sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b",
"sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", "sha256:9b34d8ba84454641a6bf4d6762d15847ecbd85c1316c0a7984e6e4e9f748ec2e",
"sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", "sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c",
"sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", "sha256:9c2da296c8d3415b93e6053f5a728649a87a48ce084a9aaf51d6e46c87c7f2d2",
"sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", "sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af",
"sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", "sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4",
"sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea", "sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab",
"sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", "sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82",
"sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717", "sha256:be8c01a7d5a55f9a47d1888162b76c8f49d62b234d88f0ff91a9fbebe32ffbc3",
"sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9", "sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59",
"sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", "sha256:c236a44acfb610e70f6b3e1c3ca20ff24459659231ef2f8c48e879e2d32b73da",
"sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32", "sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061",
"sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", "sha256:c92010b58a51196a5f41c3795190203ac52edfd5dc3ff99149b4659eba9d2085",
"sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", "sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b",
"sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", "sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263",
"sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", "sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e",
"sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", "sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829",
"sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", "sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4",
"sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb", "sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c",
"sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", "sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f",
"sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", "sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095",
"sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", "sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32",
"sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", "sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976",
"sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", "sha256:fdc3daab53b212472f1524d070735b2f0c214239df131903bae1d598016fa822"
"sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422",
"sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849",
"sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c",
"sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963",
"sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018"
], ],
"markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'", "markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'",
"version": "==46.0.3" "version": "==46.0.4"
}, },
"esptool": { "esptool": {
"hashes": [ "hashes": [
"sha256:2ea9bcd7eb263d380a4fe0170856a10e4c65e3f38c757ebdc73584c8dd8322da" "sha256:2ea9bcd7eb263d380a4fe0170856a10e4c65e3f38c757ebdc73584c8dd8322da"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==5.1.0" "version": "==5.1.0"
}, },
"h11": {
"hashes": [
"sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1",
"sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"
],
"markers": "python_version >= '3.8'",
"version": "==0.16.0"
},
"idna": { "idna": {
"hashes": [ "hashes": [
"sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea",
@@ -314,6 +469,14 @@
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==3.11" "version": "==3.11"
}, },
"importlib-metadata": {
"hashes": [
"sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb",
"sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"
],
"markers": "python_version >= '3.9'",
"version": "==8.7.1"
},
"intelhex": { "intelhex": {
"hashes": [ "hashes": [
"sha256:87cc5225657524ec6361354be928adfd56bcf2a3dcc646c40f8f094c39c07db4", "sha256:87cc5225657524ec6361354be928adfd56bcf2a3dcc646c40f8f094c39c07db4",
@@ -343,8 +506,33 @@
"sha256:6bb75774648091dad6833af4f86c5bf6505f8d7aec211380f9e6996c01d23cb5" "sha256:6bb75774648091dad6833af4f86c5bf6505f8d7aec211380f9e6996c01d23cb5"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.4'",
"version": "==1.27.0" "version": "==1.27.0"
}, },
"mypy-extensions": {
"hashes": [
"sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505",
"sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"
],
"markers": "python_version >= '3.8'",
"version": "==1.1.0"
},
"outcome": {
"hashes": [
"sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8",
"sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b"
],
"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": { "platformdirs": {
"hashes": [ "hashes": [
"sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda",
@@ -355,11 +543,11 @@
}, },
"pycparser": { "pycparser": {
"hashes": [ "hashes": [
"sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29",
"sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934" "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"
], ],
"markers": "implementation_name != 'PyPy'", "markers": "implementation_name != 'PyPy'",
"version": "==2.23" "version": "==3.0"
}, },
"pygments": { "pygments": {
"hashes": [ "hashes": [
@@ -371,11 +559,12 @@
}, },
"pyjwt": { "pyjwt": {
"hashes": [ "hashes": [
"sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623",
"sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb" "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469"
], ],
"index": "pypi", "index": "pypi",
"version": "==2.10.1" "markers": "python_version >= '3.9'",
"version": "==2.11.0"
}, },
"pyserial": { "pyserial": {
"hashes": [ "hashes": [
@@ -385,6 +574,22 @@
"index": "pypi", "index": "pypi",
"version": "==3.5" "version": "==3.5"
}, },
"pysocks": {
"hashes": [
"sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299",
"sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5",
"sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"
],
"version": "==1.7.1"
},
"python-dotenv": {
"hashes": [
"sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6",
"sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61"
],
"markers": "python_version >= '3.9'",
"version": "==1.2.1"
},
"pyyaml": { "pyyaml": {
"hashes": [ "hashes": [
"sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c",
@@ -471,30 +676,111 @@
], ],
"version": "==1.7.0" "version": "==1.7.0"
}, },
"requests": {
"hashes": [
"sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6",
"sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==2.32.5"
},
"rich": { "rich": {
"hashes": [ "hashes": [
"sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69",
"sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd" "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8"
], ],
"markers": "python_full_version >= '3.8.0'", "markers": "python_full_version >= '3.8.0'",
"version": "==14.2.0" "version": "==14.3.2"
}, },
"rich-click": { "rich-click": {
"hashes": [ "hashes": [
"sha256:48120531493f1533828da80e13e839d471979ec8d7d0ca7b35f86a1379cc74b6", "sha256:022997c1e30731995bdbc8ec2f82819340d42543237f033a003c7b1f843fc5dc",
"sha256:9b195721a773b1acf0e16ff9ec68cef1e7d237e53471e6e3f7ade462f86c403a" "sha256:2f99120fca78f536e07b114d3b60333bc4bb2a0969053b1250869bcdc1b5351b"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==1.9.5" "version": "==1.9.7"
},
"selenium": {
"hashes": [
"sha256:a88f5905d88ad0b84991c2386ea39e2bbde6d6c334be38df5842318ba98eaa8c",
"sha256:c8823fc02e2c771d9ad9a0cf899cee7de1a57a6697e3d0b91f67566129f2b729"
],
"index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==4.40.0"
},
"sniffio": {
"hashes": [
"sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2",
"sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"
],
"markers": "python_version >= '3.7'",
"version": "==1.3.1"
},
"sortedcontainers": {
"hashes": [
"sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88",
"sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"
],
"version": "==2.4.0"
},
"trio": {
"hashes": [
"sha256:150f29ec923bcd51231e1d4c71c7006e65247d68759dd1c19af4ea815a25806b",
"sha256:4ab65984ef8370b79a76659ec87aa3a30c5c7c83ff250b4de88c29a8ab6123c5"
],
"markers": "python_version >= '3.10'",
"version": "==0.32.0"
},
"trio-typing": {
"hashes": [
"sha256:065ee684296d52a8ab0e2374666301aec36ee5747ac0e7a61f230250f8907ac3",
"sha256:6d0e7ec9d837a2fe03591031a172533fbf4a1a95baf369edebfc51d5a49f0264"
],
"version": "==0.10.0"
},
"trio-websocket": {
"hashes": [
"sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae",
"sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6"
],
"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": { "typing-extensions": {
"hashes": [ "hashes": [
"sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466",
"sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"
], ],
"markers": "python_version < '3.13'", "markers": "python_version >= '3.9'",
"version": "==4.15.0" "version": "==4.15.0"
}, },
"urllib3": {
"extras": [
"socks"
],
"hashes": [
"sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed",
"sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"
],
"markers": "python_version >= '3.9'",
"version": "==2.6.3"
},
"watchfiles": { "watchfiles": {
"hashes": [ "hashes": [
"sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c",
@@ -608,7 +894,32 @@
"sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf" "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==1.1.1" "version": "==1.1.1"
},
"websocket-client": {
"hashes": [
"sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98",
"sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef"
],
"markers": "python_version >= '3.9'",
"version": "==1.9.0"
},
"wsproto": {
"hashes": [
"sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584",
"sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294"
],
"markers": "python_version >= '3.10'",
"version": "==1.3.2"
},
"zipp": {
"hashes": [
"sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e",
"sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"
],
"markers": "python_version >= '3.9'",
"version": "==3.23.0"
} }
}, },
"develop": {} "develop": {}

BIN
build_static/app.js.gz Normal file

Binary file not shown.

37
build_static/styles.css Normal file
View File

@@ -0,0 +1,37 @@
/* General tab styles */
.tabs {
display: flex;
justify-content: center;
margin-bottom: 20px;
}
.tab {
padding: 10px 20px;
margin: 0 10px;
cursor: pointer;
background-color: #f1f1f1;
border: 1px solid #ccc;
border-radius: 4px;
transition: background-color 0.3s ease;
}
.tab:hover {
background-color: #ddd;
}
.tab.active {
background-color: #ccc;
}
.tab-content {
display: flex;
justify-content: center;
}
.tab-pane {
display: none;
}
.tab-pane.active {
display: block;
}

BIN
build_static/styles.css.gz Normal file

Binary file not shown.

View File

@@ -1,39 +1,12 @@
{ {
"1": { "1": [
"name": "Default Colors", "#FF0000",
"colors": [ "#00FF00",
"#FF0000", "#0000FF",
"#00FF00", "#FFFF00",
"#0000FF", "#FF00FF",
"#FFFF00", "#00FFFF",
"#FF00FF", "#FFFFFF",
"#00FFFF", "#000000"
"#FFFFFF", ]
"#000000",
"#FFA500",
"#800080"
]
},
"2": {
"name": "Warm Colors",
"colors": [
"#FF6B6B",
"#FF8E53",
"#FFA07A",
"#FFD700",
"#FFA500",
"#FF6347"
]
},
"3": {
"name": "Cool Colors",
"colors": [
"#4ECDC4",
"#44A08D",
"#96CEB4",
"#A8E6CF",
"#5F9EA0",
"#4682B4"
]
}
} }

View File

@@ -1 +1,276 @@
{"1": {"name": "Warm White", "pattern": "on", "colors": ["#FFE5B4", "#FFDAB9", "#FFE4B5"], "brightness": 200, "delay": 100, "n1": 10, "n2": 10, "n3": 10, "n4": 10, "n5": 0, "n6": 0, "n7": 0, "n8": 0}, "2": {"name": "Rainbow", "pattern": "rainbow", "colors": ["#FF0000", "#FF7F00", "#FFFF00", "#00FF00", "#0000FF", "#4B0082", "#9400D3"], "brightness": 255, "delay": 50, "n1": 20, "n2": 15, "n3": 10, "n4": 5, "n5": 0, "n6": 0, "Step Rate": 20, "n7": 0, "n8": 0}, "3": {"name": "Pulse Red", "pattern": "pulse", "colors": ["#FF0000", "#CC0000", "#990000"], "brightness": 180, "delay": 200, "n1": 30, "n2": 20, "n3": 10, "n4": 5, "n5": 0, "n6": 0}} {
"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"
}
}

View File

@@ -1 +1,11 @@
{"1": {"name": "Default", "tabs": ["1", "2"], "scenes": ["1", "2"], "palette": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"]}, "2": {"name": "test", "type": "tabs", "tabs": ["12", "13"], "scenes": [], "palette": ["#b93c3c", "#3cb961"], "color_palette": ["#b93c3c", "#3cb961"]}} {
"1": {
"name": "default",
"type": "tabs",
"tabs": [
"1"
],
"scenes": [],
"palette_id": "1"
}
}

View File

@@ -1 +1,27 @@
{"1": {"name": "Main", "names": ["1", "2", "3"], "presets": [["1", "2", "3"]], "presets_flat": ["1", "2", "3"]}, "2": {"name": "Accent", "names": ["4", "5"], "presets": []}, "3": {"name": "", "names": [], "presets": []}, "4": {"name": "", "names": [], "presets": []}, "5": {"name": "", "names": [], "presets": []}, "6": {"name": "", "names": [], "presets": []}, "7": {"name": "", "names": [], "presets": []}, "8": {"name": "", "names": [], "presets": []}, "9": {"name": "", "names": [], "presets": []}, "10": {"name": "", "names": [], "presets": []}, "11": {"name": "", "names": [], "presets": []}, "12": {"name": "test2", "names": ["1"], "presets": [], "colors": ["#b93c3c", "#761e1e", "#ffffff"]}, "13": {"name": "test5", "names": ["1"], "presets": []}} {
"1": {
"name": "default",
"names": [
"a","b","c","d","e","f","g","h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "1", "2", "3", "4", "5", "6", "7", "8", "9", "0"
],
"presets": [
[
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"11",
"12",
"13",
"14",
"15"
]
]
}
}

View File

@@ -1,504 +1,263 @@
# LED Controller API Specification # LED Driver ESPNow API Documentation
**Base URL:** `http://device-ip/` or `http://192.168.4.1/` (when in AP mode) This document describes the ESPNow message format for controlling LED driver devices.
**Protocol:** HTTP/1.1
**Content-Type:** `application/json`
## Presets API ## Message Format
### GET /presets All messages are JSON objects sent via ESPNow with the following structure:
List all presets.
**Response:** `200 OK`
```json ```json
{ {
"preset1": { "v": "1",
"name": "preset1", "presets": { ... },
"pattern": "on", "select": { ... }
"colors": [[255, 0, 0]], }
"delay": 100, ```
"n1": 0,
"n2": 0, ### Version Field
"n3": 0,
"n4": 0, - **`v`** (required): Message version, must be `"1"`. Messages with other versions are ignored.
"n5": 0,
"n6": 0, ## Presets
"n7": 0,
"n8": 0 Presets define LED patterns with their configuration. Each preset has a name and contains pattern-specific settings.
### Preset Structure
```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
}
} }
} }
``` ```
### GET /presets/{name} ### Preset Fields
Get a specific preset by name. - **`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
#### Rainbow
- **`n1`**: Step increment (how many color wheel positions to advance 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)
#### Transition
- **`delay`**: Transition duration in milliseconds
#### 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)
#### Circle
- **`n1`**: Head movement rate (LEDs per second)
- **`n2`**: Maximum length
- **`n3`**: Tail movement rate (LEDs per second)
- **`n4`**: Minimum length
## Select Messages
Select messages control which preset is active on which device. The format uses a list to support step synchronization.
### Select Format
**Response:** `200 OK`
```json ```json
{ {
"name": "preset1", "select": {
"pattern": "on", "device_name": ["preset_name"],
"colors": [[255, 0, 0]], "device_name2": ["preset_name2", step_value]
"delay": 100,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0
}
```
**Response:** `404 Not Found`
```json
{
"error": "Preset not found"
}
```
### POST /presets
Create a new preset.
**Request Body:**
```json
{
"name": "preset1",
"pattern": "on",
"colors": [[255, 0, 0]],
"delay": 100,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0
}
```
**Response:** `201 Created` - Returns the created preset
**Response:** `400 Bad Request`
```json
{
"error": "Name is required"
}
```
**Response:** `409 Conflict`
```json
{
"error": "Preset already exists"
}
```
### PUT /presets/{name}
Update an existing preset.
**Request Body:**
```json
{
"delay": 200,
"colors": [[0, 255, 0]]
}
```
**Response:** `200 OK` - Returns the updated preset
**Response:** `404 Not Found`
```json
{
"error": "Preset not found"
}
```
### DELETE /presets/{name}
Delete a preset.
**Response:** `200 OK`
```json
{
"message": "Preset deleted successfully"
}
```
**Response:** `404 Not Found`
```json
{
"error": "Preset not found"
}
```
## Profiles API
### GET /profiles
List all profiles.
**Response:** `200 OK`
```json
{
"profile1": {
"name": "profile1",
"description": "Profile description",
"scenes": []
} }
} }
``` ```
### GET /profiles/{name} ### Select Fields
Get a specific profile by name. - **`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)
**Response:** `200 OK` ### Step Synchronization
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 ```json
{ // Beat 1
"name": "profile1", {"select": {"device1": ["rainbow_preset"]}}
"description": "Profile description",
"scenes": [] // Beat 2 (same preset = beat)
} {"select": {"device1": ["rainbow_preset"]}}
// Beat 3
{"select": {"device1": ["rainbow_preset"]}}
``` ```
**Response:** `404 Not Found` ## Synchronization
### Using "off" Pattern
Selecting the "off" pattern resets the step counter to 0, providing a synchronization point:
```json ```json
{ {
"error": "Profile not found" "select": {
} "device1": ["off"],
``` "device2": ["off"]
### POST /profiles
Create a new profile.
**Request Body:**
```json
{
"name": "profile1",
"description": "Profile description",
"scenes": []
}
```
**Response:** `201 Created` - Returns the created profile
**Response:** `400 Bad Request`
```json
{
"error": "Name is required"
}
```
**Response:** `409 Conflict`
```json
{
"error": "Profile already exists"
}
```
### PUT /profiles/{name}
Update an existing profile.
**Request Body:**
```json
{
"description": "Updated description"
}
```
**Response:** `200 OK` - Returns the updated profile
**Response:** `404 Not Found`
```json
{
"error": "Profile not found"
}
```
### DELETE /profiles/{name}
Delete a profile.
**Response:** `200 OK`
```json
{
"message": "Profile deleted successfully"
}
```
**Response:** `404 Not Found`
```json
{
"error": "Profile not found"
}
```
## Scenes API
### GET /scenes
List all scenes. Optionally filter by profile using query parameter.
**Query Parameters:**
- `profile` (optional): Filter scenes by profile name
**Example:** `GET /scenes?profile=profile1`
**Response:** `200 OK`
```json
{
"profile1:scene1": {
"name": "scene1",
"profile_name": "profile1",
"description": "Scene description",
"transition_time": 0,
"devices": [
{"device_name": "device1", "preset_name": "preset1"},
{"device_name": "device2", "preset_name": "preset2"}
]
} }
} }
``` ```
### GET /scenes/{profile_name}/{scene_name} After all devices are "off", switching to a pattern ensures they all start from step 0:
Get a specific scene.
**Response:** `200 OK`
```json ```json
{ {
"name": "scene1", "select": {
"profile_name": "profile1", "device1": ["rainbow_preset"],
"description": "Scene description", "device2": ["rainbow_preset"]
"transition_time": 0, }
"devices": [
{"device_name": "device1", "preset_name": "preset1"},
{"device_name": "device2", "preset_name": "preset2"}
]
} }
``` ```
**Response:** `404 Not Found` ### Using Step Parameter
For precise synchronization, use the step parameter:
```json ```json
{ {
"error": "Scene not found" "select": {
"device1": ["rainbow_preset", 10],
"device2": ["rainbow_preset", 10],
"device3": ["rainbow_preset", 10]
}
} }
``` ```
### POST /scenes All devices will start at step 10 and advance together on subsequent beats.
Create a new scene. ## Complete Example
**Request Body:**
```json ```json
{ {
"name": "scene1", "v": "1",
"profile_name": "profile1", "presets": {
"description": "Scene description", "red_blink": {
"transition_time": 0, "pattern": "blink",
"devices": [ "colors": ["#FF0000"],
{"device_name": "device1", "preset_name": "preset1"}, "delay": 200,
{"device_name": "device2", "preset_name": "preset2"} "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
}
},
"select": {
"device1": ["red_blink"],
"device2": ["rainbow_manual", 0],
"device3": ["pulse_slow"]
}
} }
``` ```
**Response:** `201 Created` - Returns the created scene ## Message Processing
**Response:** `400 Bad Request` 1. **Version Check**: Messages with `v != "1"` are rejected
```json 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
"error": "Name is required" 4. **Selection**: Devices select their assigned preset, optionally with step value
}
```
or
```json
{
"error": "Profile name is required"
}
```
**Response:** `409 Conflict` ## Best Practices
```json
{
"error": "Scene already exists"
}
```
### PUT /scenes/{profile_name}/{scene_name} 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
Update an existing scene. ## Error Handling
**Request Body:** - Invalid version: Message is ignored
```json - Missing preset: Selection fails, device keeps current preset
{ - Invalid pattern: Selection fails, device keeps current preset
"transition_time": 500, - Missing colors: Pattern uses default white color
"description": "Updated description" - Invalid step: Step value is used as-is (may cause unexpected behavior)
}
```
**Response:** `200 OK` - Returns the updated scene ## Notes
**Response:** `404 Not Found` - Colors are automatically converted from hex strings to RGB tuples
```json - Color order reordering happens automatically based on device settings
{ - Step counter wraps around (0-255 for rainbow, unbounded for others)
"error": "Scene not found" - Manual mode patterns stop after one step/cycle, waiting for next beat
} - Auto mode patterns run continuously until changed
```
### DELETE /scenes/{profile_name}/{scene_name}
Delete a scene.
**Response:** `200 OK`
```json
{
"message": "Scene deleted successfully"
}
```
**Response:** `404 Not Found`
```json
{
"error": "Scene not found"
}
```
### POST /scenes/{profile_name}/{scene_name}/devices
Add a device assignment to a scene.
**Request Body:**
```json
{
"device_name": "device1",
"preset_name": "preset1"
}
```
**Response:** `200 OK` - Returns the updated scene
**Response:** `400 Bad Request`
```json
{
"error": "Device name and preset name are required"
}
```
**Response:** `404 Not Found`
```json
{
"error": "Scene not found"
}
```
### DELETE /scenes/{profile_name}/{scene_name}/devices/{device_name}
Remove a device assignment from a scene.
**Response:** `200 OK` - Returns the updated scene
**Response:** `404 Not Found`
```json
{
"error": "Scene not found"
}
```
## Patterns API
### GET /patterns
Get the list of available pattern names.
**Response:** `200 OK`
```json
["on", "bl", "cl", "rb", "sb", "o"]
```
### POST /patterns
Add a new pattern name to the list.
**Request Body:**
```json
{
"name": "new_pattern"
}
```
**Response:** `201 Created` - Returns the updated list of patterns
```json
["on", "bl", "cl", "rb", "sb", "o", "new_pattern"]
```
**Response:** `400 Bad Request`
```json
{
"error": "Name is required"
}
```
**Response:** `409 Conflict`
```json
{
"error": "Pattern already exists"
}
```
### DELETE /patterns/{name}
Remove a pattern name from the list.
**Response:** `200 OK`
```json
{
"message": "Pattern deleted successfully"
}
```
**Response:** `404 Not Found`
```json
{
"error": "Pattern not found"
}
```
## Error Responses
All endpoints may return the following error responses:
**400 Bad Request** - Invalid request data
```json
{
"error": "Error message"
}
```
**404 Not Found** - Resource not found
```json
{
"error": "Resource not found"
}
```
**409 Conflict** - Resource already exists
```json
{
"error": "Resource already exists"
}
```
**500 Internal Server Error** - Server error
```json
{
"error": "Error message"
}
```

244
flash.sh Executable file
View File

@@ -0,0 +1,244 @@
#!/usr/bin/env sh
set -eu
# Environment variables:
# PORT - serial port (default: /dev/ttyUSB0)
# BAUD - baud rate (default: 460800)
# FIRMWARE - local path to firmware .bin
# FW_URL - URL to download firmware if FIRMWARE not provided or missing
PORT=${PORT:-}
BAUD=${BAUD:-460800}
CHIP=${CHIP:-esp32} # esp32 | esp32c3
# Map chip-specific settings
ESPT_CHIP="$CHIP"
FLASH_OFFSET=0x1000
DEFAULT_DOWNLOAD_PAGE="https://micropython.org/download/ESP32/"
BOARD_ID="ESP32_GENERIC"
BOARD_PAGE="https://micropython.org/download/${BOARD_ID}/"
case "$CHIP" in
esp32c3)
ESPT_CHIP="esp32c3"
FLASH_OFFSET=0x0
DEFAULT_DOWNLOAD_PAGE="https://micropython.org/download/ESP32C3/"
BOARD_ID="ESP32_GENERIC_C3"
BOARD_PAGE="https://micropython.org/download/${BOARD_ID}/"
;;
esp32)
ESPT_CHIP="esp32"
FLASH_OFFSET=0x1000
DEFAULT_DOWNLOAD_PAGE="https://micropython.org/download/ESP32/"
BOARD_ID="ESP32_GENERIC"
BOARD_PAGE="https://micropython.org/download/${BOARD_ID}/"
;;
*)
echo "Unsupported CHIP: $CHIP (supported: esp32, esp32c3)" >&2
exit 1
;;
esac
# Download-only mode: fetch the appropriate firmware and exit
if [ -n "${DOWNLOAD_ONLY:-}" ]; then
# Prefer resolving latest if nothing provided
if [ -z "${FIRMWARE:-}" ] && [ -z "${FW_URL:-}" ]; then
LATEST=1
fi
if ! resolve_firmware; then
echo "Failed to resolve firmware for CHIP=$CHIP" >&2
exit 1
fi
echo "$FIRMWARE"
exit 0
fi
# Helper: resolve the latest firmware URL for a given board pattern with multiple fallbacks
resolve_latest_url() {
board_pattern="$1" # e.g., ESP32_GENERIC_C3-.*\.bin
# Candidate pages to try in order
pages="${BOARD_PAGE} ${DOWNLOAD_PAGE:-$DEFAULT_DOWNLOAD_PAGE} https://micropython.org/download/ https://micropython.org/resources/firmware/"
for page in $pages; do
echo "Trying to resolve latest from $page" >&2
html=$(curl -fsSL -H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36' -e 'https://micropython.org/download/' "$page" || true)
[ -z "$html" ] && continue
# Prefer matching the board pattern
url=$(printf "%s" "$html" \
| sed -n 's/.*href=\"\([^\"]*\.bin\)\".*/\1/p' \
| grep -E "$board_pattern" \
| head -n1)
if [ -n "$url" ]; then
case "$url" in
http*) echo "$url"; return 0 ;;
/*) echo "https://micropython.org$url"; return 0 ;;
*) echo "$page$url"; return 0 ;;
esac
fi
done
return 1
}
# If LATEST is set and neither FIRMWARE nor FW_URL are provided, auto-detect latest URL
if [ -n "${LATEST:-}" ] && [ -z "${FIRMWARE:-}" ] && [ -z "${FW_URL:-}" ]; then
# Default board identifiers for each chip
case "$CHIP" in
esp32c3) BOARD_ID="ESP32_GENERIC_C3" ;;
esp32) BOARD_ID="ESP32_GENERIC" ;;
*) BOARD_ID="ESP32_GENERIC" ;;
esac
pattern="${BOARD_ID}-.*\\.bin"
echo "Resolving latest firmware for $BOARD_ID"
if FW_URL=$(resolve_latest_url "$pattern"); then
export FW_URL
echo "Latest firmware resolved to: $FW_URL"
else
echo "Failed to resolve latest firmware for pattern $pattern" >&2
exit 1
fi
fi
# Resolve firmware path, downloading if needed
resolve_firmware() {
if [ -z "${FIRMWARE:-}" ]; then
if [ -n "${FW_URL:-}" ] || [ -n "${LATEST:-}" ]; then
# If FW_URL still unset, resolve latest using board-specific pattern
if [ -z "${FW_URL:-}" ]; then
case "$CHIP" in
esp32c3) BOARD_ID="ESP32_GENERIC_C3" ;;
esp32) BOARD_ID="ESP32_GENERIC" ;;
*) BOARD_ID="ESP32_GENERIC" ;;
esac
pattern="${BOARD_ID}-.*\\.bin"
echo "Resolving latest firmware for $BOARD_ID"
if ! FW_URL=$(resolve_latest_url "$pattern"); then
echo "Failed to resolve latest firmware for pattern $pattern" >&2
exit 1
fi
fi
mkdir -p .cache
FIRMWARE=".cache/$(basename "$FW_URL")"
if [ ! -f "$FIRMWARE" ]; then
echo "Downloading firmware from $FW_URL to $FIRMWARE"
curl -L --fail -o "$FIRMWARE" "$FW_URL"
else
echo "Firmware already downloaded at $FIRMWARE"
fi
else
# Default fallback: fetch latest using board-specific pattern
case "$CHIP" in
esp32c3) BOARD_ID="ESP32_GENERIC_C3" ;;
esp32) BOARD_ID="ESP32_GENERIC" ;;
*) BOARD_ID="ESP32_GENERIC" ;;
esac
pattern="${BOARD_ID}-.*\\.bin"
echo "No FIRMWARE or FW_URL specified. Auto-fetching latest for $BOARD_ID"
if ! FW_URL=$(resolve_latest_url "$pattern"); then
echo "Failed to resolve latest firmware for pattern $pattern" >&2
exit 1
fi
mkdir -p .cache
FIRMWARE=".cache/$(basename "$FW_URL")"
if [ ! -f "$FIRMWARE" ]; then
echo "Downloading firmware from $FW_URL to $FIRMWARE"
curl -L --fail -o "$FIRMWARE" "$FW_URL"
else
echo "Firmware already downloaded at $FIRMWARE"
fi
fi
else
if [ ! -f "$FIRMWARE" ]; then
if [ -n "${FW_URL:-}" ]; then
mkdir -p "$(dirname "$FIRMWARE")"
echo "Firmware not found at $FIRMWARE. Downloading from $FW_URL"
curl -L --fail -o "$FIRMWARE" "$FW_URL"
else
echo "Firmware file not found: $FIRMWARE. Provide FW_URL to download automatically." >&2
exit 1
fi
fi
fi
}
# Auto-detect PORT if not specified
if [ -z "$PORT" ]; then
candidates="$(ls /dev/tty/ACM* /dev/tty/USB* 2>/dev/null || true)"
# Some systems expose without /dev/tty/ prefix patterns; try common Linux paths
[ -z "$candidates" ] && candidates="$(ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null || true)"
# Prefer ACM (often for C3) then USB
PORT=$(printf "%s\n" $candidates | grep -E "/dev/ttyACM[0-9]+" | head -n1 || true)
[ -z "$PORT" ] && PORT=$(printf "%s\n" $candidates | grep -E "/dev/ttyUSB[0-9]+" | head -n1 || true)
if [ -z "$PORT" ]; then
echo "No serial port detected. Connect the board and set PORT=/dev/ttyACM0 (or /dev/ttyUSB0)." >&2
exit 1
fi
echo "Auto-detected PORT=$PORT"
fi
# Preflight: ensure port exists
if [ ! -e "$PORT" ]; then
echo "Port $PORT does not exist. Detected candidates:" >&2
ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null || true
exit 1
fi
ESPL="python -m esptool"
detect_chip() {
# Try to detect actual connected chip using esptool and override if needed
out=$($ESPL --port "$PORT" --baud "$BAUD" chip_id 2>&1 || true)
case "$out" in
*"ESP32-C3"*) DETECTED_CHIP=esp32c3 ;;
*"ESP32"*) DETECTED_CHIP=esp32 ;;
*) DETECTED_CHIP="" ;;
esac
if [ -n "$DETECTED_CHIP" ] && [ "$DETECTED_CHIP" != "$ESPT_CHIP" ]; then
echo "Detected chip $DETECTED_CHIP differs from requested $ESPT_CHIP. Using detected chip."
ESPT_CHIP="$DETECTED_CHIP"
case "$ESPT_CHIP" in
esp32c3) FLASH_OFFSET=0x0 ;;
esp32) FLASH_OFFSET=0x1000 ;;
esac
fi
}
detect_chip
# Now that we know the actual chip, resolve the correct firmware for it
resolve_firmware
# Validate firmware matches detected chip; if not, auto-correct by fetching the right image
EXPECTED_BOARD_ID="ESP32_GENERIC"
case "$ESPT_CHIP" in
esp32c3) EXPECTED_BOARD_ID="ESP32_GENERIC_C3" ;;
esp32) EXPECTED_BOARD_ID="ESP32_GENERIC" ;;
esac
FW_BASENAME="$(basename "$FIRMWARE")"
case "$FW_BASENAME" in
${EXPECTED_BOARD_ID}-*.bin) : ;; # ok
*)
echo "Firmware $FW_BASENAME does not match detected chip ($ESPT_CHIP). Fetching correct image for $EXPECTED_BOARD_ID..."
pattern="${EXPECTED_BOARD_ID}-.*\\.bin"
if ! FW_URL=$(resolve_latest_url "$pattern"); then
echo "Failed to resolve a firmware matching $EXPECTED_BOARD_ID" >&2
exit 1
fi
mkdir -p .cache
FIRMWARE=".cache/$(basename "$FW_URL")"
if [ ! -f "$FIRMWARE" ]; then
echo "Downloading firmware from $FW_URL to $FIRMWARE"
curl -L --fail -o "$FIRMWARE" "$FW_URL"
else
echo "Firmware already downloaded at $FIRMWARE"
fi
;;
esac
$ESPL --chip "$ESPT_CHIP" --port "$PORT" --baud "$BAUD" erase_flash
echo "Writing firmware $FIRMWARE to $FLASH_OFFSET..."
$ESPL --chip "$ESPT_CHIP" --port "$PORT" --baud "$BAUD" write_flash -z "$FLASH_OFFSET" "$FIRMWARE"
echo "Done."

4
install.sh Executable file
View File

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

View File

@@ -1,4 +1,25 @@
import jwt try:
import jwt
HAS_JWT = True
except ImportError:
HAS_JWT = False
try:
import ubinascii
except ImportError:
import binascii as ubinascii
try:
import uhashlib as hashlib
except ImportError:
import hashlib
try:
import uhmac as hmac
except ImportError:
try:
import hmac
except ImportError:
hmac = None
import json
from microdot.microdot import invoke_handler from microdot.microdot import invoke_handler
from microdot.helpers import wraps from microdot.helpers import wraps
@@ -125,16 +146,61 @@ class Session:
return response return response
def encode(self, payload, secret_key=None): def encode(self, payload, secret_key=None):
return jwt.encode(payload, secret_key or self.secret_key, """Encode session data using JWT if available, otherwise use simple HMAC."""
algorithm='HS256') if HAS_JWT:
return jwt.encode(payload, secret_key or self.secret_key,
algorithm='HS256')
else:
# Simple encoding for MicroPython: base64(json) + HMAC signature
key = (secret_key or self.secret_key).encode() if isinstance(secret_key or self.secret_key, str) else (secret_key or self.secret_key)
payload_json = json.dumps(payload)
payload_b64 = ubinascii.b2a_base64(payload_json.encode()).decode().strip()
# Create HMAC signature
if hmac:
# Use hmac module if available
h = hmac.new(key, payload_json.encode(), hashlib.sha256)
else:
# Fallback: simple SHA256(key + message)
h = hashlib.sha256(key + payload_json.encode())
signature = ubinascii.b2a_base64(h.digest()).decode().strip()
return f"{payload_b64}.{signature}"
def decode(self, session, secret_key=None): def decode(self, session, secret_key=None):
try: """Decode session data using JWT if available, otherwise use simple HMAC."""
payload = jwt.decode(session, secret_key or self.secret_key, if HAS_JWT:
algorithms=['HS256']) try:
except jwt.exceptions.PyJWTError: # pragma: no cover payload = jwt.decode(session, secret_key or self.secret_key,
return {} algorithms=['HS256'])
return payload except jwt.exceptions.PyJWTError: # pragma: no cover
return {}
return payload
else:
try:
# Simple decoding for MicroPython
if '.' not in session:
return {}
payload_b64, signature = session.rsplit('.', 1)
payload_json = ubinascii.a2b_base64(payload_b64).decode()
# Verify HMAC signature
key = (secret_key or self.secret_key).encode() if isinstance(secret_key or self.secret_key, str) else (secret_key or self.secret_key)
if hmac:
# Use hmac module if available
h = hmac.new(key, payload_json.encode(), hashlib.sha256)
else:
# Fallback: simple SHA256(key + message)
h = hashlib.sha256(key + payload_json.encode())
expected_signature = ubinascii.b2a_base64(h.digest()).decode().strip()
if signature != expected_signature:
return {}
return json.loads(payload_json)
except Exception:
return {}
def with_session(f): def with_session(f):

23
msg.json Normal file
View File

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

View File

@@ -87,6 +87,8 @@ async def run_local():
from microdot import Microdot, send_file from microdot import Microdot, send_file
from microdot.websocket import with_websocket from microdot.websocket import with_websocket
from microdot.session import Session
import controllers.preset as preset import controllers.preset as preset
import controllers.profile as profile import controllers.profile as profile
import controllers.group as group import controllers.group as group
@@ -94,9 +96,15 @@ async def run_local():
import controllers.tab as tab import controllers.tab as tab
import controllers.palette as palette import controllers.palette as palette
import controllers.scene as scene import controllers.scene as scene
import controllers.pattern as pattern
import controllers.settings as settings_controller
app = Microdot() 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 # Mount model controllers as subroutes
app.mount(preset.controller, '/presets') app.mount(preset.controller, '/presets')
app.mount(profile.controller, '/profiles') app.mount(profile.controller, '/profiles')
@@ -105,6 +113,8 @@ async def run_local():
app.mount(tab.controller, '/tabs') app.mount(tab.controller, '/tabs')
app.mount(palette.controller, '/palettes') app.mount(palette.controller, '/palettes')
app.mount(scene.controller, '/scenes') app.mount(scene.controller, '/scenes')
app.mount(pattern.controller, '/patterns')
app.mount(settings_controller.controller, '/settings')
# Serve index.html at root # Serve index.html at root
@app.route('/') @app.route('/')
@@ -112,6 +122,17 @@ async def run_local():
"""Serve the main web UI.""" """Serve the main web UI."""
return send_file('src/templates/index.html') 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 # Static file route
@app.route("/static/<path:path>") @app.route("/static/<path:path>")
def static_handler(request, path): def static_handler(request, path):

Binary file not shown.

View File

@@ -8,14 +8,18 @@ palettes = Palette()
@controller.get('') @controller.get('')
async def list_palettes(request): async def list_palettes(request):
"""List all palettes.""" """List all palettes."""
return json.dumps(palettes), 200, {'Content-Type': 'application/json'} data = {}
for pid in palettes.list():
colors = palettes.read(pid)
data[pid] = colors
return json.dumps(data), 200, {'Content-Type': 'application/json'}
@controller.get('/<id>') @controller.get('/<id>')
async def get_palette(request, id): async def get_palette(request, id):
"""Get a specific palette by ID.""" """Get a specific palette by ID."""
palette = palettes.read(id) palette = palettes.read(id)
if palette: if palette:
return json.dumps(palette), 200, {'Content-Type': 'application/json'} return json.dumps({"colors": palette, "id": str(id)}), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Palette not found"}), 404 return json.dumps({"error": "Palette not found"}), 404
@controller.post('') @controller.post('')
@@ -23,12 +27,14 @@ async def create_palette(request):
"""Create a new palette.""" """Create a new palette."""
try: try:
data = request.json or {} data = request.json or {}
name = data.get("name", "")
colors = data.get("colors", None) colors = data.get("colors", None)
palette_id = palettes.create(name, colors) # Palette no longer needs a name; only colors are stored.
if data: palette_id = palettes.create("", colors)
palettes.update(palette_id, data) palette = palettes.read(palette_id) or {}
return json.dumps(palettes.read(palette_id)), 201, {'Content-Type': 'application/json'} # 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'}
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}), 400 return json.dumps({"error": str(e)}), 400
@@ -36,9 +42,15 @@ async def create_palette(request):
async def update_palette(request, id): async def update_palette(request, id):
"""Update an existing palette.""" """Update an existing palette."""
try: try:
data = request.json data = request.json or {}
# Ignore any name field; only colors are relevant.
if "name" in data:
data.pop("name", None)
if palettes.update(id, data): if palettes.update(id, data):
return json.dumps(palettes.read(id)), 200, {'Content-Type': 'application/json'} 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'}
return json.dumps({"error": "Palette not found"}), 404 return json.dumps({"error": "Palette not found"}), 404
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}), 400 return json.dumps({"error": str(e)}), 400

View File

@@ -1,40 +1,92 @@
from microdot import Microdot from microdot import Microdot
from microdot.session import with_session
from models.preset import Preset from models.preset import Preset
from models.profile import Profile
from models.espnow import ESPNow
from util.espnow_message import build_message, build_preset_dict, ESPNOW_MAX_PAYLOAD_BYTES
import asyncio
import json import json
controller = Microdot() controller = Microdot()
presets = Preset() presets = Preset()
profiles = Profile()
def get_current_profile_id(session=None):
"""Get the current active profile ID from session or fallback to first."""
profile_list = profiles.list()
session_profile = None
if session is not None:
session_profile = session.get('current_profile')
if session_profile and session_profile in profile_list:
return session_profile
if profile_list:
return profile_list[0]
return None
@controller.get('') @controller.get('')
async def list_presets(request): @with_session
"""List all presets.""" async def list_presets(request, session):
return json.dumps(presets), 200, {'Content-Type': 'application/json'} """List presets for the current profile."""
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return json.dumps({}), 200, {'Content-Type': 'application/json'}
scoped = {
pid: pdata for pid, pdata in presets.items()
if isinstance(pdata, dict) and str(pdata.get("profile_id")) == str(current_profile_id)
}
return json.dumps(scoped), 200, {'Content-Type': 'application/json'}
@controller.get('/<id>') @controller.get('/<id>')
async def get_preset(request, id): @with_session
"""Get a specific preset by ID.""" async def get_preset(request, id, session):
"""Get a specific preset by ID (current profile only)."""
preset = presets.read(id) preset = presets.read(id)
if preset: current_profile_id = get_current_profile_id(session)
if preset and str(preset.get("profile_id")) == str(current_profile_id):
return json.dumps(preset), 200, {'Content-Type': 'application/json'} return json.dumps(preset), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Preset not found"}), 404 return json.dumps({"error": "Preset not found"}), 404
@controller.post('') @controller.post('')
async def create_preset(request): @with_session
"""Create a new preset.""" async def create_preset(request, session):
"""Create a new preset for the current profile."""
try: try:
data = request.json try:
preset_id = presets.create() data = request.json or {}
except Exception:
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return json.dumps({"error": "No profile available"}), 404
preset_id = presets.create(current_profile_id)
if not isinstance(data, dict):
data = {}
data = dict(data)
data["profile_id"] = str(current_profile_id)
if presets.update(preset_id, data): if presets.update(preset_id, data):
return json.dumps(presets.read(preset_id)), 201, {'Content-Type': 'application/json'} preset_data = presets.read(preset_id)
return json.dumps({preset_id: preset_data}), 201, {'Content-Type': 'application/json'}
return json.dumps({"error": "Failed to create preset"}), 400 return json.dumps({"error": "Failed to create preset"}), 400
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}), 400 return json.dumps({"error": str(e)}), 400
@controller.put('/<id>') @controller.put('/<id>')
async def update_preset(request, id): @with_session
"""Update an existing preset.""" async def update_preset(request, id, session):
"""Update an existing preset (current profile only)."""
try: try:
data = request.json preset = presets.read(id)
current_profile_id = get_current_profile_id(session)
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
return json.dumps({"error": "Preset not found"}), 404
try:
data = request.json or {}
except Exception:
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
if not isinstance(data, dict):
data = {}
data = dict(data)
data["profile_id"] = str(current_profile_id)
if presets.update(id, data): if presets.update(id, data):
return json.dumps(presets.read(id)), 200, {'Content-Type': 'application/json'} return json.dumps(presets.read(id)), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Preset not found"}), 404 return json.dumps({"error": "Preset not found"}), 404
@@ -42,8 +94,111 @@ async def update_preset(request, id):
return json.dumps({"error": str(e)}), 400 return json.dumps({"error": str(e)}), 400
@controller.delete('/<id>') @controller.delete('/<id>')
async def delete_preset(request, id): @with_session
"""Delete a preset.""" async def delete_preset(request, id, session):
"""Delete a preset (current profile only)."""
preset = presets.read(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(id):
return json.dumps({"message": "Preset deleted successfully"}), 200 return json.dumps({"message": "Preset deleted successfully"}), 200
return json.dumps({"error": "Preset not found"}), 404 return json.dumps({"error": "Preset not found"}), 404
@controller.post('/send')
@with_session
async def send_presets(request, session):
"""
Send one or more presets over ESPNow.
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.
"""
try:
data = request.json or {}
except Exception:
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
preset_ids = data.get('preset_ids') or data.get('ids')
if not isinstance(preset_ids, list) or not preset_ids:
return json.dumps({"error": "preset_ids must be a non-empty list"}), 400, {'Content-Type': 'application/json'}
save_flag = data.get('save', True)
save_flag = bool(save_flag)
default_id = data.get('default')
# Build API-compliant preset map keyed by preset ID, include name
current_profile_id = get_current_profile_id(session)
presets_by_name = {}
for pid in preset_ids:
preset_data = presets.read(str(pid))
if not preset_data:
continue
if str(preset_data.get("profile_id")) != str(current_profile_id):
continue
preset_key = str(pid)
preset_payload = build_preset_dict(preset_data)
preset_payload["name"] = preset_data.get("name", "")
presets_by_name[preset_key] = preset_payload
if not presets_by_name:
return json.dumps({"error": "No matching presets found"}), 404, {'Content-Type': 'application/json'}
if default_id is not None and str(default_id) not in presets_by_name:
default_id = None
# Use shared ESPNow singleton
esp = ESPNow()
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)
MAX_BYTES = ESPNOW_MAX_PAYLOAD_BYTES
SEND_DELAY_MS = 100
entries = list(presets_by_name.items())
total_presets = len(entries)
messages_sent = 0
batch = {}
last_msg = None
for name, preset_obj in entries:
test_batch = dict(batch)
test_batch[name] = preset_obj
test_msg = build_message(presets=test_batch, save=save_flag, default=default_id)
size = len(test_msg)
if size <= MAX_BYTES or not batch:
batch = test_batch
last_msg = test_msg
else:
try:
await send_chunk(batch)
except Exception:
return json.dumps({"error": "ESP-NOW send failed"}), 503, {'Content-Type': 'application/json'}
await asyncio.sleep_ms(SEND_DELAY_MS)
messages_sent += 1
batch = {name: preset_obj}
last_msg = build_message(presets=batch, save=save_flag, default=default_id)
if batch:
try:
await send_chunk(batch)
except Exception:
return json.dumps({"error": "ESP-NOW send failed"}), 503, {'Content-Type': 'application/json'}
await asyncio.sleep_ms(SEND_DELAY_MS)
messages_sent += 1
return json.dumps({
"message": "Presets sent via ESPNow",
"presets_sent": total_presets,
"messages_sent": messages_sent
}), 200, {'Content-Type': 'application/json'}

View File

@@ -1,15 +1,41 @@
from microdot import Microdot from microdot import Microdot
from microdot.session import with_session from microdot.session import with_session
from models.profile import Profile from models.profile import Profile
from models.tab import Tab
from models.preset import Preset
import json import json
controller = Microdot() controller = Microdot()
profiles = Profile() profiles = Profile()
tabs = Tab()
presets = Preset()
@controller.get('') @controller.get('')
async def list_profiles(request): @with_session
"""List all profiles.""" async def list_profiles(request, session):
return json.dumps(profiles), 200, {'Content-Type': 'application/json'} """List all profiles with current profile info."""
profile_list = profiles.list()
current_id = session.get('current_profile')
if current_id and current_id not in profile_list:
current_id = None
# If no current profile in session, use first one
if not current_id and profile_list:
current_id = profile_list[0]
session['current_profile'] = str(current_id)
session.save()
# Build profiles object
profiles_data = {}
for profile_id in profile_list:
profile_data = profiles.read(profile_id)
if profile_data:
profiles_data[profile_id] = profile_data
return json.dumps({
"profiles": profiles_data,
"current_profile_id": current_id
}), 200, {'Content-Type': 'application/json'}
@controller.get('/current') @controller.get('/current')
@with_session @with_session
@@ -17,6 +43,8 @@ async def get_current_profile(request, session):
"""Get the current profile ID from session (or fallback).""" """Get the current profile ID from session (or fallback)."""
profile_list = profiles.list() profile_list = profiles.list()
current_id = session.get('current_profile') current_id = session.get('current_profile')
if current_id and current_id not in profile_list:
current_id = None
if not current_id and profile_list: if not current_id and profile_list:
current_id = profile_list[0] current_id = profile_list[0]
session['current_profile'] = str(current_id) session['current_profile'] = str(current_id)
@@ -27,8 +55,13 @@ async def get_current_profile(request, session):
return json.dumps({"error": "No profile available"}), 404 return json.dumps({"error": "No profile available"}), 404
@controller.get('/<id>') @controller.get('/<id>')
async def get_profile(request, id): @with_session
async def get_profile(request, id, session):
"""Get a specific profile by ID.""" """Get a specific profile by ID."""
# Handle 'current' as a special case
if id == 'current':
return await get_current_profile(request, session)
profile = profiles.read(id) profile = profiles.read(id)
if profile: if profile:
return json.dumps(profile), 200, {'Content-Type': 'application/json'} return json.dumps(profile), 200, {'Content-Type': 'application/json'}
@@ -53,7 +86,120 @@ async def create_profile(request):
profile_id = profiles.create(name) profile_id = profiles.create(name)
if data: if data:
profiles.update(profile_id, data) profiles.update(profile_id, data)
return json.dumps(profiles.read(profile_id)), 201, {'Content-Type': 'application/json'} profile_data = profiles.read(profile_id)
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.post('/<id>/clone')
async def clone_profile(request, id):
"""Clone an existing profile along with its tabs and palette."""
try:
source = profiles.read(id)
if not source:
return json.dumps({"error": "Profile not found"}), 404
data = request.json or {}
source_name = source.get("name") or f"Profile {id}"
new_name = data.get("name") or source_name
profile_type = source.get("type", "tabs")
def allocate_id(model, cache):
if "next" not in cache:
max_id = max((int(k) for k in model.keys() if str(k).isdigit()), default=0)
cache["next"] = max_id + 1
next_id = str(cache["next"])
cache["next"] += 1
return next_id
def map_preset_container(value, id_map, preset_cache, new_profile_id, new_presets):
if isinstance(value, list):
return [map_preset_container(v, id_map, preset_cache, new_profile_id, new_presets) for v in value]
if value is None:
return None
preset_id = str(value)
if preset_id in id_map:
return id_map[preset_id]
preset_data = presets.read(preset_id)
if not preset_data:
return None
new_preset_id = allocate_id(presets, preset_cache)
clone_data = dict(preset_data)
clone_data["profile_id"] = str(new_profile_id)
new_presets[new_preset_id] = clone_data
id_map[preset_id] = new_preset_id
return new_preset_id
# Prepare new IDs without writing until everything is ready.
profile_cache = {}
palette_cache = {}
tab_cache = {}
preset_cache = {}
new_profile_id = allocate_id(profiles, profile_cache)
new_palette_id = allocate_id(profiles._palette_model, palette_cache)
# Clone palette colors into the new profile's palette
src_palette_id = source.get("palette_id")
palette_colors = []
if src_palette_id:
try:
palette_colors = profiles._palette_model.read(src_palette_id)
except Exception:
palette_colors = []
# Clone tabs and presets used by those tabs
source_tabs = source.get("tabs")
if not isinstance(source_tabs, list) or len(source_tabs) == 0:
source_tabs = source.get("tab_order", [])
source_tabs = source_tabs or []
cloned_tab_ids = []
preset_id_map = {}
new_tabs = {}
new_presets = {}
for tab_id in source_tabs:
tab = tabs.read(tab_id)
if not tab:
continue
tab_name = tab.get("name") or f"Tab {tab_id}"
clone_name = tab_name
mapped_presets = map_preset_container(tab.get("presets"), preset_id_map, preset_cache, new_profile_id, new_presets)
clone_id = allocate_id(tabs, tab_cache)
clone_data = {
"name": clone_name,
"names": tab.get("names") or [],
"presets": mapped_presets if mapped_presets is not None else []
}
extra = {k: v for k, v in tab.items() if k not in ("name", "names", "presets")}
if "presets_flat" in extra:
extra["presets_flat"] = map_preset_container(extra.get("presets_flat"), preset_id_map, preset_cache, new_profile_id, new_presets)
if extra:
clone_data.update(extra)
new_tabs[clone_id] = clone_data
cloned_tab_ids.append(clone_id)
new_profile_data = {
"name": new_name,
"type": profile_type,
"tabs": cloned_tab_ids,
"scenes": list(source.get("scenes", [])) if isinstance(source.get("scenes", []), list) else [],
"palette_id": str(new_palette_id),
}
# Commit all changes and save once per model.
profiles._palette_model[str(new_palette_id)] = list(palette_colors) if palette_colors else []
for pid, pdata in new_presets.items():
presets[pid] = pdata
for tid, tdata in new_tabs.items():
tabs[tid] = tdata
profiles[str(new_profile_id)] = new_profile_data
profiles._palette_model.save()
presets.save()
tabs.save()
profiles.save()
return json.dumps({new_profile_id: new_profile_data}), 201, {'Content-Type': 'application/json'}
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}), 400 return json.dumps({"error": str(e)}), 400

View File

@@ -0,0 +1,80 @@
from microdot import Microdot, send_file
from settings import Settings
import util.wifi as wifi
import json
controller = Microdot()
settings = Settings()
@controller.get('')
async def get_settings(request):
"""Get all settings."""
# Settings is already a dict subclass; avoid dict() wrapper which can
# trigger MicroPython's "dict update sequence has wrong length" quirk.
return json.dumps(settings), 200, {'Content-Type': 'application/json'}
@controller.get('/wifi/ap')
async def get_ap_config(request):
"""Get 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
@controller.post('/wifi/ap')
async def configure_ap(request):
"""Configure Access Point."""
try:
data = request.json
ssid = data.get('ssid')
password = data.get('password', '')
channel = data.get('channel')
if not ssid:
return json.dumps({"error": "SSID is required"}), 400
# Validate channel (1-11 for 2.4GHz)
if channel is not None:
channel = int(channel)
if channel < 1 or channel > 11:
return json.dumps({"error": "Channel must be between 1 and 11"}), 400
# 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",
"ssid": ssid,
"channel": channel
}), 200, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 500
@controller.put('/settings')
async def update_settings(request):
"""Update general settings."""
try:
data = request.json
for key, value in data.items():
settings[key] = value
settings.save()
return json.dumps({"message": "Settings updated successfully"}), 200, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 500
@controller.get('/page')
async def settings_page(request):
"""Serve the settings page."""
return send_file('templates/settings.html')

View File

@@ -33,13 +33,13 @@ def get_profile_tab_order(profile_id):
return [] return []
def get_current_tab_id(request, session=None): def get_current_tab_id(request, session=None):
"""Get the current tab ID from session.""" """Get the current tab ID from cookie."""
if session: # Read from cookie first
current_tab = session.get('current_tab') current_tab = request.cookies.get('current_tab')
if current_tab: if current_tab:
return current_tab return current_tab
# Fallback to first tab in current profile if no session # Fallback to first tab in current profile
profile_id = get_current_profile_id(session) profile_id = get_current_profile_id(session)
if profile_id: if profile_id:
profile = profiles.read(profile_id) profile = profiles.read(profile_id)
@@ -50,16 +50,8 @@ def get_current_tab_id(request, session=None):
return tabs_list[0] return tabs_list[0]
return None return None
@controller.get('') def _render_tabs_list_fragment(request, session):
async def list_tabs(request): """Helper function to render tabs list HTML fragment."""
"""List all tabs."""
return json.dumps(tabs), 200, {'Content-Type': 'application/json'}
# HTML Fragment endpoints for htmx - must be before /<id> route
@controller.get('/list-fragment')
@with_session
async def tabs_list_fragment(request, session):
"""Return HTML fragment for the tabs list."""
profile_id = get_current_profile_id(session) profile_id = get_current_profile_id(session)
# #region agent log # #region agent log
try: try:
@@ -69,7 +61,7 @@ async def tabs_list_fragment(request, session):
"sessionId": "debug-session", "sessionId": "debug-session",
"runId": "tabs-pre-fix", "runId": "tabs-pre-fix",
"hypothesisId": "H1", "hypothesisId": "H1",
"location": "src/controllers/tab.py:tabs_list_fragment", "location": "src/controllers/tab.py:_render_tabs_list_fragment",
"message": "tabs list fragment", "message": "tabs list fragment",
"data": { "data": {
"profile_id": profile_id, "profile_id": profile_id,
@@ -106,49 +98,18 @@ async def tabs_list_fragment(request, session):
html += '</div>' html += '</div>'
return html, 200, {'Content-Type': 'text/html'} return html, 200, {'Content-Type': 'text/html'}
@controller.get('/create-form-fragment') def _render_tab_content_fragment(request, session, id):
async def create_tab_form_fragment(request): """Helper function to render tab content HTML fragment."""
"""Return the create tab form HTML fragment."""
html = '''
<h2>Add New Tab</h2>
<form hx-post="/tabs"
hx-target="#tabs-list"
hx-swap="innerHTML"
hx-headers='{"Accept": "text/html"}'
hx-on::after-request="if(event.detail.successful) { document.getElementById('add-tab-modal').classList.remove('active'); document.body.dispatchEvent(new Event('tabs-updated')); }">
<label>Tab Name:</label>
<input type="text" name="name" placeholder="Enter tab name" required>
<label>Device IDs (comma-separated):</label>
<input type="text" name="ids" placeholder="1,2,3" value="1">
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Add</button>
<button type="button" class="btn btn-secondary" onclick="document.getElementById('add-tab-modal').classList.remove('active')">Cancel</button>
</div>
</form>
'''
return html, 200, {'Content-Type': 'text/html'}
@controller.get('/current')
@with_session
async def get_current_tab(request, session):
"""Get the current tab from session."""
current_tab_id = get_current_tab_id(request, session)
if not current_tab_id:
accept_header = request.headers.get('Accept', '')
wants_html = 'text/html' in accept_header
if wants_html:
return '<div class="error">No current tab set</div>', 404, {'Content-Type': 'text/html'}
return json.dumps({"error": "No current tab set"}), 404
return await tab_content_fragment.__wrapped__(request, session, current_tab_id)
@controller.get('/<id>/content-fragment')
@with_session
async def tab_content_fragment(request, session, id):
"""Return HTML fragment for tab content."""
# Handle 'current' as a special case # Handle 'current' as a special case
if id == 'current': if id == 'current':
return await get_current_tab(request, session) current_tab_id = get_current_tab_id(request, session)
if not current_tab_id:
accept_header = request.headers.get('Accept', '')
wants_html = 'text/html' in accept_header
if wants_html:
return '<div class="error">No current tab set</div>', 404, {'Content-Type': 'text/html'}
return json.dumps({"error": "No current tab set"}), 404
id = current_tab_id
tab = tabs.read(id) tab = tabs.read(id)
if not tab: if not tab:
@@ -167,9 +128,7 @@ async def tab_content_fragment(request, session, id):
html = ( html = (
'<div class="presets-section" data-tab-id="' + str(id) + '">' '<div class="presets-section" data-tab-id="' + str(id) + '">'
'<h3>Presets</h3>' '<h3>Presets</h3>'
'<div class="profiles-actions" style="margin-bottom: 1rem;">' '<div class="profiles-actions" style="margin-bottom: 1rem;"></div>'
'<button class="btn btn-primary" id="preset-add-btn-tab">Add Preset</button>'
'</div>'
'<div id="presets-list-tab" class="presets-list">' '<div id="presets-list-tab" class="presets-list">'
'<!-- Presets will be loaded here -->' '<!-- Presets will be loaded here -->'
'</div>' '</div>'
@@ -177,6 +136,62 @@ async def tab_content_fragment(request, session, id):
) )
return html, 200, {'Content-Type': 'text/html'} return html, 200, {'Content-Type': 'text/html'}
@controller.get('')
@with_session
async def list_tabs(request, session):
"""List all tabs with current tab info."""
profile_id = get_current_profile_id(session)
current_tab_id = get_current_tab_id(request, session)
# Get tab order for current profile
tab_order = get_profile_tab_order(profile_id) if profile_id else []
# Build tabs list with metadata
tabs_data = {}
for tab_id in tabs.list():
tab_data = tabs.read(tab_id)
if tab_data:
tabs_data[tab_id] = tab_data
return json.dumps({
"tabs": tabs_data,
"tab_order": tab_order,
"current_tab_id": current_tab_id,
"profile_id": profile_id
}), 200, {'Content-Type': 'application/json'}
# Get current tab - returns JSON with tab data and content info
@controller.get('/current')
@with_session
async def get_current_tab(request, session):
"""Get the current tab from session."""
current_tab_id = get_current_tab_id(request, session)
if not current_tab_id:
return json.dumps({"error": "No current tab set", "tab": None, "tab_id": None}), 404
tab = tabs.read(current_tab_id)
if tab:
return json.dumps({
"tab": tab,
"tab_id": current_tab_id
}), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Tab not found", "tab": None, "tab_id": None}), 404
@controller.post('/<id>/set-current')
async def set_current_tab(request, id):
"""Set a tab as the current tab in cookie."""
tab = tabs.read(id)
if not tab:
return json.dumps({"error": "Tab not found"}), 404
# Set cookie with current tab
response_data = json.dumps({"message": "Current tab set", "tab_id": id})
response = response_data, 200, {
'Content-Type': 'application/json',
'Set-Cookie': f'current_tab={id}; Path=/; Max-Age=31536000' # 1 year expiry
}
return response
@controller.get('/<id>') @controller.get('/<id>')
async def get_tab(request, id): async def get_tab(request, id):
"""Get a specific tab by ID.""" """Get a specific tab by ID."""
@@ -198,84 +213,60 @@ async def update_tab(request, id):
@controller.delete('/<id>') @controller.delete('/<id>')
@with_session @with_session
async def delete_tab(request, id, session): async def delete_tab(request, session, id):
"""Delete a tab.""" """Delete a tab."""
# Check if this is an htmx request (wants HTML fragment) try:
accept_header = request.headers.get('Accept', '') # Handle 'current' tab ID
wants_html = 'text/html' in accept_header if id == 'current':
current_tab_id = get_current_tab_id(request, session)
# Handle 'current' tab ID if current_tab_id:
if id == 'current': id = current_tab_id
current_tab_id = get_current_tab_id(request, session) else:
if current_tab_id: return json.dumps({"error": "No current tab to delete"}), 404
id = current_tab_id
else:
if wants_html:
return '<div class="error">No current tab to delete</div>', 404, {'Content-Type': 'text/html'}
return json.dumps({"error": "No current tab to delete"}), 404
if tabs.delete(id):
# Remove from profile's tabs
profile_id = get_current_profile_id(session)
if profile_id:
profile = profiles.read(profile_id)
if profile:
# Support both "tabs" (new) and "tab_order" (old) format
tabs_list = profile.get('tabs', profile.get('tab_order', []))
if id in tabs_list:
tabs_list.remove(id)
profile['tabs'] = tabs_list
# Remove old tab_order if it exists
if 'tab_order' in profile:
del profile['tab_order']
profiles.update(profile_id, profile)
# Clear session if the deleted tab was the current tab if tabs.delete(id):
current_tab_id = get_current_tab_id(request, session) # Remove from profile's tabs
if current_tab_id == id: profile_id = get_current_profile_id(session)
if 'current_tab' in session: if profile_id:
session.pop('current_tab', None) profile = profiles.read(profile_id)
session.save() if profile:
# Support both "tabs" (new) and "tab_order" (old) format
if wants_html: tabs_list = profile.get('tabs', profile.get('tab_order', []))
return await tabs_list_fragment.__wrapped__(request, session) if id in tabs_list:
else: tabs_list.remove(id)
profile['tabs'] = tabs_list
# Remove old tab_order if it exists
if 'tab_order' in profile:
del profile['tab_order']
profiles.update(profile_id, profile)
# Clear cookie if the deleted tab was the current tab
current_tab_id = get_current_tab_id(request, session)
if current_tab_id == id:
response_data = json.dumps({"message": "Tab deleted successfully"})
response = response_data, 200, {
'Content-Type': 'application/json',
'Set-Cookie': 'current_tab=; Path=/; Max-Age=0' # Clear cookie
}
return response
return json.dumps({"message": "Tab deleted successfully"}), 200, {'Content-Type': 'application/json'} return json.dumps({"message": "Tab deleted successfully"}), 200, {'Content-Type': 'application/json'}
if wants_html: return json.dumps({"error": "Tab not found"}), 404
return '<div class="error">Tab not found</div>', 404, {'Content-Type': 'text/html'} except Exception as e:
return json.dumps({"error": "Tab not found"}), 404 import sys
try:
sys.print_exception(e)
except:
pass
return json.dumps({"error": str(e)}), 500, {'Content-Type': 'application/json'}
@controller.post('') @controller.post('')
@with_session @with_session
async def create_tab(request, session): async def create_tab(request, session):
"""Create a new tab.""" """Create a new tab."""
# Check if this is an htmx request (wants HTML fragment)
accept_header = request.headers.get('Accept', '')
wants_html = 'text/html' in accept_header
# #region agent log
try: try:
os.makedirs('/home/pi/led-controller/.cursor', exist_ok=True) # Handle form data or JSON
with open('/home/pi/led-controller/.cursor/debug.log', 'a') as _log:
_log.write(json.dumps({
"sessionId": "debug-session",
"runId": "tabs-pre-fix",
"hypothesisId": "H3",
"location": "src/controllers/tab.py:create_tab_htmx",
"message": "create tab with session",
"data": {
"wants_html": wants_html,
"has_form": bool(request.form),
"accept": accept_header
},
"timestamp": int(time.time() * 1000)
}) + "\n")
except Exception:
pass
# #endregion
try:
# Handle form data (htmx) or JSON
if request.form: if request.form:
name = request.form.get('name', '').strip() name = request.form.get('name', '').strip()
ids_str = request.form.get('ids', '1').strip() ids_str = request.form.get('ids', '1').strip()
@@ -288,8 +279,6 @@ async def create_tab(request, session):
preset_ids = data.get("presets", None) preset_ids = data.get("presets", None)
if not name: if not name:
if wants_html:
return '<div class="error">Tab name cannot be empty</div>', 400, {'Content-Type': 'text/html'}
return json.dumps({"error": "Tab name cannot be empty"}), 400 return json.dumps({"error": "Tab name cannot be empty"}), 400
tab_id = tabs.create(name, names, preset_ids) tab_id = tabs.create(name, names, preset_ids)
@@ -308,36 +297,50 @@ async def create_tab(request, session):
if 'tab_order' in profile: if 'tab_order' in profile:
del profile['tab_order'] del profile['tab_order']
profiles.update(profile_id, profile) profiles.update(profile_id, profile)
# #region agent log
try:
os.makedirs('/home/pi/led-controller/.cursor', exist_ok=True)
with open('/home/pi/led-controller/.cursor/debug.log', 'a') as _log:
_log.write(json.dumps({
"sessionId": "debug-session",
"runId": "tabs-pre-fix",
"hypothesisId": "H4",
"location": "src/controllers/tab.py:create_tab_htmx",
"message": "tab created and profile updated",
"data": {
"tab_id": tab_id,
"profile_id": profile_id,
"profile_tabs": tabs_list if profile_id and profile else None
},
"timestamp": int(time.time() * 1000)
}) + "\n")
except Exception:
pass
# #endregion
if wants_html: # Return JSON response with tab ID
# Return HTML fragment for tabs list tab_data = tabs.read(tab_id)
return await tabs_list_fragment.__wrapped__(request, session) return json.dumps({tab_id: tab_data}), 201, {'Content-Type': 'application/json'}
else:
# Return JSON response
return json.dumps(tabs.read(tab_id)), 201, {'Content-Type': 'application/json'}
except Exception as e: except Exception as e:
import sys import sys
sys.print_exception(e) sys.print_exception(e)
if wants_html: return json.dumps({"error": str(e)}), 400
return f'<div class="error">Error: {str(e)}</div>', 400, {'Content-Type': 'text/html'}
@controller.post('/<id>/clone')
@with_session
async def clone_tab(request, session, id):
"""Clone an existing tab and add it to the current profile."""
try:
source = tabs.read(id)
if not source:
return json.dumps({"error": "Tab not found"}), 404
data = request.json or {}
source_name = source.get("name") or f"Tab {id}"
new_name = data.get("name") or f"{source_name} Copy"
clone_id = tabs.create(new_name, source.get("names"), source.get("presets"))
extra = {k: v for k, v in source.items() if k not in ("name", "names", "presets")}
if extra:
tabs.update(clone_id, extra)
profile_id = get_current_profile_id(session)
if profile_id:
profile = profiles.read(profile_id)
if profile:
tabs_list = profile.get('tabs', profile.get('tab_order', []))
if clone_id not in tabs_list:
tabs_list.append(clone_id)
profile['tabs'] = tabs_list
if 'tab_order' in profile:
del profile['tab_order']
profiles.update(profile_id, profile)
tab_data = tabs.read(clone_id)
return json.dumps({clone_id: tab_data}), 201, {'Content-Type': 'application/json'}
except Exception as e:
import sys
try:
sys.print_exception(e)
except:
pass
return json.dumps({"error": str(e)}), 400 return json.dumps({"error": str(e)}), 400

View File

@@ -1,13 +1,14 @@
import asyncio import asyncio
from settings import Settings
import gc import gc
import json
import machine import machine
from machine import Pin
from microdot import Microdot, send_file from microdot import Microdot, send_file
from microdot.websocket import with_websocket from microdot.websocket import with_websocket
from microdot.session import Session from microdot.session import Session
from settings import Settings
import aioespnow import aioespnow
import network
import controllers.preset as preset import controllers.preset as preset
import controllers.profile as profile import controllers.profile as profile
import controllers.group as group import controllers.group as group
@@ -16,18 +17,18 @@ import controllers.tab as tab
import controllers.palette as palette import controllers.palette as palette
import controllers.scene as scene import controllers.scene as scene
import controllers.pattern as pattern import controllers.pattern as pattern
import controllers.settings as settings_controller
from models.espnow import ESPNow
from util.espnow_message import split_espnow_message
async def main(port=80): async def main(port=80):
settings = Settings() settings = Settings()
print(settings)
print("Starting") print("Starting")
network.WLAN(network.STA_IF).active(True) # Initialize ESPNow singleton (config + peers)
esp = ESPNow()
e = aioespnow.AIOESPNow()
e.active(True)
e.add_peer(b"\xbb\xbb\xbb\xbb\xbb\xbb")
app = Microdot() app = Microdot()
@@ -56,6 +57,7 @@ async def main(port=80):
app.mount(palette.controller, '/palettes') app.mount(palette.controller, '/palettes')
app.mount(scene.controller, '/scenes') app.mount(scene.controller, '/scenes')
app.mount(pattern.controller, '/patterns') app.mount(pattern.controller, '/patterns')
app.mount(settings_controller.controller, '/settings')
# Serve index.html at root # Serve index.html at root
@app.route('/') @app.route('/')
@@ -63,6 +65,17 @@ async def main(port=80):
"""Serve the main web UI.""" """Serve the main web UI."""
return send_file('templates/index.html') return send_file('templates/index.html')
# Serve settings page
@app.route('/settings')
def settings_page(request):
"""Serve the settings page."""
return send_file('templates/settings.html')
# Favicon: avoid 404 in browser console (no file needed)
@app.route('/favicon.ico')
def favicon(request):
return '', 204
# Static file route # Static file route
@app.route("/static/<path:path>") @app.route("/static/<path:path>")
def static_handler(request, path): def static_handler(request, path):
@@ -77,9 +90,31 @@ async def main(port=80):
async def ws(request, ws): async def ws(request, ws):
while True: while True:
data = await ws.receive() data = await ws.receive()
print(data)
if data: if data:
await e.asend(b"\xbb\xbb\xbb\xbb\xbb\xbb", data) # Debug: log incoming WebSocket data
print(data) try:
parsed = json.loads(data)
print("WS received JSON:", parsed)
except Exception:
print("WS received raw:", data)
# Forward JSON over ESPNow; split into multiple frames if > 250 bytes
try:
try:
parsed = json.loads(data)
chunks = split_espnow_message(parsed)
except (json.JSONDecodeError, ValueError):
chunks = [data]
for i, chunk in enumerate(chunks):
if i > 0:
await asyncio.sleep_ms(100)
await esp.send(chunk)
except Exception:
try:
await ws.send(json.dumps({"error": "ESP-NOW send failed"}))
except Exception:
pass
else: else:
break break
@@ -87,13 +122,23 @@ async def main(port=80):
server = asyncio.create_task(app.start_server(host="0.0.0.0", port=port)) server = asyncio.create_task(app.start_server(host="0.0.0.0", port=port))
wdt = machine.WDT(timeout=10000) #wdt = machine.WDT(timeout=10000)
wdt.feed() #wdt.feed()
# Initialize heartbeat LED (XIAO ESP32S3 built-in LED on GPIO 21)
led = Pin(15, Pin.OUT)
led_state = False
while True: while True:
gc.collect() gc.collect()
for i in range(60): for i in range(60):
wdt.feed() #wdt.feed()
# Heartbeat: toggle LED every 500 ms
led.value(not led.value())
await asyncio.sleep_ms(500) await asyncio.sleep_ms(500)
# cleanup before ending the application # cleanup before ending the application

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

@@ -0,0 +1,69 @@
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

@@ -45,6 +45,12 @@ class Model(dict):
j = json.dumps(self) j = json.dumps(self)
with open(self.file, 'w') as file: with open(self.file, 'w') as file:
file.write(j) file.write(j)
file.flush() # Ensure data is written to buffer
# Try to sync filesystem if available (MicroPython)
try:
os.sync()
except (AttributeError, OSError):
pass # os.sync() not available on all platforms
print(f"{self.class_name} saved successfully to {self.file}") print(f"{self.class_name} saved successfully to {self.file}")
except Exception as e: except Exception as e:
print(f"Error saving {self.class_name} to {self.file}: {e}") print(f"Error saving {self.class_name} to {self.file}: {e}")
@@ -53,11 +59,46 @@ class Model(dict):
def load(self): def load(self):
try: try:
with open(self.file, 'r') as file: # Check if file exists first
loaded_settings = json.load(file) try:
self.update(loaded_settings) with open(self.file, 'r') as file:
content = file.read().strip()
except OSError:
# File doesn't exist
raise
if not content:
# Empty file
loaded_settings = {}
else:
# Parse JSON content
loaded_settings = json.loads(content)
# Verify it's a dictionary
if not isinstance(loaded_settings, dict):
raise ValueError(f"File does not contain a dictionary, got {type(loaded_settings)}")
# Clear and update with loaded data
# Clear first
self.clear()
# Manually copy items to avoid any update() method issues
for key, value in loaded_settings.items():
self[key] = value
print(f"{self.class_name} loaded successfully.") print(f"{self.class_name} loaded successfully.")
except Exception as e: except OSError as e:
print(f"Error loading {self.class_name}") # File doesn't exist yet - this is normal on first run
# Create an empty file with defaults
self.set_defaults()
self.save()
print(f"{self.class_name} initialized (new file created).")
except ValueError:
# JSON parsing error - file exists but is corrupted
# Note: MicroPython uses ValueError for JSON errors, not JSONDecodeError
print(f"Error loading {self.class_name}: Invalid JSON format. Resetting to defaults.")
self.set_defaults()
self.save()
except Exception:
# Other unexpected errors - avoid trying to format exception to prevent further errors
print(f"Error loading {self.class_name}. Resetting to defaults.")
self.set_defaults() self.set_defaults()
self.save() self.save()

View File

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

View File

@@ -1,10 +1,26 @@
from models.model import Model from models.model import Model
from models.profile import Profile
class Preset(Model): class Preset(Model):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
# Backfill profile ownership for existing presets.
try:
profiles = Profile()
profile_list = profiles.list()
default_profile_id = profile_list[0] if profile_list else None
changed = False
for preset_id, preset_data in list(self.items()):
if isinstance(preset_data, dict) and "profile_id" not in preset_data:
if default_profile_id is not None:
preset_data["profile_id"] = str(default_profile_id)
changed = True
if changed:
self.save()
except Exception:
pass
def create(self): def create(self, profile_id=None):
next_id = self.get_next_id() next_id = self.get_next_id()
self[next_id] = { self[next_id] = {
"name": "", "name": "",
@@ -20,6 +36,7 @@ class Preset(Model):
"n6": 0, "n6": 0,
"n7": 0, "n7": 0,
"n8": 0, "n8": 0,
"profile_id": str(profile_id) if profile_id is not None else None,
} }
self.save() self.save()
return next_id return next_id

View File

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

View File

@@ -9,7 +9,8 @@ class Tab(Model):
self[next_id] = { self[next_id] = {
"name": name, "name": name,
"names": names if names else [], "names": names if names else [],
"presets": presets if presets else [] "presets": presets if presets else [],
"default_preset": None
} }
self.save() self.save()
return next_id return next_id

0
src/profile.py Normal file
View File

View File

@@ -12,6 +12,7 @@ document.addEventListener('DOMContentLoaded', () => {
} }
let currentProfileId = null; let currentProfileId = null;
let currentPaletteId = null;
let currentPalette = []; let currentPalette = [];
let currentProfileName = null; let currentProfileName = null;
@@ -84,7 +85,27 @@ document.addEventListener('DOMContentLoaded', () => {
return; return;
} }
currentPalette = profile.palette || profile.color_palette || []; // Prefer palette_id-based storage; fall back to legacy inline palette.
currentPaletteId = profile.palette_id || profile.paletteId || null;
if (currentPaletteId) {
try {
const palResponse = await fetch(`/palettes/${currentPaletteId}`, {
headers: { Accept: 'application/json' },
});
if (palResponse.ok) {
const palData = await palResponse.json();
currentPalette = (palData.colors) || [];
} else {
currentPalette = [];
}
} catch (e) {
console.error('Failed to load palette by id:', e);
currentPalette = [];
}
} else {
// Legacy: palette stored directly on profile
currentPalette = profile.palette || profile.color_palette || [];
}
renderPalette(); renderPalette();
} catch (error) { } catch (error) {
console.error('Failed to load palette:', error); console.error('Failed to load palette:', error);
@@ -99,17 +120,42 @@ document.addEventListener('DOMContentLoaded', () => {
return; return;
} }
try { try {
const response = await fetch('/profiles/current', { // Ensure we have a palette ID for this profile.
method: 'PUT', if (!currentPaletteId) {
headers: { 'Content-Type': 'application/json' }, const createResponse = await fetch('/palettes', {
body: JSON.stringify({ method: 'POST',
palette: newPalette, headers: { 'Content-Type': 'application/json' },
color_palette: newPalette, body: JSON.stringify({ colors: newPalette }),
}), });
}); if (!createResponse.ok) {
if (!response.ok) { throw new Error('Failed to create palette');
throw new Error('Failed to save palette'); }
const pal = await createResponse.json();
currentPaletteId = pal.id || Object.keys(pal)[0];
// Link the new palette to the current profile.
const linkResponse = await fetch('/profiles/current', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
palette_id: currentPaletteId,
}),
});
if (!linkResponse.ok) {
throw new Error('Failed to link palette to profile');
}
} else {
// Update existing palette colors
const updateResponse = await fetch(`/palettes/${currentPaletteId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ colors: newPalette }),
});
if (!updateResponse.ok) {
throw new Error('Failed to save palette');
}
} }
currentPalette = newPalette; currentPalette = newPalette;
renderPalette(); renderPalette();
} catch (error) { } catch (error) {

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -26,7 +26,11 @@ document.addEventListener("DOMContentLoaded", () => {
if (Array.isArray(profiles)) { if (Array.isArray(profiles)) {
entries = profiles.map((profileId) => [profileId, {}]); entries = profiles.map((profileId) => [profileId, {}]);
} else if (profiles && typeof profiles === "object") { } else if (profiles && typeof profiles === "object") {
entries = Object.entries(profiles); // Make sure we're iterating over profile entries, not metadata
entries = Object.entries(profiles).filter(([key]) => {
// Skip metadata keys like 'current_profile_id' if they exist
return key !== 'current_profile_id' && key !== 'profiles';
});
} }
if (entries.length === 0) { if (entries.length === 0) {
@@ -69,6 +73,70 @@ document.addEventListener("DOMContentLoaded", () => {
} }
}); });
const cloneButton = document.createElement("button");
cloneButton.className = "btn btn-secondary btn-small";
cloneButton.textContent = "Clone";
cloneButton.addEventListener("click", async () => {
const baseName = (profile && profile.name) || profileId;
const suggested = `${baseName}`;
const name = prompt("New profile name:", suggested);
if (name === null) {
return;
}
const trimmed = String(name).trim();
if (!trimmed) {
alert("Profile name cannot be empty.");
return;
}
try {
const response = await fetch(`/profiles/${profileId}/clone`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ name: trimmed }),
});
if (!response.ok) {
throw new Error("Failed to clone profile");
}
const data = await response.json().catch(() => null);
let newProfileId = null;
if (data && typeof data === "object") {
if (data.id) {
newProfileId = String(data.id);
} else {
const ids = Object.keys(data);
if (ids.length > 0) {
newProfileId = String(ids[0]);
}
}
}
if (newProfileId) {
await fetch(`/profiles/${newProfileId}/apply`, {
method: "POST",
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>
`;
}
} catch (error) {
console.error("Clone profile failed:", error);
alert("Failed to clone profile.");
}
});
const deleteButton = document.createElement("button"); const deleteButton = document.createElement("button");
deleteButton.className = "btn btn-danger btn-small"; deleteButton.className = "btn btn-danger btn-small";
deleteButton.textContent = "Delete"; deleteButton.textContent = "Delete";
@@ -94,6 +162,7 @@ document.addEventListener("DOMContentLoaded", () => {
row.appendChild(label); row.appendChild(label);
row.appendChild(applyButton); row.appendChild(applyButton);
row.appendChild(cloneButton);
row.appendChild(deleteButton); row.appendChild(deleteButton);
profilesList.appendChild(row); profilesList.appendChild(row);
}); });
@@ -113,19 +182,10 @@ document.addEventListener("DOMContentLoaded", () => {
if (!response.ok) { if (!response.ok) {
throw new Error("Failed to load profiles"); throw new Error("Failed to load profiles");
} }
const profiles = await response.json(); const data = await response.json();
let currentProfileId = null; // Handle both old format (just profiles object) and new format (with current_profile_id)
try { const profiles = data.profiles || data;
const currentResponse = await fetch("/profiles/current", { const currentProfileId = data.current_profile_id || null;
headers: { Accept: "application/json" },
});
if (currentResponse.ok) {
const currentData = await currentResponse.json();
currentProfileId = currentData.id || null;
}
} catch (error) {
console.warn("Failed to load current profile:", error);
}
renderProfiles(profiles, currentProfileId); renderProfiles(profiles, currentProfileId);
} catch (error) { } catch (error) {
console.error("Load profiles failed:", error); console.error("Load profiles failed:", error);
@@ -155,8 +215,44 @@ document.addEventListener("DOMContentLoaded", () => {
if (!response.ok) { if (!response.ok) {
throw new Error("Failed to create profile"); throw new Error("Failed to create profile");
} }
const data = await response.json().catch(() => null);
let newProfileId = null;
if (data && typeof data === "object") {
if (data.id) {
newProfileId = String(data.id);
} else {
const ids = Object.keys(data);
if (ids.length > 0) {
newProfileId = String(ids[0]);
}
}
}
if (newProfileId) {
await fetch(`/profiles/${newProfileId}/apply`, {
method: "POST",
headers: { Accept: "application/json" },
});
}
newProfileInput.value = ""; newProfileInput.value = "";
// Clear current tab and refresh the UI so the new profile starts empty.
document.cookie = "current_tab=; path=/; max-age=0";
await loadProfiles(); 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>
`;
}
} catch (error) { } catch (error) {
console.error("Create profile failed:", error); console.error("Create profile failed:", error);
alert("Failed to create profile."); alert("Failed to create profile.");

View File

@@ -20,25 +20,65 @@ body {
header { header {
background-color: #1a1a1a; background-color: #1a1a1a;
padding: 1rem 2rem; padding: 0.75rem 1rem;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
border-bottom: 2px solid #4a4a4a; border-bottom: 2px solid #4a4a4a;
gap: 0.75rem;
} }
header h1 { header h1 {
font-size: 1.5rem; font-size: 1.35rem;
font-weight: 600; font-weight: 600;
} }
.header-actions { .header-actions {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.header-menu-mobile {
display: none;
position: relative;
}
.main-menu-dropdown {
position: absolute;
top: 100%;
right: 0;
background-color: #1a1a1a;
border: 1px solid #4a4a4a;
border-radius: 4px;
padding: 0.25rem 0;
display: none;
min-width: 160px;
z-index: 1100;
}
.main-menu-dropdown.open {
display: block;
}
.main-menu-dropdown button {
width: 100%;
background: none;
border: none;
color: white;
text-align: left;
padding: 0.4rem 0.75rem;
font-size: 0.85rem;
cursor: pointer;
}
.main-menu-dropdown button:hover {
background-color: #333;
} }
.btn { .btn {
padding: 0.5rem 1rem; padding: 0.45rem 0.9rem;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
@@ -87,15 +127,22 @@ header h1 {
} }
.tabs-container { .tabs-container {
background-color: #1a1a1a; background-color: transparent;
border-bottom: 2px solid #4a4a4a; padding: 0.5rem 0;
padding: 0.5rem 1rem; flex: 1;
min-width: 0;
align-self: stretch;
display: flex;
align-items: center;
} }
.tabs-list { .tabs-list {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
overflow-x: auto; overflow-x: auto;
padding-bottom: 0.25rem;
flex: 1;
min-width: 0;
} }
.tab-button { .tab-button {
@@ -121,10 +168,27 @@ header h1 {
.tab-content { .tab-content {
flex: 1; flex: 1;
display: block;
overflow-y: auto;
overflow-x: hidden;
padding: 0.5rem 1rem 1rem;
}
.presets-toolbar {
align-items: center;
}
.tab-brightness-group {
display: flex; display: flex;
overflow: hidden; flex-direction: column;
padding: 1rem; align-items: stretch;
gap: 1rem; gap: 0.25rem;
margin-left: auto;
}
.tab-brightness-group label {
white-space: nowrap;
font-size: 0.85rem;
} }
.left-panel { .left-panel {
@@ -356,6 +420,149 @@ header h1 {
font-size: 1.1rem; font-size: 1.1rem;
} }
/* Make the presets area fill available vertical space; no border around presets */
.presets-section {
display: flex;
flex-direction: column;
height: 100%;
min-width: 0;
overflow-x: hidden;
border: none;
background-color: transparent;
padding: 0;
}
/* Tab preset selecting area: 3 columns, vertical scroll only */
#presets-list-tab {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-auto-rows: 5rem;
column-gap: 0.3rem;
row-gap: 0.3rem;
align-content: start;
width: 100%;
}
/* Settings modal layout */
.settings-section {
background-color: #1a1a1a;
border-radius: 8px;
padding: 1rem;
margin-top: 1rem;
border: 1px solid #4a4a4a;
}
.settings-section h3 {
font-size: 1.1rem;
margin-bottom: 0.75rem;
color: #fff;
border-bottom: 1px solid #4a4a4a;
padding-bottom: 0.25rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #ccc;
font-weight: 500;
}
.form-group input[type="text"],
.form-group input[type="password"],
.form-group input[type="number"],
.form-group select {
width: 100%;
padding: 0.5rem;
background-color: #2e2e2e;
border: 1px solid #4a4a4a;
border-radius: 4px;
color: white;
font-size: 0.95rem;
}
.form-group small {
display: block;
margin-top: 0.25rem;
color: #888;
font-size: 0.8rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.status-info {
background-color: #2e2e2e;
border: 1px solid #4a4a4a;
border-radius: 4px;
padding: 0.75rem;
margin-bottom: 0.75rem;
}
.status-info h3,
.status-info h4 {
font-size: 1rem;
margin-bottom: 0.5rem;
color: #fff;
}
.status-info p {
color: #aaa;
margin: 0.25rem 0;
font-size: 0.9rem;
}
.status-connected {
color: #4caf50;
}
.status-disconnected {
color: #f44336;
}
.btn-group {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
.btn-full {
flex: 1;
}
.message {
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
display: none;
}
.message.success {
background-color: #1b5e20;
color: #4caf50;
border: 1px solid #4caf50;
}
.message.error {
background-color: #5e1b1b;
color: #f44336;
border: 1px solid #f44336;
}
.message.show {
display: block;
}
.patterns-list { .patterns-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -363,21 +570,38 @@ header h1 {
} }
.presets-list { .presets-list {
display: grid; display: flex;
grid-template-columns: repeat(3, 1fr); flex-wrap: wrap;
gap: 0.75rem; gap: 0.75rem;
width: 100%;
} }
.pattern-button { .pattern-button {
padding: 0.75rem; height: 5rem;
padding: 0 0.5rem;
background-color: #3a3a3a; background-color: #3a3a3a;
color: white; color: white;
border: none; border: 3px solid #000;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 0.9rem; font-size: 0.85rem;
text-align: left; text-align: left;
transition: background-color 0.2s; transition: background-color 0.2s;
line-height: 1;
display: flex;
align-items: center;
overflow: hidden;
box-shadow: none;
outline: none;
position: relative;
}
/* Preset select buttons inside the tab grid */
#presets-list-tab .pattern-button {
display: flex;
}
.pattern-button .pattern-button-label {
text-shadow: 0 0 2px rgba(0,0,0,0.8), 0 1px 2px rgba(0,0,0,0.6);
} }
.pattern-button:hover { .pattern-button:hover {
@@ -387,10 +611,28 @@ header h1 {
.pattern-button.active { .pattern-button.active {
background-color: #6a5acd; background-color: #6a5acd;
color: white; color: white;
border-color: #ffffff;
}
.pattern-button.active[style*="background-image"] {
background-color: transparent;
}
.pattern-button.active::after {
content: '';
position: absolute;
inset: -3px;
border-radius: 7px;
padding: 3px;
pointer-events: none;
background: #ffffff;
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
mask-composite: exclude;
} }
.pattern-button.default-preset { .pattern-button.default-preset {
border: 2px solid #6a5acd; /* No border; active state shows selection */
} }
.color-palette { .color-palette {
@@ -489,7 +731,7 @@ header h1 {
background-color: #2e2e2e; background-color: #2e2e2e;
padding: 2rem; padding: 2rem;
border-radius: 8px; border-radius: 8px;
min-width: 400px; min-width: 320px;
max-width: 500px; max-width: 500px;
} }
@@ -546,3 +788,265 @@ header h1 {
background: #5a5a5a; background: #5a5a5a;
} }
/* Mobile-friendly layout */
@media (max-width: 800px) {
header {
flex-direction: row;
align-items: center;
gap: 0.25rem;
} header h1 {
font-size: 1.1rem;
} /* On mobile, hide header buttons; all actions (including Tabs) are in the Menu dropdown */
.header-actions {
display: none;
}
.header-menu-mobile {
display: block;
margin-top: 0;
margin-left: auto;
}
.btn {
font-size: 0.8rem;
padding: 0.4rem 0.7rem;
}
.tabs-container {
padding: 0.5rem 0;
border-bottom: none;
}
.tab-content {
padding: 0.5rem;
}
.left-panel {
flex: 1;
border-right: none;
padding-right: 0;
}
.right-panel {
padding-left: 0;
margin-top: 1rem;
}
/* Hide the "Presets for ..." heading to save space on mobile */
.presets-section h3 {
display: none;
}
.modal-content {
min-width: 280px;
max-width: 95vw;
padding: 1.25rem;
}
.form-row {
grid-template-columns: 1fr;
}
}
/* Styles moved from inline <style> in templates/index.html */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.7);
}
.modal.active {
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background-color: #2e2e2e;
padding: 2rem;
border-radius: 8px;
min-width: 400px;
max-width: 600px;
}
.modal-content label {
display: block;
margin-top: 1rem;
margin-bottom: 0.5rem;
}
.modal-content input[type="text"] {
width: 100%;
padding: 0.5rem;
background-color: #3a3a3a;
border: 1px solid #4a4a4a;
border-radius: 4px;
color: white;
}
.profiles-actions {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.profiles-actions input[type="text"] {
flex: 1;
}
.profiles-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 1rem;
max-height: 50vh;
overflow-y: auto;
}
.profiles-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem;
background-color: #3a3a3a;
border-radius: 4px;
}
/* Hide any text content in palette rows - only show color swatches */
#palette-container .profiles-row {
font-size: 0; /* Hide any text nodes */
}
#palette-container .profiles-row > * {
font-size: 1rem; /* Restore font size for buttons */
}
#palette-container .profiles-row > span:not(.btn),
#palette-container .profiles-row > label,
#palette-container .profiles-row::before,
#palette-container .profiles-row::after {
display: none !important;
content: none !important;
}
/* Preset colors container */
#preset-colors-container {
min-height: 80px;
padding: 0.5rem;
background-color: #2a2a2a;
border-radius: 4px;
margin-bottom: 0.5rem;
}
#preset-colors-container .muted-text {
color: #888;
font-size: 0.9rem;
padding: 1rem;
text-align: center;
}
.muted-text {
text-align: center;
color: #888;
}
.modal-actions {
display: flex;
gap: 0.5rem;
margin-top: 1.5rem;
justify-content: flex-end;
}
.error {
color: #d32f2f;
padding: 0.5rem;
background-color: #3a1a1a;
border-radius: 4px;
margin-top: 0.5rem;
}
/* Drag and drop styles for presets */
.draggable-preset {
cursor: move;
transition: opacity 0.2s, transform 0.2s;
}
.draggable-preset.dragging {
opacity: 0.5;
transform: scale(0.95);
}
.draggable-preset:hover {
opacity: 0.8;
}
/* Drag and drop styles for color swatches */
.draggable-color-swatch {
transition: opacity 0.2s, transform 0.2s;
}
.draggable-color-swatch.dragging-color {
opacity: 0.5;
transform: scale(0.9);
}
.draggable-color-swatch.drag-over-color {
transform: scale(1.1);
}
.color-swatches-container {
min-height: 80px;
}
/* Presets list: 3 columns and vertical scroll (defined above); mobile same */
@media (max-width: 800px) {
#presets-list-tab {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
/* Help modal readability */
#help-modal .modal-content {
max-width: 720px;
line-height: 1.6;
font-size: 0.95rem;
}
#help-modal .modal-content h2 {
margin-bottom: 0.75rem;
}
#help-modal .modal-content h3 {
margin-top: 1.25rem;
margin-bottom: 0.4rem;
font-size: 1.05rem;
font-weight: 600;
}
#help-modal .modal-content p {
text-align: left;
margin-bottom: 0.5rem;
}
#help-modal .modal-content ul {
margin-top: 0.25rem;
margin-left: 1.25rem;
padding-left: 0;
text-align: left;
}
#help-modal .modal-content li {
margin: 0.2rem 0;
line-height: 1.5;
}
#help-modal .muted-text {
text-align: left;
color: #bbb;
font-size: 0.9rem;
}
/* Tab content placeholder (no tab selected) */
.tab-content-placeholder {
padding: 2rem;
text-align: center;
color: #aaa;
}
/* Preset editor: color actions row */
#preset-editor-modal .preset-colors-container + .profiles-actions {
margin-top: 0.5rem;
}
/* Preset editor: brightness/delay field wrappers */
.preset-editor-field {
flex: 1;
display: flex;
flex-direction: column;
}
/* Settings modal */
#settings-modal .modal-content {
max-width: 900px;
max-height: 90vh;
overflow-y: auto;
}#settings-modal .modal-content > p.muted-text {
margin-bottom: 1rem;
}#settings-modal .settings-section.ap-settings-section {
margin-top: 1.5rem;
}

809
src/static/tabs.js Normal file
View File

@@ -0,0 +1,809 @@
// Tab management JavaScript
let currentTabId = null;
// Get current tab from cookie
function getCurrentTabFromCookie() {
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'current_tab') {
return value;
}
}
return null;
}
// Load tabs list
async function loadTabs() {
try {
const response = await fetch('/tabs');
const data = await response.json();
// Get current tab from cookie first, then from server response
const cookieTabId = getCurrentTabFromCookie();
const serverCurrent = data.current_tab_id;
const tabs = data.tabs || {};
const tabIds = Object.keys(tabs);
let candidateId = cookieTabId || serverCurrent || null;
// If the candidate doesn't exist anymore (e.g. after DB reset), fall back to first tab.
if (candidateId && !tabIds.includes(String(candidateId))) {
candidateId = tabIds.length > 0 ? tabIds[0] : null;
// Clear stale cookie
document.cookie = 'current_tab=; path=/; max-age=0';
}
currentTabId = candidateId;
renderTabsList(data.tabs, data.tab_order, currentTabId);
// Load current tab content if available
if (currentTabId) {
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]);
}
} catch (error) {
console.error('Failed to load tabs:', error);
const container = document.getElementById('tabs-list');
if (container) {
container.innerHTML = '<div class="error">Failed to load tabs</div>';
}
}
}
// Render tabs list in the main UI
function renderTabsList(tabs, tabOrder, currentTabId) {
const container = document.getElementById('tabs-list');
if (!container) return;
if (!tabOrder || tabOrder.length === 0) {
container.innerHTML = '<div class="muted-text">No tabs available</div>';
return;
}
let html = '<div class="tabs-list">';
for (const tabId of tabOrder) {
const tab = tabs[tabId];
if (tab) {
const activeClass = tabId === currentTabId ? 'active' : '';
const tabName = tab.name || `Tab ${tabId}`;
html += `
<button class="tab-button ${activeClass}"
data-tab-id="${tabId}"
title="Click to select, right-click to edit"
onclick="selectTab('${tabId}')">
${tabName}
</button>
`;
}
}
html += '</div>';
container.innerHTML = html;
}
// Render tabs list in modal (like profiles)
function renderTabsListModal(tabs, tabOrder, currentTabId) {
const container = document.getElementById('tabs-list-modal');
if (!container) return;
container.innerHTML = "";
let entries = [];
if (Array.isArray(tabOrder)) {
entries = tabOrder.map((tabId) => [tabId, tabs[tabId] || {}]);
} else if (tabs && typeof tabs === "object") {
entries = Object.entries(tabs).filter(([key]) => {
return key !== 'current_tab_id' && key !== 'tabs' && key !== 'tab_order';
});
}
if (entries.length === 0) {
const empty = document.createElement("p");
empty.className = "muted-text";
empty.textContent = "No tabs found.";
container.appendChild(empty);
return;
}
entries.forEach(([tabId, tab]) => {
const row = document.createElement("div");
row.className = "profiles-row";
const label = document.createElement("span");
label.textContent = (tab && tab.name) || tabId;
if (String(tabId) === String(currentTabId)) {
label.textContent = `${label.textContent}`;
label.style.fontWeight = "bold";
label.style.color = "#FFD700";
}
const applyButton = document.createElement("button");
applyButton.className = "btn btn-secondary btn-small";
applyButton.textContent = "Select";
applyButton.addEventListener("click", async () => {
await selectTab(tabId);
document.getElementById('tabs-modal').classList.remove('active');
});
const editButton = document.createElement("button");
editButton.className = "btn btn-secondary btn-small";
editButton.textContent = "Edit";
editButton.addEventListener("click", () => {
openEditTabModal(tabId, tab);
});
const sendPresetsButton = document.createElement("button");
sendPresetsButton.className = "btn btn-secondary btn-small";
sendPresetsButton.textContent = "Send Presets";
sendPresetsButton.addEventListener("click", async () => {
await sendTabPresets(tabId);
});
const cloneButton = document.createElement("button");
cloneButton.className = "btn btn-secondary btn-small";
cloneButton.textContent = "Clone";
cloneButton.addEventListener("click", async () => {
const baseName = (tab && tab.name) || tabId;
const suggested = `${baseName} Copy`;
const name = prompt("New tab name:", suggested);
if (name === null) {
return;
}
const trimmed = String(name).trim();
if (!trimmed) {
alert("Tab name cannot be empty.");
return;
}
try {
const response = await fetch(`/tabs/${tabId}/clone`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({ name: trimmed }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: "Failed to clone tab" }));
throw new Error(errorData.error || "Failed to clone tab");
}
const data = await response.json().catch(() => null);
let newTabId = null;
if (data && typeof data === "object") {
if (data.id) {
newTabId = String(data.id);
} else {
const ids = Object.keys(data);
if (ids.length > 0) {
newTabId = String(ids[0]);
}
}
}
await loadTabsModal();
if (newTabId) {
await selectTab(newTabId);
} else {
await loadTabs();
}
} catch (error) {
console.error("Clone tab failed:", error);
alert("Failed to clone tab: " + error.message);
}
});
const deleteButton = document.createElement("button");
deleteButton.className = "btn btn-danger btn-small";
deleteButton.textContent = "Delete";
deleteButton.addEventListener("click", async () => {
const confirmed = confirm(`Delete tab "${label.textContent}"?`);
if (!confirmed) {
return;
}
try {
const response = await fetch(`/tabs/${tabId}`, {
method: "DELETE",
headers: { Accept: "application/json" },
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: "Failed to delete tab" }));
throw new Error(errorData.error || "Failed to delete tab");
}
// Clear cookie if deleted tab was current
if (tabId === currentTabId) {
document.cookie = 'current_tab=; path=/; max-age=0';
currentTabId = null;
}
await loadTabsModal();
await loadTabs(); // Reload main tabs list
} catch (error) {
console.error("Delete tab failed:", error);
alert("Failed to delete tab: " + error.message);
}
});
row.appendChild(label);
row.appendChild(applyButton);
row.appendChild(editButton);
row.appendChild(sendPresetsButton);
row.appendChild(cloneButton);
row.appendChild(deleteButton);
container.appendChild(row);
});
}
// Load tabs in modal
async function loadTabsModal() {
const container = document.getElementById('tabs-list-modal');
if (!container) return;
container.innerHTML = "";
const loading = document.createElement("p");
loading.className = "muted-text";
loading.textContent = "Loading tabs...";
container.appendChild(loading);
try {
const response = await fetch("/tabs", {
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error("Failed to load tabs");
}
const data = await response.json();
const tabs = data.tabs || data;
const currentTabId = getCurrentTabFromCookie() || data.current_tab_id || null;
renderTabsListModal(tabs, data.tab_order || [], currentTabId);
} catch (error) {
console.error("Load tabs failed:", error);
container.innerHTML = "";
const errorMessage = document.createElement("p");
errorMessage.className = "muted-text";
errorMessage.textContent = "Failed to load tabs.";
container.appendChild(errorMessage);
}
}
// Select a tab
async function selectTab(tabId) {
// Update active state
document.querySelectorAll('.tab-button').forEach(btn => {
btn.classList.remove('active');
});
const btn = document.querySelector(`[data-tab-id="${tabId}"]`);
if (btn) {
btn.classList.add('active');
}
// Set as current tab
await setCurrentTab(tabId);
// Load tab content
loadTabContent(tabId);
}
// Set current tab in cookie
async function setCurrentTab(tabId) {
try {
const response = await fetch(`/tabs/${tabId}/set-current`, {
method: 'POST'
});
const data = await response.json();
if (response.ok) {
currentTabId = tabId;
// Also set cookie on client side
document.cookie = `current_tab=${tabId}; path=/; max-age=31536000`;
} else {
console.error('Failed to set current tab:', data.error);
}
} catch (error) {
console.error('Error setting current tab:', error);
}
}
// Load tab content
async function loadTabContent(tabId) {
const container = document.getElementById('tab-content');
if (!container) return;
try {
const response = await fetch(`/tabs/${tabId}`);
const tab = await response.json();
if (tab.error) {
container.innerHTML = `<div class="error">${tab.error}</div>`;
return;
}
// Render tab content (presets section)
const tabName = tab.name || `Tab ${tabId}`;
const deviceNames = Array.isArray(tab.names) ? tab.names.join(',') : '';
container.innerHTML = `
<div class="presets-section" data-tab-id="${tabId}" data-device-names="${deviceNames}">
<div class="profiles-actions presets-toolbar" style="margin-bottom: 1rem;">
<div class="tab-brightness-group">
<label for="tab-brightness-slider">Brightness</label>
<input type="range" id="tab-brightness-slider" min="0" max="255" value="255">
</div>
</div>
<div id="presets-list-tab" class="presets-list">
<!-- Presets will be loaded here by presets.js -->
</div>
</div>
`;
// Wire up per-tab brightness slider to send global brightness via ESPNow.
const brightnessSlider = container.querySelector('#tab-brightness-slider');
let brightnessSendTimeout = null;
if (brightnessSlider) {
brightnessSlider.addEventListener('input', (e) => {
const val = parseInt(e.target.value, 10) || 0;
if (brightnessSendTimeout) {
clearTimeout(brightnessSendTimeout);
}
brightnessSendTimeout = setTimeout(() => {
if (typeof window.sendEspnowRaw === 'function') {
try {
window.sendEspnowRaw({ v: '1', b: val });
} catch (err) {
console.error('Failed to send brightness via ESPNow:', err);
}
}
}, 150);
});
}
// Trigger presets loading if the function exists
if (typeof renderTabPresets === 'function') {
renderTabPresets(tabId);
}
} catch (error) {
console.error('Failed to load tab content:', error);
container.innerHTML = '<div class="error">Failed to load tab content</div>';
}
}
// Send all presets used by a tab via the /presets/send HTTP endpoint.
async function sendTabPresets(tabId) {
try {
// Load tab data to determine which presets are used
const tabResponse = await fetch(`/tabs/${tabId}`, {
headers: { Accept: 'application/json' },
});
if (!tabResponse.ok) {
alert('Failed to load tab to send presets.');
return;
}
const tabData = await tabResponse.json();
// Extract preset IDs from tab (supports grid, flat, and legacy formats)
let presetIds = [];
if (Array.isArray(tabData.presets_flat)) {
presetIds = tabData.presets_flat;
} else if (Array.isArray(tabData.presets)) {
if (tabData.presets.length && typeof tabData.presets[0] === 'string') {
// Flat array of IDs
presetIds = tabData.presets;
} else if (tabData.presets.length && Array.isArray(tabData.presets[0])) {
// 2D grid
presetIds = tabData.presets.flat();
}
}
presetIds = (presetIds || []).filter(Boolean);
if (!presetIds.length) {
alert('This tab has no presets to send.');
return;
}
// Call server-side ESPNow sender with just the IDs; it handles chunking.
const payload = { preset_ids: presetIds };
if (tabData.default_preset) {
payload.default = tabData.default_preset;
}
const response = await fetch('/presets/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(payload),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const msg = (data && data.error) || 'Failed to send presets.';
alert(msg);
return;
}
const sent = typeof data.presets_sent === 'number' ? data.presets_sent : presetIds.length;
const messages = typeof data.messages_sent === 'number' ? data.messages_sent : '?';
alert(`Sent ${sent} preset(s) in ${messages} ESPNow message(s).`);
} catch (error) {
console.error('Failed to send tab presets:', error);
alert('Failed to send tab presets.');
}
}
// Send all presets used by all tabs in the current profile via /presets/send.
async function sendProfilePresets() {
try {
// Load current profile to get its tabs
const profileRes = await fetch('/profiles/current', {
headers: { Accept: 'application/json' },
});
if (!profileRes.ok) {
alert('Failed to load current profile.');
return;
}
const profileData = await profileRes.json();
const profile = profileData.profile || {};
let tabList = null;
if (Array.isArray(profile.tabs)) {
tabList = profile.tabs;
} else if (profile.tabs) {
tabList = [profile.tabs];
}
if (!tabList || tabList.length === 0) {
if (Array.isArray(profile.tab_order)) {
tabList = profile.tab_order;
} else if (profile.tab_order) {
tabList = [profile.tab_order];
} else {
tabList = [];
}
}
if (!tabList || tabList.length === 0) {
console.warn('sendProfilePresets: no tabs found', {
profileData,
profile,
});
}
if (!tabList.length) {
alert('Current profile has no tabs to send presets for.');
return;
}
let totalSent = 0;
let totalMessages = 0;
let tabsWithPresets = 0;
for (const tabId of tabList) {
try {
const tabResp = await fetch(`/tabs/${tabId}`, {
headers: { Accept: 'application/json' },
});
if (!tabResp.ok) {
continue;
}
const tabData = await tabResp.json();
let presetIds = [];
if (Array.isArray(tabData.presets_flat)) {
presetIds = tabData.presets_flat;
} else if (Array.isArray(tabData.presets)) {
if (tabData.presets.length && typeof tabData.presets[0] === 'string') {
presetIds = tabData.presets;
} else if (tabData.presets.length && Array.isArray(tabData.presets[0])) {
presetIds = tabData.presets.flat();
}
}
presetIds = (presetIds || []).filter(Boolean);
if (!presetIds.length) {
continue;
}
tabsWithPresets += 1;
const payload = { preset_ids: presetIds };
if (tabData.default_preset) {
payload.default = tabData.default_preset;
}
const response = await fetch('/presets/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(payload),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const msg = (data && data.error) || `Failed to send presets for tab ${tabId}.`;
console.warn(msg);
continue;
}
totalSent += typeof data.presets_sent === 'number' ? data.presets_sent : presetIds.length;
totalMessages += typeof data.messages_sent === 'number' ? data.messages_sent : 0;
} catch (e) {
console.error('Failed to send profile presets for tab:', tabId, e);
}
}
if (!tabsWithPresets) {
alert('No presets to send for the current profile.');
return;
}
const messagesLabel = totalMessages ? totalMessages : '?';
alert(`Sent ${totalSent} preset(s) across ${tabsWithPresets} tab(s) in ${messagesLabel} ESPNow message(s).`);
} catch (error) {
console.error('Failed to send profile presets:', error);
alert('Failed to send profile presets.');
}
}
// Populate the "Add presets to this tab" list: only presets NOT already in the tab, each with a Select button.
async function populateEditTabPresetsList(tabId) {
const listEl = document.getElementById('edit-tab-presets-list');
if (!listEl) return;
listEl.innerHTML = '<span class="muted-text">Loading…</span>';
try {
const tabRes = await fetch(`/tabs/${tabId}`, { headers: { Accept: 'application/json' } });
if (!tabRes.ok) {
listEl.innerHTML = '<span class="muted-text">Failed to load presets.</span>';
return;
}
const tabData = await tabRes.json();
let inTabIds = [];
if (Array.isArray(tabData.presets_flat)) {
inTabIds = tabData.presets_flat;
} else if (Array.isArray(tabData.presets)) {
if (tabData.presets.length && typeof tabData.presets[0] === 'string') {
inTabIds = tabData.presets;
} else if (tabData.presets.length && Array.isArray(tabData.presets[0])) {
inTabIds = tabData.presets.flat();
}
}
const presetsRes = await fetch('/presets', { headers: { Accept: 'application/json' } });
const allPresets = presetsRes.ok ? await presetsRes.json() : {};
const allIds = Object.keys(allPresets);
const availableToAdd = allIds.filter(id => !inTabIds.includes(id));
listEl.innerHTML = '';
if (availableToAdd.length === 0) {
listEl.innerHTML = '<span class="muted-text">No presets to add. All presets are already in this tab.</span>';
return;
}
for (const presetId of availableToAdd) {
const preset = allPresets[presetId] || {};
const name = preset.name || presetId;
const row = document.createElement('div');
row.className = 'profiles-row';
row.style.display = 'flex';
row.style.alignItems = 'center';
row.style.justifyContent = 'space-between';
row.style.gap = '0.5rem';
const label = document.createElement('span');
label.textContent = name;
const selectBtn = document.createElement('button');
selectBtn.type = 'button';
selectBtn.className = 'btn btn-primary btn-small';
selectBtn.textContent = 'Select';
selectBtn.addEventListener('click', async () => {
if (typeof window.addPresetToTab === 'function') {
await window.addPresetToTab(presetId, tabId);
await populateEditTabPresetsList(tabId);
}
});
row.appendChild(label);
row.appendChild(selectBtn);
listEl.appendChild(row);
}
} catch (e) {
console.error('populateEditTabPresetsList:', e);
listEl.innerHTML = '<span class="muted-text">Failed to load presets.</span>';
}
}
// Open edit tab modal
function openEditTabModal(tabId, tab) {
const modal = document.getElementById('edit-tab-modal');
const idInput = document.getElementById('edit-tab-id');
const nameInput = document.getElementById('edit-tab-name');
const idsInput = document.getElementById('edit-tab-ids');
if (idInput) idInput.value = tabId;
if (nameInput) nameInput.value = tab ? (tab.name || '') : '';
if (idsInput) idsInput.value = tab && tab.names ? tab.names.join(', ') : '1';
if (modal) modal.classList.add('active');
populateEditTabPresetsList(tabId);
}
// Update an existing tab
async function updateTab(tabId, name, ids) {
try {
const names = ids ? ids.split(',').map(id => id.trim()) : ['1'];
const response = await fetch(`/tabs/${tabId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: name,
names: names
})
});
const data = await response.json();
if (response.ok) {
// Reload tabs list
await loadTabsModal();
await loadTabs();
// Close modal
document.getElementById('edit-tab-modal').classList.remove('active');
return true;
} else {
alert(`Error: ${data.error || 'Failed to update tab'}`);
return false;
}
} catch (error) {
console.error('Failed to update tab:', error);
alert('Failed to update tab');
return false;
}
}
// Create a new tab
async function createTab(name, ids) {
try {
const names = ids ? ids.split(',').map(id => id.trim()) : ['1'];
const response = await fetch('/tabs', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: name,
names: names
})
});
const data = await response.json();
if (response.ok) {
// Reload tabs list
await loadTabsModal();
await loadTabs();
// Select the new tab
if (data && Object.keys(data).length > 0) {
const newTabId = Object.keys(data)[0];
await selectTab(newTabId);
}
return true;
} else {
alert(`Error: ${data.error || 'Failed to create tab'}`);
return false;
}
} catch (error) {
console.error('Failed to create tab:', error);
alert('Failed to create tab');
return false;
}
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', () => {
loadTabs();
// Set up tabs modal
const tabsButton = document.getElementById('tabs-btn');
const tabsModal = document.getElementById('tabs-modal');
const tabsCloseButton = document.getElementById('tabs-close-btn');
const newTabNameInput = document.getElementById('new-tab-name');
const newTabIdsInput = document.getElementById('new-tab-ids');
const createTabButton = document.getElementById('create-tab-btn');
if (tabsButton && tabsModal) {
tabsButton.addEventListener('click', () => {
tabsModal.classList.add('active');
loadTabsModal();
});
}
if (tabsCloseButton) {
tabsCloseButton.addEventListener('click', () => {
tabsModal.classList.remove('active');
});
}
if (tabsModal) {
tabsModal.addEventListener('click', (event) => {
if (event.target === tabsModal) {
tabsModal.classList.remove('active');
}
});
}
// Right-click on a tab button in the main header bar to edit that tab
document.addEventListener('contextmenu', async (event) => {
const btn = event.target.closest('.tab-button');
if (!btn || !btn.dataset.tabId) {
return;
}
event.preventDefault();
const tabId = btn.dataset.tabId;
try {
const response = await fetch(`/tabs/${tabId}`);
if (response.ok) {
const tab = await response.json();
openEditTabModal(tabId, tab);
} else {
alert('Failed to load tab for editing');
}
} catch (error) {
console.error('Failed to load tab:', error);
alert('Failed to load tab for editing');
}
});
// Set up create tab
const createTabHandler = async () => {
if (!newTabNameInput) return;
const name = newTabNameInput.value.trim();
const ids = (newTabIdsInput && newTabIdsInput.value.trim()) || '1';
if (name) {
await createTab(name, ids);
if (newTabNameInput) newTabNameInput.value = '';
if (newTabIdsInput) newTabIdsInput.value = '1';
}
};
if (createTabButton) {
createTabButton.addEventListener('click', createTabHandler);
}
if (newTabNameInput) {
newTabNameInput.addEventListener('keypress', (event) => {
if (event.key === 'Enter') {
createTabHandler();
}
});
}
// Set up edit tab form
const editTabForm = document.getElementById('edit-tab-form');
if (editTabForm) {
editTabForm.addEventListener('submit', async (e) => {
e.preventDefault();
const idInput = document.getElementById('edit-tab-id');
const nameInput = document.getElementById('edit-tab-name');
const idsInput = document.getElementById('edit-tab-ids');
const tabId = idInput ? idInput.value : null;
const name = nameInput ? nameInput.value.trim() : '';
const ids = idsInput ? idsInput.value.trim() : '1';
if (tabId && name) {
await updateTab(tabId, name, ids);
editTabForm.reset();
}
});
}
// Close edit modal when clicking outside
const editTabModal = document.getElementById('edit-tab-modal');
if (editTabModal) {
editTabModal.addEventListener('click', (event) => {
if (event.target === editTabModal) {
editTabModal.classList.remove('active');
}
});
}
// Profile-wide "Send Presets" button in header
const sendProfilePresetsBtn = document.getElementById('send-profile-presets-btn');
if (sendProfilePresetsBtn) {
sendProfilePresetsBtn.addEventListener('click', async () => {
await sendProfilePresets();
});
}
});
// Export for use in other scripts
window.tabsManager = {
loadTabs,
selectTab,
createTab,
updateTab,
openEditTabModal,
getCurrentTabId: () => currentTabId
};

View File

@@ -5,88 +5,82 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LED Controller - Tab Mode</title> <title>LED Controller - Tab Mode</title>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js"></script>
</head> </head>
<body> <body>
<div class="app-container"> <div class="app-container">
<header> <header>
<h1>LED Controller - Tab Mode</h1> <div class="tabs-container">
<div id="tabs-list">
Loading tabs...
</div>
</div>
<div class="header-actions"> <div class="header-actions">
<button class="btn btn-primary" <button class="btn btn-secondary" id="tabs-btn">Tabs</button>
hx-get="/tabs/create-form-fragment"
hx-target="#add-tab-modal .modal-content"
hx-swap="innerHTML"
onclick="document.getElementById('add-tab-modal').classList.add('active')">
+ Add Tab
</button>
<button class="btn btn-secondary" id="edit-tab-btn">Edit Tab</button>
<button class="btn btn-danger"
hx-delete="/tabs/current"
hx-target="#tabs-list"
hx-swap="innerHTML"
hx-headers='{"Accept": "text/html"}'
hx-confirm="Are you sure you want to delete this tab?">
Delete Tab
</button>
<button class="btn btn-secondary" id="color-palette-btn">Color Palette</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" 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="patterns-btn">Patterns</button>
<button class="btn btn-secondary" id="profiles-btn">Profiles</button> <button class="btn btn-secondary" 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>
</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" 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" data-target="settings-btn">Settings</button>
<button type="button" data-target="help-btn">Help</button>
</div>
</div> </div>
</header> </header>
<div class="main-content"> <div class="main-content">
<div class="tabs-container"> <div id="tab-content" class="tab-content">
<div id="tabs-list" <div class="tab-content-placeholder">
hx-get="/tabs/list-fragment"
hx-trigger="load, tabs-updated from:body"
hx-swap="innerHTML">
Loading tabs...
</div>
</div>
<div id="tab-content"
class="tab-content"
hx-get="/tabs/current"
hx-trigger="load, tabs-updated from:body"
hx-swap="innerHTML"
hx-headers='{"Accept": "text/html"}'>
<div style="padding: 2rem; text-align: center; color: #aaa;">
Select a tab to get started Select a tab to get started
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Add Tab Modal --> <!-- Tabs Modal -->
<div id="add-tab-modal" class="modal"> <div id="tabs-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<h2>Add New Tab</h2> <h2>Tabs</h2>
<form hx-post="/tabs" <div class="profiles-actions">
hx-target="#tabs-list" <input type="text" id="new-tab-name" placeholder="Tab name">
hx-swap="innerHTML" <input type="text" id="new-tab-ids" placeholder="Device IDs (1,2,3)" value="1">
hx-headers='{"Accept": "text/html"}' <button class="btn btn-primary" id="create-tab-btn">Create</button>
hx-on::after-request="if(event.detail.successful) { document.getElementById('add-tab-modal').classList.remove('active'); document.body.dispatchEvent(new Event('tabs-updated')); }"> </div>
<label>Tab Name:</label> <div id="tabs-list-modal" class="profiles-list"></div>
<input type="text" name="name" placeholder="Enter tab name" required> <div class="modal-actions">
<label>Device IDs (comma-separated):</label> <button class="btn btn-secondary" id="tabs-close-btn">Close</button>
<input type="text" name="ids" placeholder="1,2,3" value="1"> </div>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Add</button>
<button type="button" class="btn btn-secondary" onclick="document.getElementById('add-tab-modal').classList.remove('active')">Cancel</button>
</div>
</form>
</div> </div>
</div> </div>
<!-- Edit Tab Modal (placeholder for now) --> <!-- Edit Tab Modal -->
<div id="edit-tab-modal" class="modal"> <div id="edit-tab-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<h2>Edit Tab</h2> <h2>Edit Tab</h2>
<p>Edit functionality coming soon...</p> <form id="edit-tab-form">
<div class="modal-actions"> <input type="hidden" id="edit-tab-id">
<button class="btn btn-secondary" onclick="document.getElementById('edit-tab-modal').classList.remove('active')">Close</button> <div class="modal-actions" style="margin-bottom: 1rem;">
</div> <button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-tab-modal').classList.remove('active')">Close</button>
</div>
<label>Tab Name:</label>
<input type="text" id="edit-tab-name" placeholder="Enter tab name" required>
<label>Device IDs (comma-separated):</label>
<input type="text" id="edit-tab-ids" placeholder="1,2,3" required>
<label style="margin-top: 1rem;">Add presets to this tab</label>
<div id="edit-tab-presets-list" class="profiles-list" style="max-height: 200px; overflow-y: auto; margin-bottom: 1rem;"></div>
</form>
</div> </div>
</div> </div>
@@ -131,14 +125,20 @@
</div> </div>
<label>Colors</label> <label>Colors</label>
<div id="preset-colors-container" class="preset-colors-container"></div> <div id="preset-colors-container" class="preset-colors-container"></div>
<div class="profiles-actions" style="margin-top: 0.5rem;"> <div class="profiles-actions">
<input type="color" id="preset-new-color" value="#ffffff"> <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-color-btn">Add Color</button>
<button class="btn btn-secondary btn-small" id="preset-add-from-palette-btn">Add from Palette</button> <button class="btn btn-secondary btn-small" id="preset-add-from-palette-btn">Add from Palette</button>
</div> </div>
<div class="profiles-actions"> <div class="profiles-actions">
<input type="number" id="preset-brightness-input" placeholder="Brightness" min="0" max="255" value="0"> <div class="preset-editor-field">
<input type="number" id="preset-delay-input" placeholder="Delay" min="0" max="10000" value="0"> <label for="preset-brightness-input">Brightness (0255)</label>
<input type="number" id="preset-brightness-input" placeholder="Brightness" min="0" max="255" value="0">
</div>
<div class="preset-editor-field">
<label for="preset-delay-input">Delay (ms)</label>
<input type="number" id="preset-delay-input" placeholder="Delay" min="0" max="10000" value="0">
</div>
</div> </div>
<div class="n-params-grid"> <div class="n-params-grid">
<div class="n-param-group"> <div class="n-param-group">
@@ -175,7 +175,10 @@
</div> </div>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-primary" id="preset-save-btn">Save</button> <button class="btn btn-secondary" id="preset-send-btn">Try</button>
<button class="btn btn-secondary" id="preset-default-btn">Default</button>
<button class="btn btn-primary" id="preset-save-btn">Save &amp; Send</button>
<button class="btn btn-danger" id="preset-remove-from-tab-btn">Remove from Tab</button>
<button class="btn btn-secondary" id="preset-clear-btn">Clear</button> <button class="btn btn-secondary" id="preset-clear-btn">Clear</button>
<button class="btn btn-secondary" id="preset-editor-close-btn">Close</button> <button class="btn btn-secondary" id="preset-editor-close-btn">Close</button>
</div> </div>
@@ -209,145 +212,106 @@
</div> </div>
</div> </div>
<style> <!-- Help Modal -->
.modal { <div id="help-modal" class="modal">
display: none; <div class="modal-content">
position: fixed; <h2>Help</h2>
z-index: 1000; <p class="muted-text">How to use the LED controller UI.</p>
left: 0;
top: 0; <h3>Tabs & devices</h3>
width: 100%; <ul>
height: 100%; <li><strong>Select tab</strong>: left-click a tab button in the top bar.</li>
background-color: rgba(0,0,0,0.7); <li><strong>Edit tab</strong>: right-click a tab button, or click <strong>Edit</strong> in the Tabs modal.</li>
} <li><strong>Send all presets</strong>: open the <strong>Tabs</strong> menu and click <strong>Send Presets</strong> next to the tab to push every preset used in that tab to all devices.</li>
.modal.active { </ul>
display: flex;
align-items: center; <h3>Presets in a tab</h3>
justify-content: center; <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>
.modal-content { <li><strong>Edit preset</strong>: right-click a preset tile and choose <strong>Edit preset…</strong>.</li>
background-color: #2e2e2e; <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>
padding: 2rem; <li><strong>Reorder presets</strong>: drag preset tiles to change their order; the new layout is saved automatically.</li>
border-radius: 8px; </ul>
min-width: 400px;
max-width: 600px; <h3>Presets, profiles & colors</h3>
} <ul>
.modal-content label { <li><strong>Presets</strong>: use the <strong>Presets</strong> button in the header to create and manage reusable presets.</li>
display: block; <li><strong>Profiles</strong>: use <strong>Profiles</strong> to save and recall groups of settings.</li>
margin-top: 1rem; <li><strong>Color Palette</strong>: use <strong>Color Palette</strong> to build a reusable set of colors you can pull into presets.</li>
margin-bottom: 0.5rem; </ul>
}
.modal-content input[type="text"] { <div class="modal-actions">
width: 100%; <button class="btn btn-secondary" id="help-close-btn">Close</button>
padding: 0.5rem; </div>
background-color: #3a3a3a; </div>
border: 1px solid #4a4a4a; </div>
border-radius: 4px;
color: white; <!-- Settings Modal -->
} <div id="settings-modal" class="modal">
.profiles-actions { <div class="modal-content">
display: flex; <h2>Device Settings</h2>
gap: 0.5rem; <p class="muted-text">Configure WiFi Access Point and device settings.</p>
margin-top: 1rem;
} <div id="settings-message" class="message"></div>
.profiles-actions input[type="text"] {
flex: 1; <!-- Device Name -->
} <div class="settings-section">
.profiles-list { <h3>Device</h3>
display: flex; <form id="device-form">
flex-direction: column; <div class="form-group">
gap: 0.5rem; <label for="device-name-input">Device Name</label>
margin-top: 1rem; <input type="text" id="device-name-input" name="device_name" placeholder="e.g. led-controller" required>
max-height: 50vh; <small>This name may be used for mDNS (e.g. <code>name.local</code>) and UI display.</small>
overflow-y: auto; </div>
} <div class="btn-group">
.profiles-row { <button type="submit" class="btn btn-primary btn-full">Save Name</button>
display: flex; </div>
align-items: center; </form>
justify-content: space-between; </div>
gap: 0.5rem;
padding: 0.5rem; <!-- WiFi Access Point Settings -->
background-color: #3a3a3a; <div class="settings-section ap-settings-section">
border-radius: 4px; <h3>WiFi Access Point</h3>
}
/* Hide any text content in palette rows - only show color swatches */ <div id="ap-status" class="status-info">
#palette-container .profiles-row { <h4>AP Status</h4>
font-size: 0; /* Hide any text nodes */ <p>Loading...</p>
} </div>
#palette-container .profiles-row > * {
font-size: 1rem; /* Restore font size for buttons */ <form id="ap-form">
} <div class="form-group">
#palette-container .profiles-row > span:not(.btn), <label for="ap-ssid">AP SSID (Network Name)</label>
#palette-container .profiles-row > label, <input type="text" id="ap-ssid" name="ssid" placeholder="Enter AP name" required>
#palette-container .profiles-row::before, <small>The name of the WiFi access point this device creates</small>
#palette-container .profiles-row::after { </div>
display: none !important;
content: none !important; <div class="form-group">
} <label for="ap-password">AP Password</label>
/* Preset colors container */ <input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)">
#preset-colors-container { <small>Leave empty for open network (min 8 characters if set)</small>
min-height: 80px; </div>
padding: 0.5rem;
background-color: #2a2a2a; <div class="form-group">
border-radius: 4px; <label for="ap-channel">Channel (1-11)</label>
margin-bottom: 0.5rem; <input type="number" id="ap-channel" name="channel" min="1" max="11" placeholder="Auto">
} <small>WiFi channel (1-11 for 2.4GHz). Leave empty for auto.</small>
#preset-colors-container .muted-text { </div>
color: #888;
font-size: 0.9rem; <div class="btn-group">
padding: 1rem; <button type="submit" class="btn btn-primary btn-full">Configure AP</button>
text-align: center; </div>
} </form>
.muted-text { </div>
text-align: center;
color: #888; <div class="modal-actions">
} <button class="btn btn-secondary" id="settings-close-btn">Close</button>
.modal-actions { </div>
display: flex; </div>
gap: 0.5rem; </div>
margin-top: 1.5rem;
justify-content: flex-end; <!-- Styles moved to /static/style.css -->
} <script src="/static/tabs.js"></script>
.error { <script src="/static/help.js"></script>
color: #d32f2f;
padding: 0.5rem;
background-color: #3a1a1a;
border-radius: 4px;
margin-top: 0.5rem;
}
/* Drag and drop styles for presets */
.draggable-preset {
cursor: move;
transition: opacity 0.2s, transform 0.2s;
}
.draggable-preset.dragging {
opacity: 0.5;
transform: scale(0.95);
}
.draggable-preset:hover {
opacity: 0.8;
}
/* Drag and drop styles for color swatches */
.draggable-color-swatch {
transition: opacity 0.2s, transform 0.2s;
}
.draggable-color-swatch.dragging-color {
opacity: 0.5;
transform: scale(0.9);
}
.draggable-color-swatch.drag-over-color {
transform: scale(1.1);
}
.color-swatches-container {
min-height: 80px;
}
/* Ensure presets list uses grid layout */
#presets-list-tab {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
}
</style>
<script src="/static/color_palette.js"></script> <script src="/static/color_palette.js"></script>
<script src="/static/profiles.js"></script> <script src="/static/profiles.js"></script>
<script src="/static/tab_palette.js"></script> <script src="/static/tab_palette.js"></script>

309
src/templates/settings.html Normal file
View File

@@ -0,0 +1,309 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LED Controller - Settings</title>
<link rel="stylesheet" href="/static/style.css">
<style>
.settings-container {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
overflow-y: auto;
height: 100%;
}
.settings-header {
margin-bottom: 2rem;
}
.settings-header h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.settings-header p {
color: #aaa;
}
.settings-section {
background-color: #1a1a1a;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
border: 1px solid #4a4a4a;
}
.settings-section h2 {
font-size: 1.3rem;
margin-bottom: 1rem;
color: #fff;
border-bottom: 2px solid #4a4a4a;
padding-bottom: 0.5rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #ccc;
font-weight: 500;
}
.form-group input[type="text"],
.form-group input[type="password"],
.form-group input[type="number"],
.form-group select {
width: 100%;
padding: 0.75rem;
background-color: #2e2e2e;
border: 1px solid #4a4a4a;
border-radius: 4px;
color: white;
font-size: 1rem;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #5a5a5a;
}
.form-group small {
display: block;
margin-top: 0.25rem;
color: #888;
font-size: 0.875rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.status-info {
background-color: #2e2e2e;
border: 1px solid #4a4a4a;
border-radius: 4px;
padding: 1rem;
margin-bottom: 1rem;
}
.status-info h3 {
font-size: 1rem;
margin-bottom: 0.5rem;
color: #fff;
}
.status-info p {
color: #aaa;
margin: 0.25rem 0;
font-size: 0.9rem;
}
.status-connected {
color: #4caf50;
}
.status-disconnected {
color: #f44336;
}
.btn-group {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.btn-full {
flex: 1;
}
.back-link {
display: inline-block;
margin-bottom: 1rem;
color: #aaa;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.2s;
}
.back-link:hover {
background-color: #2e2e2e;
color: white;
}
.message {
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
display: none;
}
.message.success {
background-color: #1b5e20;
color: #4caf50;
border: 1px solid #4caf50;
}
.message.error {
background-color: #5e1b1b;
color: #f44336;
border: 1px solid #f44336;
}
.message.show {
display: block;
}
</style>
</head>
<body>
<div class="app-container">
<div class="settings-container">
<a href="/" class="back-link">← Back to Dashboard</a>
<div class="settings-header">
<h1>Device Settings</h1>
<p>Configure WiFi Access Point settings</p>
</div>
<div id="message" class="message"></div>
<!-- WiFi Access Point Settings -->
<div class="settings-section">
<h2>WiFi Access Point Settings</h2>
<div id="ap-status" class="status-info">
<h3>AP Status</h3>
<p>Loading...</p>
</div>
<form id="ap-form">
<div class="form-group">
<label for="ap-ssid">AP SSID (Network Name)</label>
<input type="text" id="ap-ssid" name="ssid" placeholder="Enter AP name" required>
<small>The name of the WiFi access point this device creates</small>
</div>
<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)">
<small>Leave empty for open network (min 8 characters if set)</small>
</div>
<div class="form-group">
<label for="ap-channel">Channel (1-11)</label>
<input type="number" id="ap-channel" name="channel" min="1" max="11" placeholder="Auto">
<small>WiFi channel (1-11 for 2.4GHz). Leave empty for auto.</small>
</div>
<div class="btn-group">
<button type="submit" class="btn btn-primary btn-full">Configure AP</button>
</div>
</form>
</div>
</div>
</div>
<script>
// Show message helper
function showMessage(text, type = 'success') {
const messageEl = document.getElementById('message');
messageEl.textContent = text;
messageEl.className = `message ${type} show`;
setTimeout(() => {
messageEl.classList.remove('show');
}, 5000);
}
// Load AP status and config
async function loadAPStatus() {
try {
const response = await fetch('/settings/wifi/ap');
const config = await response.json();
const statusEl = document.getElementById('ap-status');
if (config.active) {
statusEl.innerHTML = `
<h3>AP Status: <span class="status-connected">Active</span></h3>
<p><strong>SSID:</strong> ${config.ssid || 'N/A'}</p>
<p><strong>Channel:</strong> ${config.channel || 'Auto'}</p>
<p><strong>IP Address:</strong> ${config.ip || 'N/A'}</p>
`;
} else {
statusEl.innerHTML = `
<h3>AP Status: <span class="status-disconnected">Inactive</span></h3>
<p>Access Point is not currently active</p>
`;
}
// Load saved values
if (config.saved_ssid) document.getElementById('ap-ssid').value = config.saved_ssid;
if (config.saved_channel) document.getElementById('ap-channel').value = config.saved_channel;
} catch (error) {
console.error('Error loading AP status:', error);
}
}
// AP form submission
document.getElementById('ap-form').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = {
ssid: document.getElementById('ap-ssid').value,
password: document.getElementById('ap-password').value,
channel: document.getElementById('ap-channel').value || null
};
// Validate password length if provided
if (formData.password && formData.password.length > 0 && formData.password.length < 8) {
showMessage('AP password must be at least 8 characters', 'error');
return;
}
// Convert channel to number if provided
if (formData.channel) {
formData.channel = parseInt(formData.channel);
if (formData.channel < 1 || formData.channel > 11) {
showMessage('Channel must be between 1 and 11', 'error');
return;
}
}
try {
const response = await fetch('/settings/wifi/ap', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
const result = await response.json();
if (response.ok) {
showMessage('Access Point configured successfully!', 'success');
setTimeout(loadAPStatus, 1000);
} else {
showMessage(`Error: ${result.error || 'Failed to configure AP'}`, 'error');
}
} catch (error) {
showMessage(`Error: ${error.message}`, 'error');
}
});
// Load all data on page load
loadAPStatus();
// Refresh status every 10 seconds
setInterval(loadAPStatus, 10000);
</script>
</body>
</html>

80
src/util/README.md Normal file
View File

@@ -0,0 +1,80 @@
# ESPNow Message Builder
This utility module provides functions to build ESPNow messages according to the LED Driver API specification.
## Usage
### Basic Message Building
```python
from util.espnow_message import build_message, build_preset_dict, build_select_dict
# Build a message with presets and select
presets = {
"red_blink": build_preset_dict({
"pattern": "blink",
"colors": ["#FF0000"],
"delay": 200,
"brightness": 255,
"auto": True
})
}
select = build_select_dict({
"device1": "red_blink"
})
message = build_message(presets=presets, select=select)
# Result: {"v": "1", "presets": {...}, "select": {...}}
```
### Building Select Messages with Step Synchronization
```python
from util.espnow_message import build_message, build_select_dict
# Select with step for synchronization
select = build_select_dict(
{"device1": "rainbow_preset", "device2": "rainbow_preset"},
step_mapping={"device1": 10, "device2": 10}
)
message = build_message(select=select)
# Result: {"v": "1", "select": {"device1": ["rainbow_preset", 10], "device2": ["rainbow_preset", 10]}}
```
### Converting Presets
```python
from util.espnow_message import build_preset_dict, build_presets_dict
# Single preset
preset = build_preset_dict({
"name": "my_preset",
"pattern": "rainbow",
"colors": ["#FF0000", "#00FF00"], # Can be hex strings or RGB tuples
"delay": 100,
"brightness": 127,
"auto": False,
"n1": 2
})
# Multiple presets
presets_data = {
"preset1": {"pattern": "on", "colors": ["#FF0000"]},
"preset2": {"pattern": "blink", "colors": ["#00FF00"]}
}
presets = build_presets_dict(presets_data)
```
## API Specification
See `docs/API.md` for the complete ESPNow API specification.
## Key Features
- **Version Field**: All messages include `"v": "1"` for version tracking
- **Preset Format**: Presets use hex color strings (`#RRGGBB`), not RGB tuples
- **Select Format**: Select values are always lists: `["preset_name"]` or `["preset_name", step]`
- **Color Conversion**: Automatically converts RGB tuples to hex strings
- **Default Values**: Provides sensible defaults for missing fields

274
src/util/espnow_message.py Normal file
View File

@@ -0,0 +1,274 @@
"""
ESPNow message builder utility for LED driver communication.
This module provides utilities to build ESPNow messages according to the API specification.
ESPNow has a 250-byte payload limit; messages larger than that must be split into multiple
frames.
"""
import json
# ESPNow payload limit (bytes). Messages larger than this must be split.
ESPNOW_MAX_PAYLOAD_BYTES = 240
def build_message(presets=None, select=None, save=False, default=None):
"""
Build an ESPNow message according to the API specification.
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
Example:
message = build_message(
presets={
"red_blink": {
"pattern": "blink",
"colors": ["#FF0000"],
"delay": 200,
"brightness": 255,
"auto": True
}
},
select={
"device1": ["red_blink"]
}
)
"""
message = {
"v": "1"
}
if presets:
message["presets"] = presets
# When sending presets, optionally include a save flag so the
# led-driver can persist them.
if save:
message["save"] = True
if select:
message["select"] = select
if default is not None:
message["default"] = default
return json.dumps(message)
def split_espnow_message(msg_dict, max_bytes=None):
"""
Split a message dict into one or more JSON strings each within ESPNow payload limit.
If the message fits in max_bytes, returns a single-element list. Otherwise splits
"select" and/or "presets" into multiple messages (other keys like v, b, default, save
are included only in the first message).
Args:
msg_dict: Full message as a dict (e.g. from json.loads).
max_bytes: Max payload size in bytes (default ESPNOW_MAX_PAYLOAD_BYTES).
Returns:
List of JSON strings, each <= max_bytes, to send in order.
"""
if max_bytes is None:
max_bytes = ESPNOW_MAX_PAYLOAD_BYTES
single = json.dumps(msg_dict)
if len(single) <= max_bytes:
return [single]
# Keys to attach only to the first message we emit
first_only = {k: msg_dict[k] for k in ("b", "default", "save") if k in msg_dict}
out = []
def emit(chunk_dict, is_first):
m = {"v": msg_dict.get("v", "1")}
if is_first and first_only:
m.update(first_only)
m.update(chunk_dict)
s = json.dumps(m)
if len(s) > max_bytes:
raise ValueError(f"Chunk still too large ({len(s)} > {max_bytes})")
out.append(s)
def chunk_dict(key, items_dict):
if not items_dict:
return
items = list(items_dict.items())
i = 0
first = True
while i < len(items):
chunk = {}
while i < len(items):
k, v = items[i]
trial = dict(chunk)
trial[k] = v
trial_msg = {"v": msg_dict.get("v", "1"), key: trial}
if first_only and first:
trial_msg.update(first_only)
if len(json.dumps(trial_msg)) <= max_bytes:
chunk[k] = v
i += 1
else:
if not chunk:
# Single entry too large; send as-is and hope receiver accepts
chunk[k] = v
i += 1
break
if chunk:
emit({key: chunk}, first)
first = False
if not chunk:
break
if "select" in msg_dict:
chunk_dict("select", msg_dict["select"])
if "presets" in msg_dict:
chunk_dict("presets", msg_dict["presets"])
if not out:
# Fallback: emit one message even if over limit (receiver may reject)
out = [single]
return out
def build_select_message(device_name, preset_name, step=None):
"""
Build a select message for a single device.
Args:
device_name: Name of the device
preset_name: Name of the preset to select
step: Optional step value for synchronization
Returns:
Dictionary with select field ready to use in build_message
Example:
select = build_select_message("device1", "rainbow_preset", step=10)
message = build_message(select=select)
"""
select_list = [preset_name]
if step is not None:
select_list.append(step)
return {device_name: select_list}
def build_preset_dict(preset_data):
"""
Convert preset data to API-compliant format.
Args:
preset_data: Dictionary with preset fields (may include name, pattern, colors, etc.)
Returns:
Dictionary with preset in API-compliant format (without name field)
Example:
preset = build_preset_dict({
"name": "red_blink",
"pattern": "blink",
"colors": ["#FF0000"],
"delay": 200,
"brightness": 255,
"auto": True,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0
})
"""
# Ensure colors are in hex format
colors = preset_data.get("colors", preset_data.get("c", ["#FFFFFF"]))
if colors:
# Convert RGB tuples to hex strings if needed
if isinstance(colors[0], list) and len(colors[0]) == 3:
# RGB tuple format [r, g, b]
colors = [f"#{r:02x}{g:02x}{b:02x}" for r, g, b in colors]
elif not isinstance(colors[0], str):
# Handle other formats - convert to hex
colors = ["#FFFFFF"]
# Ensure all colors start with #
colors = [c if c.startswith("#") else f"#{c}" for c in colors]
else:
colors = ["#FFFFFF"]
# Build payload using the short keys expected by led-driver
preset = {
"p": preset_data.get("pattern", preset_data.get("p", "off")),
"c": colors,
"d": preset_data.get("delay", preset_data.get("d", 100)),
"b": preset_data.get("brightness", preset_data.get("b", preset_data.get("br", 127))),
"a": preset_data.get("auto", preset_data.get("a", True)),
"n1": preset_data.get("n1", 0),
"n2": preset_data.get("n2", 0),
"n3": preset_data.get("n3", 0),
"n4": preset_data.get("n4", 0),
"n5": preset_data.get("n5", 0),
"n6": preset_data.get("n6", 0)
}
return preset
def build_presets_dict(presets_data):
"""
Convert multiple presets to API-compliant format.
Args:
presets_data: Dictionary mapping preset names to preset data
Returns:
Dictionary mapping preset names to API-compliant preset objects
Example:
presets = build_presets_dict({
"red_blink": {
"pattern": "blink",
"colors": ["#FF0000"],
"delay": 200
},
"blue_pulse": {
"pattern": "pulse",
"colors": ["#0000FF"],
"delay": 100
}
})
"""
result = {}
for preset_name, preset_data in presets_data.items():
result[preset_name] = build_preset_dict(preset_data)
return result
def build_select_dict(device_preset_mapping, step_mapping=None):
"""
Build a select dictionary mapping device names to select lists.
Args:
device_preset_mapping: Dictionary mapping device names to preset names
step_mapping: Optional dictionary mapping device names to step values
Returns:
Dictionary with select field ready to use in build_message
Example:
select = build_select_dict(
{"device1": "rainbow_preset", "device2": "pulse_preset"},
step_mapping={"device1": 10}
)
message = build_message(select=select)
"""
select = {}
for device_name, preset_name in device_preset_mapping.items():
select_list = [preset_name]
if step_mapping and device_name in step_mapping:
select_list.append(step_mapping[device_name])
select[device_name] = select_list
return select

View File

@@ -1,34 +1,15 @@
import network import network
from time import sleep
def connect(ssid, password, ip, gateway):
if ssid is None or password is None:
print("Missing ssid or password")
return None
try:
sta_if = network.WLAN(network.STA_IF)
if ip is not None and gateway is not None:
sta_if.ifconfig((ip, '255.255.255.0', gateway, '1.1.1.1'))
if not sta_if.isconnected():
print('connecting to network...')
sta_if.active(True)
sta_if.connect(ssid, password)
sleep(0.1)
if sta_if.isconnected():
return sta_if.ifconfig()
return None
return sta_if.ifconfig()
except Exception as e:
print(f"Failed to connect to wifi {e}")
return None
def ap(ssid, password): def ap(ssid, password, channel=None):
ap_if = network.WLAN(network.AP_IF) ap_if = network.WLAN(network.AP_IF)
ap_mac = ap_if.config('mac') ap_mac = ap_if.config('mac')
print(ssid) print(ssid)
ap_if.active(True) ap_if.active(True)
ap_if.config(essid=ssid, password=password) 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(False)
ap_if.active(True) ap_if.active(True)
print(ap_if.ifconfig()) print(ap_if.ifconfig())
@@ -36,3 +17,26 @@ def ap(ssid, password):
def get_mac(): def get_mac():
ap_if = network.WLAN(network.AP_IF) ap_if = network.WLAN(network.AP_IF)
return ap_if.config('mac') 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

79
tests/README.md Normal file
View File

@@ -0,0 +1,79 @@
# Tests
This directory contains tests for the LED Controller project.
## Directory Structure
- `test_endpoints.py` - HTTP endpoint tests that mimic web browser requests (runs against 192.168.4.1)
- `test_ws.py` - WebSocket tests
- `test_p2p.py` - ESP-NOW P2P tests
- `models/` - Model unit tests
- `web.py` - Local development web server
## Running Tests
### Browser Tests (Real Browser Automation)
Tests the web interface in an actual browser using Selenium:
```bash
python tests/test_browser.py
```
These tests:
- Open a real Chrome browser
- Navigate to the device at 192.168.4.1
- Interact with UI elements (buttons, forms, modals)
- Test complete user workflows
- Verify visual elements and interactions
**Requirements:**
```bash
pip install selenium
# Also need ChromeDriver installed and in PATH
# Download from: https://chromedriver.chromium.org/
```
### Endpoint Tests (Browser-like HTTP)
Tests HTTP endpoints by making requests to the device at 192.168.4.1:
```bash
python tests/test_endpoints.py
```
These tests:
- Mimic web browser requests with proper headers
- Handle cookies for session management
- Test all CRUD operations (GET, POST, PUT, DELETE)
- Verify responses and status codes
**Requirements:**
```bash
pip install requests
```
### WebSocket Tests
```bash
python tests/test_ws.py
```
**Requirements:**
```bash
pip install websockets
```
### Model Tests
```bash
python tests/models/run_all.py
```
### Local Development Server
Run the local development server (port 5000):
```bash
python tests/web.py
```

1041
tests/test_browser.py Normal file

File diff suppressed because it is too large Load Diff

563
tests/test_endpoints.py Normal file
View File

@@ -0,0 +1,563 @@
#!/usr/bin/env python3
"""
Endpoint tests that mimic web browser requests.
Tests run against the device at 192.168.4.1
"""
import requests
import json
import sys
from typing import Dict, Optional
# Base URL for the device
BASE_URL = "http://192.168.4.1"
class TestClient:
"""HTTP client that mimics a web browser with cookie support."""
def __init__(self, base_url: str = BASE_URL):
self.base_url = base_url
self.session = requests.Session()
self.session.headers.update({
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
'Accept': 'application/json',
'Accept-Language': 'en-US,en;q=0.9',
})
def get(self, path: str, **kwargs) -> requests.Response:
"""GET request."""
url = f"{self.base_url}{path}"
return self.session.get(url, **kwargs)
def post(self, path: str, data: Optional[Dict] = None, json_data: Optional[Dict] = None, **kwargs) -> requests.Response:
"""POST request."""
url = f"{self.base_url}{path}"
if json_data:
return self.session.post(url, json=json_data, **kwargs)
return self.session.post(url, data=data, **kwargs)
def put(self, path: str, json_data: Optional[Dict] = None, **kwargs) -> requests.Response:
"""PUT request."""
url = f"{self.base_url}{path}"
return self.session.put(url, json=json_data, **kwargs)
def delete(self, path: str, **kwargs) -> requests.Response:
"""DELETE request."""
url = f"{self.base_url}{path}"
return self.session.delete(url, **kwargs)
def set_cookie(self, name: str, value: str):
"""Set a cookie manually."""
self.session.cookies.set(name, value, domain='192.168.4.1', path='/')
def get_cookie(self, name: str) -> Optional[str]:
"""Get a cookie value."""
return self.session.cookies.get(name)
def test_connection(client: TestClient) -> bool:
"""Test basic connection to the server."""
print("Testing connection...")
try:
response = client.get('/')
if response.status_code == 200:
print("✓ Connection successful")
return True
else:
print(f"✗ Connection failed: {response.status_code}")
return False
except requests.exceptions.ConnectionError:
print(f"✗ Cannot connect to {BASE_URL}")
print(" Make sure the device is running and accessible at 192.168.4.1")
return False
except Exception as e:
print(f"✗ Connection error: {e}")
return False
def test_tabs(client: TestClient) -> bool:
"""Test tabs endpoints."""
print("\n=== Testing Tabs Endpoints ===")
passed = 0
total = 0
# Test 1: List tabs
total += 1
try:
response = client.get('/tabs')
if response.status_code == 200:
data = response.json()
print(f"✓ GET /tabs - Found {len(data.get('tabs', {}))} tabs")
passed += 1
else:
print(f"✗ GET /tabs - Status: {response.status_code}")
except Exception as e:
print(f"✗ GET /tabs - Error: {e}")
# Test 2: Create tab
total += 1
try:
tab_data = {
"name": "Test Tab",
"names": ["1", "2"]
}
response = client.post('/tabs', json_data=tab_data)
if response.status_code == 201:
created_tab = response.json()
# Response format: {tab_id: {tab_data}}
if isinstance(created_tab, dict):
# Get the first key which should be the tab ID
tab_id = next(iter(created_tab.keys())) if created_tab else None
else:
tab_id = None
print(f"✓ POST /tabs - Created tab: {tab_id}")
passed += 1
# Test 3: Get specific tab
if tab_id:
total += 1
response = client.get(f'/tabs/{tab_id}')
if response.status_code == 200:
print(f"✓ GET /tabs/{tab_id} - Retrieved tab")
passed += 1
else:
print(f"✗ GET /tabs/{tab_id} - Status: {response.status_code}")
# Test 4: Set current tab
total += 1
response = client.post(f'/tabs/{tab_id}/set-current')
if response.status_code == 200:
print(f"✓ POST /tabs/{tab_id}/set-current - Set current tab")
# Check cookie was set
cookie = client.get_cookie('current_tab')
if cookie == tab_id:
print(f" ✓ Cookie 'current_tab' set to {tab_id}")
passed += 1
else:
print(f"✗ POST /tabs/{tab_id}/set-current - Status: {response.status_code}")
# Test 5: Get current tab
total += 1
response = client.get('/tabs/current')
if response.status_code == 200:
data = response.json()
if data.get('tab_id') == tab_id:
print(f"✓ GET /tabs/current - Current tab is {tab_id}")
passed += 1
else:
print(f"✗ GET /tabs/current - Wrong tab ID")
else:
print(f"✗ GET /tabs/current - Status: {response.status_code}")
# Test 6: Update tab (edit functionality)
total += 1
update_data = {
"name": "Updated Test Tab",
"names": ["1", "2", "3"] # Update device IDs too
}
response = client.put(f'/tabs/{tab_id}', json_data=update_data)
if response.status_code == 200:
updated = response.json()
if updated.get('name') == "Updated Test Tab" and updated.get('names') == ["1", "2", "3"]:
print(f"✓ PUT /tabs/{tab_id} - Updated tab (name and device IDs)")
passed += 1
else:
print(f"✗ PUT /tabs/{tab_id} - Update didn't work correctly")
print(f" Expected name='Updated Test Tab', got '{updated.get('name')}'")
print(f" Expected names=['1','2','3'], got {updated.get('names')}")
else:
print(f"✗ PUT /tabs/{tab_id} - Status: {response.status_code}, Response: {response.text}")
# Test 6b: Verify update persisted
total += 1
response = client.get(f'/tabs/{tab_id}')
if response.status_code == 200:
verified = response.json()
if verified.get('name') == "Updated Test Tab":
print(f"✓ GET /tabs/{tab_id} - Verified update persisted")
passed += 1
else:
print(f"✗ GET /tabs/{tab_id} - Update didn't persist")
else:
print(f"✗ GET /tabs/{tab_id} - Status: {response.status_code}")
# Test 7: Delete tab
total += 1
response = client.delete(f'/tabs/{tab_id}')
if response.status_code == 200:
print(f"✓ DELETE /tabs/{tab_id} - Deleted tab")
passed += 1
else:
print(f"✗ DELETE /tabs/{tab_id} - Status: {response.status_code}")
else:
print(f"✗ POST /tabs - Status: {response.status_code}, Response: {response.text}")
except Exception as e:
print(f"✗ POST /tabs - Error: {e}")
import traceback
traceback.print_exc()
print(f"\nTabs tests: {passed}/{total} passed")
return passed == total
def test_profiles(client: TestClient) -> bool:
"""Test profiles endpoints."""
print("\n=== Testing Profiles Endpoints ===")
passed = 0
total = 0
# Test 1: List profiles
total += 1
try:
response = client.get('/profiles')
if response.status_code == 200:
data = response.json()
profiles = data.get('profiles', {})
current_id = data.get('current_profile_id')
print(f"✓ GET /profiles - Found {len(profiles)} profiles, current: {current_id}")
passed += 1
else:
print(f"✗ GET /profiles - Status: {response.status_code}")
except Exception as e:
print(f"✗ GET /profiles - Error: {e}")
# Test 2: Get current profile
total += 1
try:
response = client.get('/profiles/current')
if response.status_code == 200:
data = response.json()
print(f"✓ GET /profiles/current - Current profile: {data.get('id')}")
passed += 1
else:
print(f"✗ GET /profiles/current - Status: {response.status_code}")
except Exception as e:
print(f"✗ GET /profiles/current - Error: {e}")
# Test 3: Create profile
total += 1
try:
profile_data = {"name": "Test Profile"}
response = client.post('/profiles', json_data=profile_data)
if response.status_code == 201:
created = response.json()
# Response format: {profile_id: {profile_data}}
if isinstance(created, dict):
profile_id = next(iter(created.keys())) if created else None
else:
profile_id = None
print(f"✓ POST /profiles - Created profile: {profile_id}")
passed += 1
# Test 4: Apply profile
if profile_id:
total += 1
response = client.post(f'/profiles/{profile_id}/apply')
if response.status_code == 200:
print(f"✓ POST /profiles/{profile_id}/apply - Applied profile")
passed += 1
else:
print(f"✗ POST /profiles/{profile_id}/apply - Status: {response.status_code}")
# Test 5: Delete profile
total += 1
response = client.delete(f'/profiles/{profile_id}')
if response.status_code == 200:
print(f"✓ DELETE /profiles/{profile_id} - Deleted profile")
passed += 1
else:
print(f"✗ DELETE /profiles/{profile_id} - Status: {response.status_code}")
else:
print(f"✗ POST /profiles - Status: {response.status_code}")
except Exception as e:
print(f"✗ POST /profiles - Error: {e}")
print(f"\nProfiles tests: {passed}/{total} passed")
return passed == total
def test_presets(client: TestClient) -> bool:
"""Test presets endpoints."""
print("\n=== Testing Presets Endpoints ===")
passed = 0
total = 0
# Test 1: List presets
total += 1
try:
response = client.get('/presets')
if response.status_code == 200:
data = response.json()
preset_count = len(data) if isinstance(data, dict) else 0
print(f"✓ GET /presets - Found {preset_count} presets")
passed += 1
else:
print(f"✗ GET /presets - Status: {response.status_code}")
except Exception as e:
print(f"✗ GET /presets - Error: {e}")
# Test 2: Create preset
total += 1
try:
preset_data = {
"name": "Test Preset",
"pattern": "on",
"colors": ["#ff0000"],
"brightness": 200
}
response = client.post('/presets', json_data=preset_data)
if response.status_code == 201:
created = response.json()
# Response format: {preset_id: {preset_data}}
if isinstance(created, dict):
preset_id = next(iter(created.keys())) if created else None
else:
preset_id = None
print(f"✓ POST /presets - Created preset: {preset_id}")
passed += 1
# Test 3: Get specific preset
if preset_id:
total += 1
response = client.get(f'/presets/{preset_id}')
if response.status_code == 200:
print(f"✓ GET /presets/{preset_id} - Retrieved preset")
passed += 1
else:
print(f"✗ GET /presets/{preset_id} - Status: {response.status_code}")
# Test 4: Update preset
total += 1
update_data = {"brightness": 150}
response = client.put(f'/presets/{preset_id}', json_data=update_data)
if response.status_code == 200:
print(f"✓ PUT /presets/{preset_id} - Updated preset")
passed += 1
else:
print(f"✗ PUT /presets/{preset_id} - Status: {response.status_code}")
# Test 5: Send preset via /presets/send
total += 1
try:
send_body = {"preset_ids": [preset_id]}
response = client.post('/presets/send', json_data=send_body)
if response.status_code == 200:
data = response.json()
sent = data.get('presets_sent')
print(f"✓ POST /presets/send - Sent presets (presets_sent={sent})")
passed += 1
else:
print(f"✗ POST /presets/send - Status: {response.status_code}, Response: {response.text}")
except Exception as e:
print(f"✗ POST /presets/send - Error: {e}")
# Test 6: Delete preset
total += 1
response = client.delete(f'/presets/{preset_id}')
if response.status_code == 200:
print(f"✓ DELETE /presets/{preset_id} - Deleted preset")
passed += 1
else:
print(f"✗ DELETE /presets/{preset_id} - Status: {response.status_code}")
else:
print(f"✗ POST /presets - Status: {response.status_code}")
except Exception as e:
print(f"✗ POST /presets - Error: {e}")
print(f"\nPresets tests: {passed}/{total} passed")
return passed == total
def test_patterns(client: TestClient) -> bool:
"""Test patterns endpoints."""
print("\n=== Testing Patterns Endpoints ===")
passed = 0
total = 0
# Test 1: List patterns
total += 1
try:
response = client.get('/patterns')
if response.status_code == 200:
data = response.json()
pattern_count = len(data) if isinstance(data, dict) else 0
print(f"✓ GET /patterns - Found {pattern_count} patterns")
passed += 1
else:
print(f"✗ GET /patterns - Status: {response.status_code}")
except Exception as e:
print(f"✗ GET /patterns - Error: {e}")
# Test 2: Get pattern definitions
total += 1
try:
response = client.get('/patterns/definitions')
if response.status_code == 200:
data = response.json()
print(f"✓ GET /patterns/definitions - Retrieved definitions")
passed += 1
else:
print(f"✗ GET /patterns/definitions - Status: {response.status_code}")
except Exception as e:
print(f"✗ GET /patterns/definitions - Error: {e}")
print(f"\nPatterns tests: {passed}/{total} passed")
return passed == total
def test_tab_edit_workflow(client: TestClient) -> bool:
"""Test complete tab edit workflow like a browser would."""
print("\n=== Testing Tab Edit Workflow ===")
passed = 0
total = 0
# Step 1: Create a tab to edit
total += 1
try:
tab_data = {
"name": "Tab to Edit",
"names": ["1"]
}
response = client.post('/tabs', json_data=tab_data)
if response.status_code == 201:
created = response.json()
if isinstance(created, dict):
tab_id = next(iter(created.keys())) if created else None
else:
tab_id = None
if tab_id:
print(f"✓ Created tab {tab_id} for editing")
passed += 1
# Step 2: Get the tab to verify initial state
total += 1
response = client.get(f'/tabs/{tab_id}')
if response.status_code == 200:
original_tab = response.json()
print(f"✓ Retrieved tab - Name: '{original_tab.get('name')}', IDs: {original_tab.get('names')}")
passed += 1
# Step 3: Edit the tab (simulate browser edit form submission)
total += 1
edit_data = {
"name": "Edited Tab Name",
"names": ["2", "3", "4"]
}
response = client.put(f'/tabs/{tab_id}', json_data=edit_data)
if response.status_code == 200:
edited = response.json()
if edited.get('name') == "Edited Tab Name" and edited.get('names') == ["2", "3", "4"]:
print(f"✓ PUT /tabs/{tab_id} - Successfully edited tab")
print(f" New name: '{edited.get('name')}'")
print(f" New device IDs: {edited.get('names')}")
passed += 1
else:
print(f"✗ PUT /tabs/{tab_id} - Edit didn't work correctly")
print(f" Got: {edited}")
else:
print(f"✗ PUT /tabs/{tab_id} - Status: {response.status_code}, Response: {response.text}")
# Step 4: Verify edit persisted by getting the tab again
total += 1
response = client.get(f'/tabs/{tab_id}')
if response.status_code == 200:
verified = response.json()
if verified.get('name') == "Edited Tab Name" and verified.get('names') == ["2", "3", "4"]:
print(f"✓ GET /tabs/{tab_id} - Verified edit persisted")
passed += 1
else:
print(f"✗ GET /tabs/{tab_id} - Edit didn't persist")
print(f" Expected name='Edited Tab Name', got '{verified.get('name')}'")
print(f" Expected names=['2','3','4'], got {verified.get('names')}")
else:
print(f"✗ GET /tabs/{tab_id} - Status: {response.status_code}")
# Step 5: Clean up - delete the test tab
total += 1
response = client.delete(f'/tabs/{tab_id}')
if response.status_code == 200:
print(f"✓ DELETE /tabs/{tab_id} - Cleaned up test tab")
passed += 1
else:
print(f"✗ DELETE /tabs/{tab_id} - Status: {response.status_code}")
else:
print(f"✗ Failed to extract tab ID from create response")
else:
print(f"✗ POST /tabs - Status: {response.status_code}, Response: {response.text}")
except Exception as e:
print(f"✗ Tab edit workflow - Error: {e}")
import traceback
traceback.print_exc()
print(f"\nTab edit workflow tests: {passed}/{total} passed")
return passed == total
def test_static_files(client: TestClient) -> bool:
"""Test static file serving."""
print("\n=== Testing Static Files ===")
passed = 0
total = 0
static_files = [
'/static/style.css',
'/static/app.js',
'/static/tabs.js',
'/static/presets.js',
'/static/profiles.js',
]
for file_path in static_files:
total += 1
try:
response = client.get(file_path)
if response.status_code == 200:
print(f"✓ GET {file_path} - Retrieved")
passed += 1
else:
print(f"✗ GET {file_path} - Status: {response.status_code}")
except Exception as e:
print(f"✗ GET {file_path} - Error: {e}")
print(f"\nStatic files tests: {passed}/{total} passed")
return passed == total
def main():
"""Run all endpoint tests."""
print("=" * 60)
print("LED Controller Endpoint Tests")
print(f"Testing against: {BASE_URL}")
print("=" * 60)
client = TestClient()
# Test connection first
if not test_connection(client):
print("\n✗ Cannot connect to device. Exiting.")
sys.exit(1)
results = []
# Run all tests
results.append(("Tabs", test_tabs(client)))
results.append(("Tab Edit Workflow", test_tab_edit_workflow(client)))
results.append(("Profiles", test_profiles(client)))
results.append(("Presets", test_presets(client)))
results.append(("Patterns", test_patterns(client)))
results.append(("Static Files", test_static_files(client)))
# Summary
print("\n" + "=" * 60)
print("Test Summary")
print("=" * 60)
all_passed = True
for name, passed in results:
status = "✓ PASS" if passed else "✗ FAIL"
print(f"{status} - {name}")
if not passed:
all_passed = False
print("=" * 60)
if all_passed:
print("✓ All tests passed!")
sys.exit(0)
else:
print("✗ Some tests failed")
sys.exit(1)
if __name__ == "__main__":
main()