13 Commits

45 changed files with 1806 additions and 485 deletions

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

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

View File

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

1
.gitignore vendored
View File

@@ -23,6 +23,7 @@ ENV/
Thumbs.db
# Project specific
docs/.help-print.html
settings.json
*.log
*.db

View File

@@ -15,6 +15,7 @@ adafruit-ampy = "*"
microdot = "*"
[dev-packages]
pytest = "*"
[requires]
python_version = "3.12"
@@ -25,3 +26,4 @@ watch = "python -m watchfiles 'python tests/web.py' src tests"
install = "pipenv install"
run = "sh -c 'cd src && python main.py'"
dev = "watchfiles \"sh -c 'cd src && python main.py'\" src"
help-pdf = "sh scripts/build_help_pdf.sh"

303
Pipfile.lock generated
View File

@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "12b64c3bf5857d958f790f2416072408e2244631242ba2598210d89df330e184"
"sha256": "6cec0fe6dec67c9177363a558131f333153b6caa47e1ddeca303cb0d19954cf8"
},
"pipfile-spec": 6,
"requires": {
@@ -26,19 +26,19 @@
},
"anyio": {
"hashes": [
"sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703",
"sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"
"sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708",
"sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc"
],
"markers": "python_version >= '3.9'",
"version": "==4.12.1"
"markers": "python_version >= '3.10'",
"version": "==4.13.0"
},
"attrs": {
"hashes": [
"sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11",
"sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"
"sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309",
"sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"
],
"markers": "python_version >= '3.9'",
"version": "==25.4.0"
"version": "==26.1.0"
},
"bitarray": {
"hashes": [
@@ -257,122 +257,138 @@
},
"charset-normalizer": {
"hashes": [
"sha256:014837af6fabf57121b6254fa8ade10dceabc3528b27b721a64bbc7b8b1d4eb4",
"sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66",
"sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54",
"sha256:02a9d1b01c1e12c27883b0c9349e0bcd9ae92e727ff1a277207e1a262b1cbf05",
"sha256:036c079aa08a6a592b82487f97c60b439428320ed1b2ea0b3912e99d30c77765",
"sha256:039215608ac7b358c4da0191d10fc76868567fbf276d54c14721bdedeb6de064",
"sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819",
"sha256:0a45e504f5e1be0bd385935a8e1507c442349ca36f511a47057a71c9d1d6ea9e",
"sha256:0b362bcd27819f9c07cbf23db4e0e8cd4b44c5ecd900c2ff907b2b92274a7412",
"sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc",
"sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e",
"sha256:10b473fc8dca1c3ad8559985794815f06ca3fc71942c969129070f2c3cdf7281",
"sha256:131716d6786ad5e3dc542f5cc6f397ba3339dc0fb87f87ac30e550e8987756af",
"sha256:14498a429321de554b140013142abe7608f9d8ccc04d7baf2ad60498374aefa2",
"sha256:149ec69866c3d6c2fb6f758dbc014ecb09f30b35a5ca90b6a8a2d4e54e18fdfe",
"sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8",
"sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262",
"sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac",
"sha256:1a374cc0b88aa710e8865dc1bd6edb3743c59f27830f0293ab101e4cf3ce9f85",
"sha256:1d1401945cb77787dbd3af2446ff2d75912327c4c3a1526ab7955ecf8600687c",
"sha256:1f2da5cbb9becfcd607757a169e38fb82aa5fd86fae6653dea716e7b613fe2cf",
"sha256:259cd1ca995ad525f638e131dbcc2353a586564c038fc548a3fe450a91882139",
"sha256:2820a98460c83663dd8ec015d9ddfd1e4879f12e06bb7d0500f044fb477d2770",
"sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d",
"sha256:2b970382e4a36bed897c19f310f31d7d13489c11b4f468ddfba42d41cddfb918",
"sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3",
"sha256:30987f4a8ed169983f93e1be8ffeea5214a779e27ed0b059835c7afe96550ad7",
"sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39",
"sha256:340810d34ef83af92148e96e3e44cb2d3f910d2bf95e5618a5c467d9f102231d",
"sha256:3f64c6bf8f32f9133b668c7f7a7cbdbc453412bc95ecdbd157f3b1e377a92990",
"sha256:4167a621a9a1a986c73777dbc15d4b5eac8ac5c10393374109a343d4013ec765",
"sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1",
"sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa",
"sha256:4b8551b6e6531e156db71193771c93bda78ffc4d1e6372517fe58ad3b91e4659",
"sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d",
"sha256:50bcbca6603c06a1dcc7b056ed45c37715fb5d2768feb3bcd37d2313c587a5b9",
"sha256:530beedcec9b6e027e7a4b6ce26eed36678aa39e17da85e6e03d7bd9e8e9d7c9",
"sha256:568e3c34b58422075a1b49575a6abc616d9751b4d61b23f712e12ebb78fe47b2",
"sha256:573ef5814c4b7c0d59a7710aa920eaaaef383bd71626aa420fba27b5cab92e8d",
"sha256:58ad8270cfa5d4bef1bc85bd387217e14ff154d6630e976c6f56f9a040757475",
"sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c",
"sha256:5bcb3227c3d9aaf73eaaab1db7ccd80a8995c509ee9941e2aae060ca6e4e5d81",
"sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67",
"sha256:5fea359734b140d0d6741189fea5478c6091b54ffc69d7ce119e0a05637d8c99",
"sha256:60d68e820af339df4ae8358c7a2e7596badeb61e544438e489035f9fbf3246a5",
"sha256:610f72c0ee565dfb8ae1241b666119582fdbfe7c0975c175be719f940e110694",
"sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf",
"sha256:65b3c403a5b6b8034b655e7385de4f72b7b244869a22b32d4030b99a60593eca",
"sha256:66dee73039277eb35380d1b82cccc69cc82b13a66f9f4a18da32d573acf02b7c",
"sha256:708c7acde173eedd4bfa4028484426ba689d2103b28588c513b9db2cd5ecde9c",
"sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636",
"sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f",
"sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02",
"sha256:75ee9c1cce2911581a70a3c0919d8bccf5b1cbc9b0e5171400ec736b4b569497",
"sha256:76a9d0de4d0eab387822e7b35d8f89367dd237c72e82ab42b9f7bf5e15ada00f",
"sha256:77be992288f720306ab4108fe5c74797de327f3248368dfc7e1a916d6ed9e5a2",
"sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d",
"sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873",
"sha256:82cc7c2ad42faec8b574351f8bc2a0c049043893853317bd9bb309f5aba6cb5a",
"sha256:8a28afb04baa55abf26df544e3e5c6534245d3daa5178bc4a8eeb48202060d0e",
"sha256:8b78d8a609a4b82c273257ee9d631ded7fac0d875bdcdccc109f3ee8328cfcb1",
"sha256:8ce11cd4d62d11166f2b441e30ace226c19a3899a7cf0796f668fba49a9fb123",
"sha256:8fff79bf5978c693c9b1a4d71e4a94fddfb5fe744eb062a318e15f4a2f63a550",
"sha256:92263f7eca2f4af326cd20de8d16728d2602f7cfea02e790dcde9d83c365d7cc",
"sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36",
"sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644",
"sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4",
"sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0",
"sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e",
"sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f",
"sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4",
"sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98",
"sha256:aa2f963b4da26daf46231d9b9e0e2c9408a751f8f0d0f44d2de56d3caf51d294",
"sha256:aa92ec1102eaff840ccd1021478af176a831f1bccb08e526ce844b7ddda85c22",
"sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23",
"sha256:ae8b03427410731469c4033934cf473426faff3e04b69d2dfb64a4281a3719f8",
"sha256:afca7f78067dd27c2b848f1b234623d26b87529296c6c5652168cc1954f2f3b2",
"sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362",
"sha256:b3e71afc578b98512bfe7bdb822dd6bc57d4b0093b4b6e5487c1e96ad4ace242",
"sha256:ba20bdf69bd127f66d0174d6f2a93e69045e0b4036dc1ca78e091bcc765830c4",
"sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95",
"sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d",
"sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94",
"sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6",
"sha256:c7e84e0c0005e3bdc1a9211cd4e62c78ba80bc37b2365ef4410cd2007a9047f2",
"sha256:cace89841c0599d736d3d74a27bc5821288bb47c5441923277afc6059d7fbcb4",
"sha256:cd2d0f0ec9aa977a27731a3209ebbcacebebaf41f902bd453a928bfd281cf7f8",
"sha256:d01de5e768328646e6a3fa9e562706f8f6641708c115c62588aef2b941a4f88e",
"sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a",
"sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce",
"sha256:d29dd9c016f2078b43d0c357511e87eee5b05108f3dd603423cb389b89813969",
"sha256:d31f0d1671e1534e395f9eb84a68e0fb670e1edb1fe819a9d7f564ae3bc4e53f",
"sha256:d4eb8ac7469b2a5d64b5b8c04f84d8bf3ad340f4514b98523805cbf46e3b3923",
"sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6",
"sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee",
"sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6",
"sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467",
"sha256:e09f671a54ce70b79a1fc1dc6da3072b7ef7251fadb894ed92d9aa8218465a5f",
"sha256:e22d1059b951e7ae7c20ef6b06afd10fb95e3c41bf3c4fbc874dba113321c193",
"sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7",
"sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9",
"sha256:e545b51da9f9af5c67815ca0eb40676c0f016d0b0381c86f20451e35696c5f95",
"sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763",
"sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7",
"sha256:ec56a2266f32bc06ed3c3e2a8f58417ce02f7e0356edc89786e52db13c593c98",
"sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60",
"sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade",
"sha256:ed98364e1c262cf5f9363c3eca8c2df37024f52a8fa1180a3610014f26eac51c",
"sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2",
"sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f",
"sha256:f8102ae93c0bc863b1d41ea0f4499c20a83229f52ed870850892df555187154a",
"sha256:fc1c64934b8faf7584924143eb9db4770bbdb16659626e1a1a4d9efbcb68d947",
"sha256:ff95a9283de8a457e6b12989de3f9f5193430f375d64297d323a615ea52cbdb3"
"sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e",
"sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c",
"sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5",
"sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815",
"sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f",
"sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0",
"sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484",
"sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407",
"sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6",
"sha256:1cf0a70018692f85172348fe06d3a4b63f94ecb055e13a00c644d368eb82e5b8",
"sha256:1ed80ff870ca6de33f4d953fda4d55654b9a2b340ff39ab32fa3adbcd718f264",
"sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815",
"sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2",
"sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4",
"sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579",
"sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f",
"sha256:2bd9d128ef93637a5d7a6af25363cf5dec3fa21cf80e68055aad627f280e8afa",
"sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95",
"sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab",
"sha256:2f7fdd9b6e6c529d6a2501a2d36b240109e78a8ceaef5687cfcfa2bbe671d297",
"sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a",
"sha256:31215157227939b4fb3d740cd23fe27be0439afef67b785a1eb78a3ae69cba9e",
"sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84",
"sha256:3516bbb8d42169de9e61b8520cbeeeb716f12f4ecfe3fd30a9919aa16c806ca8",
"sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0",
"sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9",
"sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f",
"sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1",
"sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843",
"sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565",
"sha256:461598cd852bfa5a61b09cae2b1c02e2efcd166ee5516e243d540ac24bfa68a7",
"sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c",
"sha256:48696db7f18afb80a068821504296eb0787d9ce239b91ca15059d1d3eaacf13b",
"sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7",
"sha256:4d1d02209e06550bdaef34af58e041ad71b88e624f5d825519da3a3308e22687",
"sha256:4f41da960b196ea355357285ad1316a00099f22d0929fe168343b99b254729c9",
"sha256:517ad0e93394ac532745129ceabdf2696b609ec9f87863d337140317ebce1c14",
"sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89",
"sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f",
"sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0",
"sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9",
"sha256:54fae94be3d75f3e573c9a1b5402dc593de19377013c9a0e4285e3d402dd3a2a",
"sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389",
"sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0",
"sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30",
"sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd",
"sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e",
"sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9",
"sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc",
"sha256:659a1e1b500fac8f2779dd9e1570464e012f43e580371470b45277a27baa7532",
"sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d",
"sha256:69dd852c2f0ad631b8b60cfbe25a28c0058a894de5abb566619c205ce0550eae",
"sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2",
"sha256:71be7e0e01753a89cf024abf7ecb6bca2c81738ead80d43004d9b5e3f1244e64",
"sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f",
"sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557",
"sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e",
"sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff",
"sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398",
"sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db",
"sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a",
"sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43",
"sha256:802168e03fba8bbc5ce0d866d589e4b1ca751d06edee69f7f3a19c5a9fe6b597",
"sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c",
"sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e",
"sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2",
"sha256:8761ac29b6c81574724322a554605608a9960769ea83d2c73e396f3df896ad54",
"sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e",
"sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4",
"sha256:8bc5f0687d796c05b1e28ab0d38a50e6309906ee09375dd3aff6a9c09dd6e8f4",
"sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7",
"sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6",
"sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5",
"sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194",
"sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69",
"sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f",
"sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316",
"sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e",
"sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73",
"sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8",
"sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923",
"sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88",
"sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f",
"sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21",
"sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4",
"sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6",
"sha256:ab30e5e3e706e3063bc6de96b118688cb10396b70bb9864a430f67df98c61ecc",
"sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2",
"sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866",
"sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021",
"sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2",
"sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d",
"sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8",
"sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de",
"sha256:bf625105bb9eef28a56a943fec8c8a98aeb80e7d7db99bd3c388137e6eb2d237",
"sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4",
"sha256:c45a03a4c69820a399f1dda9e1d8fbf3562eda46e7720458180302021b08f778",
"sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb",
"sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc",
"sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602",
"sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4",
"sha256:d08ec48f0a1c48d75d0356cea971921848fb620fdeba805b28f937e90691209f",
"sha256:d1a2ee9c1499fc8f86f4521f27a973c914b211ffa87322f4ee33bb35392da2c5",
"sha256:d5f5d1e9def3405f60e3ca8232d56f35c98fb7bf581efcc60051ebf53cb8b611",
"sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8",
"sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf",
"sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d",
"sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b",
"sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db",
"sha256:df01808ee470038c3f8dc4f48620df7225c49c2d6639e38f96e6d6ac6e6f7b0e",
"sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077",
"sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd",
"sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef",
"sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e",
"sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8",
"sha256:e8aeb10fcbe92767f0fa69ad5a72deca50d0dca07fbde97848997d778a50c9fe",
"sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058",
"sha256:ecbbd45615a6885fe3240eb9db73b9e62518b611850fdf8ab08bd56de7ad2b17",
"sha256:ee4ec14bc1680d6b0afab9aea2ef27e26d2024f18b24a2d7155a52b60da7e833",
"sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421",
"sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550",
"sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff",
"sha256:f50498891691e0864dc3da965f340fada0771f6142a378083dc4608f4ea513e2",
"sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc",
"sha256:f61aa92e4aad0be58eb6eb4e0c21acf32cf8065f4b2cae5665da756c4ceef982",
"sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d",
"sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed",
"sha256:f98059e4fcd3e3e4e2d632b7cf81c2faae96c43c60b569e9c621468082f1d104",
"sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659"
],
"markers": "python_version >= '3.7'",
"version": "==3.4.5"
"version": "==3.4.6"
},
"click": {
"hashes": [
@@ -897,5 +913,46 @@
"version": "==1.3.2"
}
},
"develop": {}
"develop": {
"iniconfig": {
"hashes": [
"sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730",
"sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"
],
"markers": "python_version >= '3.10'",
"version": "==2.3.0"
},
"packaging": {
"hashes": [
"sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4",
"sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"
],
"markers": "python_version >= '3.8'",
"version": "==26.0"
},
"pluggy": {
"hashes": [
"sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3",
"sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"
],
"markers": "python_version >= '3.9'",
"version": "==1.6.0"
},
"pygments": {
"hashes": [
"sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887",
"sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"
],
"markers": "python_version >= '3.8'",
"version": "==2.19.2"
},
"pytest": {
"hashes": [
"sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b",
"sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"
],
"index": "pypi",
"version": "==9.0.2"
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,22 @@ All JSON APIs use `Content-Type: application/json` for bodies and responses unle
---
## UI behavior notes
The main UI has two modes controlled by the mode toggle:
- **Run mode**: optimized for operation (tab/preset selection and profile apply).
- **Edit mode**: shows editing/management controls (tabs, presets, patterns, colour palette, send presets, and profile management actions).
Profiles are available in both modes, but behavior differs:
- **Run mode**: profile **apply** only.
- **Edit mode**: profile **create/clone/delete/apply**.
`POST /presets/send` is wired to the **Send Presets** UI action, which is exposed in Edit mode.
---
## Session and scoping
Several routes use **`@with_session`**: the server stores a **current profile** in the session (cookie). Endpoints that scope data to “the current profile” (notably **`/presets`**) only return or mutate presets whose `profile_id` matches that session value.
@@ -61,7 +77,7 @@ Below, `<id>` values are string identifiers used by the JSON stores (numeric str
| GET | `/profiles` | `{"profiles": {...}, "current_profile_id": "<id>"}`. Ensures a default current profile when possible. |
| GET | `/profiles/current` | `{"id": "...", "profile": {...}}` |
| GET | `/profiles/<id>` | Single profile. If `<id>` is `current`, same as `/profiles/current`. |
| POST | `/profiles` | Create profile. Body may include `name` and other fields. Returns `{ "<id>": { ... } }` with status 201. |
| POST | `/profiles` | Create profile. Body may include `name` and other fields. Optional `seed_dj_tab` (request-only) seeds a DJ tab + presets. New profiles always get a populated `default` tab. Returns `{ "<id>": { ... } }` with status 201. |
| POST | `/profiles/<id>/apply` | Sets session current profile to `<id>`. |
| POST | `/profiles/<id>/clone` | Clone profile (tabs, palettes, presets). Body may include `name`. |
| PUT | `/profiles/current` | Update the current profile (from session). |
@@ -99,6 +115,11 @@ Scoped to **current profile** in session (see above).
Response on success includes `presets_sent`, `messages_sent` (chunking splits payloads so each JSON string stays ≤ 240 bytes).
Stored preset records can include:
- `colors`: resolved hex colours for editor/display.
- `palette_refs`: optional array of palette indexes parallel to `colors`. If a slot contains an integer index, the colour is linked to the current profile palette at that index.
### Tabs — `/tabs`
| Method | Path | Description |
@@ -116,10 +137,10 @@ Response on success includes `presets_sent`, `messages_sent` (chunking splits pa
| Method | Path | Description |
|--------|------|-------------|
| GET | `/palettes` | Map of id → color list. |
| GET | `/palettes` | Map of id → colour list. |
| GET | `/palettes/<id>` | `{"colors": [...], "id": "<id>"}` |
| POST | `/palettes` | Body may include `colors`. Returns palette object with `id`, 201. |
| PUT | `/palettes/<id>` | Update colors (`name` ignored). |
| PUT | `/palettes/<id>` | Update colours (`name` ignored). |
| DELETE | `/palettes/<id>` | Delete palette. |
### Groups — `/groups`
@@ -196,7 +217,7 @@ On the wire, presets use **short keys** (saves space in the ≤240-byte chunks):
| Key | Meaning | Notes |
|-----|---------|--------|
| `p` | Pattern id | `off`, `on`, `blink`, `rainbow`, `pulse`, `transition`, `chase`, `circle` |
| `c` | Colors | Array of `"#RRGGBB"` hex strings; converted to RGB on device |
| `c` | Colours | Array of `"#RRGGBB"` hex strings; converted to RGB on device |
| `d` | Delay ms | Default 100 |
| `b` | Preset brightness | 0255; combined with global `b` on the device |
| `a` | Auto | `true`: run continuously; `false`: one step/cycle per “beat” |
@@ -207,7 +228,7 @@ The HTTP apps **`POST /presets/send`** path builds this from stored presets v
### Pattern-specific parameters (`n1``n6`)
#### Rainbow
- **`n1`**: Step increment on the color wheel per update (default 1).
- **`n1`**: Step increment on the colour wheel per update (default 1).
#### Pulse
- **`n1`**: Attack (fade in) ms
@@ -219,8 +240,8 @@ The HTTP apps **`POST /presets/send`** path builds this from stored presets v
- **`d`**: Transition duration ms
#### Chase
- **`n1`**: LEDs with first color
- **`n2`**: LEDs with second color
- **`n1`**: LEDs with first colour
- **`n2`**: LEDs with second colour
- **`n3`**: Movement on even steps (may be negative)
- **`n4`**: Movement on odd steps (may be negative)
@@ -278,7 +299,7 @@ The HTTP apps **`POST /presets/send`** path builds this from stored presets v
1. Reject if `v != "1"`.
2. Apply optional top-level **`b`** (global brightness).
3. For each entry in **`presets`**, normalize colors and upsert preset by id.
3. For each entry in **`presets`**, normalize colours and upsert preset by id.
4. If this devices **`name`** appears in **`select`**, run selection (optional step).
5. If **`default`** is set, store startup preset id.
6. If **`save`** is set, persist presets.

View File

@@ -44,7 +44,7 @@ The LED Driver system is a MicroPython-based application for controlling LED str
- Pattern configuration and control (patterns run on remote devices)
- Real-time brightness and speed control
- Global brightness setting (system-wide brightness multiplier)
- Multi-color support with customizable color palettes
- Multi-colour support with customizable colour palettes
- Device grouping for synchronized control
- Preset system for saving and loading pattern configurations
- Profile and Scene system for complex lighting setups
@@ -239,7 +239,7 @@ Primary interface for real-time LED control and monitoring.
- **Grid Layout:** 4-column responsive grid
- Pattern Selection Card
- Brightness & Speed Card
- Color Selection Card
- Colour Selection Card
- Device Status Card
- **Action Bar:** Apply and Save buttons
@@ -273,12 +273,12 @@ Primary interface for real-time LED control and monitoring.
- **Default:** 100ms
- **Step:** 10ms increments
**Color Selection**
- **Type:** Color picker inputs (HTML5 color input)
- **Quantity:** Multiple colors (minimum 2, expandable)
- **Format:** Hex color codes (e.g., #FF0000)
- **Display:** Large color swatches (60x60px)
- **Action:** "Add Color" button for additional colors
**Colour Selection**
- **Type:** Colour picker inputs (HTML5 colour input)
- **Quantity:** Multiple colours (minimum 2, expandable)
- **Format:** Hex colour codes (e.g., #FF0000)
- **Display:** Large colour swatches (60x60px)
- **Action:** "Add Colour" button for additional colours
**Device Status List**
- **Type:** List of connected devices
@@ -295,7 +295,7 @@ Primary interface for real-time LED control and monitoring.
- **Save to Device:** Persist settings to device storage
#### Design Specifications
- **Color Scheme:** Purple gradient background (#667eea to #764ba2)
- **Colour Scheme:** Purple gradient background (#667eea to #764ba2)
- **Cards:** White background, rounded corners (12px), shadow
- **Hover Effects:** Card lift (translateY -2px), increased shadow
- **Typography:** System font stack, 1.25rem headings
@@ -509,7 +509,7 @@ Comprehensive device configuration interface.
- Device Name (text input)
- LED Pin (number input, 0-40)
- Number of LEDs (number input, 1-1000)
- Color Order (visual selector with 6 options: RGB, RBG, GRB, GBR, BRG, BGR)
- Colour Order (visual selector with 6 options: RGB, RBG, GRB, GBR, BRG, BGR)
**2. Pattern Settings**
- Pattern (dropdown selection)
@@ -577,16 +577,16 @@ Comprehensive device configuration interface.
- Range: Slider with real-time value display
- Select: Dropdown menu
- Checkbox: Toggle switch
- Color: HTML5 color picker
- Colour: HTML5 colour picker
**Color Order Selector**
**Colour Order Selector**
- **Type:** Visual button grid
- **Options:** RGB, RBG, GRB, GBR, BRG, BGR
- **Display:** Color boxes showing order (R=red, G=green, B=blue)
- **Display:** Colour boxes showing order (R=red, G=green, B=blue)
- **Selection:** Single selection with visual feedback
#### Design Specifications
- **Section Headers:** Purple color (#667eea), 1.5rem font, bottom border
- **Section Headers:** Purple colour (#667eea), 1.5rem font, bottom border
- **Form Groups:** 24px spacing between fields
- **Labels:** Bold, 500 weight, dark gray (#333)
- **Help Text:** Small gray text below inputs
@@ -611,7 +611,7 @@ Save, load, and manage preset configurations for quick pattern switching.
Each preset card displays:
- **Name:** Preset name (bold, 1.25rem)
- **Pattern Badge:** Current pattern type
- **Color Preview:** Swatches showing preset colors
- **Colour Preview:** Swatches showing preset colours
- **Quick Info:** Delay and brightness values
- **Actions:** Apply, Edit, Delete buttons
@@ -620,7 +620,7 @@ Each preset card displays:
**Fields:**
- Preset Name (text input, required)
- Pattern (dropdown selection)
- Colors (multiple color pickers, minimum 2)
- Colours (multiple colour pickers, minimum 2)
- Delay (slider, 10-1000ms)
- Step Offset (number input, optional, default: 0)
- Tooltip: "Step offset for group synchronization. Applied per device when preset is used in a group."
@@ -667,7 +667,7 @@ Each preset card displays:
#### Design Specifications
- **Card Style:** White background, rounded corners, shadow
- **Pattern Badge:** Colored pill with pattern name
- **Color Swatches:** 40x40px squares in card header
- **Colour Swatches:** 40x40px squares in card header
- **Hover Effect:** Card lift, border highlight
- **Selected State:** Purple border, subtle background tint
@@ -681,7 +681,7 @@ Patterns are configured on the controller and sent to remote devices for executi
- **Pattern Type:** Identifier for the pattern (e.g., "on", "off", "blink", "chase", "pulse", "rainbow", etc.)
- **Pattern Parameters:** Numeric parameters (N1-N8) that configure pattern-specific behavior
- **Colors:** Color palette for the pattern
- **Colours:** Colour palette for the pattern
- **Timing:** Delay and speed settings
**Note:** Pattern execution happens on the remote devices. The controller sends configuration commands to devices.
@@ -698,7 +698,7 @@ Pattern-specific numeric parameters:
#### Overview
Presets allow users to save complete pattern configurations for quick recall and application. A preset encapsulates all pattern settings including pattern type, colors, timing, and all pattern parameters.
Presets allow users to save complete pattern configurations for quick recall and application. A preset encapsulates all pattern settings including pattern type, colours, timing, and all pattern parameters.
**Note:** Presets are optional. Devices can be controlled directly without presets.
@@ -708,7 +708,7 @@ A preset contains the following fields:
- **name** (string, required): Unique identifier for the preset
- **pattern** (string, required): Pattern type identifier (sent to remote devices)
- **colors** (array of strings, required): Array of hex color codes (minimum 2 colors)
- **colours** (array of strings, required): Array of hex colour codes (minimum 2 colours)
- **delay** (integer, required): Delay in milliseconds (10-1000)
- **n1** (integer, optional): Pattern parameter 1 (0-255, default: 0)
- **n2** (integer, optional): Pattern parameter 2 (0-255, default: 0)
@@ -889,7 +889,7 @@ A preset contains the following fields:
#### Group Properties
- **Name:** Unique group identifier
- **Devices:** List of device names (can include master and/or slaves)
- **Settings:** Pattern, delay, colors
- **Settings:** Pattern, delay, colours
- **Step Offset:** Per-device step offset sent to devices for synchronized patterns (integer, can be negative)
- Each device in group can receive different step offset
- Creates wave/chase effect across multiple LED strips
@@ -953,7 +953,7 @@ Byte 1: Flags (bit 0: names, bit 1: groups, bit 2: settings, bit 3: save)
|-----|------|-------------|--------------|
| `pt` | string | Pattern type | on, off, blink, chase, circle, pulse, rainbow, transition |
| `pm` | string | Pattern mode | auto, single_shot |
| `cl` | array | Colors (hex strings) | Array of hex color codes |
| `cl` | array | Colours (hex strings) | Array of hex colour codes |
| `br` | int | Global brightness | 0-100 |
| `dl` | int | Delay (ms) | 10-1000 |
| `n1` | int | Parameter 1 | 0-255 |
@@ -966,7 +966,7 @@ Byte 1: Flags (bit 0: names, bit 1: groups, bit 2: settings, bit 3: save)
| `n8` | int | Parameter 8 | 0-255 |
| `led_pin` | int | GPIO pin | 0-40 |
| `num_leds` | int | LED count | 1-1000 |
| `color_order` | string | Color order | rgb, rbg, grb, gbr, brg, bgr |
| `color_order` | string | Colour order | rgb, rbg, grb, gbr, brg, bgr |
| `name` | string | Device name | Any string |
| `brightness` | int | Global brightness | 0-100 |
| `delay` | int | Delay | 10-1000 |
@@ -1247,7 +1247,7 @@ CREATE TABLE IF NOT EXISTS presets (
**Preset Fields:**
- `name` (string, required): Unique preset identifier
- `pattern` (string, required): Pattern type
- `colors` (array of strings, required): Hex color codes (minimum 2)
- `colors` (array of strings, required): Hex colour codes (minimum 2)
- `delay` (integer, required): Delay in milliseconds (10-1000)
- `n1` through `n8` (integer, optional): Pattern parameters (0-255, default: 0)
@@ -1289,7 +1289,7 @@ CREATE TABLE IF NOT EXISTS presets (
**POST /api/presets**
- Create a new preset
- Body: Preset object (name, pattern, colors, delay, n1-n8)
- Body: Preset object (name, pattern, colours, delay, n1-n8)
- Response: Created preset object
**GET /api/presets/{name}**
@@ -1506,7 +1506,7 @@ peak_mem = usqlite.mem_peak()
1. User navigates to Settings page
2. User modifies settings in sections:
- Basic Settings (pin, LED count, color order)
- Basic Settings (pin, LED count, colour order)
- Pattern Settings (pattern, delay)
- Global Brightness
- Advanced Settings (N1-N8 parameters)
@@ -1519,7 +1519,7 @@ peak_mem = usqlite.mem_peak()
### Flow 4: Multi-Device Control
1. User selects multiple devices or a group
2. User changes pattern/colors/global brightness
2. User changes pattern/colours/global brightness
3. User clicks "Apply Settings"
4. System sends message targeting selected devices/groups
5. All targeted devices update simultaneously
@@ -1585,7 +1585,7 @@ peak_mem = usqlite.mem_peak()
## Design Guidelines
### Color Palette
### Colour Palette
- **Primary:** Purple gradient `#667eea` to `#764ba2`, Light gradient `#f5f7fa` to `#c3cfe2`
- **UI:** Primary `#667eea`, Hover `#5568d3`, Secondary `#e0e0e0`
@@ -1612,8 +1612,8 @@ peak_mem = usqlite.mem_peak()
- Disabled: 50% opacity, no pointer events
**Inputs:**
- Focus: Border color changes to primary purple
- Hover: Slight border color change
- Focus: Border colour changes to primary purple
- Hover: Slight border colour change
- Error: Red border
**Cards:**
@@ -1738,7 +1738,7 @@ peak_mem = usqlite.mem_peak()
- Validation
**Preset Management:**
- Preset creation with all fields (name, pattern, colors, delay, n1-n8)
- Preset creation with all fields (name, pattern, colours, delay, n1-n8)
- Preset loading and application
- Preset editing and deletion
- Name uniqueness validation
@@ -1758,7 +1758,7 @@ peak_mem = usqlite.mem_peak()
- Configuration parameters are properly formatted
**Preset Application:**
- Preset loads all parameters correctly (pattern, colors, delay, n1-n8)
- Preset loads all parameters correctly (pattern, colours, delay, n1-n8)
- Preset applies to single device
- Preset applies to device group
- Preset values match saved configuration

112
docs/help.md Normal file
View File

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

BIN
docs/help.pdf Normal file

Binary file not shown.

View File

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

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

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

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

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

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

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

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

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

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -1,13 +1,13 @@
# Custom Color Picker Component
# Custom Colour Picker Component
A cross-platform, cross-browser color picker component that provides a consistent user experience across all operating systems and browsers.
A cross-platform, cross-browser colour picker component that provides a consistent user experience across all operating systems and browsers.
## Features
**Consistent UI** - Same appearance and behavior on Windows, macOS, Linux, iOS, and Android
**Browser Support** - Works in Chrome, Firefox, Safari, Edge, Opera, and mobile browsers
**Touch Support** - Full touch/gesture support for mobile devices
**HSB Color Model** - Uses Hue, Saturation, Brightness for intuitive color selection
**HSB Colour Model** - Uses Hue, Saturation, Brightness for intuitive colour selection
**Multiple Input Methods** - Hex input, RGB inputs, and visual picker
**Accessible** - Keyboard accessible and screen reader friendly
**Customizable** - Easy to style and integrate
@@ -33,7 +33,7 @@ A cross-platform, cross-browser color picker component that provides a consisten
<div id="my-color-picker"></div>
```
### 3. Initialize the color picker
### 3. Initialize the colour picker
```javascript
const picker = new ColorPicker('#my-color-picker', {
@@ -57,8 +57,8 @@ new ColorPicker(container, options)
- `options` (object) - Configuration options
**Options:**
- `initialColor` (string) - Initial color in hex format (default: '#FF0000')
- `onColorChange` (function) - Callback when color changes (receives hex color string)
- `initialColor` (string) - Initial colour in hex format (default: '#FF0000')
- `onColorChange` (function) - Callback when colour changes (receives hex colour string)
- `showHexInput` (boolean) - Show hex input field (default: true)
### Methods
@@ -101,7 +101,7 @@ const picker = new ColorPicker('#picker1', {
});
```
### Multiple Color Pickers
### Multiple Colour Pickers
```javascript
const colors = ['#FF0000', '#00FF00', '#0000FF'];
@@ -116,7 +116,7 @@ const pickers = colors.map((color, index) => {
});
```
### Dynamic Color Picker Creation
### Dynamic Colour Picker Creation
```javascript
function addColorPicker(containerId, initialColor = '#000000') {
@@ -139,12 +139,12 @@ addColorPicker('color-2', '#00FF00');
## Styling
The color picker uses CSS classes that can be customized:
The colour picker uses CSS classes that can be customized:
- `.color-picker-container` - Main container
- `.color-picker-preview` - Color preview button
- `.color-picker-preview` - Colour preview button
- `.color-picker-panel` - Dropdown panel
- `.color-picker-main` - Main color area
- `.color-picker-main` - Main colour area
- `.color-picker-hue` - Hue slider
- `.color-picker-controls` - Controls section
@@ -183,20 +183,20 @@ The color picker uses CSS classes that can be customized:
- ✅ iOS 12+
- ✅ Android 7+
## Color Format
## Colour Format
The color picker uses **hex color format** (`#RRGGBB`):
The colour picker uses **hex colour format** (`#RRGGBB`):
- Always returns uppercase hex strings (e.g., `#FF0000`)
- Accepts both uppercase and lowercase input
- Automatically validates hex format
## Integration with LED Driver Mockups
The color picker is integrated into:
- `dashboard.html` - Color selection for patterns
- `presets.html` - Color selection when creating/editing presets
The colour picker is integrated into:
- `dashboard.html` - Colour selection for patterns
- `presets.html` - Colour selection when creating/editing presets
### Example: Getting Colors from Multiple Pickers
### Example: Getting Colours from Multiple Pickers
```javascript
const colorPickers = [];
@@ -218,7 +218,7 @@ function sendColorsToDevice() {
## Performance
- Lightweight: ~14KB JavaScript, ~4KB CSS
- Fast rendering: Uses Canvas API for color gradients
- Fast rendering: Uses Canvas API for colour gradients
- Smooth interactions: Optimized event handling
- Memory efficient: No external dependencies
@@ -235,5 +235,5 @@ Part of the LED Driver project. Use freely in your projects.
## Demo
See `color-picker-demo.html` for a live demonstration of the color picker component.
See `color-picker-demo.html` for a live demonstration of the colour picker component.

View File

@@ -8,8 +8,14 @@ import time
UART_BAUD = 912000
BROADCAST = b"\xff\xff\xff\xff\xff\xff"
MAX_PEERS = 20
# Match led-driver / controller default settings wifi_channel (111)
WIFI_CHANNEL = 6
sta = network.WLAN(network.STA_IF)
sta.active(True)
sta.config(pm=network.WLAN.PM_NONE, channel=WIFI_CHANNEL)
print("WiFi STA channel:", sta.config("channel"), "(WIFI_CHANNEL=%s)" % WIFI_CHANNEL)
network.WLAN(network.STA_IF).active(True)
esp = espnow.ESPNow()
esp.active(True)
esp.add_peer(BROADCAST)

4
pytest.ini Normal file
View File

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

19
scripts/build_help_pdf.sh Executable file
View File

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

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

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

View File

@@ -47,11 +47,23 @@ async def get_pattern(request, id):
async def create_pattern(request):
"""Create a new pattern."""
try:
data = request.json or {}
name = data.get("name", "")
pattern_id = patterns.create(name, data.get("data", {}))
if data:
patterns.update(pattern_id, data)
payload = request.json or {}
name = payload.get("name", "")
pattern_data = payload.get("data", {})
# IMPORTANT:
# `patterns.create()` stores `pattern_data` as the underlying dict value.
# If we then call `patterns.update(pattern_id, payload)` with the full
# request object, it may assign `payload["data"]` back onto that same
# dict object, creating a circular reference (json.dumps fails).
pattern_id = patterns.create(name, pattern_data)
# Only merge "extra" metadata fields (anything except name/data).
extra = dict(payload)
extra.pop("name", None)
extra.pop("data", None)
if extra:
patterns.update(pattern_id, extra)
return json.dumps(patterns.read(pattern_id)), 201, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 400

View File

@@ -171,9 +171,13 @@ async def send_presets(request, session):
if not sender:
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
async def send_chunk(chunk_presets):
# Include save flag so the led-driver can persist when desired.
msg = build_message(presets=chunk_presets, save=save_flag, default=default_id)
async def send_chunk(chunk_presets, is_last):
# Save/default should only be sent with the final presets chunk.
msg = build_message(
presets=chunk_presets,
save=save_flag and is_last,
default=default_id if is_last else None,
)
await sender.send(msg, addr=destination_mac)
MAX_BYTES = 240
@@ -195,7 +199,7 @@ async def send_presets(request, session):
last_msg = test_msg
else:
try:
await send_chunk(batch)
await send_chunk(batch, False)
except Exception:
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
await asyncio.sleep(send_delay_s)
@@ -205,7 +209,7 @@ async def send_presets(request, session):
if batch:
try:
await send_chunk(batch)
await send_chunk(batch, True)
except Exception:
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
await asyncio.sleep(send_delay_s)

View File

@@ -55,15 +55,28 @@ async def configure_ap(request):
except Exception as e:
return json.dumps({"error": str(e)}), 500
def _validate_wifi_channel(value):
"""Return int 111 or raise ValueError."""
ch = int(value)
if ch < 1 or ch > 11:
raise ValueError("wifi_channel must be between 1 and 11")
return ch
@controller.put('/settings')
async def update_settings(request):
"""Update general settings."""
try:
data = request.json
for key, value in data.items():
settings[key] = value
if key == 'wifi_channel' and value is not None:
settings[key] = _validate_wifi_channel(value)
else:
settings[key] = value
settings.save()
return json.dumps({"message": "Settings updated successfully"}), 200, {'Content-Type': 'application/json'}
except ValueError as e:
return json.dumps({"error": str(e)}), 400
except Exception as e:
return json.dumps({"error": str(e)}), 500

View File

@@ -45,6 +45,9 @@ class Settings(dict):
self['session_secret_key'] = self.generate_secret_key()
# Save immediately when generating a new key
self.save()
# ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 111
if 'wifi_channel' not in self:
self['wifi_channel'] = 6
def save(self):
try:

View File

@@ -122,22 +122,6 @@ class LightingController {
document.getElementById('add-color-btn').addEventListener('click', () => this.addColorToPalette());
document.getElementById('remove-color-btn').addEventListener('click', () => this.removeSelectedColor());
// Close modals on outside click
document.getElementById('add-tab-modal').addEventListener('click', (e) => {
if (e.target.id === 'add-tab-modal') this.hideModal('add-tab-modal');
});
document.getElementById('edit-tab-modal').addEventListener('click', (e) => {
if (e.target.id === 'edit-tab-modal') this.hideModal('edit-tab-modal');
});
document.getElementById('profiles-modal').addEventListener('click', (e) => {
if (e.target.id === 'profiles-modal') this.hideModal('profiles-modal');
});
document.getElementById('presets-modal').addEventListener('click', (e) => {
if (e.target.id === 'presets-modal') this.hideModal('presets-modal');
});
document.getElementById('preset-editor-modal').addEventListener('click', (e) => {
if (e.target.id === 'preset-editor-modal') this.hideModal('preset-editor-modal');
});
}
renderTabs() {

View File

@@ -191,9 +191,4 @@ document.addEventListener('DOMContentLoaded', () => {
// Add when the picker closes (user confirms selection).
paletteNewColor.addEventListener('change', addSelectedColor);
}
paletteModal.addEventListener('click', (event) => {
if (event.target === paletteModal) {
closeModal();
}
});
});

View File

@@ -18,14 +18,6 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
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', () => {
@@ -43,13 +35,6 @@ document.addEventListener('DOMContentLoaded', () => {
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).
@@ -75,6 +60,12 @@ document.addEventListener('DOMContentLoaded', () => {
if (nameInput && data && typeof data === 'object') {
nameInput.value = data.device_name || 'led-controller';
}
const chInput = document.getElementById('wifi-channel-input');
if (chInput && data && typeof data === 'object') {
const ch = data.wifi_channel;
chInput.value =
ch !== undefined && ch !== null && ch !== '' ? String(ch) : '6';
}
} catch (error) {
console.error('Error loading device settings:', error);
}
@@ -121,14 +112,6 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
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) => {
@@ -139,15 +122,29 @@ document.addEventListener('DOMContentLoaded', () => {
showSettingsMessage('Device name is required', 'error');
return;
}
const chRaw = document.getElementById('wifi-channel-input')
? document.getElementById('wifi-channel-input').value
: '6';
const wifiChannel = parseInt(chRaw, 10);
if (Number.isNaN(wifiChannel) || wifiChannel < 1 || wifiChannel > 11) {
showSettingsMessage('WiFi channel must be between 1 and 11', 'error');
return;
}
try {
const response = await fetch('/settings/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_name: deviceName }),
body: JSON.stringify({
device_name: deviceName,
wifi_channel: wifiChannel,
}),
});
const result = await response.json();
if (response.ok) {
showSettingsMessage('Device name saved. It will be used on next restart.', 'success');
showSettingsMessage(
'Device settings saved. They will apply on next restart where relevant.',
'success',
);
} else {
showSettingsMessage(`Error: ${result.error || 'Failed to save device name'}`, 'error');
}

View File

@@ -78,9 +78,4 @@ document.addEventListener('DOMContentLoaded', () => {
patternsCloseButton.addEventListener('click', closeModal);
}
patternsModal.addEventListener('click', (event) => {
if (event.target === patternsModal) {
closeModal();
}
});
});

View File

@@ -174,12 +174,11 @@ document.addEventListener('DOMContentLoaded', () => {
const presetBrightnessInput = document.getElementById('preset-brightness-input');
const presetDelayInput = document.getElementById('preset-delay-input');
const presetDefaultButton = document.getElementById('preset-default-btn');
const presetSaveButton = document.getElementById('preset-save-btn');
const presetClearButton = document.getElementById('preset-clear-btn');
const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-btn');
const presetRemoveFromTabButton = document.getElementById('preset-remove-from-tab-btn');
const presetSaveButton = document.getElementById('preset-save-btn');
const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-btn');
if (!presetsButton || !presetsModal || !presetsList || !presetSaveButton || !presetClearButton) {
if (!presetsButton || !presetsModal || !presetsList || !presetSaveButton) {
return;
}
@@ -534,6 +533,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
}
updatePresetEditorTabActionsVisibility();
};
const clearForm = () => {
@@ -567,6 +567,7 @@ document.addEventListener('DOMContentLoaded', () => {
presetPatternInput.style.backgroundColor = '';
presetPatternInput.style.cursor = '';
}
updatePresetEditorTabActionsVisibility();
};
const getActiveTabId = () => {
@@ -577,6 +578,12 @@ document.addEventListener('DOMContentLoaded', () => {
return section ? section.dataset.tabId : null;
};
const updatePresetEditorTabActionsVisibility = () => {
if (!presetRemoveFromTabButton) return;
const show = Boolean(currentEditTabId && currentEditId);
presetRemoveFromTabButton.hidden = !show;
};
const updateTabDefaultPreset = async (presetId) => {
const tabId = getActiveTabId();
if (!tabId) {
@@ -637,20 +644,55 @@ document.addEventListener('DOMContentLoaded', () => {
return payload;
};
const normalizePatternMap = (raw) => {
if (!raw) return {};
if (typeof raw === 'object' && !Array.isArray(raw)) {
// Support wrapped payloads like { patterns: {...} }.
if (raw.patterns && typeof raw.patterns === 'object' && !Array.isArray(raw.patterns)) {
return raw.patterns;
}
return raw;
}
if (Array.isArray(raw)) {
// Support list payloads like [{name: "blink", ...}, ...].
return raw.reduce((acc, item, idx) => {
if (item && typeof item === 'object') {
const name = item.name || item.id || String(idx);
acc[String(name)] = item;
}
return acc;
}, {});
}
return {};
};
const loadPatterns = async () => {
if (!presetPatternInput) {
return;
}
try {
// Load pattern definitions from pattern.json
const response = await fetch('/patterns/definitions', {
let patternsPayload = null;
let response = await fetch('/patterns/definitions', {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
return;
if (response.ok) {
patternsPayload = await response.json();
}
const patterns = await response.json();
cachedPatterns = patterns || {};
let normalized = normalizePatternMap(patternsPayload);
if (!Object.keys(normalized).length) {
// Fallback when definitions route is unavailable or returns an empty map.
response = await fetch('/patterns', {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
return;
}
patternsPayload = await response.json();
normalized = normalizePatternMap(patternsPayload);
}
cachedPatterns = normalized;
const entries = Object.keys(cachedPatterns);
const desiredPattern = presetPatternInput.value;
presetPatternInput.innerHTML = '<option value="">Pattern</option>';
@@ -753,6 +795,7 @@ document.addEventListener('DOMContentLoaded', () => {
editButton.textContent = 'Edit';
editButton.addEventListener('click', async () => {
currentEditId = presetId;
currentEditTabId = null;
const paletteColors = await getCurrentProfilePaletteColors();
const presetForEditor = {
...(preset || {}),
@@ -966,12 +1009,6 @@ document.addEventListener('DOMContentLoaded', () => {
modal.remove();
});
// Close on outside click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.remove();
}
});
} catch (error) {
console.error('Failed to show add preset modal:', error);
alert('Failed to load presets.');
@@ -1068,7 +1105,6 @@ document.addEventListener('DOMContentLoaded', () => {
if (presetEditorCloseButton) {
presetEditorCloseButton.addEventListener('click', closeEditor);
}
presetClearButton.addEventListener('click', clearForm);
if (presetPatternInput) {
presetPatternInput.addEventListener('change', () => {
updatePresetNLabels(presetPatternInput.value);
@@ -1144,9 +1180,6 @@ document.addEventListener('DOMContentLoaded', () => {
const close = () => modal.remove();
modal.querySelector('#pick-palette-close-btn').addEventListener('click', close);
modal.addEventListener('click', (e) => {
if (e.target === modal) close();
});
list.addEventListener('click', (e) => {
const btn = e.target.closest('button');
@@ -1181,7 +1214,7 @@ document.addEventListener('DOMContentLoaded', () => {
const presetSendButton = document.getElementById('preset-send-btn');
if (presetSendButton) {
presetSendButton.addEventListener('click', () => {
presetSendButton.addEventListener('click', async () => {
const payload = buildPresetPayload();
if (!payload.name) {
alert('Preset name is required to send.');
@@ -1195,10 +1228,8 @@ document.addEventListener('DOMContentLoaded', () => {
: [];
// Work out the preset ID: for existing presets use currentEditId, otherwise fall back to name
const presetId = currentEditId || payload.name;
// First send/override the preset definition under its ID
sendPresetViaEspNow(presetId, payload, null, true, false);
// Then send a separate select-only message for this preset ID to all devices in the tab
sendSelectForCurrentTabDevices(presetId, section);
// Try sends preset first, then select; never persist on device.
await sendPresetViaEspNow(presetId, payload, deviceNames, false, false);
});
}
@@ -1220,6 +1251,16 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
if (presetRemoveFromTabButton) {
presetRemoveFromTabButton.addEventListener('click', async () => {
if (!currentEditTabId || !currentEditId) return;
if (!window.confirm('Remove this preset from this tab?')) return;
await removePresetFromTab(currentEditTabId, currentEditId);
clearForm();
closeEditor();
});
}
presetSaveButton.addEventListener('click', async () => {
const payload = buildPresetPayload();
if (!payload.name) {
@@ -1238,33 +1279,26 @@ document.addEventListener('DOMContentLoaded', () => {
throw new Error('Failed to save preset');
}
// Determine device names from current tab (if any)
let deviceNames = [];
const section = document.querySelector('.presets-section[data-tab-id]');
if (section) {
const namesAttr = section.getAttribute('data-device-names');
deviceNames = namesAttr
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
: [];
}
// Use saved preset from server response for sending
const saved = await response.json().catch(() => null);
if (saved && typeof saved === 'object') {
if (currentEditId) {
// PUT returns the preset object directly; use the existing ID
sendPresetViaEspNow(currentEditId, saved, deviceNames, true, false);
// Save & Send should not force-select the preset on devices.
sendPresetViaEspNow(currentEditId, saved, [], true, false);
} else {
// POST returns { id: preset }
const entries = Object.entries(saved);
if (entries.length > 0) {
const [newId, presetData] = entries[0];
sendPresetViaEspNow(newId, presetData, deviceNames, true, false);
// Save & Send should not force-select the preset on devices.
sendPresetViaEspNow(newId, presetData, [], true, false);
}
}
} else {
// Fallback: send what we just built
sendPresetViaEspNow(payload.name, payload, deviceNames, true, false);
// Save & Send should not force-select the preset on devices.
sendPresetViaEspNow(payload.name, payload, [], true, false);
}
await loadPresets();
@@ -1303,40 +1337,13 @@ document.addEventListener('DOMContentLoaded', () => {
openEditor();
});
if (presetRemoveFromTabButton) {
presetRemoveFromTabButton.addEventListener('click', async () => {
if (!currentEditId) {
alert('No preset loaded to remove.');
return;
}
try {
await removePresetFromTab(currentEditTabId, currentEditId);
closeEditor();
} catch (e) {
// removePresetFromTab already logs and alerts on error
}
});
}
presetsModal.addEventListener('click', (event) => {
if (event.target === presetsModal) {
closeModal();
}
});
if (presetEditorModal) {
presetEditorModal.addEventListener('click', (event) => {
if (event.target === presetEditorModal) {
closeEditor();
}
});
}
clearForm();
});
// Build an ESPNow preset message for a single preset and optionally include a select
// for the given device names, then send it via WebSocket.
// Build ESPNow messages for a single preset.
// Send order:
// 1) preset payload (optionally with save)
// 2) optional select for device names (never with save)
// saveToDevice defaults to true.
const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice = true, setDefault = false) => {
try {
@@ -1346,7 +1353,7 @@ const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice =
const paletteColors = await getCurrentProfilePaletteColors();
const colors = resolveColorsWithPaletteRefs(baseColors, preset.palette_refs, paletteColors);
const message = {
const presetMessage = {
v: '1',
presets: {
[presetId]: {
@@ -1367,14 +1374,16 @@ const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice =
},
};
if (saveToDevice) {
// Instruct led-driver to save this preset when received.
message.save = true;
presetMessage.save = true;
}
if (setDefault) {
message.default = presetId;
presetMessage.default = presetId;
}
// Optionally include a select section for specific devices
// 1) Send presets first, without save.
sendEspnowMessage(presetMessage);
// Optionally send a separate select message for specific devices.
if (Array.isArray(deviceNames) && deviceNames.length > 0) {
const select = {};
deviceNames.forEach((name) => {
@@ -1383,11 +1392,12 @@ const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice =
}
});
if (Object.keys(select).length > 0) {
message.select = select;
// Small gap helps slower receivers process preset update before select.
await new Promise((resolve) => setTimeout(resolve, 30));
sendEspnowMessage({ v: '1', select });
}
}
sendEspnowMessage(message);
} catch (error) {
console.error('Failed to send preset via ESPNow:', error);
alert('Failed to send preset via ESPNow.');
@@ -1399,17 +1409,16 @@ const sendDefaultPreset = (presetId, deviceNames) => {
alert('Select a preset to set as default.');
return;
}
// Default should only set startup preset, not trigger live selection.
// Save is attached to default messages.
// When device names are provided, scope the default update to those devices.
const targets = Array.isArray(deviceNames)
? deviceNames.map((n) => (n || '').trim()).filter((n) => n.length > 0)
: [];
const message = { v: '1', default: presetId };
if (Array.isArray(deviceNames) && deviceNames.length > 0) {
const select = {};
deviceNames.forEach((name) => {
if (name) {
select[name] = [presetId];
}
});
if (Object.keys(select).length > 0) {
message.select = select;
}
message.save = true;
if (targets.length > 0) {
message.targets = targets;
}
sendEspnowMessage(message);
};
@@ -1789,21 +1798,7 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
editPresetFromTab(presetId, tabId, preset);
});
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'btn btn-danger btn-small';
removeBtn.textContent = 'Remove';
removeBtn.title = 'Remove from this tab';
removeBtn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
if (isDraggingPreset) return;
if (!window.confirm('Remove this preset from this tab?')) return;
await removePresetFromTab(tabId, presetId);
});
actions.appendChild(editBtn);
actions.appendChild(removeBtn);
row.appendChild(actions);
}

View File

@@ -11,8 +11,22 @@ document.addEventListener("DOMContentLoaded", () => {
return;
}
const isEditModeActive = () => {
const toggle = document.querySelector('.ui-mode-toggle');
return !!(toggle && toggle.getAttribute('aria-pressed') === 'true');
};
const updateProfileEditorControlsVisibility = () => {
const editMode = isEditModeActive();
const actions = profilesModal.querySelector('.profiles-actions');
if (actions) {
actions.style.display = editMode ? '' : 'none';
}
};
const openModal = () => {
profilesModal.classList.add("active");
updateProfileEditorControlsVisibility();
loadProfiles();
};
@@ -54,6 +68,7 @@ document.addEventListener("DOMContentLoaded", () => {
return;
}
const editMode = isEditModeActive();
entries.forEach(([profileId, profile]) => {
const row = document.createElement("div");
row.className = "profiles-row";
@@ -161,8 +176,10 @@ document.addEventListener("DOMContentLoaded", () => {
row.appendChild(label);
row.appendChild(applyButton);
row.appendChild(cloneButton);
row.appendChild(deleteButton);
if (editMode) {
row.appendChild(cloneButton);
row.appendChild(deleteButton);
}
profilesList.appendChild(row);
});
};
@@ -197,6 +214,9 @@ document.addEventListener("DOMContentLoaded", () => {
};
const createProfile = async () => {
if (!isEditModeActive()) {
return;
}
if (!newProfileInput) {
return;
}
@@ -264,9 +284,14 @@ document.addEventListener("DOMContentLoaded", () => {
});
}
profilesModal.addEventListener("click", (event) => {
if (event.target === profilesModal) {
closeModal();
}
// Keep modal controls in sync with run/edit mode.
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
btn.addEventListener('click', () => {
if (profilesModal.classList.contains('active')) {
updateProfileEditorControlsVisibility();
loadProfiles();
}
});
});
});

View File

@@ -620,22 +620,30 @@ body.preset-ui-run .edit-mode-only {
height: 5rem;
}
/* Edit only beside the preset tile in edit mode. */
.preset-tile-actions {
display: flex;
flex-direction: column;
justify-content: stretch;
gap: 0.2rem;
justify-content: center;
flex-shrink: 0;
padding: 0.15rem 0 0.15rem 0.25rem;
width: auto;
min-width: 0;
}
.preset-editor-modal-actions {
flex-wrap: wrap;
gap: 0.35rem;
}
.preset-tile-actions .btn {
flex: 1 1 0;
min-height: 0;
width: 100%;
min-height: 2.35rem;
padding: 0.15rem 0.35rem;
font-size: 0.68rem;
line-height: 1.15;
white-space: nowrap;
white-space: normal;
}
.ui-mode-toggle--edit {

View File

@@ -142,13 +142,6 @@ function renderTabsListModal(tabs, tabOrder, currentTabId) {
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";
@@ -233,7 +226,6 @@ function renderTabsListModal(tabs, tabOrder, currentTabId) {
row.appendChild(label);
row.appendChild(applyButton);
row.appendChild(sendPresetsButton);
if (editMode) {
row.appendChild(editButton);
row.appendChild(cloneButton);
@@ -354,7 +346,7 @@ async function loadTabContent(tabId) {
brightnessSendTimeout = setTimeout(() => {
if (typeof window.sendEspnowRaw === 'function') {
try {
window.sendEspnowRaw({ v: '1', b: val });
window.sendEspnowRaw({ v: '1', b: val, save: true });
} catch (err) {
console.error('Failed to send brightness via ESPNow:', err);
}
@@ -373,69 +365,6 @@ async function loadTabContent(tabId) {
}
}
// 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 {
@@ -715,14 +644,6 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
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) => {
if (!isEditModeActive()) {
@@ -793,16 +714,6 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
// 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) {

View File

@@ -15,13 +15,12 @@
</div>
</div>
<div class="header-actions">
<button class="btn btn-secondary" id="profiles-btn">Profiles</button>
<button class="btn btn-secondary edit-mode-only" id="tabs-btn">Tabs</button>
<button class="btn btn-secondary edit-mode-only" id="color-palette-btn">Color Palette</button>
<button class="btn btn-secondary edit-mode-only" id="presets-btn">Presets</button>
<button class="btn btn-secondary" id="send-profile-presets-btn">Send Presets</button>
<button class="btn btn-secondary edit-mode-only" id="patterns-btn">Patterns</button>
<button class="btn btn-secondary edit-mode-only" id="profiles-btn">Profiles</button>
<button class="btn btn-secondary" id="settings-btn">Settings</button>
<button class="btn btn-secondary edit-mode-only" id="color-palette-btn">Colour Palette</button>
<button class="btn btn-secondary edit-mode-only" id="send-profile-presets-btn">Send Presets</button>
<button class="btn btn-secondary" id="help-btn">Help</button>
<button type="button" class="btn btn-secondary ui-mode-toggle" id="ui-mode-toggle" aria-pressed="false" title="Switch preset strip mode — label is the mode you will switch to">Edit mode</button>
</div>
@@ -29,13 +28,12 @@
<button class="btn btn-secondary" id="main-menu-btn">Menu</button>
<div id="main-menu-dropdown" class="main-menu-dropdown">
<button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
<button type="button" data-target="profiles-btn">Profiles</button>
<button type="button" class="edit-mode-only" data-target="tabs-btn">Tabs</button>
<button type="button" class="edit-mode-only" data-target="color-palette-btn">Color Palette</button>
<button type="button" class="edit-mode-only" data-target="presets-btn">Presets</button>
<button type="button" data-target="send-profile-presets-btn">Send Presets</button>
<button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</button>
<button type="button" class="edit-mode-only" data-target="profiles-btn">Profiles</button>
<button type="button" data-target="settings-btn">Settings</button>
<button type="button" class="edit-mode-only" data-target="color-palette-btn">Colour Palette</button>
<button type="button" class="edit-mode-only" data-target="send-profile-presets-btn">Send Presets</button>
<button type="button" data-target="help-btn">Help</button>
</div>
</div>
@@ -131,10 +129,10 @@
<option value="">Pattern</option>
</select>
</div>
<label>Colors</label>
<label>Colours</label>
<div id="preset-colors-container" class="preset-colors-container"></div>
<div class="profiles-actions">
<input type="color" id="preset-new-color" value="#ffffff" title="Choose color (auto-adds)">
<input type="color" id="preset-new-color" value="#ffffff" title="Choose colour (auto-adds)">
<button class="btn btn-secondary btn-small" id="preset-add-from-palette-btn">From Palette</button>
</div>
<div class="profiles-actions">
@@ -181,12 +179,11 @@
<input type="number" id="preset-n8-input" min="0" max="255" value="0" class="n-input">
</div>
</div>
<div class="modal-actions">
<div class="modal-actions preset-editor-modal-actions">
<button class="btn btn-secondary" id="preset-send-btn">Try</button>
<button class="btn btn-secondary" id="preset-default-btn">Default</button>
<button type="button" class="btn btn-danger" id="preset-remove-from-tab-btn" hidden>Remove from tab</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-editor-close-btn">Close</button>
</div>
</div>
@@ -203,10 +200,10 @@
</div>
</div>
<!-- Color Palette Modal -->
<!-- Colour Palette Modal -->
<div id="color-palette-modal" class="modal">
<div class="modal-content">
<h2>Color Palette</h2>
<h2>Colour Palette</h2>
<p class="muted-text">Profile: <span id="palette-current-profile-name">None</span></p>
<div id="palette-container" class="profiles-list"></div>
<div class="profiles-actions">
@@ -224,27 +221,23 @@
<h2>Help</h2>
<p class="muted-text">How to use the LED controller UI.</p>
<h3>Tabs & devices</h3>
<h3>Run mode</h3>
<ul>
<li><strong>Select tab</strong>: left-click a tab button in the top bar.</li>
<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>
<li><strong>Select preset</strong>: left-click a preset tile to send a <code>select</code> message to all devices in the tab.</li>
<li><strong>Profiles</strong>: open <strong>Profiles</strong> to apply a profile. Profile editing actions are hidden in Run mode.</li>
<li><strong>Send all presets</strong>: this action is available in <strong>Edit mode</strong> and pushes every preset used in the current tab to all tab devices.</li>
<li><strong>Switch modes</strong>: use the mode button in the menu. The button label shows the mode you will switch to.</li>
</ul>
<h3>Presets in a tab</h3>
<h3>Edit mode</h3>
<ul>
<li><strong>Select preset</strong>: left-click a preset tile to select it and send a <code>select</code> message to all devices in the tab.</li>
<li><strong>Run vs Edit mode</strong>: use the mode button in the menu (it shows the mode you will <em>switch to</em>). In <strong>Edit mode</strong>, each preset tile shows <strong>Edit</strong> and <strong>Remove</strong> on the right.</li>
<li><strong>Edit preset</strong>: switch to <strong>Edit mode</strong> (menu button) and use <strong>Edit</strong> on each tile.</li>
<li><strong>Remove from tab</strong>: in <strong>Edit mode</strong>, use <strong>Remove</strong> on the tile (the preset itself is not deleted, only its link from this tab).</li>
<li><strong>Reorder presets</strong>: in <strong>Edit mode</strong> only, drag tiles to change order; the layout saves automatically.</li>
</ul>
<h3>Presets, profiles & colors</h3>
<ul>
<li><strong>Presets</strong>: use the <strong>Presets</strong> button in the header to create and manage reusable presets.</li>
<li><strong>Profiles</strong>: use <strong>Profiles</strong> to save and recall groups of settings.</li>
<li><strong>Color Palette</strong>: use <strong>Color Palette</strong> to build a reusable set of colors you can pull into presets.</li>
<li><strong>Tabs</strong>: create, edit, and manage tabs and device assignments.</li>
<li><strong>Presets</strong>: create/manage reusable presets and edit preset details.</li>
<li><strong>Preset tiles</strong>: each tile shows <strong>Edit</strong> and <strong>Remove</strong> controls in Edit mode.</li>
<li><strong>Reorder presets</strong>: drag and drop preset tiles to save tab order.</li>
<li><strong>Profiles</strong>: create/clone/delete profiles. New profiles get a populated <strong>default</strong> tab and can optionally seed a <strong>DJ tab</strong>.</li>
<li><strong>Colour Palette</strong>: build profile colours and use <strong>From Palette</strong> in preset editor to add linked colours (badge <strong>P</strong>) that update when palette colours change.</li>
</ul>
<div class="modal-actions">
@@ -270,8 +263,13 @@
<input type="text" id="device-name-input" name="device_name" placeholder="e.g. led-controller" required>
<small>This name may be used for mDNS (e.g. <code>name.local</code>) and UI display.</small>
</div>
<div class="form-group">
<label for="wifi-channel-input">WiFi channel (ESP-NOW)</label>
<input type="number" id="wifi-channel-input" name="wifi_channel" min="1" max="11" required>
<small>STA channel (111) for LED drivers and the serial bridge. Use the same value everywhere.</small>
</div>
<div class="btn-group">
<button type="submit" class="btn btn-primary btn-full">Save Name</button>
<button type="submit" class="btn btn-primary btn-full">Save device settings</button>
</div>
</form>
</div>

View File

@@ -170,11 +170,26 @@
<div class="settings-header">
<h1>Device Settings</h1>
<p>Configure WiFi Access Point settings</p>
<p>Configure WiFi Access Point and ESP-NOW options</p>
</div>
<div id="message" class="message"></div>
<!-- ESP-NOW (LED driver / bridge channel) -->
<div class="settings-section">
<h2>ESP-NOW</h2>
<form id="espnow-form">
<div class="form-group">
<label for="wifi-channel-page-input">WiFi channel (ESP-NOW)</label>
<input type="number" id="wifi-channel-page-input" name="wifi_channel" min="1" max="11" required>
<small>STA channel (111) for LED drivers and the serial bridge. Use the same value on every device.</small>
</div>
<div class="btn-group">
<button type="submit" class="btn btn-primary btn-full">Save channel</button>
</div>
</form>
</div>
<!-- WiFi Access Point Settings -->
<div class="settings-section">
<h2>WiFi Access Point Settings</h2>
@@ -222,6 +237,46 @@
}, 5000);
}
async function loadEspnowChannel() {
try {
const response = await fetch('/settings');
const data = await response.json();
const chInput = document.getElementById('wifi-channel-page-input');
if (chInput && data && typeof data === 'object') {
const ch = data.wifi_channel;
chInput.value =
ch !== undefined && ch !== null && ch !== '' ? String(ch) : '6';
}
} catch (error) {
console.error('Error loading ESP-NOW channel:', error);
}
}
document.getElementById('espnow-form').addEventListener('submit', async (e) => {
e.preventDefault();
const chRaw = document.getElementById('wifi-channel-page-input').value;
const wifiChannel = parseInt(chRaw, 10);
if (Number.isNaN(wifiChannel) || wifiChannel < 1 || wifiChannel > 11) {
showMessage('WiFi channel must be between 1 and 11', 'error');
return;
}
try {
const response = await fetch('/settings/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ wifi_channel: wifiChannel }),
});
const result = await response.json();
if (response.ok) {
showMessage('ESP-NOW channel saved.', 'success');
} else {
showMessage(`Error: ${result.error || 'Failed to save'}`, 'error');
}
} catch (error) {
showMessage(`Error: ${error.message}`, 'error');
}
});
// Load AP status and config
async function loadAPStatus() {
try {
@@ -299,6 +354,7 @@
});
// Load all data on page load
loadEspnowChannel();
loadAPStatus();
// Refresh status every 10 seconds

View File

@@ -74,7 +74,7 @@ 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
- **Preset Format**: Presets use hex colour 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
- **Colour Conversion**: Automatically converts RGB tuples to hex strings
- **Default Values**: Provides sensible defaults for missing fields

182
tests/async_tcp_server.py Normal file
View File

@@ -0,0 +1,182 @@
#!/usr/bin/env python3
# Standalone async TCP server (stdlib only). Multiple simultaneous clients.
# No watchdog: runs on a full host (e.g. Raspberry Pi); ESP32 clients may use WDT.
# For RTT latency, clients may send lines like ``rtt 12345`` (ticks); they are echoed back.
#
# Run from anywhere (default: all IPv4 interfaces, port 9000):
# python3 async_tcp_server.py
# python3 async_tcp_server.py --port 9000
# Localhost only:
# python3 async_tcp_server.py --host 127.0.0.1
#
# Or from this directory:
# chmod +x async_tcp_server.py && ./async_tcp_server.py
import argparse
import asyncio
import time
class _ClientRegistry:
"""Track writers and broadcast newline-terminated lines to all clients."""
def __init__(self) -> None:
self._writers: set[asyncio.StreamWriter] = set()
def add(self, writer: asyncio.StreamWriter) -> None:
self._writers.add(writer)
def remove(self, writer: asyncio.StreamWriter) -> None:
self._writers.discard(writer)
def count(self) -> int:
return len(self._writers)
async def broadcast_line(self, line: str) -> None:
data = (line.rstrip("\r\n") + "\n").encode("utf-8")
for writer in list(self._writers):
try:
writer.write(data)
await writer.drain()
except Exception as e:
print(f"[tcp] broadcast failed, dropping client: {e}")
self._writers.discard(writer)
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
async def _periodic_broadcast(
registry: _ClientRegistry,
interval_sec: float,
message: str,
) -> None:
while True:
await asyncio.sleep(interval_sec)
if registry.count() == 0:
continue
line = message.format(t=time.time())
print(f"[tcp] broadcast to {registry.count()} client(s): {line!r}")
await registry.broadcast_line(line)
async def _handle_client(
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
registry: _ClientRegistry,
) -> None:
peer = writer.get_extra_info("peername")
print(f"[tcp] connected: {peer}")
registry.add(writer)
try:
while not reader.at_eof():
data = await reader.readline()
if not data:
break
message = data.decode("utf-8", errors="replace").rstrip("\r\n")
# Echo newline-delimited lines (simple test harness behaviour).
# Clients may send ``rtt <ticks>`` for round-trip timing; echo unchanged.
t0 = time.perf_counter()
writer.write((message + "\n").encode("utf-8"))
await writer.drain()
if message.startswith("rtt "):
server_ms = (time.perf_counter() - t0) * 1000.0
print(
f"[tcp] echoed rtt from {peer} "
f"(host write+drain ~{server_ms:.2f} ms)"
)
finally:
registry.remove(writer)
writer.close()
await writer.wait_closed()
print(f"[tcp] disconnected: {peer}")
def _make_client_handler(registry: _ClientRegistry):
async def _handler(
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
) -> None:
await _handle_client(reader, writer, registry)
return _handler
async def _run(
host: str,
port: int,
broadcast_interval: float | None,
broadcast_message: str,
) -> None:
registry = _ClientRegistry()
handler = _make_client_handler(registry)
server = await asyncio.start_server(handler, host, port)
print(f"[tcp] listening on {host}:{port} (Ctrl+C to stop)")
if broadcast_interval is not None and broadcast_interval > 0:
print(
f"[tcp] periodic broadcast every {broadcast_interval}s "
f"(use {{t}} in --message for unix time)"
)
async with server:
tasks = []
if broadcast_interval is not None and broadcast_interval > 0:
tasks.append(
asyncio.create_task(
_periodic_broadcast(registry, broadcast_interval, broadcast_message),
name="broadcast",
)
)
try:
if tasks:
await asyncio.gather(server.serve_forever(), *tasks)
else:
await server.serve_forever()
finally:
for t in tasks:
t.cancel()
for t in tasks:
try:
await t
except asyncio.CancelledError:
pass
def main() -> None:
parser = argparse.ArgumentParser(
description="Standalone asyncio TCP server (multiple connections).",
)
parser.add_argument(
"--host",
default="0.0.0.0",
help="bind address (default: all IPv4 interfaces)",
)
parser.add_argument("--port", type=int, default=9000, help="bind port")
parser.add_argument(
"--interval",
type=float,
default=5.0,
metavar="SEC",
help="seconds between broadcast lines to all clients (default: 5)",
)
parser.add_argument(
"--message",
default="ping {t:.0f}",
help='broadcast line (newline added); use "{t}" for time.time() (default: %(default)s)',
)
parser.add_argument(
"--no-broadcast",
action="store_true",
help="disable periodic broadcast (echo-only)",
)
args = parser.parse_args()
interval = None if args.no_broadcast else args.interval
try:
asyncio.run(_run(args.host, args.port, interval, args.message))
except KeyboardInterrupt:
print("\n[tcp] stopped")
if __name__ == "__main__":
main()

12
tests/conftest.py Normal file
View File

@@ -0,0 +1,12 @@
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SRC_PATH = PROJECT_ROOT / "src"
LIB_PATH = PROJECT_ROOT / "lib"
for p in (str(SRC_PATH), str(LIB_PATH), str(PROJECT_ROOT)):
if p in sys.path:
sys.path.remove(p)
sys.path.insert(0, p)

View File

@@ -7,6 +7,15 @@ On Pi OS Lite (no desktop) these tests are skipped unless headless Chromium
and chromedriver are installed (e.g. chromium-browser chromium-chromedriver).
"""
import os
import pytest
if os.environ.get("LED_CONTROLLER_RUN_BROWSER_TESTS") != "1":
pytest.skip(
"Legacy device browser automation script; enable explicitly to run.",
allow_module_level=True,
)
import sys
import time
import requests

View File

@@ -4,6 +4,15 @@ Endpoint tests that mimic web browser requests.
Tests run against the device at 192.168.4.1
"""
import os
import pytest
if os.environ.get("LED_CONTROLLER_RUN_DEVICE_ENDPOINT_TESTS") != "1":
pytest.skip(
"Legacy device integration endpoint tests; enable explicitly to run.",
allow_module_level=True,
)
import requests
import json
import sys

View File

@@ -0,0 +1,594 @@
import asyncio
import builtins
import json
import os
import sys
import threading
import time
import uuid
from pathlib import Path
from typing import Any, Dict, Optional
import pytest
import requests
# Ensure imports resolve to the repo's `src/` + `lib/` code.
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SRC_PATH = PROJECT_ROOT / "src"
LIB_PATH = PROJECT_ROOT / "lib"
for p in (str(SRC_PATH), str(LIB_PATH), str(PROJECT_ROOT)):
if p in sys.path:
sys.path.remove(p)
sys.path.insert(0, p)
from microdot import Microdot, send_file # noqa: E402
from microdot.session import Session # noqa: E402
from microdot.websocket import with_websocket # noqa: E402
class DummySender:
def __init__(self):
self.sent: list[tuple[str, Optional[str]]] = []
async def send(self, data: Any, addr: Optional[str] = None):
if isinstance(data, (bytes, bytearray)):
data = bytes(data).decode(errors="ignore")
self.sent.append((data, addr))
return True
def _json(resp: requests.Response) -> Dict[str, Any]:
# Many endpoints already set Content-Type; but be tolerant for now.
return resp.json() # pragma: no cover
def _find_id_by_field(list_resp_json: Dict[str, Any], field: str, value: str) -> str:
for obj_id, data in list_resp_json.items():
if isinstance(data, dict) and data.get(field) == value:
return str(obj_id)
raise AssertionError(f"Could not find id for {field}={value!r}")
def _start_microdot_server(app: Microdot, host: str, port: int):
"""
Start Microdot server on a background thread.
Returns (thread, chosen_port).
"""
def runner():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(app.start_server(host=host, port=port))
finally:
try:
loop.close()
except Exception:
pass
thread = threading.Thread(target=runner, daemon=True)
thread.start()
# Poll until the socket is bound and app.server is available.
chosen_port = None
deadline = time.time() + 5.0
while time.time() < deadline:
server = getattr(app, "server", None)
if server and getattr(server, "sockets", None):
sockets = server.sockets or []
if sockets:
chosen_port = sockets[0].getsockname()[1]
break
time.sleep(0.05)
if chosen_port is None:
raise RuntimeError("Microdot server failed to start in time")
return thread, chosen_port
@pytest.fixture(scope="function")
def server(monkeypatch, tmp_path_factory):
"""
Start the Microdot app in-process and return a test client.
"""
tmp_root = tmp_path_factory.mktemp("endpoint-tests")
tmp_db_dir = tmp_root / "db"
tmp_settings_file = tmp_root / "settings.json"
# Be defensive: pytest runners can sometimes alter sys.path ordering.
for p in (str(SRC_PATH), str(LIB_PATH), str(PROJECT_ROOT)):
if p in sys.path:
sys.path.remove(p)
sys.path.insert(0, p)
# Patch Settings so endpoint tests never touch real `settings.json`.
import settings as settings_mod # noqa: E402
settings_mod.Settings.SETTINGS_FILE = str(tmp_settings_file)
# Patch the Model db directory so endpoint CRUD is isolated.
import models.model as model_mod # noqa: E402
monkeypatch.setattr(model_mod, "_db_dir", lambda: str(tmp_db_dir))
# Reset model singletons (controllers instantiate model classes at import time).
# Import the classes first so we can delete their `_instance` attribute if present.
import models.preset as models_preset # noqa: E402
import models.profile as models_profile # noqa: E402
import models.group as models_group # noqa: E402
import models.tab as models_tab # noqa: E402
import models.pallet as models_pallet # noqa: E402
import models.scene as models_scene # noqa: E402
import models.pattern as models_pattern # noqa: E402
import models.squence as models_sequence # noqa: E402
for cls in (
models_preset.Preset,
models_profile.Profile,
models_group.Group,
models_tab.Tab,
models_pallet.Palette,
models_scene.Scene,
models_pattern.Pattern,
models_sequence.Sequence,
):
if hasattr(cls, "_instance"):
delattr(cls, "_instance")
# Patch open() so pattern definitions work after we `chdir` into src/.
orig_open = builtins.open
def patched_open(file, *args, **kwargs):
if isinstance(file, str):
# Pattern controller loads definitions from a relative db/ path.
if file in {"db/pattern.json", "pattern.json", "/db/pattern.json"}:
file = str(PROJECT_ROOT / "db" / "pattern.json")
return orig_open(file, *args, **kwargs)
monkeypatch.setattr(builtins, "open", patched_open)
old_cwd = os.getcwd()
os.chdir(str(SRC_PATH))
dummy_sender = DummySender()
try:
# Ensure controllers are imported fresh after our patching.
for mod_name in (
"controllers.preset",
"controllers.profile",
"controllers.group",
"controllers.sequence",
"controllers.tab",
"controllers.palette",
"controllers.scene",
"controllers.pattern",
"controllers.settings",
):
sys.modules.pop(mod_name, None)
# Import controllers after patching db/settings/model singletons.
import controllers.preset as preset_ctl # noqa: E402
import controllers.profile as profile_ctl # noqa: E402
import controllers.group as group_ctl # noqa: E402
import controllers.sequence as sequence_ctl # noqa: E402
import controllers.tab as tab_ctl # noqa: E402
import controllers.palette as palette_ctl # noqa: E402
import controllers.scene as scene_ctl # noqa: E402
import controllers.pattern as pattern_ctl # noqa: E402
import controllers.settings as settings_ctl # noqa: E402
# Configure transport sender used by /presets/send.
from models.transport import set_sender # noqa: E402
set_sender(dummy_sender)
app = Microdot()
# Session secret key comes from settings (patched to tmp).
settings = settings_mod.Settings()
secret_key = settings.get(
"session_secret_key",
"led-controller-secret-key-change-in-production",
)
Session(app, secret_key=secret_key)
# Mount model controllers under their public prefixes.
app.mount(preset_ctl.controller, "/presets")
app.mount(profile_ctl.controller, "/profiles")
app.mount(group_ctl.controller, "/groups")
app.mount(sequence_ctl.controller, "/sequences")
app.mount(tab_ctl.controller, "/tabs")
app.mount(palette_ctl.controller, "/palettes")
app.mount(scene_ctl.controller, "/scenes")
app.mount(pattern_ctl.controller, "/patterns")
app.mount(settings_ctl.controller, "/settings")
@app.route("/")
def index(request):
return send_file("templates/index.html")
@app.route("/settings")
def settings_page(request):
return send_file("templates/settings.html")
@app.route("/favicon.ico")
def favicon(request):
return "", 204
@app.route("/static/<path:path>")
def static_handler(request, path):
if ".." in path:
return "Not found", 404
return send_file("static/" + path)
@app.route("/ws")
@with_websocket
async def ws(request, ws):
# Minimal websocket handler: forward raw JSON/text payloads to dummy sender.
while True:
data = await ws.receive()
if not data:
break
try:
parsed = json.loads(data)
addr = parsed.pop("to", None)
payload = json.dumps(parsed) if parsed else data
await dummy_sender.send(payload, addr=addr)
except Exception:
await dummy_sender.send(data)
thread, chosen_port = _start_microdot_server(app, host="127.0.0.1", port=0)
base_url = f"http://127.0.0.1:{chosen_port}"
client = requests.Session()
client.headers.update(
{
"User-Agent": "pytest/requests",
"Accept": "application/json",
}
)
yield {
"base_url": base_url,
"client": client,
"sender": dummy_sender,
"thread": thread,
"app": app,
}
finally:
# Stop server cleanly.
try:
app = locals().get("app")
if app is not None:
app.shutdown()
except Exception:
pass
# Give it a moment to close sockets.
time.sleep(0.1)
try:
thread = locals().get("thread")
if thread is not None:
thread.join(timeout=5)
except Exception:
pass
os.chdir(old_cwd)
def test_main_routes(server):
c: requests.Session = server["client"]
base_url: str = server["base_url"]
resp = c.get(f"{base_url}/")
assert resp.status_code == 200
assert "LED Controller" in resp.text
resp = c.get(f"{base_url}/favicon.ico")
assert resp.status_code == 204
resp = c.get(f"{base_url}/static/style.css")
assert resp.status_code == 200
resp = c.get(f"{base_url}/settings/page")
assert resp.status_code == 200
assert "LED Controller" in resp.text
resp = c.get(f"{base_url}/ws")
# WebSocket endpoints should reject non-upgraded HTTP requests.
assert resp.status_code != 200
assert resp.status_code in {400, 401, 403, 404, 405, 426}
def test_settings_controller(server):
c: requests.Session = server["client"]
base_url: str = server["base_url"]
resp = c.get(f"{base_url}/settings")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, dict)
assert "wifi_channel" in data
resp = c.get(f"{base_url}/settings/wifi/ap")
assert resp.status_code == 200
ap = resp.json()
assert "saved_ssid" in ap
assert "active" in ap
unique_ssid = f"pytest-ssid-{uuid.uuid4().hex[:8]}"
resp = c.post(
f"{base_url}/settings/wifi/ap",
json={"ssid": unique_ssid, "password": "secret", "channel": 1},
)
assert resp.status_code == 200
msg = resp.json()
assert msg["ssid"] == unique_ssid
assert msg["channel"] == 1
resp = c.post(
f"{base_url}/settings/wifi/ap",
json={"ssid": "bad-ssid", "password": "secret", "channel": 12},
)
assert resp.status_code == 400
resp = c.put(f"{base_url}/settings/settings", json={"wifi_channel": 11})
assert resp.status_code == 200
resp = c.put(f"{base_url}/settings/settings", json={"wifi_channel": 12})
assert resp.status_code == 400
def test_profiles_presets_tabs_endpoints(server):
c: requests.Session = server["client"]
base_url: str = server["base_url"]
sender: DummySender = server["sender"]
unique_profile_name = f"pytest-profile-{uuid.uuid4().hex[:8]}"
resp = c.post(f"{base_url}/profiles", json={"name": unique_profile_name})
assert resp.status_code == 201
created = resp.json()
assert isinstance(created, dict)
profile_id = next(iter(created.keys()))
resp = c.post(f"{base_url}/profiles/{profile_id}/apply")
assert resp.status_code == 200
resp = c.get(f"{base_url}/profiles/current")
assert resp.status_code == 200
current = resp.json()
assert str(current["id"]) == str(profile_id)
# Presets CRUD (scoped to current profile session).
resp = c.get(f"{base_url}/presets")
assert resp.status_code == 200
presets = resp.json()
assert isinstance(presets, dict)
assert presets # seeded presets should exist
first_preset_id = next(iter(presets.keys()))
resp = c.get(f"{base_url}/presets/{first_preset_id}")
assert resp.status_code == 200
assert resp.json() # dict
unique_preset_name = f"pytest-preset-{uuid.uuid4().hex[:8]}"
resp = c.post(
f"{base_url}/presets",
json={
"name": unique_preset_name,
"pattern": "on",
"colors": ["#ff0000"],
"brightness": 123,
"delay": 100,
},
)
assert resp.status_code == 201
created_preset = resp.json()
new_preset_id = next(iter(created_preset.keys()))
assert created_preset[new_preset_id]["profile_id"] == str(profile_id)
resp = c.put(
f"{base_url}/presets/{new_preset_id}",
json={"brightness": 77},
)
assert resp.status_code == 200
assert resp.json()["brightness"] == 77
sender.sent.clear()
resp = c.post(
f"{base_url}/presets/send",
json={"preset_ids": [new_preset_id], "save": False},
)
assert resp.status_code == 200
sent_result = resp.json()
assert sent_result["presets_sent"] >= 1
assert len(sender.sent) >= 1
resp = c.delete(f"{base_url}/presets/{new_preset_id}")
assert resp.status_code == 200
resp = c.get(f"{base_url}/presets/{new_preset_id}")
assert resp.status_code == 404
# Tabs CRUD (scoped to current profile session).
unique_tab_name = f"pytest-tab-{uuid.uuid4().hex[:8]}"
resp = c.post(
f"{base_url}/tabs",
json={"name": unique_tab_name, "names": ["1", "2"]},
)
assert resp.status_code == 201
created_tabs = resp.json()
tab_id = next(iter(created_tabs.keys()))
resp = c.get(f"{base_url}/tabs/{tab_id}")
assert resp.status_code == 200
assert resp.json()["name"] == unique_tab_name
resp = c.post(f"{base_url}/tabs/{tab_id}/set-current")
assert resp.status_code == 200
resp = c.get(f"{base_url}/tabs/current")
assert resp.status_code == 200
assert resp.json()["tab_id"] == str(tab_id)
resp = c.put(
f"{base_url}/tabs/{tab_id}",
json={"name": f"{unique_tab_name}-updated", "names": ["3"]},
)
assert resp.status_code == 200
assert resp.json()["names"] == ["3"]
resp = c.post(f"{base_url}/tabs/{tab_id}/clone", json={"name": "pytest-tab-clone"})
assert resp.status_code == 201
clone_payload = resp.json()
clone_id = next(iter(clone_payload.keys()))
resp = c.get(f"{base_url}/tabs/{clone_id}")
assert resp.status_code == 200
resp = c.delete(f"{base_url}/tabs/{clone_id}")
assert resp.status_code == 200
resp = c.delete(f"{base_url}/tabs/{tab_id}")
assert resp.status_code == 200
# Profile clone + update endpoints.
clone_name = f"pytest-profile-clone-{uuid.uuid4().hex[:8]}"
resp = c.post(f"{base_url}/profiles/{profile_id}/clone", json={"name": clone_name})
assert resp.status_code == 201
cloned = resp.json()
clone_profile_id = next(iter(cloned.keys()))
resp = c.post(f"{base_url}/profiles/{clone_profile_id}/apply")
assert resp.status_code == 200
resp = c.put(
f"{base_url}/profiles/current",
json={"name": f"{clone_name}-updated"},
)
assert resp.status_code == 200
resp = c.put(
f"{base_url}/profiles/{clone_profile_id}",
json={"name": f"{clone_name}-updated-2"},
)
assert resp.status_code == 200
resp = c.delete(f"{base_url}/profiles/{clone_profile_id}")
assert resp.status_code == 200
resp = c.delete(f"{base_url}/profiles/{profile_id}")
assert resp.status_code == 200
def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
c: requests.Session = server["client"]
base_url: str = server["base_url"]
# Groups.
unique_group_name = f"pytest-group-{uuid.uuid4().hex[:8]}"
resp = c.post(f"{base_url}/groups", json={"name": unique_group_name})
assert resp.status_code == 201
groups_list = c.get(f"{base_url}/groups").json()
group_id = _find_id_by_field(groups_list, "name", unique_group_name)
resp = c.get(f"{base_url}/groups/{group_id}")
assert resp.status_code == 200
assert resp.json()["name"] == unique_group_name
resp = c.put(f"{base_url}/groups/{group_id}", json={"brightness": 10})
assert resp.status_code == 200
assert resp.json()["brightness"] == 10
resp = c.delete(f"{base_url}/groups/{group_id}")
assert resp.status_code == 200
# Sequences.
unique_seq_group_name = f"pytest-seq-group-{uuid.uuid4().hex[:8]}"
resp = c.post(
f"{base_url}/sequences",
json={"group_name": unique_seq_group_name, "presets": []},
)
assert resp.status_code == 201
sequences_list = c.get(f"{base_url}/sequences").json()
seq_id = _find_id_by_field(sequences_list, "group_name", unique_seq_group_name)
resp = c.get(f"{base_url}/sequences/{seq_id}")
assert resp.status_code == 200
resp = c.put(f"{base_url}/sequences/{seq_id}", json={"sequence_duration": 1234})
assert resp.status_code == 200
assert resp.json()["sequence_duration"] == 1234
resp = c.delete(f"{base_url}/sequences/{seq_id}")
assert resp.status_code == 200
# Scenes.
unique_scene_name = f"pytest-scene-{uuid.uuid4().hex[:8]}"
resp = c.post(f"{base_url}/scenes", json={"name": unique_scene_name})
assert resp.status_code == 201
scenes_list = c.get(f"{base_url}/scenes").json()
scene_id = _find_id_by_field(scenes_list, "name", unique_scene_name)
resp = c.get(f"{base_url}/scenes/{scene_id}")
assert resp.status_code == 200
resp = c.put(f"{base_url}/scenes/{scene_id}", json={"name": unique_scene_name + "-updated"})
assert resp.status_code == 200
assert resp.json()["name"].endswith("-updated")
resp = c.delete(f"{base_url}/scenes/{scene_id}")
assert resp.status_code == 200
# Palettes.
colors = ["#112233", "#445566"]
resp = c.post(f"{base_url}/palettes", json={"colors": colors})
assert resp.status_code == 201
palette_payload = resp.json()
palette_id = str(palette_payload["id"])
resp = c.get(f"{base_url}/palettes/{palette_id}")
assert resp.status_code == 200
assert resp.json()["id"] == palette_id
resp = c.put(f"{base_url}/palettes/{palette_id}", json={"colors": ["#000000"]})
assert resp.status_code == 200
assert resp.json()["colors"] == ["#000000"]
resp = c.delete(f"{base_url}/palettes/{palette_id}")
assert resp.status_code == 200
# Patterns.
resp = c.get(f"{base_url}/patterns/definitions")
assert resp.status_code == 200
definitions = resp.json()
assert isinstance(definitions, dict)
pattern_id = f"pytest_pattern_{uuid.uuid4().hex[:8]}"
resp = c.post(
f"{base_url}/patterns",
json={"name": pattern_id, "data": {"foo": "bar"}},
)
assert resp.status_code == 201
assert resp.json()["foo"] == "bar"
resp = c.get(f"{base_url}/patterns")
assert resp.status_code == 200
patterns_list = resp.json()
assert pattern_id in patterns_list
resp = c.get(f"{base_url}/patterns/{pattern_id}")
assert resp.status_code == 200
assert resp.json()["foo"] == "bar"
resp = c.put(f"{base_url}/patterns/{pattern_id}", json={"baz": 1})
assert resp.status_code == 200
assert resp.json()["baz"] == 1
resp = c.delete(f"{base_url}/patterns/{pattern_id}")
assert resp.status_code == 200

View File

@@ -1,3 +1,7 @@
import pytest
pytest.skip("Legacy manual server script (not a pytest suite).", allow_module_level=True)
from microdot import Microdot
from src.profile import profile_app