Compare commits
22 Commits
v1.0
...
764d918d5b
| Author | SHA1 | Date | |
|---|---|---|---|
| 764d918d5b | |||
| edadb40cb6 | |||
| 9323719a85 | |||
| 91de705647 | |||
| 3ee7b74152 | |||
| 98bbdcbb3d | |||
| a2abd3e833 | |||
| 550217c443 | |||
| 2d2032e8b9 | |||
| 81bf4dded5 | |||
| a75e27e3d2 | |||
| 13538c39a6 | |||
| 7b724e9ce1 | |||
| aaca5435e9 | |||
| b64dacc1c3 | |||
| 8689bdb6ef | |||
| c178e87966 | |||
| dfe7ae50d2 | |||
| 8e87559af6 | |||
| aa3546e9ac | |||
| b56af23cbf | |||
| ac9fca8d4b |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -23,7 +23,7 @@ ENV/
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Project specific
|
# Project specific
|
||||||
|
settings.json
|
||||||
*.log
|
*.log
|
||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
|
||||||
|
|||||||
6
.gitmodules
vendored
Normal file
6
.gitmodules
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[submodule "led-driver"]
|
||||||
|
path = led-driver
|
||||||
|
url = git@git.technical.kiwi:technicalkiwi/led-driver.git
|
||||||
|
[submodule "led-tool"]
|
||||||
|
path = led-tool
|
||||||
|
url = git@git.technical.kiwi:technicalkiwi/led-tool.git
|
||||||
5
Pipfile
5
Pipfile
@@ -12,6 +12,7 @@ watchfiles = "*"
|
|||||||
requests = "*"
|
requests = "*"
|
||||||
selenium = "*"
|
selenium = "*"
|
||||||
adafruit-ampy = "*"
|
adafruit-ampy = "*"
|
||||||
|
microdot = "*"
|
||||||
|
|
||||||
[dev-packages]
|
[dev-packages]
|
||||||
|
|
||||||
@@ -21,4 +22,6 @@ python_version = "3.12"
|
|||||||
[scripts]
|
[scripts]
|
||||||
web = "python /home/pi/led-controller/tests/web.py"
|
web = "python /home/pi/led-controller/tests/web.py"
|
||||||
watch = "python -m watchfiles 'python tests/web.py' src tests"
|
watch = "python -m watchfiles 'python tests/web.py' src tests"
|
||||||
install = "pipenv install"
|
install = "pipenv install"
|
||||||
|
run = "sh -c 'cd src && python main.py'"
|
||||||
|
dev = "watchfiles \"sh -c 'cd src && python main.py'\" src"
|
||||||
|
|||||||
499
Pipfile.lock
generated
499
Pipfile.lock
generated
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"_meta": {
|
"_meta": {
|
||||||
"hash": {
|
"hash": {
|
||||||
"sha256": "c963cd52164ac13fda5e6f3c5975bc14db6cea03ad4973de02ad91a0ab10d2ea"
|
"sha256": "12b64c3bf5857d958f790f2416072408e2244631242ba2598210d89df330e184"
|
||||||
},
|
},
|
||||||
"pipfile-spec": 6,
|
"pipfile-spec": 6,
|
||||||
"requires": {
|
"requires": {
|
||||||
@@ -32,14 +32,6 @@
|
|||||||
"markers": "python_version >= '3.9'",
|
"markers": "python_version >= '3.9'",
|
||||||
"version": "==4.12.1"
|
"version": "==4.12.1"
|
||||||
},
|
},
|
||||||
"async-generator": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b",
|
|
||||||
"sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.5'",
|
|
||||||
"version": "==1.10"
|
|
||||||
},
|
|
||||||
"attrs": {
|
"attrs": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11",
|
"sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11",
|
||||||
@@ -159,19 +151,19 @@
|
|||||||
},
|
},
|
||||||
"bitstring": {
|
"bitstring": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:69d1587f0ac18dc7d93fc7e80d5f447161a33e57027e726dc18a0a8bacf1711a",
|
"sha256:e682ac522bb63e041d16cbc9d0ca86a4f00194db16d0847c7efe066f836b2e37",
|
||||||
"sha256:a08bc09d3857216d4c0f412a1611056f1cc2b64fd254fb1e8a0afba7cfa1a95a"
|
"sha256:feac49524fcf3ef27e6081e86f02b10d2adf6c3773bf22fbe0e7eea9534bc737"
|
||||||
],
|
],
|
||||||
"markers": "python_version >= '3.8'",
|
"markers": "python_version >= '3.8'",
|
||||||
"version": "==4.3.1"
|
"version": "==4.4.0"
|
||||||
},
|
},
|
||||||
"certifi": {
|
"certifi": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c",
|
"sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa",
|
||||||
"sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"
|
"sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"
|
||||||
],
|
],
|
||||||
"markers": "python_version >= '3.7'",
|
"markers": "python_version >= '3.7'",
|
||||||
"version": "==2026.1.4"
|
"version": "==2026.2.25"
|
||||||
},
|
},
|
||||||
"cffi": {
|
"cffi": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -265,122 +257,122 @@
|
|||||||
},
|
},
|
||||||
"charset-normalizer": {
|
"charset-normalizer": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad",
|
"sha256:014837af6fabf57121b6254fa8ade10dceabc3528b27b721a64bbc7b8b1d4eb4",
|
||||||
"sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93",
|
"sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66",
|
||||||
"sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394",
|
"sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54",
|
||||||
"sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89",
|
"sha256:02a9d1b01c1e12c27883b0c9349e0bcd9ae92e727ff1a277207e1a262b1cbf05",
|
||||||
"sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc",
|
"sha256:036c079aa08a6a592b82487f97c60b439428320ed1b2ea0b3912e99d30c77765",
|
||||||
"sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86",
|
"sha256:039215608ac7b358c4da0191d10fc76868567fbf276d54c14721bdedeb6de064",
|
||||||
"sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63",
|
"sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819",
|
||||||
"sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d",
|
"sha256:0a45e504f5e1be0bd385935a8e1507c442349ca36f511a47057a71c9d1d6ea9e",
|
||||||
"sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f",
|
"sha256:0b362bcd27819f9c07cbf23db4e0e8cd4b44c5ecd900c2ff907b2b92274a7412",
|
||||||
"sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8",
|
"sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc",
|
||||||
"sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0",
|
"sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e",
|
||||||
"sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505",
|
"sha256:10b473fc8dca1c3ad8559985794815f06ca3fc71942c969129070f2c3cdf7281",
|
||||||
"sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161",
|
"sha256:131716d6786ad5e3dc542f5cc6f397ba3339dc0fb87f87ac30e550e8987756af",
|
||||||
"sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af",
|
"sha256:14498a429321de554b140013142abe7608f9d8ccc04d7baf2ad60498374aefa2",
|
||||||
"sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152",
|
"sha256:149ec69866c3d6c2fb6f758dbc014ecb09f30b35a5ca90b6a8a2d4e54e18fdfe",
|
||||||
"sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318",
|
"sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8",
|
||||||
"sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72",
|
"sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262",
|
||||||
"sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4",
|
"sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac",
|
||||||
"sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e",
|
"sha256:1a374cc0b88aa710e8865dc1bd6edb3743c59f27830f0293ab101e4cf3ce9f85",
|
||||||
"sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3",
|
"sha256:1d1401945cb77787dbd3af2446ff2d75912327c4c3a1526ab7955ecf8600687c",
|
||||||
"sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576",
|
"sha256:1f2da5cbb9becfcd607757a169e38fb82aa5fd86fae6653dea716e7b613fe2cf",
|
||||||
"sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c",
|
"sha256:259cd1ca995ad525f638e131dbcc2353a586564c038fc548a3fe450a91882139",
|
||||||
"sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1",
|
"sha256:2820a98460c83663dd8ec015d9ddfd1e4879f12e06bb7d0500f044fb477d2770",
|
||||||
"sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8",
|
"sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d",
|
||||||
"sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1",
|
"sha256:2b970382e4a36bed897c19f310f31d7d13489c11b4f468ddfba42d41cddfb918",
|
||||||
"sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2",
|
"sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3",
|
||||||
"sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44",
|
"sha256:30987f4a8ed169983f93e1be8ffeea5214a779e27ed0b059835c7afe96550ad7",
|
||||||
"sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26",
|
"sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39",
|
||||||
"sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88",
|
"sha256:340810d34ef83af92148e96e3e44cb2d3f910d2bf95e5618a5c467d9f102231d",
|
||||||
"sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016",
|
"sha256:3f64c6bf8f32f9133b668c7f7a7cbdbc453412bc95ecdbd157f3b1e377a92990",
|
||||||
"sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede",
|
"sha256:4167a621a9a1a986c73777dbc15d4b5eac8ac5c10393374109a343d4013ec765",
|
||||||
"sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf",
|
"sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1",
|
||||||
"sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a",
|
"sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa",
|
||||||
"sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc",
|
"sha256:4b8551b6e6531e156db71193771c93bda78ffc4d1e6372517fe58ad3b91e4659",
|
||||||
"sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0",
|
"sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d",
|
||||||
"sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84",
|
"sha256:50bcbca6603c06a1dcc7b056ed45c37715fb5d2768feb3bcd37d2313c587a5b9",
|
||||||
"sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db",
|
"sha256:530beedcec9b6e027e7a4b6ce26eed36678aa39e17da85e6e03d7bd9e8e9d7c9",
|
||||||
"sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1",
|
"sha256:568e3c34b58422075a1b49575a6abc616d9751b4d61b23f712e12ebb78fe47b2",
|
||||||
"sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7",
|
"sha256:573ef5814c4b7c0d59a7710aa920eaaaef383bd71626aa420fba27b5cab92e8d",
|
||||||
"sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed",
|
"sha256:58ad8270cfa5d4bef1bc85bd387217e14ff154d6630e976c6f56f9a040757475",
|
||||||
"sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8",
|
"sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c",
|
||||||
"sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133",
|
"sha256:5bcb3227c3d9aaf73eaaab1db7ccd80a8995c509ee9941e2aae060ca6e4e5d81",
|
||||||
"sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e",
|
"sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67",
|
||||||
"sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef",
|
"sha256:5fea359734b140d0d6741189fea5478c6091b54ffc69d7ce119e0a05637d8c99",
|
||||||
"sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14",
|
"sha256:60d68e820af339df4ae8358c7a2e7596badeb61e544438e489035f9fbf3246a5",
|
||||||
"sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2",
|
"sha256:610f72c0ee565dfb8ae1241b666119582fdbfe7c0975c175be719f940e110694",
|
||||||
"sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0",
|
"sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf",
|
||||||
"sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d",
|
"sha256:65b3c403a5b6b8034b655e7385de4f72b7b244869a22b32d4030b99a60593eca",
|
||||||
"sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828",
|
"sha256:66dee73039277eb35380d1b82cccc69cc82b13a66f9f4a18da32d573acf02b7c",
|
||||||
"sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f",
|
"sha256:708c7acde173eedd4bfa4028484426ba689d2103b28588c513b9db2cd5ecde9c",
|
||||||
"sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf",
|
"sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636",
|
||||||
"sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6",
|
"sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f",
|
||||||
"sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328",
|
"sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02",
|
||||||
"sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090",
|
"sha256:75ee9c1cce2911581a70a3c0919d8bccf5b1cbc9b0e5171400ec736b4b569497",
|
||||||
"sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa",
|
"sha256:76a9d0de4d0eab387822e7b35d8f89367dd237c72e82ab42b9f7bf5e15ada00f",
|
||||||
"sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381",
|
"sha256:77be992288f720306ab4108fe5c74797de327f3248368dfc7e1a916d6ed9e5a2",
|
||||||
"sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c",
|
"sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d",
|
||||||
"sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb",
|
"sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873",
|
||||||
"sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc",
|
"sha256:82cc7c2ad42faec8b574351f8bc2a0c049043893853317bd9bb309f5aba6cb5a",
|
||||||
"sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a",
|
"sha256:8a28afb04baa55abf26df544e3e5c6534245d3daa5178bc4a8eeb48202060d0e",
|
||||||
"sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec",
|
"sha256:8b78d8a609a4b82c273257ee9d631ded7fac0d875bdcdccc109f3ee8328cfcb1",
|
||||||
"sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc",
|
"sha256:8ce11cd4d62d11166f2b441e30ace226c19a3899a7cf0796f668fba49a9fb123",
|
||||||
"sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac",
|
"sha256:8fff79bf5978c693c9b1a4d71e4a94fddfb5fe744eb062a318e15f4a2f63a550",
|
||||||
"sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e",
|
"sha256:92263f7eca2f4af326cd20de8d16728d2602f7cfea02e790dcde9d83c365d7cc",
|
||||||
"sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313",
|
"sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36",
|
||||||
"sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569",
|
"sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644",
|
||||||
"sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3",
|
"sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4",
|
||||||
"sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d",
|
"sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0",
|
||||||
"sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525",
|
"sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e",
|
||||||
"sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894",
|
"sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f",
|
||||||
"sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3",
|
"sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4",
|
||||||
"sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9",
|
"sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98",
|
||||||
"sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a",
|
"sha256:aa2f963b4da26daf46231d9b9e0e2c9408a751f8f0d0f44d2de56d3caf51d294",
|
||||||
"sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9",
|
"sha256:aa92ec1102eaff840ccd1021478af176a831f1bccb08e526ce844b7ddda85c22",
|
||||||
"sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14",
|
"sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23",
|
||||||
"sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25",
|
"sha256:ae8b03427410731469c4033934cf473426faff3e04b69d2dfb64a4281a3719f8",
|
||||||
"sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50",
|
"sha256:afca7f78067dd27c2b848f1b234623d26b87529296c6c5652168cc1954f2f3b2",
|
||||||
"sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf",
|
"sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362",
|
||||||
"sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1",
|
"sha256:b3e71afc578b98512bfe7bdb822dd6bc57d4b0093b4b6e5487c1e96ad4ace242",
|
||||||
"sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3",
|
"sha256:ba20bdf69bd127f66d0174d6f2a93e69045e0b4036dc1ca78e091bcc765830c4",
|
||||||
"sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac",
|
"sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95",
|
||||||
"sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e",
|
"sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d",
|
||||||
"sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815",
|
"sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94",
|
||||||
"sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c",
|
"sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6",
|
||||||
"sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6",
|
"sha256:c7e84e0c0005e3bdc1a9211cd4e62c78ba80bc37b2365ef4410cd2007a9047f2",
|
||||||
"sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6",
|
"sha256:cace89841c0599d736d3d74a27bc5821288bb47c5441923277afc6059d7fbcb4",
|
||||||
"sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e",
|
"sha256:cd2d0f0ec9aa977a27731a3209ebbcacebebaf41f902bd453a928bfd281cf7f8",
|
||||||
"sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4",
|
"sha256:d01de5e768328646e6a3fa9e562706f8f6641708c115c62588aef2b941a4f88e",
|
||||||
"sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84",
|
"sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a",
|
||||||
"sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69",
|
"sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce",
|
||||||
"sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15",
|
"sha256:d29dd9c016f2078b43d0c357511e87eee5b05108f3dd603423cb389b89813969",
|
||||||
"sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191",
|
"sha256:d31f0d1671e1534e395f9eb84a68e0fb670e1edb1fe819a9d7f564ae3bc4e53f",
|
||||||
"sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0",
|
"sha256:d4eb8ac7469b2a5d64b5b8c04f84d8bf3ad340f4514b98523805cbf46e3b3923",
|
||||||
"sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897",
|
"sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6",
|
||||||
"sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd",
|
"sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee",
|
||||||
"sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2",
|
"sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6",
|
||||||
"sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794",
|
"sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467",
|
||||||
"sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d",
|
"sha256:e09f671a54ce70b79a1fc1dc6da3072b7ef7251fadb894ed92d9aa8218465a5f",
|
||||||
"sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074",
|
"sha256:e22d1059b951e7ae7c20ef6b06afd10fb95e3c41bf3c4fbc874dba113321c193",
|
||||||
"sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3",
|
"sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7",
|
||||||
"sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224",
|
"sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9",
|
||||||
"sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838",
|
"sha256:e545b51da9f9af5c67815ca0eb40676c0f016d0b0381c86f20451e35696c5f95",
|
||||||
"sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a",
|
"sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763",
|
||||||
"sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d",
|
"sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7",
|
||||||
"sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d",
|
"sha256:ec56a2266f32bc06ed3c3e2a8f58417ce02f7e0356edc89786e52db13c593c98",
|
||||||
"sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f",
|
"sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60",
|
||||||
"sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8",
|
"sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade",
|
||||||
"sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490",
|
"sha256:ed98364e1c262cf5f9363c3eca8c2df37024f52a8fa1180a3610014f26eac51c",
|
||||||
"sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966",
|
"sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2",
|
||||||
"sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9",
|
"sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f",
|
||||||
"sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3",
|
"sha256:f8102ae93c0bc863b1d41ea0f4499c20a83229f52ed870850892df555187154a",
|
||||||
"sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e",
|
"sha256:fc1c64934b8faf7584924143eb9db4770bbdb16659626e1a1a4d9efbcb68d947",
|
||||||
"sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"
|
"sha256:ff95a9283de8a457e6b12989de3f9f5193430f375d64297d323a615ea52cbdb3"
|
||||||
],
|
],
|
||||||
"markers": "python_version >= '3.7'",
|
"markers": "python_version >= '3.7'",
|
||||||
"version": "==3.4.4"
|
"version": "==3.4.5"
|
||||||
},
|
},
|
||||||
"click": {
|
"click": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -392,66 +384,65 @@
|
|||||||
},
|
},
|
||||||
"cryptography": {
|
"cryptography": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa",
|
"sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72",
|
||||||
"sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc",
|
"sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235",
|
||||||
"sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da",
|
"sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9",
|
||||||
"sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255",
|
"sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356",
|
||||||
"sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2",
|
"sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257",
|
||||||
"sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485",
|
"sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad",
|
||||||
"sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0",
|
"sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4",
|
||||||
"sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d",
|
"sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c",
|
||||||
"sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616",
|
"sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614",
|
||||||
"sha256:44cc0675b27cadb71bdbb96099cca1fa051cd11d2ade09e5cd3a2edb929ed947",
|
"sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed",
|
||||||
"sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0",
|
"sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31",
|
||||||
"sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908",
|
"sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229",
|
||||||
"sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81",
|
"sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0",
|
||||||
"sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc",
|
"sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731",
|
||||||
"sha256:62217ba44bf81b30abaeda1488686a04a702a261e26f87db51ff61d9d3510abd",
|
"sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b",
|
||||||
"sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b",
|
"sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4",
|
||||||
"sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019",
|
"sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4",
|
||||||
"sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7",
|
"sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263",
|
||||||
"sha256:766330cce7416c92b5e90c3bb71b1b79521760cdcfc3a6a1a182d4c9fab23d2b",
|
"sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595",
|
||||||
"sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973",
|
"sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1",
|
||||||
"sha256:829c2b12bbc5428ab02d6b7f7e9bbfd53e33efd6672d21341f2177470171ad8b",
|
"sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678",
|
||||||
"sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5",
|
"sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48",
|
||||||
"sha256:8a15fb869670efa8f83cbffbc8753c1abf236883225aed74cd179b720ac9ec80",
|
"sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76",
|
||||||
"sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef",
|
"sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0",
|
||||||
"sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0",
|
"sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18",
|
||||||
"sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b",
|
"sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d",
|
||||||
"sha256:9b34d8ba84454641a6bf4d6762d15847ecbd85c1316c0a7984e6e4e9f748ec2e",
|
"sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d",
|
||||||
"sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c",
|
"sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1",
|
||||||
"sha256:9c2da296c8d3415b93e6053f5a728649a87a48ce084a9aaf51d6e46c87c7f2d2",
|
"sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981",
|
||||||
"sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af",
|
"sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7",
|
||||||
"sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4",
|
"sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82",
|
||||||
"sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab",
|
"sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2",
|
||||||
"sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82",
|
"sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4",
|
||||||
"sha256:be8c01a7d5a55f9a47d1888162b76c8f49d62b234d88f0ff91a9fbebe32ffbc3",
|
"sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663",
|
||||||
"sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59",
|
"sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c",
|
||||||
"sha256:c236a44acfb610e70f6b3e1c3ca20ff24459659231ef2f8c48e879e2d32b73da",
|
"sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d",
|
||||||
"sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061",
|
"sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a",
|
||||||
"sha256:c92010b58a51196a5f41c3795190203ac52edfd5dc3ff99149b4659eba9d2085",
|
"sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a",
|
||||||
"sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b",
|
"sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d",
|
||||||
"sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263",
|
"sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b",
|
||||||
"sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e",
|
"sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a",
|
||||||
"sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829",
|
"sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826",
|
||||||
"sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4",
|
"sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee",
|
||||||
"sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c",
|
"sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9",
|
||||||
"sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f",
|
"sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648",
|
||||||
"sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095",
|
"sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da",
|
||||||
"sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32",
|
"sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2",
|
||||||
"sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976",
|
"sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2",
|
||||||
"sha256:fdc3daab53b212472f1524d070735b2f0c214239df131903bae1d598016fa822"
|
"sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87"
|
||||||
],
|
],
|
||||||
"markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'",
|
"markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'",
|
||||||
"version": "==46.0.4"
|
"version": "==46.0.5"
|
||||||
},
|
},
|
||||||
"esptool": {
|
"esptool": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:2ea9bcd7eb263d380a4fe0170856a10e4c65e3f38c757ebdc73584c8dd8322da"
|
"sha256:9c355b7d6331cc92979cc710ae5c41f59830d1ea29ec24c467c6005a092c06d6"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"markers": "python_version >= '3.10'",
|
"version": "==5.2.0"
|
||||||
"version": "==5.1.0"
|
|
||||||
},
|
},
|
||||||
"h11": {
|
"h11": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -469,14 +460,6 @@
|
|||||||
"markers": "python_version >= '3.8'",
|
"markers": "python_version >= '3.8'",
|
||||||
"version": "==3.11"
|
"version": "==3.11"
|
||||||
},
|
},
|
||||||
"importlib-metadata": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb",
|
|
||||||
"sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.9'",
|
|
||||||
"version": "==8.7.1"
|
|
||||||
},
|
|
||||||
"intelhex": {
|
"intelhex": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:87cc5225657524ec6361354be928adfd56bcf2a3dcc646c40f8f094c39c07db4",
|
"sha256:87cc5225657524ec6361354be928adfd56bcf2a3dcc646c40f8f094c39c07db4",
|
||||||
@@ -500,23 +483,22 @@
|
|||||||
"markers": "python_version >= '3.7'",
|
"markers": "python_version >= '3.7'",
|
||||||
"version": "==0.1.2"
|
"version": "==0.1.2"
|
||||||
},
|
},
|
||||||
|
"microdot": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:363e3ebfc80b7e0415779848c9332e4e7fb7bd365ee54d3620abffe42ed82946",
|
||||||
|
"sha256:abfb82ca31cc430174e4761cc7356adc4bff00ea758d437c2b258883dc63f464"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==2.6.0"
|
||||||
|
},
|
||||||
"mpremote": {
|
"mpremote": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:11d134c69b21b487dae3d03eed54c8ccbf84c916c8732a3e069a97cae47be3d4",
|
"sha256:11d134c69b21b487dae3d03eed54c8ccbf84c916c8732a3e069a97cae47be3d4",
|
||||||
"sha256:6bb75774648091dad6833af4f86c5bf6505f8d7aec211380f9e6996c01d23cb5"
|
"sha256:6bb75774648091dad6833af4f86c5bf6505f8d7aec211380f9e6996c01d23cb5"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"markers": "python_version >= '3.4'",
|
|
||||||
"version": "==1.27.0"
|
"version": "==1.27.0"
|
||||||
},
|
},
|
||||||
"mypy-extensions": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505",
|
|
||||||
"sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.8'",
|
|
||||||
"version": "==1.1.0"
|
|
||||||
},
|
|
||||||
"outcome": {
|
"outcome": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8",
|
"sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8",
|
||||||
@@ -525,21 +507,13 @@
|
|||||||
"markers": "python_version >= '3.7'",
|
"markers": "python_version >= '3.7'",
|
||||||
"version": "==1.3.0.post0"
|
"version": "==1.3.0.post0"
|
||||||
},
|
},
|
||||||
"packaging": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4",
|
|
||||||
"sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.8'",
|
|
||||||
"version": "==26.0"
|
|
||||||
},
|
|
||||||
"platformdirs": {
|
"platformdirs": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda",
|
"sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934",
|
||||||
"sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31"
|
"sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868"
|
||||||
],
|
],
|
||||||
"markers": "python_version >= '3.10'",
|
"markers": "python_version >= '3.10'",
|
||||||
"version": "==4.5.1"
|
"version": "==4.9.4"
|
||||||
},
|
},
|
||||||
"pycparser": {
|
"pycparser": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -559,12 +533,11 @@
|
|||||||
},
|
},
|
||||||
"pyjwt": {
|
"pyjwt": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623",
|
"sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c",
|
||||||
"sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469"
|
"sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"markers": "python_version >= '3.9'",
|
"version": "==2.12.1"
|
||||||
"version": "==2.11.0"
|
|
||||||
},
|
},
|
||||||
"pyserial": {
|
"pyserial": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -584,11 +557,11 @@
|
|||||||
},
|
},
|
||||||
"python-dotenv": {
|
"python-dotenv": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6",
|
"sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a",
|
||||||
"sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61"
|
"sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"
|
||||||
],
|
],
|
||||||
"markers": "python_version >= '3.9'",
|
"markers": "python_version >= '3.10'",
|
||||||
"version": "==1.2.1"
|
"version": "==1.2.2"
|
||||||
},
|
},
|
||||||
"pyyaml": {
|
"pyyaml": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -682,16 +655,15 @@
|
|||||||
"sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"
|
"sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"markers": "python_version >= '3.9'",
|
|
||||||
"version": "==2.32.5"
|
"version": "==2.32.5"
|
||||||
},
|
},
|
||||||
"rich": {
|
"rich": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69",
|
"sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d",
|
||||||
"sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8"
|
"sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b"
|
||||||
],
|
],
|
||||||
"markers": "python_full_version >= '3.8.0'",
|
"markers": "python_full_version >= '3.8.0'",
|
||||||
"version": "==14.3.2"
|
"version": "==14.3.3"
|
||||||
},
|
},
|
||||||
"rich-click": {
|
"rich-click": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -703,12 +675,11 @@
|
|||||||
},
|
},
|
||||||
"selenium": {
|
"selenium": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:a88f5905d88ad0b84991c2386ea39e2bbde6d6c334be38df5842318ba98eaa8c",
|
"sha256:003e971f805231ad63e671783a2b91a299355d10cefb9de964c36ff3819115aa",
|
||||||
"sha256:c8823fc02e2c771d9ad9a0cf899cee7de1a57a6697e3d0b91f67566129f2b729"
|
"sha256:b8ccde8d2e7642221ca64af184a92c19eee6accf2e27f20f30472f5efae18eb1"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"markers": "python_version >= '3.10'",
|
"version": "==4.41.0"
|
||||||
"version": "==4.40.0"
|
|
||||||
},
|
},
|
||||||
"sniffio": {
|
"sniffio": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -725,20 +696,50 @@
|
|||||||
],
|
],
|
||||||
"version": "==2.4.0"
|
"version": "==2.4.0"
|
||||||
},
|
},
|
||||||
|
"tibs": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:01ea5258bdf942d21560dc07d532082cd04f07cfef65fedd58ae84f7d0d2562a",
|
||||||
|
"sha256:0a7ce857ef05c59dc61abadc31c4b9b1e3c62f9e5fb29217988c308936aea71e",
|
||||||
|
"sha256:130bc68ff500fc8185677df7a97350b5d5339e6ba7e325bc3031337f6424ede7",
|
||||||
|
"sha256:173dfbecb2309edd9771f453580c88cf251e775613461566b23dbd756b3d54cb",
|
||||||
|
"sha256:1906729038b85c3b4c040aa28a456d85bc976d0c5007177350eb73374ffa0fd0",
|
||||||
|
"sha256:1b56583db148e5094d781c3d746815dbcbb6378c6f813c8ce291efd4ab21da8b",
|
||||||
|
"sha256:1d5521cc6768bfa6282a0c591ba06b079ab91b5c7d5696925ad2abac59779a54",
|
||||||
|
"sha256:1f95d5db62960205a1e9eba73ce67dc14e7366ae080cd4e5b6f005ebd90faf02",
|
||||||
|
"sha256:29480bf03e3372a5f9cc59ea0541f76f8efd696d4f0d214715e94247c342a037",
|
||||||
|
"sha256:2a618de62004d9217d2d2ab0f7f9bbdd098c12642dc01f07b3fb00f0b5f3131a",
|
||||||
|
"sha256:42725200f1b02687ed6e6a1c01e0ec150dc829d21d901ffc74cc0ac4d821f57f",
|
||||||
|
"sha256:477608f9b87e24a22ab6d50b81da04a5cb59bfa49598ff7ec5165035a18fb392",
|
||||||
|
"sha256:4b7510235379368b7523f624d46e0680f3706e3a3965877a6583cdcb598b8bac",
|
||||||
|
"sha256:501728d096e10d9a165aa526743d47418a6bbfd7b084fa47ecb22be7641d3edb",
|
||||||
|
"sha256:63255749f937c5e6fedcc7d54e7bd359aef711017e6855f373b0510a14ee2215",
|
||||||
|
"sha256:6a9feed5931b881809a950eca0e01e757113e2383a2af06a3e6982f110c869e2",
|
||||||
|
"sha256:76746f01b3db9dbd802f5e615f11f68df7a29ecef521b082dca53f3fa7d0084f",
|
||||||
|
"sha256:77103a9f1af72ac4cf5006828d0fb21578d19ce55fd990e9a1c8e46fd549561f",
|
||||||
|
"sha256:7d6592ed93c6748acd39df484c1ee24d40ee247c2a20ca38ba03363506fd24f3",
|
||||||
|
"sha256:847709c108800ad6a45efaf9a040628278956938a4897f7427a2587013dc3b98",
|
||||||
|
"sha256:859f05315ffb307d3474c505d694f3a547f00730a024c982f5f60316a5505b3c",
|
||||||
|
"sha256:a61d36155f8ab8642e1b6744e13822f72050fc7ec4f86ec6965295afa04949e2",
|
||||||
|
"sha256:a883ca13a922a66b2c1326a9c188123a574741a72510a4bf52fd6f97db191e44",
|
||||||
|
"sha256:ac0aa2aae38f7325c91c261ce1d18f769c4c7033c98d6ea3ea5534585cf16452",
|
||||||
|
"sha256:ace018a057459e3dccd06a4aae1c5c8cd57e352b263dcef534ae39bf3e03b5cf",
|
||||||
|
"sha256:ad61df93b50f875b277ab736c5d37b6bce56f9abce489a22f4e02d9daa2966e3",
|
||||||
|
"sha256:b9535dc7b7484904a58b51bd8e64da7efbf1d8466ff7e84ed1d78f4ddc561c99",
|
||||||
|
"sha256:d4f3ff613d486650816bc5516760c0382a2cc0ca8aeddd8914d011bc3b81d9a2",
|
||||||
|
"sha256:e13b9c7ff2604b0146772025e1ac6f85c8c625bf6ac73736ff671eaf357dda41",
|
||||||
|
"sha256:f5eea45851c960628a2bd29847765d55e19a687c5374456ad2c8cf6410eb1efa",
|
||||||
|
"sha256:f70bd250769381c73110d6f24feaf8b6fcd44f680b3cb28a20ea06db3d04fb6f"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.8'",
|
||||||
|
"version": "==0.5.7"
|
||||||
|
},
|
||||||
"trio": {
|
"trio": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:150f29ec923bcd51231e1d4c71c7006e65247d68759dd1c19af4ea815a25806b",
|
"sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b",
|
||||||
"sha256:4ab65984ef8370b79a76659ec87aa3a30c5c7c83ff250b4de88c29a8ab6123c5"
|
"sha256:a29b92b73f09d4b48ed249acd91073281a7f1063f09caba5dc70465b5c7aa970"
|
||||||
],
|
],
|
||||||
"markers": "python_version >= '3.10'",
|
"markers": "python_version >= '3.10'",
|
||||||
"version": "==0.32.0"
|
"version": "==0.33.0"
|
||||||
},
|
|
||||||
"trio-typing": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:065ee684296d52a8ab0e2374666301aec36ee5747ac0e7a61f230250f8907ac3",
|
|
||||||
"sha256:6d0e7ec9d837a2fe03591031a172533fbf4a1a95baf369edebfc51d5a49f0264"
|
|
||||||
],
|
|
||||||
"version": "==0.10.0"
|
|
||||||
},
|
},
|
||||||
"trio-websocket": {
|
"trio-websocket": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -748,20 +749,6 @@
|
|||||||
"markers": "python_version >= '3.8'",
|
"markers": "python_version >= '3.8'",
|
||||||
"version": "==0.12.2"
|
"version": "==0.12.2"
|
||||||
},
|
},
|
||||||
"types-certifi": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:72cf7798d165bc0b76e1c10dd1ea3097c7063c42c21d664523b928e88b554a4f",
|
|
||||||
"sha256:b2d1e325e69f71f7c78e5943d410e650b4707bb0ef32e4ddf3da37f54176e88a"
|
|
||||||
],
|
|
||||||
"version": "==2021.10.8.3"
|
|
||||||
},
|
|
||||||
"types-urllib3": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:229b7f577c951b8c1b92c1bc2b2fdb0b49847bd2af6d1cc2a2e3dd340f3bda8f",
|
|
||||||
"sha256:9683bbb7fb72e32bfe9d2be6e04875fbe1b3eeec3cbb4ea231435aa7fd6b4f0e"
|
|
||||||
],
|
|
||||||
"version": "==1.26.25.14"
|
|
||||||
},
|
|
||||||
"typing-extensions": {
|
"typing-extensions": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466",
|
"sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466",
|
||||||
@@ -771,9 +758,6 @@
|
|||||||
"version": "==4.15.0"
|
"version": "==4.15.0"
|
||||||
},
|
},
|
||||||
"urllib3": {
|
"urllib3": {
|
||||||
"extras": [
|
|
||||||
"socks"
|
|
||||||
],
|
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed",
|
"sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed",
|
||||||
"sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"
|
"sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"
|
||||||
@@ -894,7 +878,6 @@
|
|||||||
"sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf"
|
"sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"markers": "python_version >= '3.9'",
|
|
||||||
"version": "==1.1.1"
|
"version": "==1.1.1"
|
||||||
},
|
},
|
||||||
"websocket-client": {
|
"websocket-client": {
|
||||||
@@ -912,14 +895,6 @@
|
|||||||
],
|
],
|
||||||
"markers": "python_version >= '3.10'",
|
"markers": "python_version >= '3.10'",
|
||||||
"version": "==1.3.2"
|
"version": "==1.3.2"
|
||||||
},
|
|
||||||
"zipp": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e",
|
|
||||||
"sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.9'",
|
|
||||||
"version": "==3.23.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"develop": {}
|
"develop": {}
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
# led-controller
|
# led-controller
|
||||||
|
|
||||||
|
## Run on port 80 without root
|
||||||
|
|
||||||
|
Run once: `sudo scripts/setup-port80.sh`. Then start the app with: `pipenv run run`.
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -1,37 +0,0 @@
|
|||||||
/* General tab styles */
|
|
||||||
.tabs {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab {
|
|
||||||
padding: 10px 20px;
|
|
||||||
margin: 0 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
background-color: #f1f1f1;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: background-color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab:hover {
|
|
||||||
background-color: #ddd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab.active {
|
|
||||||
background-color: #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-content {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-pane {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-pane.active {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
Binary file not shown.
@@ -1,2 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
rm -f /home/pi/led-controller/.cursor/debug.log
|
|
||||||
1
db/device.json
Normal file
1
db/device.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -1,17 +1 @@
|
|||||||
{
|
{"1": {"name": "Main Group", "devices": ["1", "2", "3"]}, "2": {"name": "Accent Group", "devices": ["4", "5"]}}
|
||||||
"1": {
|
|
||||||
"name": "Main Group",
|
|
||||||
"devices": [
|
|
||||||
"1",
|
|
||||||
"2",
|
|
||||||
"3"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"2": {
|
|
||||||
"name": "Accent Group",
|
|
||||||
"devices": [
|
|
||||||
"4",
|
|
||||||
"5"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1 @@
|
|||||||
{
|
{"1": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "2": [], "3": [], "4": [], "5": [], "6": [], "7": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "8": [], "9": [], "10": [], "11": [], "12": ["#890b0b", "#0b8935"]}
|
||||||
"1": [
|
|
||||||
"#FF0000",
|
|
||||||
"#00FF00",
|
|
||||||
"#0000FF",
|
|
||||||
"#FFFF00",
|
|
||||||
"#FF00FF",
|
|
||||||
"#00FFFF",
|
|
||||||
"#FFFFFF",
|
|
||||||
"#000000"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
277
db/preset.json
277
db/preset.json
@@ -1,276 +1 @@
|
|||||||
{
|
{"1": {"name": "on", "pattern": "on", "colors": ["#FFFFFF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "2": {"name": "off", "pattern": "off", "colors": [], "brightness": 0, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "3": {"name": "rainbow", "pattern": "rainbow", "colors": [], "brightness": 255, "delay": 100, "auto": true, "n1": 2, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "4": {"name": "transition", "pattern": "transition", "colors": ["#FF0000", "#00FF00", "#0000FF"], "brightness": 255, "delay": 500, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "5": {"name": "chase", "pattern": "chase", "colors": ["#FF0000", "#0000FF"], "brightness": 255, "delay": 200, "auto": true, "n1": 5, "n2": 5, "n3": 1, "n4": 1, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "6": {"name": "pulse", "pattern": "pulse", "colors": ["#00FF00"], "brightness": 255, "delay": 500, "auto": true, "n1": 1000, "n2": 500, "n3": 1000, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "7": {"name": "circle", "pattern": "circle", "colors": ["#FFA500", "#800080"], "brightness": 255, "delay": 200, "auto": true, "n1": 2, "n2": 10, "n3": 2, "n4": 5, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "8": {"name": "blink", "pattern": "blink", "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00"], "brightness": 255, "delay": 1000, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "9": {"name": "warm white", "pattern": "on", "colors": ["#FFF5E6"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "10": {"name": "cool white", "pattern": "on", "colors": ["#E6F2FF"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "11": {"name": "red", "pattern": "on", "colors": ["#FF0000"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "12": {"name": "blue", "pattern": "on", "colors": ["#0000FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "13": {"name": "rainbow slow", "pattern": "rainbow", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "14": {"name": "pulse slow", "pattern": "pulse", "colors": ["#FF6600"], "brightness": 255, "delay": 800, "auto": true, "n1": 2000, "n2": 1000, "n3": 2000, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "15": {"name": "blink red green", "pattern": "blink", "colors": ["#FF0000", "#00FF00"], "brightness": 255, "delay": 500, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "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"
|
|
||||||
],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 500,
|
|
||||||
"auto": true,
|
|
||||||
"n1": 0,
|
|
||||||
"n2": 0,
|
|
||||||
"n3": 0,
|
|
||||||
"n4": 0,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0,
|
|
||||||
"n7": 0,
|
|
||||||
"n8": 0,
|
|
||||||
"profile_id": "1"
|
|
||||||
},
|
|
||||||
"5": {
|
|
||||||
"name": "chase",
|
|
||||||
"pattern": "chase",
|
|
||||||
"colors": [
|
|
||||||
"#FF0000",
|
|
||||||
"#0000FF"
|
|
||||||
],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 200,
|
|
||||||
"auto": true,
|
|
||||||
"n1": 5,
|
|
||||||
"n2": 5,
|
|
||||||
"n3": 1,
|
|
||||||
"n4": 1,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0,
|
|
||||||
"n7": 0,
|
|
||||||
"n8": 0,
|
|
||||||
"profile_id": "1"
|
|
||||||
},
|
|
||||||
"6": {
|
|
||||||
"name": "pulse",
|
|
||||||
"pattern": "pulse",
|
|
||||||
"colors": [
|
|
||||||
"#00FF00"
|
|
||||||
],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 500,
|
|
||||||
"auto": true,
|
|
||||||
"n1": 1000,
|
|
||||||
"n2": 500,
|
|
||||||
"n3": 1000,
|
|
||||||
"n4": 0,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0,
|
|
||||||
"n7": 0,
|
|
||||||
"n8": 0,
|
|
||||||
"profile_id": "1"
|
|
||||||
},
|
|
||||||
"7": {
|
|
||||||
"name": "circle",
|
|
||||||
"pattern": "circle",
|
|
||||||
"colors": [
|
|
||||||
"#FFA500",
|
|
||||||
"#800080"
|
|
||||||
],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 200,
|
|
||||||
"auto": true,
|
|
||||||
"n1": 2,
|
|
||||||
"n2": 10,
|
|
||||||
"n3": 2,
|
|
||||||
"n4": 5,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0,
|
|
||||||
"n7": 0,
|
|
||||||
"n8": 0,
|
|
||||||
"profile_id": "1"
|
|
||||||
},
|
|
||||||
"8": {
|
|
||||||
"name": "blink",
|
|
||||||
"pattern": "blink",
|
|
||||||
"colors": [
|
|
||||||
"#FF0000",
|
|
||||||
"#00FF00",
|
|
||||||
"#0000FF",
|
|
||||||
"#FFFF00"
|
|
||||||
],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 1000,
|
|
||||||
"auto": true,
|
|
||||||
"n1": 0,
|
|
||||||
"n2": 0,
|
|
||||||
"n3": 0,
|
|
||||||
"n4": 0,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0,
|
|
||||||
"n7": 0,
|
|
||||||
"n8": 0,
|
|
||||||
"profile_id": "1"
|
|
||||||
},
|
|
||||||
"9": {
|
|
||||||
"name": "warm white",
|
|
||||||
"pattern": "on",
|
|
||||||
"colors": ["#FFF5E6"],
|
|
||||||
"brightness": 200,
|
|
||||||
"delay": 100,
|
|
||||||
"auto": true,
|
|
||||||
"n1": 0,
|
|
||||||
"n2": 0,
|
|
||||||
"n3": 0,
|
|
||||||
"n4": 0,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0,
|
|
||||||
"n7": 0,
|
|
||||||
"n8": 0,
|
|
||||||
"profile_id": "1"
|
|
||||||
},
|
|
||||||
"10": {
|
|
||||||
"name": "cool white",
|
|
||||||
"pattern": "on",
|
|
||||||
"colors": ["#E6F2FF"],
|
|
||||||
"brightness": 200,
|
|
||||||
"delay": 100,
|
|
||||||
"auto": true,
|
|
||||||
"n1": 0,
|
|
||||||
"n2": 0,
|
|
||||||
"n3": 0,
|
|
||||||
"n4": 0,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0,
|
|
||||||
"n7": 0,
|
|
||||||
"n8": 0,
|
|
||||||
"profile_id": "1"
|
|
||||||
},
|
|
||||||
"11": {
|
|
||||||
"name": "red",
|
|
||||||
"pattern": "on",
|
|
||||||
"colors": ["#FF0000"],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 100,
|
|
||||||
"auto": true,
|
|
||||||
"n1": 0,
|
|
||||||
"n2": 0,
|
|
||||||
"n3": 0,
|
|
||||||
"n4": 0,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0,
|
|
||||||
"n7": 0,
|
|
||||||
"n8": 0,
|
|
||||||
"profile_id": "1"
|
|
||||||
},
|
|
||||||
"12": {
|
|
||||||
"name": "blue",
|
|
||||||
"pattern": "on",
|
|
||||||
"colors": ["#0000FF"],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 100,
|
|
||||||
"auto": true,
|
|
||||||
"n1": 0,
|
|
||||||
"n2": 0,
|
|
||||||
"n3": 0,
|
|
||||||
"n4": 0,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0,
|
|
||||||
"n7": 0,
|
|
||||||
"n8": 0,
|
|
||||||
"profile_id": "1"
|
|
||||||
},
|
|
||||||
"13": {
|
|
||||||
"name": "rainbow slow",
|
|
||||||
"pattern": "rainbow",
|
|
||||||
"colors": [],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 150,
|
|
||||||
"auto": true,
|
|
||||||
"n1": 1,
|
|
||||||
"n2": 0,
|
|
||||||
"n3": 0,
|
|
||||||
"n4": 0,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0,
|
|
||||||
"n7": 0,
|
|
||||||
"n8": 0,
|
|
||||||
"profile_id": "1"
|
|
||||||
},
|
|
||||||
"14": {
|
|
||||||
"name": "pulse slow",
|
|
||||||
"pattern": "pulse",
|
|
||||||
"colors": ["#FF6600"],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 800,
|
|
||||||
"auto": true,
|
|
||||||
"n1": 2000,
|
|
||||||
"n2": 1000,
|
|
||||||
"n3": 2000,
|
|
||||||
"n4": 0,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0,
|
|
||||||
"n7": 0,
|
|
||||||
"n8": 0,
|
|
||||||
"profile_id": "1"
|
|
||||||
},
|
|
||||||
"15": {
|
|
||||||
"name": "blink red green",
|
|
||||||
"pattern": "blink",
|
|
||||||
"colors": ["#FF0000", "#00FF00"],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 500,
|
|
||||||
"auto": true,
|
|
||||||
"n1": 0,
|
|
||||||
"n2": 0,
|
|
||||||
"n3": 0,
|
|
||||||
"n4": 0,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0,
|
|
||||||
"n7": 0,
|
|
||||||
"n8": 0,
|
|
||||||
"profile_id": "1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +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"
|
|
||||||
],
|
|
||||||
"scenes": [],
|
|
||||||
"palette_id": "1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +1 @@
|
|||||||
{
|
{"1": {"group_name": "Main Group", "presets": ["1", "2"], "sequence_duration": 3000, "sequence_transition": 500, "sequence_loop": true, "sequence_repeat_count": 0, "sequence_active": false, "sequence_index": 0, "sequence_start_time": 0}, "2": {"group_name": "Accent Group", "presets": ["2", "3"], "sequence_duration": 2000, "sequence_transition": 300, "sequence_loop": true, "sequence_repeat_count": 0, "sequence_active": false, "sequence_index": 0, "sequence_start_time": 0}}
|
||||||
"1": {
|
|
||||||
"group_name": "Main Group",
|
|
||||||
"presets": [
|
|
||||||
"1",
|
|
||||||
"2"
|
|
||||||
],
|
|
||||||
"sequence_duration": 3000,
|
|
||||||
"sequence_transition": 500,
|
|
||||||
"sequence_loop": true,
|
|
||||||
"sequence_repeat_count": 0,
|
|
||||||
"sequence_active": false,
|
|
||||||
"sequence_index": 0,
|
|
||||||
"sequence_start_time": 0
|
|
||||||
},
|
|
||||||
"2": {
|
|
||||||
"group_name": "Accent Group",
|
|
||||||
"presets": [
|
|
||||||
"2",
|
|
||||||
"3"
|
|
||||||
],
|
|
||||||
"sequence_duration": 2000,
|
|
||||||
"sequence_transition": 300,
|
|
||||||
"sequence_loop": true,
|
|
||||||
"sequence_repeat_count": 0,
|
|
||||||
"sequence_active": false,
|
|
||||||
"sequence_index": 0,
|
|
||||||
"sequence_start_time": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
28
db/tab.json
28
db/tab.json
@@ -1,27 +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": [
|
|
||||||
"a","b","c","d","e","f","g","h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "1", "2", "3", "4", "5", "6", "7", "8", "9", "0"
|
|
||||||
],
|
|
||||||
"presets": [
|
|
||||||
[
|
|
||||||
"1",
|
|
||||||
"2",
|
|
||||||
"3",
|
|
||||||
"4",
|
|
||||||
"5",
|
|
||||||
"6",
|
|
||||||
"7",
|
|
||||||
"8",
|
|
||||||
"9",
|
|
||||||
"10",
|
|
||||||
"11",
|
|
||||||
"12",
|
|
||||||
"13",
|
|
||||||
"14",
|
|
||||||
"15"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
446
docs/API.md
446
docs/API.md
@@ -1,263 +1,297 @@
|
|||||||
# LED Driver ESPNow API Documentation
|
# LED Controller API
|
||||||
|
|
||||||
This document describes the ESPNow message format for controlling LED driver devices.
|
This document covers:
|
||||||
|
|
||||||
## Message Format
|
1. **HTTP and WebSocket** exposed by the Raspberry Pi app (`src/main.py`) — profiles, presets, transport send, and related resources.
|
||||||
|
2. **LED driver JSON** — the compact message format sent over the serial→ESP-NOW bridge to devices (same logical API as ESP-NOW payloads).
|
||||||
|
|
||||||
All messages are JSON objects sent via ESPNow with the following structure:
|
Default listen address: `0.0.0.0`. Port defaults to **80**; override with the `PORT` environment variable (see `pipenv run run`).
|
||||||
|
|
||||||
|
All JSON APIs use `Content-Type: application/json` for bodies and responses unless noted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session and scoping
|
||||||
|
|
||||||
|
Several routes use **`@with_session`**: the server stores a **current profile** in the session (cookie). Endpoints that scope data to “the current profile” (notably **`/presets`**) only return or mutate presets whose `profile_id` matches that session value.
|
||||||
|
|
||||||
|
Profiles are selected with **`POST /profiles/<id>/apply`**, which sets `current_profile` in the session.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Static pages and assets
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/` | Main UI (`templates/index.html`) |
|
||||||
|
| GET | `/settings` | Settings page (`templates/settings.html`) |
|
||||||
|
| GET | `/favicon.ico` | Empty response (204) |
|
||||||
|
| GET | `/static/<path>` | Static files under `src/static/` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WebSocket: `/ws`
|
||||||
|
|
||||||
|
Connect to **`ws://<host>:<port>/ws`**.
|
||||||
|
|
||||||
|
- Send **JSON**: the object is forwarded to the transport (serial bridge → ESP-NOW) as JSON. Optional key **`to`**: 12-character hex MAC address; if present it is removed from the object and the payload is sent to that peer; otherwise the default destination is used.
|
||||||
|
- Send **non-JSON text**: forwarded as raw bytes with the default address.
|
||||||
|
- On send failure, the server may reply with `{"error": "Send failed"}`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HTTP API by resource
|
||||||
|
|
||||||
|
Below, `<id>` values are string identifiers used by the JSON stores (numeric strings in practice).
|
||||||
|
|
||||||
|
### Settings — `/settings`
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/settings` | Full settings object (from `settings.json` / `Settings` model). |
|
||||||
|
| PUT | `/settings/settings` | Merge keys into settings and save. Returns `{"message": "Settings updated successfully"}`. |
|
||||||
|
| GET | `/settings/wifi/ap` | Saved Wi‑Fi AP fields: `saved_ssid`, `saved_password`, `saved_channel`, `active` (Pi: `active` is always false). |
|
||||||
|
| POST | `/settings/wifi/ap` | Body: `ssid` (required), `password`, `channel` (1–11). Persists AP-related settings. |
|
||||||
|
| GET | `/settings/page` | Serves `templates/settings.html` (same page as `GET /settings` from the root app, for convenience). |
|
||||||
|
|
||||||
|
### Profiles — `/profiles`
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/profiles` | `{"profiles": {...}, "current_profile_id": "<id>"}`. Ensures a default current profile when possible. |
|
||||||
|
| GET | `/profiles/current` | `{"id": "...", "profile": {...}}` |
|
||||||
|
| GET | `/profiles/<id>` | Single profile. If `<id>` is `current`, same as `/profiles/current`. |
|
||||||
|
| POST | `/profiles` | Create profile. Body may include `name` and other fields. Returns `{ "<id>": { ... } }` with status 201. |
|
||||||
|
| POST | `/profiles/<id>/apply` | Sets session current profile to `<id>`. |
|
||||||
|
| POST | `/profiles/<id>/clone` | Clone profile (tabs, palettes, presets). Body may include `name`. |
|
||||||
|
| PUT | `/profiles/current` | Update the current profile (from session). |
|
||||||
|
| PUT | `/profiles/<id>` | Update profile by id. |
|
||||||
|
| DELETE | `/profiles/<id>` | Delete profile. |
|
||||||
|
|
||||||
|
### Presets — `/presets`
|
||||||
|
|
||||||
|
Scoped to **current profile** in session (see above).
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/presets` | Map of preset id → preset object for the current profile only. |
|
||||||
|
| GET | `/presets/<id>` | One preset, 404 if missing or wrong profile. |
|
||||||
|
| POST | `/presets` | Create preset; server assigns id and sets `profile_id`. Body fields stored on the preset. Returns `{ "<id>": { ... } }`, 201. |
|
||||||
|
| PUT | `/presets/<id>` | Update preset (must belong to current profile). |
|
||||||
|
| DELETE | `/presets/<id>` | Delete preset. |
|
||||||
|
| POST | `/presets/send` | Push presets to the LED driver over the configured transport (see below). |
|
||||||
|
|
||||||
|
**`POST /presets/send` body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"preset_ids": ["1", "2"],
|
||||||
|
"save": true,
|
||||||
|
"default": "1",
|
||||||
|
"destination_mac": "aabbccddeeff"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`preset_ids`** (or **`ids`**): non-empty list of preset ids to include.
|
||||||
|
- **`save`**: if true, the outgoing message includes `"save": true` so the driver may persist presets (default true).
|
||||||
|
- **`default`**: optional preset id string; forwarded as top-level `"default"` in the driver message (startup selection on device).
|
||||||
|
- **`destination_mac`** (or **`to`**): optional 12-character hex MAC for unicast; omitted uses the transport default (e.g. broadcast).
|
||||||
|
|
||||||
|
Response on success includes `presets_sent`, `messages_sent` (chunking splits payloads so each JSON string stays ≤ 240 bytes).
|
||||||
|
|
||||||
|
### Tabs — `/tabs`
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/tabs` | `tabs`, `tab_order`, `current_tab_id`, `profile_id` for the session-backed profile. |
|
||||||
|
| GET | `/tabs/current` | Current tab from cookie/session. |
|
||||||
|
| POST | `/tabs` | Create tab; optional JSON `name`, `names`, `presets`; can append to current profile’s tab list. |
|
||||||
|
| GET | `/tabs/<id>` | Tab JSON. |
|
||||||
|
| PUT | `/tabs/<id>` | Update tab. |
|
||||||
|
| DELETE | `/tabs/<id>` | Delete tab; can delete `current` to remove the active tab; updates profile tab list. |
|
||||||
|
| POST | `/tabs/<id>/set-current` | Sets `current_tab` cookie. |
|
||||||
|
| POST | `/tabs/<id>/clone` | Clone tab into current profile. |
|
||||||
|
|
||||||
|
### Palettes — `/palettes`
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/palettes` | Map of id → color list. |
|
||||||
|
| GET | `/palettes/<id>` | `{"colors": [...], "id": "<id>"}` |
|
||||||
|
| POST | `/palettes` | Body may include `colors`. Returns palette object with `id`, 201. |
|
||||||
|
| PUT | `/palettes/<id>` | Update colors (`name` ignored). |
|
||||||
|
| DELETE | `/palettes/<id>` | Delete palette. |
|
||||||
|
|
||||||
|
### Groups — `/groups`
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/groups` | All groups. |
|
||||||
|
| GET | `/groups/<id>` | One group. |
|
||||||
|
| POST | `/groups` | Create; optional `name` and fields. |
|
||||||
|
| PUT | `/groups/<id>` | Update. |
|
||||||
|
| DELETE | `/groups/<id>` | Delete. |
|
||||||
|
|
||||||
|
### Scenes — `/scenes`
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/scenes` | All scenes. |
|
||||||
|
| GET | `/scenes/<id>` | One scene. |
|
||||||
|
| POST | `/scenes` | Create (body JSON stored on scene). |
|
||||||
|
| PUT | `/scenes/<id>` | Update. |
|
||||||
|
| DELETE | `/scenes/<id>` | Delete. |
|
||||||
|
|
||||||
|
### Sequences — `/sequences`
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/sequences` | All sequences. |
|
||||||
|
| GET | `/sequences/<id>` | One sequence. |
|
||||||
|
| POST | `/sequences` | Create; may use `group_name`, `presets` in body. |
|
||||||
|
| PUT | `/sequences/<id>` | Update. |
|
||||||
|
| DELETE | `/sequences/<id>` | Delete. |
|
||||||
|
|
||||||
|
### Patterns — `/patterns`
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/patterns/definitions` | Contents of `pattern.json` (pattern metadata for the UI). |
|
||||||
|
| GET | `/patterns` | All pattern records. |
|
||||||
|
| GET | `/patterns/<id>` | One pattern. |
|
||||||
|
| POST | `/patterns` | Create (`name`, optional `data`). |
|
||||||
|
| PUT | `/patterns/<id>` | Update. |
|
||||||
|
| DELETE | `/patterns/<id>` | Delete. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LED driver message format (transport / ESP-NOW)
|
||||||
|
|
||||||
|
Messages are JSON objects. The Pi **`build_message()`** helper (`src/util/espnow_message.py`) produces the same shape sent over serial and forwarded by the ESP32 bridge.
|
||||||
|
|
||||||
|
### Top-level fields
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"v": "1",
|
"v": "1",
|
||||||
"presets": { ... },
|
"presets": { },
|
||||||
"select": { ... }
|
"select": { },
|
||||||
|
"save": true,
|
||||||
|
"default": "preset_id",
|
||||||
|
"b": 255
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Version Field
|
- **`v`** (required): Must be `"1"` or the driver ignores the message.
|
||||||
|
- **`presets`**: Map of **preset id** (string) → preset object (see below). Optional **`name`** field on each value is accepted for display; the driver keys presets by map key.
|
||||||
|
- **`select`**: Map of **device name** (as in device settings) → `[ "preset_id" ]` or `[ "preset_id", step ]`.
|
||||||
|
- **`save`**: If present (e.g. true), the driver may persist presets to flash after applying.
|
||||||
|
- **`default`**: Preset id string to use as startup default on the device.
|
||||||
|
- **`b`**: Optional **global** brightness 0–255 (driver applies this in addition to per-preset brightness).
|
||||||
|
|
||||||
- **`v`** (required): Message version, must be `"1"`. Messages with other versions are ignored.
|
### Preset object (wire / driver keys)
|
||||||
|
|
||||||
## Presets
|
On the wire, presets use **short keys** (saves space in the ≤240-byte chunks):
|
||||||
|
|
||||||
Presets define LED patterns with their configuration. Each preset has a name and contains pattern-specific settings.
|
| Key | Meaning | Notes |
|
||||||
|
|-----|---------|--------|
|
||||||
|
| `p` | Pattern id | `off`, `on`, `blink`, `rainbow`, `pulse`, `transition`, `chase`, `circle` |
|
||||||
|
| `c` | Colors | Array of `"#RRGGBB"` hex strings; converted to RGB on device |
|
||||||
|
| `d` | Delay ms | Default 100 |
|
||||||
|
| `b` | Preset brightness | 0–255; combined with global `b` on the device |
|
||||||
|
| `a` | Auto | `true`: run continuously; `false`: one step/cycle per “beat” |
|
||||||
|
| `n1`–`n6` | Pattern parameters | See below |
|
||||||
|
|
||||||
### Preset Structure
|
The HTTP app’s **`POST /presets/send`** path builds this from stored presets via **`build_preset_dict()`** (long names like `pattern` / `colors` in the DB are translated to `p` / `c` / …).
|
||||||
|
|
||||||
```json
|
### Pattern-specific parameters (`n1`–`n6`)
|
||||||
{
|
|
||||||
"presets": {
|
|
||||||
"preset_name": {
|
|
||||||
"pattern": "pattern_type",
|
|
||||||
"colors": ["#RRGGBB", ...],
|
|
||||||
"delay": 100,
|
|
||||||
"brightness": 127,
|
|
||||||
"auto": true,
|
|
||||||
"n1": 0,
|
|
||||||
"n2": 0,
|
|
||||||
"n3": 0,
|
|
||||||
"n4": 0,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Preset Fields
|
|
||||||
|
|
||||||
- **`pattern`** (required): Pattern type. Options:
|
|
||||||
- `"off"` - Turn off all LEDs
|
|
||||||
- `"on"` - Solid color
|
|
||||||
- `"blink"` - Blinking pattern
|
|
||||||
- `"rainbow"` - Rainbow color cycle
|
|
||||||
- `"pulse"` - Pulse/fade pattern
|
|
||||||
- `"transition"` - Color transition
|
|
||||||
- `"chase"` - Chasing pattern
|
|
||||||
- `"circle"` - Circle loading pattern
|
|
||||||
|
|
||||||
- **`colors`** (optional): Array of hex color strings (e.g., `"#FF0000"` for red). Default: `["#FFFFFF"]`
|
|
||||||
- Colors are automatically converted from hex to RGB and reordered based on device color order setting
|
|
||||||
- Supports multiple colors for patterns that use them
|
|
||||||
|
|
||||||
- **`delay`** (optional): Delay in milliseconds between pattern updates. Default: `100`
|
|
||||||
|
|
||||||
- **`brightness`** (optional): Brightness level (0-255). Default: `127`
|
|
||||||
|
|
||||||
- **`auto`** (optional): Auto mode flag. Default: `true`
|
|
||||||
- `true`: Pattern runs continuously
|
|
||||||
- `false`: Pattern advances one step per beat (manual mode)
|
|
||||||
|
|
||||||
- **`n1` through `n6`** (optional): Pattern-specific numeric parameters. Default: `0`
|
|
||||||
- See pattern-specific documentation below
|
|
||||||
|
|
||||||
### Pattern-Specific Parameters
|
|
||||||
|
|
||||||
#### Rainbow
|
#### Rainbow
|
||||||
- **`n1`**: Step increment (how many color wheel positions to advance per update). Default: `1`
|
- **`n1`**: Step increment on the color wheel per update (default 1).
|
||||||
|
|
||||||
#### Pulse
|
#### Pulse
|
||||||
- **`n1`**: Attack time in milliseconds (fade in)
|
- **`n1`**: Attack (fade in) ms
|
||||||
- **`n2`**: Hold time in milliseconds (full brightness)
|
- **`n2`**: Hold ms
|
||||||
- **`n3`**: Decay time in milliseconds (fade out)
|
- **`n3`**: Decay (fade out) ms
|
||||||
- **`delay`**: Delay time in milliseconds (off between pulses)
|
- **`d`**: Off time between pulses ms
|
||||||
|
|
||||||
#### Transition
|
#### Transition
|
||||||
- **`delay`**: Transition duration in milliseconds
|
- **`d`**: Transition duration ms
|
||||||
|
|
||||||
#### Chase
|
#### Chase
|
||||||
- **`n1`**: Number of LEDs with first color
|
- **`n1`**: LEDs with first color
|
||||||
- **`n2`**: Number of LEDs with second color
|
- **`n2`**: LEDs with second color
|
||||||
- **`n3`**: Movement amount on even steps (can be negative)
|
- **`n3`**: Movement on even steps (may be negative)
|
||||||
- **`n4`**: Movement amount on odd steps (can be negative)
|
- **`n4`**: Movement on odd steps (may be negative)
|
||||||
|
|
||||||
#### Circle
|
#### Circle
|
||||||
- **`n1`**: Head movement rate (LEDs per second)
|
- **`n1`**: Head speed (LEDs/s)
|
||||||
- **`n2`**: Maximum length
|
- **`n2`**: Max length
|
||||||
- **`n3`**: Tail movement rate (LEDs per second)
|
- **`n3`**: Tail speed (LEDs/s)
|
||||||
- **`n4`**: Minimum length
|
- **`n4`**: Min length
|
||||||
|
|
||||||
## Select Messages
|
### Select messages
|
||||||
|
|
||||||
Select messages control which preset is active on which device. The format uses a list to support step synchronization.
|
|
||||||
|
|
||||||
### Select Format
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"select": {
|
"select": {
|
||||||
"device_name": ["preset_name"],
|
"device_name": ["preset_id"],
|
||||||
"device_name2": ["preset_name2", step_value]
|
"other_device": ["preset_id", 10]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Select Fields
|
- One element: select preset; step behavior follows driver rules (reset on `off`, etc.).
|
||||||
|
- Two elements: explicit **step** for sync.
|
||||||
|
|
||||||
- **`select`**: Object mapping device names to selection lists
|
### Beat and sync behavior
|
||||||
- **Key**: Device name (as configured in device settings)
|
|
||||||
- **Value**: List with one or two elements:
|
|
||||||
- `["preset_name"]` - Select preset (uses default step behavior)
|
|
||||||
- `["preset_name", step]` - Select preset with explicit step value (for synchronization)
|
|
||||||
|
|
||||||
### Step Synchronization
|
- Sending **`select`** again with the **same** preset name acts as a **beat** (advances manual patterns / restarts generators per driver logic).
|
||||||
|
- Choosing **`off`** resets step as a sync point; then selecting a pattern aligns step 0 across devices unless a step is passed explicitly.
|
||||||
|
|
||||||
The step value allows precise synchronization across multiple devices:
|
### Example (compact preset map)
|
||||||
|
|
||||||
- **Without step**: `["preset_name"]`
|
|
||||||
- If switching to different preset: step resets to 0
|
|
||||||
- If selecting "off" pattern: step resets to 0
|
|
||||||
- If selecting same preset (beat): step is preserved, pattern restarts
|
|
||||||
|
|
||||||
- **With step**: `["preset_name", 10]`
|
|
||||||
- Explicitly sets step to the specified value
|
|
||||||
- Useful for synchronizing multiple devices to the same step
|
|
||||||
|
|
||||||
### Beat Functionality
|
|
||||||
|
|
||||||
Calling `select()` again with the same preset name acts as a "beat" - it restarts the pattern generator:
|
|
||||||
|
|
||||||
- **Single-tick patterns** (rainbow, chase in manual mode): Advance one step per beat
|
|
||||||
- **Multi-tick patterns** (pulse in manual mode): Run through full cycle per beat
|
|
||||||
|
|
||||||
Example beat sequence:
|
|
||||||
```json
|
|
||||||
// Beat 1
|
|
||||||
{"select": {"device1": ["rainbow_preset"]}}
|
|
||||||
|
|
||||||
// Beat 2 (same preset = beat)
|
|
||||||
{"select": {"device1": ["rainbow_preset"]}}
|
|
||||||
|
|
||||||
// Beat 3
|
|
||||||
{"select": {"device1": ["rainbow_preset"]}}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Synchronization
|
|
||||||
|
|
||||||
### Using "off" Pattern
|
|
||||||
|
|
||||||
Selecting the "off" pattern resets the step counter to 0, providing a synchronization point:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"select": {
|
|
||||||
"device1": ["off"],
|
|
||||||
"device2": ["off"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
After all devices are "off", switching to a pattern ensures they all start from step 0:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"select": {
|
|
||||||
"device1": ["rainbow_preset"],
|
|
||||||
"device2": ["rainbow_preset"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using Step Parameter
|
|
||||||
|
|
||||||
For precise synchronization, use the step parameter:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"select": {
|
|
||||||
"device1": ["rainbow_preset", 10],
|
|
||||||
"device2": ["rainbow_preset", 10],
|
|
||||||
"device3": ["rainbow_preset", 10]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
All devices will start at step 10 and advance together on subsequent beats.
|
|
||||||
|
|
||||||
## Complete Example
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"v": "1",
|
"v": "1",
|
||||||
|
"save": true,
|
||||||
"presets": {
|
"presets": {
|
||||||
"red_blink": {
|
"1": {
|
||||||
"pattern": "blink",
|
"name": "Red blink",
|
||||||
"colors": ["#FF0000"],
|
"p": "blink",
|
||||||
"delay": 200,
|
"c": ["#FF0000"],
|
||||||
"brightness": 255,
|
"d": 200,
|
||||||
"auto": true
|
"b": 255,
|
||||||
},
|
"a": true,
|
||||||
"rainbow_manual": {
|
"n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0
|
||||||
"pattern": "rainbow",
|
|
||||||
"delay": 100,
|
|
||||||
"n1": 2,
|
|
||||||
"auto": false
|
|
||||||
},
|
|
||||||
"pulse_slow": {
|
|
||||||
"pattern": "pulse",
|
|
||||||
"colors": ["#00FF00"],
|
|
||||||
"delay": 500,
|
|
||||||
"n1": 1000,
|
|
||||||
"n2": 500,
|
|
||||||
"n3": 1000,
|
|
||||||
"auto": false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"select": {
|
"select": {
|
||||||
"device1": ["red_blink"],
|
"living-room": ["1"]
|
||||||
"device2": ["rainbow_manual", 0],
|
|
||||||
"device3": ["pulse_slow"]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Message Processing
|
---
|
||||||
|
|
||||||
1. **Version Check**: Messages with `v != "1"` are rejected
|
## Processing summary (driver)
|
||||||
2. **Preset Processing**: Presets are created or updated (upsert behavior)
|
|
||||||
3. **Color Conversion**: Hex colors are converted to RGB tuples and reordered based on device color order
|
|
||||||
4. **Selection**: Devices select their assigned preset, optionally with step value
|
|
||||||
|
|
||||||
## Best Practices
|
1. Reject if `v != "1"`.
|
||||||
|
2. Apply optional top-level **`b`** (global brightness).
|
||||||
|
3. For each entry in **`presets`**, normalize colors and upsert preset by id.
|
||||||
|
4. If this device’s **`name`** appears in **`select`**, run selection (optional step).
|
||||||
|
5. If **`default`** is set, store startup preset id.
|
||||||
|
6. If **`save`** is set, persist presets.
|
||||||
|
|
||||||
1. **Always include version**: Set `"v": "1"` in all messages
|
---
|
||||||
2. **Use "off" for sync**: Select "off" pattern to synchronize devices before starting patterns
|
|
||||||
3. **Beats for manual mode**: Send select messages repeatedly with same preset name to advance manual patterns
|
|
||||||
4. **Step for precision**: Use step parameter when exact synchronization is required
|
|
||||||
5. **Color format**: Always use hex strings (`"#RRGGBB"`), conversion is automatic
|
|
||||||
|
|
||||||
## Error Handling
|
## Error handling (HTTP)
|
||||||
|
|
||||||
- Invalid version: Message is ignored
|
Controllers typically return JSON with an **`error`** string and 4xx/5xx status codes. Invalid JSON bodies often yield `{"error": "Invalid JSON"}`.
|
||||||
- Missing preset: Selection fails, device keeps current preset
|
|
||||||
- Invalid pattern: Selection fails, device keeps current preset
|
---
|
||||||
- Missing colors: Pattern uses default white color
|
|
||||||
- Invalid step: Step value is used as-is (may cause unexpected behavior)
|
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Colors are automatically converted from hex strings to RGB tuples
|
- **Human-readable preset fields** (`pattern`, `colors`, `delay`, …) are fine in the **web app / database**; the **send path** converts them to **`p` / `c` / `d`** for the driver.
|
||||||
- Color order reordering happens automatically based on device settings
|
- For a copy of the older long-key reference, see **`led-driver/docs/API.md`** in this repo (conceptually the same behavior; wire format prefers short keys).
|
||||||
- Step counter wraps around (0-255 for rainbow, unbounded for others)
|
|
||||||
- Manual mode patterns stop after one step/cycle, waiting for next beat
|
|
||||||
- Auto mode patterns run continuously until changed
|
|
||||||
|
|||||||
@@ -1,30 +1,23 @@
|
|||||||
{
|
{
|
||||||
"grps": [
|
"g":{
|
||||||
{
|
"df": {
|
||||||
"n": "group1",
|
|
||||||
"pt": "on",
|
"pt": "on",
|
||||||
"cl": [
|
"cl": ["#ff0000"],
|
||||||
"000000",
|
"br": 200,
|
||||||
"000000"
|
"n1": 10,
|
||||||
],
|
"n2": 10,
|
||||||
"br": 100,
|
"n3": 10,
|
||||||
"dl": 100,
|
"n4": 10,
|
||||||
"n1": 0,
|
"n5": 10,
|
||||||
"n2": 0,
|
"n6": 10,
|
||||||
"n3": 0,
|
|
||||||
"n4": 0,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"n": "group2",
|
|
||||||
"pt": "on",
|
|
||||||
"cl": [
|
|
||||||
"000000",
|
|
||||||
"000000"
|
|
||||||
],
|
|
||||||
"br": 100,
|
|
||||||
"dl": 100
|
"dl": 100
|
||||||
|
},
|
||||||
|
"dj": {
|
||||||
|
"pt": "blink",
|
||||||
|
"cl": ["#00ff00"],
|
||||||
|
"dl": 500
|
||||||
}
|
}
|
||||||
]
|
},
|
||||||
|
"sv": true,
|
||||||
|
"st": 0
|
||||||
}
|
}
|
||||||
112
esp32/benchmark_peers.py
Normal file
112
esp32/benchmark_peers.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# Benchmark: LRU eviction vs add-then-remove-after-use on ESP32.
|
||||||
|
# Run on device: mpremote run esp32/benchmark_peers.py
|
||||||
|
# (add/del_peer are timed; send() may fail if no peer is listening - timing still valid)
|
||||||
|
import espnow
|
||||||
|
import network
|
||||||
|
import time
|
||||||
|
|
||||||
|
BROADCAST = b"\xff\xff\xff\xff\xff\xff"
|
||||||
|
MAX_PEERS = 20
|
||||||
|
ITERATIONS = 50
|
||||||
|
PAYLOAD = b"x" * 32 # small payload
|
||||||
|
|
||||||
|
network.WLAN(network.STA_IF).active(True)
|
||||||
|
esp = espnow.ESPNow()
|
||||||
|
esp.active(True)
|
||||||
|
esp.add_peer(BROADCAST)
|
||||||
|
|
||||||
|
# Build 19 dummy MACs so we have 20 peers total (broadcast + 19).
|
||||||
|
def mac(i):
|
||||||
|
return bytes([0, 0, 0, 0, 0, i])
|
||||||
|
peers_list = [mac(i) for i in range(1, 20)]
|
||||||
|
for p in peers_list:
|
||||||
|
esp.add_peer(p)
|
||||||
|
|
||||||
|
# One "new" MAC we'll add/remove.
|
||||||
|
new_mac = bytes([0, 0, 0, 0, 0, 99])
|
||||||
|
|
||||||
|
def bench_lru():
|
||||||
|
"""LRU: ensure_peer (evict oldest + add new), send, update last_used."""
|
||||||
|
last_used = {BROADCAST: time.ticks_ms()}
|
||||||
|
for p in peers_list:
|
||||||
|
last_used[p] = time.ticks_ms()
|
||||||
|
# Pre-remove one so we have 19; ensure_peer(new) will add 20th.
|
||||||
|
esp.del_peer(peers_list[-1])
|
||||||
|
last_used.pop(peers_list[-1], None)
|
||||||
|
# Now 19 peers. Each iteration: ensure_peer(new) -> add_peer(new), send, update.
|
||||||
|
# Next iter: ensure_peer(new) -> already there, just send. So we need to force
|
||||||
|
# eviction each time: use a different "new" each time so we always evict+add.
|
||||||
|
t0 = time.ticks_us()
|
||||||
|
for i in range(ITERATIONS):
|
||||||
|
addr = bytes([0, 0, 0, 0, 0, 50 + (i % 30)]) # 30 different "new" MACs
|
||||||
|
peers = esp.get_peers()
|
||||||
|
peer_macs = [p[0] for p in peers]
|
||||||
|
if addr not in peer_macs:
|
||||||
|
if len(peer_macs) >= MAX_PEERS:
|
||||||
|
oldest_mac = None
|
||||||
|
oldest_ts = time.ticks_ms()
|
||||||
|
for m in peer_macs:
|
||||||
|
if m == BROADCAST:
|
||||||
|
continue
|
||||||
|
ts = last_used.get(m, 0)
|
||||||
|
if ts <= oldest_ts:
|
||||||
|
oldest_ts = ts
|
||||||
|
oldest_mac = m
|
||||||
|
if oldest_mac is not None:
|
||||||
|
esp.del_peer(oldest_mac)
|
||||||
|
last_used.pop(oldest_mac, None)
|
||||||
|
esp.add_peer(addr)
|
||||||
|
esp.send(addr, PAYLOAD)
|
||||||
|
last_used[addr] = time.ticks_ms()
|
||||||
|
t1 = time.ticks_us()
|
||||||
|
return time.ticks_diff(t1, t0)
|
||||||
|
|
||||||
|
def bench_add_then_remove():
|
||||||
|
"""Add peer, send, del_peer (remove after use). At 20 we must del one first."""
|
||||||
|
# Start full: 20 peers. To add new we del any one, add new, send, del new.
|
||||||
|
victim = peers_list[0]
|
||||||
|
t0 = time.ticks_us()
|
||||||
|
for i in range(ITERATIONS):
|
||||||
|
esp.del_peer(victim) # make room
|
||||||
|
esp.add_peer(new_mac)
|
||||||
|
esp.send(new_mac, PAYLOAD)
|
||||||
|
esp.del_peer(new_mac)
|
||||||
|
esp.add_peer(victim) # put victim back so we're at 20 again
|
||||||
|
t1 = time.ticks_us()
|
||||||
|
return time.ticks_diff(t1, t0)
|
||||||
|
|
||||||
|
def bench_send_existing():
|
||||||
|
"""Baseline: send to existing peer only (no add/del)."""
|
||||||
|
t0 = time.ticks_us()
|
||||||
|
for _ in range(ITERATIONS):
|
||||||
|
esp.send(peers_list[0], PAYLOAD)
|
||||||
|
t1 = time.ticks_us()
|
||||||
|
return time.ticks_diff(t1, t0)
|
||||||
|
|
||||||
|
print("ESP-NOW peer benchmark ({} iterations)".format(ITERATIONS))
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Baseline: send to existing peer
|
||||||
|
try:
|
||||||
|
us = bench_send_existing()
|
||||||
|
print("Send to existing peer only: {:>8} us total {:>7.1f} us/iter".format(us, us / ITERATIONS))
|
||||||
|
except Exception as e:
|
||||||
|
print("Send existing failed:", e)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# LRU: evict oldest then add new, send
|
||||||
|
try:
|
||||||
|
us = bench_lru()
|
||||||
|
print("LRU (evict oldest + add + send): {:>8} us total {:>7.1f} us/iter".format(us, us / ITERATIONS))
|
||||||
|
except Exception as e:
|
||||||
|
print("LRU failed:", e)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Add then remove after use
|
||||||
|
try:
|
||||||
|
us = bench_add_then_remove()
|
||||||
|
print("Add then remove after use: {:>8} us total {:>7.1f} us/iter".format(us, us / ITERATIONS))
|
||||||
|
except Exception as e:
|
||||||
|
print("Add-then-remove failed:", e)
|
||||||
|
print()
|
||||||
|
print("Done.")
|
||||||
66
esp32/main.py
Normal file
66
esp32/main.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Serial-to-ESP-NOW bridge: receives from Pi on UART, forwards to ESP-NOW peers.
|
||||||
|
# Wire format: first 6 bytes = destination MAC, rest = payload. Address is always 6 bytes.
|
||||||
|
from machine import Pin, UART
|
||||||
|
import espnow
|
||||||
|
import network
|
||||||
|
import time
|
||||||
|
|
||||||
|
UART_BAUD = 912000
|
||||||
|
BROADCAST = b"\xff\xff\xff\xff\xff\xff"
|
||||||
|
MAX_PEERS = 20
|
||||||
|
|
||||||
|
network.WLAN(network.STA_IF).active(True)
|
||||||
|
esp = espnow.ESPNow()
|
||||||
|
esp.active(True)
|
||||||
|
esp.add_peer(BROADCAST)
|
||||||
|
|
||||||
|
uart = UART(1, UART_BAUD, tx=Pin(21), rx=Pin(6))
|
||||||
|
|
||||||
|
# Track last send time per peer for LRU eviction (remove oldest when at limit).
|
||||||
|
last_used = {BROADCAST: time.ticks_ms()}
|
||||||
|
|
||||||
|
|
||||||
|
# ESP_ERR_ESPNOW_EXIST: peer already registered (ignore when adding).
|
||||||
|
ESP_ERR_ESPNOW_EXIST = -12395
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_peer(addr):
|
||||||
|
"""Ensure addr is in the peer list. When at 20 peers, remove the oldest-used (LRU)."""
|
||||||
|
peers = esp.get_peers()
|
||||||
|
peer_macs = [p[0] for p in peers]
|
||||||
|
if addr in peer_macs:
|
||||||
|
return
|
||||||
|
if len(peer_macs) >= MAX_PEERS:
|
||||||
|
# Remove the peer we used least recently (oldest).
|
||||||
|
oldest_mac = None
|
||||||
|
oldest_ts = time.ticks_ms()
|
||||||
|
for mac in peer_macs:
|
||||||
|
if mac == BROADCAST:
|
||||||
|
continue
|
||||||
|
ts = last_used.get(mac, 0)
|
||||||
|
if ts <= oldest_ts:
|
||||||
|
oldest_ts = ts
|
||||||
|
oldest_mac = mac
|
||||||
|
if oldest_mac is not None:
|
||||||
|
esp.del_peer(oldest_mac)
|
||||||
|
last_used.pop(oldest_mac, None)
|
||||||
|
try:
|
||||||
|
esp.add_peer(addr)
|
||||||
|
except OSError as e:
|
||||||
|
if e.args[0] != ESP_ERR_ESPNOW_EXIST:
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
print("Starting ESP32 main.py")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if uart.any():
|
||||||
|
data = uart.read()
|
||||||
|
if not data or len(data) < 6:
|
||||||
|
continue
|
||||||
|
print(f"Received data: {data}")
|
||||||
|
addr = data[:6]
|
||||||
|
payload = data[6:]
|
||||||
|
ensure_peer(addr)
|
||||||
|
esp.send(addr, payload)
|
||||||
|
last_used[addr] = time.ticks_ms()
|
||||||
244
flash.sh
244
flash.sh
@@ -1,244 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
# Environment variables:
|
|
||||||
# PORT - serial port (default: /dev/ttyUSB0)
|
|
||||||
# BAUD - baud rate (default: 460800)
|
|
||||||
# FIRMWARE - local path to firmware .bin
|
|
||||||
# FW_URL - URL to download firmware if FIRMWARE not provided or missing
|
|
||||||
|
|
||||||
PORT=${PORT:-}
|
|
||||||
BAUD=${BAUD:-460800}
|
|
||||||
CHIP=${CHIP:-esp32} # esp32 | esp32c3
|
|
||||||
|
|
||||||
# Map chip-specific settings
|
|
||||||
ESPT_CHIP="$CHIP"
|
|
||||||
FLASH_OFFSET=0x1000
|
|
||||||
DEFAULT_DOWNLOAD_PAGE="https://micropython.org/download/ESP32/"
|
|
||||||
BOARD_ID="ESP32_GENERIC"
|
|
||||||
BOARD_PAGE="https://micropython.org/download/${BOARD_ID}/"
|
|
||||||
case "$CHIP" in
|
|
||||||
esp32c3)
|
|
||||||
ESPT_CHIP="esp32c3"
|
|
||||||
FLASH_OFFSET=0x0
|
|
||||||
DEFAULT_DOWNLOAD_PAGE="https://micropython.org/download/ESP32C3/"
|
|
||||||
BOARD_ID="ESP32_GENERIC_C3"
|
|
||||||
BOARD_PAGE="https://micropython.org/download/${BOARD_ID}/"
|
|
||||||
;;
|
|
||||||
esp32)
|
|
||||||
ESPT_CHIP="esp32"
|
|
||||||
FLASH_OFFSET=0x1000
|
|
||||||
DEFAULT_DOWNLOAD_PAGE="https://micropython.org/download/ESP32/"
|
|
||||||
BOARD_ID="ESP32_GENERIC"
|
|
||||||
BOARD_PAGE="https://micropython.org/download/${BOARD_ID}/"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unsupported CHIP: $CHIP (supported: esp32, esp32c3)" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Download-only mode: fetch the appropriate firmware and exit
|
|
||||||
if [ -n "${DOWNLOAD_ONLY:-}" ]; then
|
|
||||||
# Prefer resolving latest if nothing provided
|
|
||||||
if [ -z "${FIRMWARE:-}" ] && [ -z "${FW_URL:-}" ]; then
|
|
||||||
LATEST=1
|
|
||||||
fi
|
|
||||||
if ! resolve_firmware; then
|
|
||||||
echo "Failed to resolve firmware for CHIP=$CHIP" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "$FIRMWARE"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Helper: resolve the latest firmware URL for a given board pattern with multiple fallbacks
|
|
||||||
resolve_latest_url() {
|
|
||||||
board_pattern="$1" # e.g., ESP32_GENERIC_C3-.*\.bin
|
|
||||||
# Candidate pages to try in order
|
|
||||||
pages="${BOARD_PAGE} ${DOWNLOAD_PAGE:-$DEFAULT_DOWNLOAD_PAGE} https://micropython.org/download/ https://micropython.org/resources/firmware/"
|
|
||||||
for page in $pages; do
|
|
||||||
echo "Trying to resolve latest from $page" >&2
|
|
||||||
html=$(curl -fsSL -H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36' -e 'https://micropython.org/download/' "$page" || true)
|
|
||||||
[ -z "$html" ] && continue
|
|
||||||
# Prefer matching the board pattern
|
|
||||||
url=$(printf "%s" "$html" \
|
|
||||||
| sed -n 's/.*href=\"\([^\"]*\.bin\)\".*/\1/p' \
|
|
||||||
| grep -E "$board_pattern" \
|
|
||||||
| head -n1)
|
|
||||||
if [ -n "$url" ]; then
|
|
||||||
case "$url" in
|
|
||||||
http*) echo "$url"; return 0 ;;
|
|
||||||
/*) echo "https://micropython.org$url"; return 0 ;;
|
|
||||||
*) echo "$page$url"; return 0 ;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# If LATEST is set and neither FIRMWARE nor FW_URL are provided, auto-detect latest URL
|
|
||||||
if [ -n "${LATEST:-}" ] && [ -z "${FIRMWARE:-}" ] && [ -z "${FW_URL:-}" ]; then
|
|
||||||
# Default board identifiers for each chip
|
|
||||||
case "$CHIP" in
|
|
||||||
esp32c3) BOARD_ID="ESP32_GENERIC_C3" ;;
|
|
||||||
esp32) BOARD_ID="ESP32_GENERIC" ;;
|
|
||||||
*) BOARD_ID="ESP32_GENERIC" ;;
|
|
||||||
esac
|
|
||||||
pattern="${BOARD_ID}-.*\\.bin"
|
|
||||||
echo "Resolving latest firmware for $BOARD_ID"
|
|
||||||
if FW_URL=$(resolve_latest_url "$pattern"); then
|
|
||||||
export FW_URL
|
|
||||||
echo "Latest firmware resolved to: $FW_URL"
|
|
||||||
else
|
|
||||||
echo "Failed to resolve latest firmware for pattern $pattern" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Resolve firmware path, downloading if needed
|
|
||||||
resolve_firmware() {
|
|
||||||
if [ -z "${FIRMWARE:-}" ]; then
|
|
||||||
if [ -n "${FW_URL:-}" ] || [ -n "${LATEST:-}" ]; then
|
|
||||||
# If FW_URL still unset, resolve latest using board-specific pattern
|
|
||||||
if [ -z "${FW_URL:-}" ]; then
|
|
||||||
case "$CHIP" in
|
|
||||||
esp32c3) BOARD_ID="ESP32_GENERIC_C3" ;;
|
|
||||||
esp32) BOARD_ID="ESP32_GENERIC" ;;
|
|
||||||
*) BOARD_ID="ESP32_GENERIC" ;;
|
|
||||||
esac
|
|
||||||
pattern="${BOARD_ID}-.*\\.bin"
|
|
||||||
echo "Resolving latest firmware for $BOARD_ID"
|
|
||||||
if ! FW_URL=$(resolve_latest_url "$pattern"); then
|
|
||||||
echo "Failed to resolve latest firmware for pattern $pattern" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
mkdir -p .cache
|
|
||||||
FIRMWARE=".cache/$(basename "$FW_URL")"
|
|
||||||
if [ ! -f "$FIRMWARE" ]; then
|
|
||||||
echo "Downloading firmware from $FW_URL to $FIRMWARE"
|
|
||||||
curl -L --fail -o "$FIRMWARE" "$FW_URL"
|
|
||||||
else
|
|
||||||
echo "Firmware already downloaded at $FIRMWARE"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
# Default fallback: fetch latest using board-specific pattern
|
|
||||||
case "$CHIP" in
|
|
||||||
esp32c3) BOARD_ID="ESP32_GENERIC_C3" ;;
|
|
||||||
esp32) BOARD_ID="ESP32_GENERIC" ;;
|
|
||||||
*) BOARD_ID="ESP32_GENERIC" ;;
|
|
||||||
esac
|
|
||||||
pattern="${BOARD_ID}-.*\\.bin"
|
|
||||||
echo "No FIRMWARE or FW_URL specified. Auto-fetching latest for $BOARD_ID"
|
|
||||||
if ! FW_URL=$(resolve_latest_url "$pattern"); then
|
|
||||||
echo "Failed to resolve latest firmware for pattern $pattern" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
mkdir -p .cache
|
|
||||||
FIRMWARE=".cache/$(basename "$FW_URL")"
|
|
||||||
if [ ! -f "$FIRMWARE" ]; then
|
|
||||||
echo "Downloading firmware from $FW_URL to $FIRMWARE"
|
|
||||||
curl -L --fail -o "$FIRMWARE" "$FW_URL"
|
|
||||||
else
|
|
||||||
echo "Firmware already downloaded at $FIRMWARE"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if [ ! -f "$FIRMWARE" ]; then
|
|
||||||
if [ -n "${FW_URL:-}" ]; then
|
|
||||||
mkdir -p "$(dirname "$FIRMWARE")"
|
|
||||||
echo "Firmware not found at $FIRMWARE. Downloading from $FW_URL"
|
|
||||||
curl -L --fail -o "$FIRMWARE" "$FW_URL"
|
|
||||||
else
|
|
||||||
echo "Firmware file not found: $FIRMWARE. Provide FW_URL to download automatically." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Auto-detect PORT if not specified
|
|
||||||
if [ -z "$PORT" ]; then
|
|
||||||
candidates="$(ls /dev/tty/ACM* /dev/tty/USB* 2>/dev/null || true)"
|
|
||||||
# Some systems expose without /dev/tty/ prefix patterns; try common Linux paths
|
|
||||||
[ -z "$candidates" ] && candidates="$(ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null || true)"
|
|
||||||
# Prefer ACM (often for C3) then USB
|
|
||||||
PORT=$(printf "%s\n" $candidates | grep -E "/dev/ttyACM[0-9]+" | head -n1 || true)
|
|
||||||
[ -z "$PORT" ] && PORT=$(printf "%s\n" $candidates | grep -E "/dev/ttyUSB[0-9]+" | head -n1 || true)
|
|
||||||
if [ -z "$PORT" ]; then
|
|
||||||
echo "No serial port detected. Connect the board and set PORT=/dev/ttyACM0 (or /dev/ttyUSB0)." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Auto-detected PORT=$PORT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Preflight: ensure port exists
|
|
||||||
if [ ! -e "$PORT" ]; then
|
|
||||||
echo "Port $PORT does not exist. Detected candidates:" >&2
|
|
||||||
ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null || true
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
ESPL="python -m esptool"
|
|
||||||
|
|
||||||
detect_chip() {
|
|
||||||
# Try to detect actual connected chip using esptool and override if needed
|
|
||||||
out=$($ESPL --port "$PORT" --baud "$BAUD" chip_id 2>&1 || true)
|
|
||||||
case "$out" in
|
|
||||||
*"ESP32-C3"*) DETECTED_CHIP=esp32c3 ;;
|
|
||||||
*"ESP32"*) DETECTED_CHIP=esp32 ;;
|
|
||||||
*) DETECTED_CHIP="" ;;
|
|
||||||
esac
|
|
||||||
if [ -n "$DETECTED_CHIP" ] && [ "$DETECTED_CHIP" != "$ESPT_CHIP" ]; then
|
|
||||||
echo "Detected chip $DETECTED_CHIP differs from requested $ESPT_CHIP. Using detected chip."
|
|
||||||
ESPT_CHIP="$DETECTED_CHIP"
|
|
||||||
case "$ESPT_CHIP" in
|
|
||||||
esp32c3) FLASH_OFFSET=0x0 ;;
|
|
||||||
esp32) FLASH_OFFSET=0x1000 ;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
detect_chip
|
|
||||||
|
|
||||||
# Now that we know the actual chip, resolve the correct firmware for it
|
|
||||||
resolve_firmware
|
|
||||||
|
|
||||||
# Validate firmware matches detected chip; if not, auto-correct by fetching the right image
|
|
||||||
EXPECTED_BOARD_ID="ESP32_GENERIC"
|
|
||||||
case "$ESPT_CHIP" in
|
|
||||||
esp32c3) EXPECTED_BOARD_ID="ESP32_GENERIC_C3" ;;
|
|
||||||
esp32) EXPECTED_BOARD_ID="ESP32_GENERIC" ;;
|
|
||||||
|
|
||||||
esac
|
|
||||||
|
|
||||||
FW_BASENAME="$(basename "$FIRMWARE")"
|
|
||||||
case "$FW_BASENAME" in
|
|
||||||
${EXPECTED_BOARD_ID}-*.bin) : ;; # ok
|
|
||||||
*)
|
|
||||||
echo "Firmware $FW_BASENAME does not match detected chip ($ESPT_CHIP). Fetching correct image for $EXPECTED_BOARD_ID..."
|
|
||||||
pattern="${EXPECTED_BOARD_ID}-.*\\.bin"
|
|
||||||
if ! FW_URL=$(resolve_latest_url "$pattern"); then
|
|
||||||
echo "Failed to resolve a firmware matching $EXPECTED_BOARD_ID" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
mkdir -p .cache
|
|
||||||
FIRMWARE=".cache/$(basename "$FW_URL")"
|
|
||||||
if [ ! -f "$FIRMWARE" ]; then
|
|
||||||
echo "Downloading firmware from $FW_URL to $FIRMWARE"
|
|
||||||
curl -L --fail -o "$FIRMWARE" "$FW_URL"
|
|
||||||
else
|
|
||||||
echo "Firmware already downloaded at $FIRMWARE"
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
$ESPL --chip "$ESPT_CHIP" --port "$PORT" --baud "$BAUD" erase_flash
|
|
||||||
|
|
||||||
echo "Writing firmware $FIRMWARE to $FLASH_OFFSET..."
|
|
||||||
$ESPL --chip "$ESPT_CHIP" --port "$PORT" --baud "$BAUD" write_flash -z "$FLASH_OFFSET" "$FIRMWARE"
|
|
||||||
|
|
||||||
echo "Done."
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Install script - runs pipenv install
|
|
||||||
|
|
||||||
pipenv install "$@"
|
|
||||||
1
led-driver
Submodule
1
led-driver
Submodule
Submodule led-driver added at 4c7646b2fe
1
led-tool
Submodule
1
led-tool
Submodule
Submodule led-tool added at 3844aa9d6a
23
msg.json
23
msg.json
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"g":{
|
|
||||||
"df": {
|
|
||||||
"pt": "on",
|
|
||||||
"cl": ["#ff0000"],
|
|
||||||
"br": 200,
|
|
||||||
"n1": 10,
|
|
||||||
"n2": 10,
|
|
||||||
"n3": 10,
|
|
||||||
"n4": 10,
|
|
||||||
"n5": 10,
|
|
||||||
"n6": 10,
|
|
||||||
"dl": 100
|
|
||||||
},
|
|
||||||
"dj": {
|
|
||||||
"pt": "blink",
|
|
||||||
"cl": ["#00ff00"],
|
|
||||||
"dl": 500
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"sv": true,
|
|
||||||
"st": 0
|
|
||||||
}
|
|
||||||
173
run_web.py
173
run_web.py
@@ -1,173 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Local development web server - imports and runs main.py with port 5000
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
# Add src and lib to path
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'lib'))
|
|
||||||
|
|
||||||
# Import the main module
|
|
||||||
from src import main as main_module
|
|
||||||
|
|
||||||
# Override the port in the main function
|
|
||||||
async def run_local():
|
|
||||||
"""Run main with port 5000 for local development."""
|
|
||||||
from settings import Settings
|
|
||||||
import gc
|
|
||||||
|
|
||||||
# Mock MicroPython modules for local development
|
|
||||||
class MockMachine:
|
|
||||||
class WDT:
|
|
||||||
def __init__(self, timeout):
|
|
||||||
pass
|
|
||||||
def feed(self):
|
|
||||||
pass
|
|
||||||
import sys as sys_module
|
|
||||||
sys_module.modules['machine'] = MockMachine()
|
|
||||||
|
|
||||||
class MockESPNow:
|
|
||||||
def __init__(self):
|
|
||||||
self.active_value = False
|
|
||||||
self.peers = []
|
|
||||||
def active(self, value):
|
|
||||||
self.active_value = value
|
|
||||||
print(f"[MOCK] ESPNow active: {value}")
|
|
||||||
def add_peer(self, peer):
|
|
||||||
self.peers.append(peer)
|
|
||||||
print(f"[MOCK] Added peer: {peer.hex() if hasattr(peer, 'hex') else peer}")
|
|
||||||
async def asend(self, peer, data):
|
|
||||||
print(f"[MOCK] Would send to {peer.hex() if hasattr(peer, 'hex') else peer}: {data}")
|
|
||||||
|
|
||||||
class MockAIOESPNow:
|
|
||||||
def __init__(self):
|
|
||||||
pass
|
|
||||||
def active(self, value):
|
|
||||||
return MockESPNow()
|
|
||||||
def add_peer(self, peer):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class MockNetwork:
|
|
||||||
class WLAN:
|
|
||||||
def __init__(self, interface):
|
|
||||||
self.interface = interface
|
|
||||||
def active(self, value):
|
|
||||||
print(f"[MOCK] WLAN({self.interface}) active: {value}")
|
|
||||||
STA_IF = 0
|
|
||||||
|
|
||||||
# Replace MicroPython modules with mocks
|
|
||||||
sys_module.modules['aioespnow'] = type('module', (), {'AIOESPNow': MockESPNow})()
|
|
||||||
sys_module.modules['network'] = MockNetwork()
|
|
||||||
|
|
||||||
# Mock gc if needed
|
|
||||||
if not hasattr(gc, 'collect'):
|
|
||||||
class MockGC:
|
|
||||||
def collect(self):
|
|
||||||
pass
|
|
||||||
gc = MockGC()
|
|
||||||
|
|
||||||
settings = Settings()
|
|
||||||
print("Starting LED Controller Web Server (Local Development)")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
# Mock network
|
|
||||||
import network
|
|
||||||
network.WLAN(network.STA_IF).active(True)
|
|
||||||
|
|
||||||
# Mock ESPNow
|
|
||||||
import aioespnow
|
|
||||||
e = aioespnow.AIOESPNow()
|
|
||||||
e.active(True)
|
|
||||||
e.add_peer(b"\xbb\xbb\xbb\xbb\xbb\xbb")
|
|
||||||
|
|
||||||
from microdot import Microdot, send_file
|
|
||||||
from microdot.websocket import with_websocket
|
|
||||||
|
|
||||||
from microdot.session import Session
|
|
||||||
|
|
||||||
import controllers.preset as preset
|
|
||||||
import controllers.profile as profile
|
|
||||||
import controllers.group as group
|
|
||||||
import controllers.sequence as sequence
|
|
||||||
import controllers.tab as tab
|
|
||||||
import controllers.palette as palette
|
|
||||||
import controllers.scene as scene
|
|
||||||
import controllers.pattern as pattern
|
|
||||||
import controllers.settings as settings_controller
|
|
||||||
|
|
||||||
app = Microdot()
|
|
||||||
|
|
||||||
# Initialize sessions with a secret key from settings
|
|
||||||
secret_key = settings.get('session_secret_key', 'led-controller-secret-key-change-in-production')
|
|
||||||
Session(app, secret_key=secret_key)
|
|
||||||
|
|
||||||
# Mount model controllers as subroutes
|
|
||||||
app.mount(preset.controller, '/presets')
|
|
||||||
app.mount(profile.controller, '/profiles')
|
|
||||||
app.mount(group.controller, '/groups')
|
|
||||||
app.mount(sequence.controller, '/sequences')
|
|
||||||
app.mount(tab.controller, '/tabs')
|
|
||||||
app.mount(palette.controller, '/palettes')
|
|
||||||
app.mount(scene.controller, '/scenes')
|
|
||||||
app.mount(pattern.controller, '/patterns')
|
|
||||||
app.mount(settings_controller.controller, '/settings')
|
|
||||||
|
|
||||||
# Serve index.html at root
|
|
||||||
@app.route('/')
|
|
||||||
def index(request):
|
|
||||||
"""Serve the main web UI."""
|
|
||||||
return send_file('src/templates/index.html')
|
|
||||||
|
|
||||||
# Serve settings page
|
|
||||||
@app.route('/settings')
|
|
||||||
def settings_page(request):
|
|
||||||
"""Serve the settings page."""
|
|
||||||
return send_file('src/templates/settings.html')
|
|
||||||
|
|
||||||
# Favicon: avoid 404 in browser console (no file needed)
|
|
||||||
@app.route('/favicon.ico')
|
|
||||||
def favicon(request):
|
|
||||||
return '', 204
|
|
||||||
|
|
||||||
# Static file route
|
|
||||||
@app.route("/static/<path:path>")
|
|
||||||
def static_handler(request, path):
|
|
||||||
"""Serve static files."""
|
|
||||||
if '..' in path:
|
|
||||||
return 'Not found', 404
|
|
||||||
return send_file('src/static/' + path)
|
|
||||||
|
|
||||||
@app.route('/ws')
|
|
||||||
@with_websocket
|
|
||||||
async def ws(request, ws):
|
|
||||||
while True:
|
|
||||||
data = await ws.receive()
|
|
||||||
if data:
|
|
||||||
await e.asend(b"\xbb\xbb\xbb\xbb\xbb\xbb", data)
|
|
||||||
print(data)
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
# Use port 5000 for local development
|
|
||||||
port = 5000
|
|
||||||
print(f"Starting server on http://0.0.0.0:{port}")
|
|
||||||
print(f"Open http://localhost:{port} in your browser")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await app.start_server(host="0.0.0.0", port=port, debug=True)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\nShutting down server...")
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
# Change to project root
|
|
||||||
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
# Override settings path for local development
|
|
||||||
import settings as settings_module
|
|
||||||
settings_module.Settings.SETTINGS_FILE = os.path.join(os.getcwd(), 'settings.json')
|
|
||||||
|
|
||||||
asyncio.run(run_local())
|
|
||||||
4
scripts/cp-esp32-main.sh
Normal file
4
scripts/cp-esp32-main.sh
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Copy esp32/main.py to the connected ESP32 as /main.py (single line, no wrap).
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
pipenv run mpremote fs cp esp32/main.py :/main.py
|
||||||
20
scripts/install-boot-service.sh
Executable file
20
scripts/install-boot-service.sh
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install systemd service so LED controller starts at boot.
|
||||||
|
# Run once: sudo scripts/install-boot-service.sh
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
REPO="$(pwd)"
|
||||||
|
SERVICE_NAME="led-controller.service"
|
||||||
|
UNIT_PATH="/etc/systemd/system/$SERVICE_NAME"
|
||||||
|
if [ ! -f "scripts/led-controller.service" ]; then
|
||||||
|
echo "Run this script from the repo root."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
chmod +x scripts/start.sh
|
||||||
|
sudo cp "scripts/led-controller.service" "$UNIT_PATH"
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable "$SERVICE_NAME"
|
||||||
|
echo "Installed and enabled $SERVICE_NAME"
|
||||||
|
echo "Start now: sudo systemctl start $SERVICE_NAME"
|
||||||
|
echo "Status: sudo systemctl status $SERVICE_NAME"
|
||||||
|
echo "Logs: journalctl -u $SERVICE_NAME -f"
|
||||||
17
scripts/led-controller.service
Normal file
17
scripts/led-controller.service
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=LED Controller web server
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=pi
|
||||||
|
WorkingDirectory=/home/pi/led-controller
|
||||||
|
Environment=PORT=80
|
||||||
|
Environment=PATH=/home/pi/.local/bin:/usr/local/bin:/usr/bin:/bin
|
||||||
|
ExecStart=/bin/bash /home/pi/led-controller/scripts/start.sh
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
35
scripts/setup-port80.sh
Executable file
35
scripts/setup-port80.sh
Executable file
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Allow the app to bind to port 80 without root.
|
||||||
|
# Run once: sudo scripts/setup-port80.sh (from repo root)
|
||||||
|
# Or: scripts/setup-port80.sh (will prompt for sudo only for setcap)
|
||||||
|
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
REPO_ROOT="$(pwd)"
|
||||||
|
# If run under sudo, use the invoking user's pipenv so the venv is found
|
||||||
|
if [ -n "$SUDO_USER" ]; then
|
||||||
|
VENV="$(sudo -u "$SUDO_USER" bash -c "cd '$REPO_ROOT' && pipenv --venv" 2>/dev/null)" || true
|
||||||
|
else
|
||||||
|
VENV="$(pipenv --venv 2>/dev/null)" || true
|
||||||
|
fi
|
||||||
|
if [ -z "$VENV" ]; then
|
||||||
|
echo "Run 'pipenv install' first, then run this script again."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
PYTHON="${VENV}/bin/python3"
|
||||||
|
if [ ! -f "$PYTHON" ]; then
|
||||||
|
PYTHON="${VENV}/bin/python"
|
||||||
|
fi
|
||||||
|
if [ ! -f "$PYTHON" ]; then
|
||||||
|
echo "Python not found in venv: $VENV"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Use the real binary (setcap can fail on symlinks or some filesystems)
|
||||||
|
REAL_PYTHON="$(readlink -f "$PYTHON" 2>/dev/null)" || REAL_PYTHON="$PYTHON"
|
||||||
|
if sudo setcap 'cap_net_bind_service=+ep' "$REAL_PYTHON" 2>/dev/null; then
|
||||||
|
echo "OK: port 80 enabled for $REAL_PYTHON"
|
||||||
|
echo "Start the app with: pipenv run run"
|
||||||
|
else
|
||||||
|
echo "setcap failed on $REAL_PYTHON"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
5
scripts/start.sh
Executable file
5
scripts/start.sh
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Start the LED controller web server (port 80 by default).
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
export PORT="${PORT:-80}"
|
||||||
|
pipenv run run
|
||||||
33
scripts/test-port80.sh
Executable file
33
scripts/test-port80.sh
Executable file
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Test the app on port 80. Run after: sudo scripts/setup-port80.sh
|
||||||
|
# Usage: ./scripts/test-port80.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
APP_URL="${APP_URL:-http://127.0.0.1:80}"
|
||||||
|
|
||||||
|
echo "Starting app on port 80 in background..."
|
||||||
|
pipenv run run &
|
||||||
|
PID=$!
|
||||||
|
trap "kill $PID 2>/dev/null; exit" EXIT
|
||||||
|
|
||||||
|
echo "Waiting for server to start..."
|
||||||
|
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||||
|
if curl -s -o /dev/null -w "%{http_code}" "$APP_URL/" 2>/dev/null | grep -q 200; then
|
||||||
|
echo "Server is up."
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Requesting $APP_URL/ ..."
|
||||||
|
CODE=$(curl -s -o /dev/null -w "%{http_code}" "$APP_URL/")
|
||||||
|
if [ "$CODE" = "200" ]; then
|
||||||
|
echo "OK: GET / returned HTTP $CODE"
|
||||||
|
curl -s "$APP_URL/" | head -5
|
||||||
|
echo "..."
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo "FAIL: GET / returned HTTP $CODE (expected 200)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import socket
|
|
||||||
import struct
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
# Connect to the WebSocket
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
s.connect(('192.168.4.1', 80))
|
|
||||||
|
|
||||||
# Send HTTP WebSocket upgrade request
|
|
||||||
key = base64.b64encode(b'test-nonce').decode('utf-8')
|
|
||||||
request = f'''GET /ws HTTP/1.1\r
|
|
||||||
Host: 192.168.4.1\r
|
|
||||||
Upgrade: websocket\r
|
|
||||||
Connection: Upgrade\r
|
|
||||||
Sec-WebSocket-Key: {key}\r
|
|
||||||
Sec-WebSocket-Version: 13\r
|
|
||||||
\r
|
|
||||||
'''
|
|
||||||
s.send(request.encode())
|
|
||||||
|
|
||||||
# Read upgrade response
|
|
||||||
response = s.recv(4096)
|
|
||||||
print(response.decode())
|
|
||||||
|
|
||||||
# Send WebSocket TEXT frame with empty JSON '{}'
|
|
||||||
payload = b'{}'
|
|
||||||
mask = b'\x12\x34\x56\x78'
|
|
||||||
payload_masked = bytes(p ^ mask[i % 4] for i, p in enumerate(payload))
|
|
||||||
|
|
||||||
frame = struct.pack('BB', 0x81, 0x80 | len(payload))
|
|
||||||
frame += mask
|
|
||||||
frame += payload_masked
|
|
||||||
|
|
||||||
s.send(frame)
|
|
||||||
print("Sent empty JSON to WebSocket")
|
|
||||||
s.close()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"session_secret_key": "06a2a6ac631cc8db0658373b37f7fe922a8a3ed3a27229e1adb5448cb368f2b7"}
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import settings
|
# Boot script (ESP only; no-op on Pi)
|
||||||
import util.wifi as wifi
|
import settings # noqa: F401
|
||||||
from settings import Settings
|
from settings import Settings
|
||||||
|
|
||||||
s = Settings()
|
s = Settings()
|
||||||
|
# AP setup was here when running on ESP; Pi uses system networking.
|
||||||
name = s.get('name', 'led-controller')
|
|
||||||
wifi.ap(name, '')
|
|
||||||
|
|||||||
68
src/controllers/device.py
Normal file
68
src/controllers/device.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
from microdot import Microdot
|
||||||
|
from models.device import Device
|
||||||
|
import json
|
||||||
|
|
||||||
|
controller = Microdot()
|
||||||
|
devices = Device()
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get("")
|
||||||
|
async def list_devices(request):
|
||||||
|
"""List all devices."""
|
||||||
|
devices_data = {}
|
||||||
|
for dev_id in devices.list():
|
||||||
|
d = devices.read(dev_id)
|
||||||
|
if d:
|
||||||
|
devices_data[dev_id] = d
|
||||||
|
return json.dumps(devices_data), 200, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get("/<id>")
|
||||||
|
async def get_device(request, id):
|
||||||
|
"""Get a device by ID."""
|
||||||
|
dev = devices.read(id)
|
||||||
|
if dev:
|
||||||
|
return json.dumps(dev), 200, {"Content-Type": "application/json"}
|
||||||
|
return json.dumps({"error": "Device not found"}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@controller.post("")
|
||||||
|
async def create_device(request):
|
||||||
|
"""Create a new device."""
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
name = data.get("name", "").strip()
|
||||||
|
address = data.get("address")
|
||||||
|
default_pattern = data.get("default_pattern")
|
||||||
|
tabs = data.get("tabs")
|
||||||
|
if isinstance(tabs, list):
|
||||||
|
tabs = [str(t) for t in tabs]
|
||||||
|
else:
|
||||||
|
tabs = []
|
||||||
|
dev_id = devices.create(name=name, address=address, default_pattern=default_pattern, tabs=tabs)
|
||||||
|
dev = devices.read(dev_id)
|
||||||
|
return json.dumps({dev_id: dev}), 201, {"Content-Type": "application/json"}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@controller.put("/<id>")
|
||||||
|
async def update_device(request, id):
|
||||||
|
"""Update a device."""
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
if "tabs" in data and isinstance(data["tabs"], list):
|
||||||
|
data["tabs"] = [str(t) for t in data["tabs"]]
|
||||||
|
if devices.update(id, data):
|
||||||
|
return json.dumps(devices.read(id)), 200, {"Content-Type": "application/json"}
|
||||||
|
return json.dumps({"error": "Device not found"}), 404
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@controller.delete("/<id>")
|
||||||
|
async def delete_device(request, id):
|
||||||
|
"""Delete a device."""
|
||||||
|
if devices.delete(id):
|
||||||
|
return json.dumps({"message": "Device deleted successfully"}), 200
|
||||||
|
return json.dumps({"error": "Device not found"}), 404
|
||||||
@@ -17,9 +17,9 @@ async def list_palettes(request):
|
|||||||
@controller.get('/<id>')
|
@controller.get('/<id>')
|
||||||
async def get_palette(request, id):
|
async def get_palette(request, id):
|
||||||
"""Get a specific palette by ID."""
|
"""Get a specific palette by ID."""
|
||||||
palette = palettes.read(id)
|
if str(id) in palettes:
|
||||||
if palette:
|
palette = palettes.read(id)
|
||||||
return json.dumps({"colors": palette, "id": str(id)}), 200, {'Content-Type': 'application/json'}
|
return json.dumps({"colors": palette or [], "id": str(id)}), 200, {'Content-Type': 'application/json'}
|
||||||
return json.dumps({"error": "Palette not found"}), 404
|
return json.dumps({"error": "Palette not found"}), 404
|
||||||
|
|
||||||
@controller.post('')
|
@controller.post('')
|
||||||
@@ -30,11 +30,8 @@ async def create_palette(request):
|
|||||||
colors = data.get("colors", None)
|
colors = data.get("colors", None)
|
||||||
# Palette no longer needs a name; only colors are stored.
|
# Palette no longer needs a name; only colors are stored.
|
||||||
palette_id = palettes.create("", colors)
|
palette_id = palettes.create("", colors)
|
||||||
palette = palettes.read(palette_id) or {}
|
created_colors = palettes.read(palette_id) or []
|
||||||
# Include the ID in the response payload so clients can link it.
|
return json.dumps({"id": str(palette_id), "colors": created_colors}), 201, {'Content-Type': 'application/json'}
|
||||||
palette_with_id = {"id": str(palette_id)}
|
|
||||||
palette_with_id.update(palette)
|
|
||||||
return json.dumps(palette_with_id), 201, {'Content-Type': 'application/json'}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"error": str(e)}), 400
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
@@ -47,10 +44,8 @@ async def update_palette(request, id):
|
|||||||
if "name" in data:
|
if "name" in data:
|
||||||
data.pop("name", None)
|
data.pop("name", None)
|
||||||
if palettes.update(id, data):
|
if palettes.update(id, data):
|
||||||
palette = palettes.read(id) or {}
|
colors = palettes.read(id) or []
|
||||||
palette_with_id = {"id": str(id)}
|
return json.dumps({"id": str(id), "colors": colors}), 200, {'Content-Type': 'application/json'}
|
||||||
palette_with_id.update(palette)
|
|
||||||
return json.dumps(palette_with_id), 200, {'Content-Type': 'application/json'}
|
|
||||||
return json.dumps({"error": "Palette not found"}), 404
|
return json.dumps({"error": "Palette not found"}), 404
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"error": str(e)}), 400
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ from microdot import Microdot
|
|||||||
from microdot.session import with_session
|
from microdot.session import with_session
|
||||||
from models.preset import Preset
|
from models.preset import Preset
|
||||||
from models.profile import Profile
|
from models.profile import Profile
|
||||||
from models.espnow import ESPNow
|
from models.transport import get_current_sender
|
||||||
from util.espnow_message import build_message, build_preset_dict, ESPNOW_MAX_PAYLOAD_BYTES
|
from util.espnow_message import build_message, build_preset_dict
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@@ -36,11 +36,11 @@ async def list_presets(request, session):
|
|||||||
}
|
}
|
||||||
return json.dumps(scoped), 200, {'Content-Type': 'application/json'}
|
return json.dumps(scoped), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
@controller.get('/<id>')
|
@controller.get('/<preset_id>')
|
||||||
@with_session
|
@with_session
|
||||||
async def get_preset(request, id, session):
|
async def get_preset(request, session, preset_id):
|
||||||
"""Get a specific preset by ID (current profile only)."""
|
"""Get a specific preset by ID (current profile only)."""
|
||||||
preset = presets.read(id)
|
preset = presets.read(preset_id)
|
||||||
current_profile_id = get_current_profile_id(session)
|
current_profile_id = get_current_profile_id(session)
|
||||||
if preset and str(preset.get("profile_id")) == str(current_profile_id):
|
if preset and str(preset.get("profile_id")) == str(current_profile_id):
|
||||||
return json.dumps(preset), 200, {'Content-Type': 'application/json'}
|
return json.dumps(preset), 200, {'Content-Type': 'application/json'}
|
||||||
@@ -70,12 +70,12 @@ async def create_preset(request, session):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"error": str(e)}), 400
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
@controller.put('/<id>')
|
@controller.put('/<preset_id>')
|
||||||
@with_session
|
@with_session
|
||||||
async def update_preset(request, id, session):
|
async def update_preset(request, session, preset_id):
|
||||||
"""Update an existing preset (current profile only)."""
|
"""Update an existing preset (current profile only)."""
|
||||||
try:
|
try:
|
||||||
preset = presets.read(id)
|
preset = presets.read(preset_id)
|
||||||
current_profile_id = get_current_profile_id(session)
|
current_profile_id = get_current_profile_id(session)
|
||||||
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
|
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
|
||||||
return json.dumps({"error": "Preset not found"}), 404
|
return json.dumps({"error": "Preset not found"}), 404
|
||||||
@@ -87,21 +87,36 @@ async def update_preset(request, id, session):
|
|||||||
data = {}
|
data = {}
|
||||||
data = dict(data)
|
data = dict(data)
|
||||||
data["profile_id"] = str(current_profile_id)
|
data["profile_id"] = str(current_profile_id)
|
||||||
if presets.update(id, data):
|
if presets.update(preset_id, data):
|
||||||
return json.dumps(presets.read(id)), 200, {'Content-Type': 'application/json'}
|
return json.dumps(presets.read(preset_id)), 200, {'Content-Type': 'application/json'}
|
||||||
return json.dumps({"error": "Preset not found"}), 404
|
return json.dumps({"error": "Preset not found"}), 404
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"error": str(e)}), 400
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
@controller.delete('/<id>')
|
@controller.delete('/<preset_id>')
|
||||||
@with_session
|
@with_session
|
||||||
async def delete_preset(request, id, session):
|
async def delete_preset(request, *args, **kwargs):
|
||||||
"""Delete a preset (current profile only)."""
|
"""Delete a preset (current profile only)."""
|
||||||
preset = presets.read(id)
|
# Be tolerant of wrapper/arg-order variations.
|
||||||
|
session = None
|
||||||
|
preset_id = None
|
||||||
|
if len(args) > 0:
|
||||||
|
session = args[0]
|
||||||
|
if len(args) > 1:
|
||||||
|
preset_id = args[1]
|
||||||
|
if 'session' in kwargs and kwargs.get('session') is not None:
|
||||||
|
session = kwargs.get('session')
|
||||||
|
if 'preset_id' in kwargs and kwargs.get('preset_id') is not None:
|
||||||
|
preset_id = kwargs.get('preset_id')
|
||||||
|
if 'id' in kwargs and kwargs.get('id') is not None and preset_id is None:
|
||||||
|
preset_id = kwargs.get('id')
|
||||||
|
if preset_id is None:
|
||||||
|
return json.dumps({"error": "Preset ID is required"}), 400
|
||||||
|
preset = presets.read(preset_id)
|
||||||
current_profile_id = get_current_profile_id(session)
|
current_profile_id = get_current_profile_id(session)
|
||||||
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
|
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
|
||||||
return json.dumps({"error": "Preset not found"}), 404
|
return json.dumps({"error": "Preset not found"}), 404
|
||||||
if presets.delete(id):
|
if presets.delete(preset_id):
|
||||||
return json.dumps({"message": "Preset deleted successfully"}), 200
|
return json.dumps({"message": "Preset deleted successfully"}), 200
|
||||||
return json.dumps({"error": "Preset not found"}), 404
|
return json.dumps({"error": "Preset not found"}), 404
|
||||||
|
|
||||||
@@ -110,16 +125,13 @@ async def delete_preset(request, id, session):
|
|||||||
@with_session
|
@with_session
|
||||||
async def send_presets(request, session):
|
async def send_presets(request, session):
|
||||||
"""
|
"""
|
||||||
Send one or more presets over ESPNow.
|
Send one or more presets to the LED driver (via serial transport).
|
||||||
|
|
||||||
Body JSON:
|
Body JSON:
|
||||||
{"preset_ids": ["1", "2", ...]} or {"ids": ["1", "2", ...]}
|
{"preset_ids": ["1", "2", ...]} or {"ids": ["1", "2", ...]}
|
||||||
|
|
||||||
The controller:
|
The controller looks up each preset, converts to API format, chunks into
|
||||||
- looks up each preset in the Preset model
|
<= 240-byte messages, and sends them over the configured transport.
|
||||||
- converts them to API-compliant format
|
|
||||||
- splits into <= 240-byte ESPNow messages
|
|
||||||
- sends each message to all configured ESPNow peers.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
@@ -132,6 +144,8 @@ async def send_presets(request, session):
|
|||||||
save_flag = data.get('save', True)
|
save_flag = data.get('save', True)
|
||||||
save_flag = bool(save_flag)
|
save_flag = bool(save_flag)
|
||||||
default_id = data.get('default')
|
default_id = data.get('default')
|
||||||
|
# Optional 12-char hex MAC to send to one device; omit for default (e.g. broadcast).
|
||||||
|
destination_mac = data.get('destination_mac') or data.get('to')
|
||||||
|
|
||||||
# Build API-compliant preset map keyed by preset ID, include name
|
# Build API-compliant preset map keyed by preset ID, include name
|
||||||
current_profile_id = get_current_profile_id(session)
|
current_profile_id = get_current_profile_id(session)
|
||||||
@@ -153,16 +167,17 @@ async def send_presets(request, session):
|
|||||||
if default_id is not None and str(default_id) not in presets_by_name:
|
if default_id is not None and str(default_id) not in presets_by_name:
|
||||||
default_id = None
|
default_id = None
|
||||||
|
|
||||||
# Use shared ESPNow singleton
|
sender = get_current_sender()
|
||||||
esp = ESPNow()
|
if not sender:
|
||||||
|
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
async def send_chunk(chunk_presets):
|
async def send_chunk(chunk_presets):
|
||||||
# Include save flag so the led-driver can persist when desired.
|
# Include save flag so the led-driver can persist when desired.
|
||||||
msg = build_message(presets=chunk_presets, save=save_flag, default=default_id)
|
msg = build_message(presets=chunk_presets, save=save_flag, default=default_id)
|
||||||
await esp.send(msg)
|
await sender.send(msg, addr=destination_mac)
|
||||||
|
|
||||||
MAX_BYTES = ESPNOW_MAX_PAYLOAD_BYTES
|
MAX_BYTES = 240
|
||||||
SEND_DELAY_MS = 100
|
send_delay_s = 0.1
|
||||||
entries = list(presets_by_name.items())
|
entries = list(presets_by_name.items())
|
||||||
total_presets = len(entries)
|
total_presets = len(entries)
|
||||||
messages_sent = 0
|
messages_sent = 0
|
||||||
@@ -182,8 +197,8 @@ async def send_presets(request, session):
|
|||||||
try:
|
try:
|
||||||
await send_chunk(batch)
|
await send_chunk(batch)
|
||||||
except Exception:
|
except Exception:
|
||||||
return json.dumps({"error": "ESP-NOW send failed"}), 503, {'Content-Type': 'application/json'}
|
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
|
||||||
await asyncio.sleep_ms(SEND_DELAY_MS)
|
await asyncio.sleep(send_delay_s)
|
||||||
messages_sent += 1
|
messages_sent += 1
|
||||||
batch = {name: preset_obj}
|
batch = {name: preset_obj}
|
||||||
last_msg = build_message(presets=batch, save=save_flag, default=default_id)
|
last_msg = build_message(presets=batch, save=save_flag, default=default_id)
|
||||||
@@ -192,12 +207,12 @@ async def send_presets(request, session):
|
|||||||
try:
|
try:
|
||||||
await send_chunk(batch)
|
await send_chunk(batch)
|
||||||
except Exception:
|
except Exception:
|
||||||
return json.dumps({"error": "ESP-NOW send failed"}), 503, {'Content-Type': 'application/json'}
|
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
|
||||||
await asyncio.sleep_ms(SEND_DELAY_MS)
|
await asyncio.sleep(send_delay_s)
|
||||||
messages_sent += 1
|
messages_sent += 1
|
||||||
|
|
||||||
return json.dumps({
|
return json.dumps({
|
||||||
"message": "Presets sent via ESPNow",
|
"message": "Presets sent",
|
||||||
"presets_sent": total_presets,
|
"presets_sent": total_presets,
|
||||||
"messages_sent": messages_sent
|
"messages_sent": messages_sent
|
||||||
}), 200, {'Content-Type': 'application/json'}
|
}), 200, {'Content-Type': 'application/json'}
|
||||||
|
|||||||
@@ -81,11 +81,117 @@ async def apply_profile(request, session, id):
|
|||||||
async def create_profile(request):
|
async def create_profile(request):
|
||||||
"""Create a new profile."""
|
"""Create a new profile."""
|
||||||
try:
|
try:
|
||||||
data = request.json or {}
|
data = dict(request.json or {})
|
||||||
name = data.get("name", "")
|
name = data.get("name", "")
|
||||||
|
seed_raw = data.get("seed_dj_tab", False)
|
||||||
|
if isinstance(seed_raw, str):
|
||||||
|
seed_dj_tab = seed_raw.strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
else:
|
||||||
|
seed_dj_tab = bool(seed_raw)
|
||||||
|
# Request-only flag: do not persist on profile records.
|
||||||
|
data.pop("seed_dj_tab", None)
|
||||||
profile_id = profiles.create(name)
|
profile_id = profiles.create(name)
|
||||||
|
# Avoid persisting request-only fields.
|
||||||
|
data.pop("name", None)
|
||||||
if data:
|
if data:
|
||||||
profiles.update(profile_id, data)
|
profiles.update(profile_id, data)
|
||||||
|
|
||||||
|
# New profiles always start with a default tab pre-populated with starter presets.
|
||||||
|
default_preset_ids = []
|
||||||
|
default_preset_defs = [
|
||||||
|
{
|
||||||
|
"name": "on",
|
||||||
|
"pattern": "on",
|
||||||
|
"colors": ["#FFFFFF"],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 100,
|
||||||
|
"auto": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "off",
|
||||||
|
"pattern": "off",
|
||||||
|
"colors": [],
|
||||||
|
"brightness": 0,
|
||||||
|
"delay": 100,
|
||||||
|
"auto": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rainbow",
|
||||||
|
"pattern": "rainbow",
|
||||||
|
"colors": [],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 100,
|
||||||
|
"auto": True,
|
||||||
|
"n1": 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "transition",
|
||||||
|
"pattern": "transition",
|
||||||
|
"colors": ["#FF0000", "#00FF00", "#0000FF"],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 500,
|
||||||
|
"auto": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for preset_data in default_preset_defs:
|
||||||
|
pid = presets.create(profile_id)
|
||||||
|
presets.update(pid, preset_data)
|
||||||
|
default_preset_ids.append(str(pid))
|
||||||
|
|
||||||
|
default_tab_id = tabs.create(name="default", names=["1"], presets=[default_preset_ids])
|
||||||
|
tabs.update(default_tab_id, {
|
||||||
|
"presets_flat": default_preset_ids,
|
||||||
|
"default_preset": default_preset_ids[0] if default_preset_ids else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
profile = profiles.read(profile_id) or {}
|
||||||
|
profile_tabs = profile.get("tabs", []) if isinstance(profile.get("tabs", []), list) else []
|
||||||
|
profile_tabs.append(str(default_tab_id))
|
||||||
|
|
||||||
|
if seed_dj_tab:
|
||||||
|
# Seed a DJ-focused tab with three starter presets.
|
||||||
|
seeded_preset_ids = []
|
||||||
|
preset_defs = [
|
||||||
|
{
|
||||||
|
"name": "DJ Rainbow",
|
||||||
|
"pattern": "rainbow",
|
||||||
|
"colors": [],
|
||||||
|
"brightness": 220,
|
||||||
|
"delay": 60,
|
||||||
|
"n1": 12,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DJ Single Color",
|
||||||
|
"pattern": "on",
|
||||||
|
"colors": ["#ff00ff"],
|
||||||
|
"brightness": 220,
|
||||||
|
"delay": 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DJ Transition",
|
||||||
|
"pattern": "transition",
|
||||||
|
"colors": ["#ff0000", "#00ff00", "#0000ff"],
|
||||||
|
"brightness": 220,
|
||||||
|
"delay": 250,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for preset_data in preset_defs:
|
||||||
|
pid = presets.create(profile_id)
|
||||||
|
presets.update(pid, preset_data)
|
||||||
|
seeded_preset_ids.append(str(pid))
|
||||||
|
|
||||||
|
dj_tab_id = tabs.create(name="dj", names=["dj"], presets=[seeded_preset_ids])
|
||||||
|
tabs.update(dj_tab_id, {
|
||||||
|
"presets_flat": seeded_preset_ids,
|
||||||
|
"default_preset": seeded_preset_ids[0] if seeded_preset_ids else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
profile_tabs.append(str(dj_tab_id))
|
||||||
|
|
||||||
|
profiles.update(profile_id, {"tabs": profile_tabs})
|
||||||
|
|
||||||
profile_data = profiles.read(profile_id)
|
profile_data = profiles.read(profile_id)
|
||||||
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
|
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from microdot import Microdot, send_file
|
from microdot import Microdot, send_file
|
||||||
from settings import Settings
|
from settings import Settings
|
||||||
import util.wifi as wifi
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
controller = Microdot()
|
controller = Microdot()
|
||||||
@@ -15,19 +14,18 @@ async def get_settings(request):
|
|||||||
|
|
||||||
@controller.get('/wifi/ap')
|
@controller.get('/wifi/ap')
|
||||||
async def get_ap_config(request):
|
async def get_ap_config(request):
|
||||||
"""Get Access Point configuration."""
|
"""Get saved AP configuration (Pi: no in-device AP)."""
|
||||||
config = wifi.get_ap_config()
|
config = {
|
||||||
if config:
|
'saved_ssid': settings.get('wifi_ap_ssid'),
|
||||||
# Also get saved settings
|
'saved_password': settings.get('wifi_ap_password'),
|
||||||
config['saved_ssid'] = settings.get('wifi_ap_ssid')
|
'saved_channel': settings.get('wifi_ap_channel'),
|
||||||
config['saved_password'] = settings.get('wifi_ap_password')
|
'active': False,
|
||||||
config['saved_channel'] = settings.get('wifi_ap_channel')
|
}
|
||||||
return json.dumps(config), 200, {'Content-Type': 'application/json'}
|
return json.dumps(config), 200, {'Content-Type': 'application/json'}
|
||||||
return json.dumps({"error": "Failed to get AP config"}), 500
|
|
||||||
|
|
||||||
@controller.post('/wifi/ap')
|
@controller.post('/wifi/ap')
|
||||||
async def configure_ap(request):
|
async def configure_ap(request):
|
||||||
"""Configure Access Point."""
|
"""Save AP configuration to settings (Pi: no in-device AP)."""
|
||||||
try:
|
try:
|
||||||
data = request.json
|
data = request.json
|
||||||
ssid = data.get('ssid')
|
ssid = data.get('ssid')
|
||||||
@@ -43,18 +41,14 @@ async def configure_ap(request):
|
|||||||
if channel < 1 or channel > 11:
|
if channel < 1 or channel > 11:
|
||||||
return json.dumps({"error": "Channel must be between 1 and 11"}), 400
|
return json.dumps({"error": "Channel must be between 1 and 11"}), 400
|
||||||
|
|
||||||
# Save to settings
|
|
||||||
settings['wifi_ap_ssid'] = ssid
|
settings['wifi_ap_ssid'] = ssid
|
||||||
settings['wifi_ap_password'] = password
|
settings['wifi_ap_password'] = password
|
||||||
if channel is not None:
|
if channel is not None:
|
||||||
settings['wifi_ap_channel'] = channel
|
settings['wifi_ap_channel'] = channel
|
||||||
settings.save()
|
settings.save()
|
||||||
|
|
||||||
# Configure AP
|
|
||||||
wifi.ap(ssid, password, channel)
|
|
||||||
|
|
||||||
return json.dumps({
|
return json.dumps({
|
||||||
"message": "AP configured successfully",
|
"message": "AP settings saved",
|
||||||
"ssid": ssid,
|
"ssid": ssid,
|
||||||
"channel": channel
|
"channel": channel
|
||||||
}), 200, {'Content-Type': 'application/json'}
|
}), 200, {'Content-Type': 'application/json'}
|
||||||
|
|||||||
65
src/main.py
65
src/main.py
@@ -1,14 +1,11 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import gc
|
|
||||||
import json
|
import json
|
||||||
import machine
|
import os
|
||||||
from machine import Pin
|
|
||||||
from microdot import Microdot, send_file
|
from microdot import Microdot, send_file
|
||||||
from microdot.websocket import with_websocket
|
from microdot.websocket import with_websocket
|
||||||
from microdot.session import Session
|
from microdot.session import Session
|
||||||
from settings import Settings
|
from settings import Settings
|
||||||
|
|
||||||
import aioespnow
|
|
||||||
import controllers.preset as preset
|
import controllers.preset as preset
|
||||||
import controllers.profile as profile
|
import controllers.profile as profile
|
||||||
import controllers.group as group
|
import controllers.group as group
|
||||||
@@ -18,8 +15,7 @@ import controllers.palette as palette
|
|||||||
import controllers.scene as scene
|
import controllers.scene as scene
|
||||||
import controllers.pattern as pattern
|
import controllers.pattern as pattern
|
||||||
import controllers.settings as settings_controller
|
import controllers.settings as settings_controller
|
||||||
from models.espnow import ESPNow
|
from models.transport import get_sender, set_sender
|
||||||
from util.espnow_message import split_espnow_message
|
|
||||||
|
|
||||||
|
|
||||||
async def main(port=80):
|
async def main(port=80):
|
||||||
@@ -27,8 +23,9 @@ async def main(port=80):
|
|||||||
print(settings)
|
print(settings)
|
||||||
print("Starting")
|
print("Starting")
|
||||||
|
|
||||||
# Initialize ESPNow singleton (config + peers)
|
# Initialize transport (serial to ESP32 bridge)
|
||||||
esp = ESPNow()
|
sender = get_sender(settings)
|
||||||
|
set_sender(sender)
|
||||||
|
|
||||||
app = Microdot()
|
app = Microdot()
|
||||||
|
|
||||||
@@ -59,7 +56,7 @@ async def main(port=80):
|
|||||||
app.mount(pattern.controller, '/patterns')
|
app.mount(pattern.controller, '/patterns')
|
||||||
app.mount(settings_controller.controller, '/settings')
|
app.mount(settings_controller.controller, '/settings')
|
||||||
|
|
||||||
# Serve index.html at root
|
# Serve index.html at root (cwd is src/ when run via pipenv run run)
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index(request):
|
def index(request):
|
||||||
"""Serve the main web UI."""
|
"""Serve the main web UI."""
|
||||||
@@ -92,27 +89,25 @@ async def main(port=80):
|
|||||||
data = await ws.receive()
|
data = await ws.receive()
|
||||||
print(data)
|
print(data)
|
||||||
if data:
|
if data:
|
||||||
# Debug: log incoming WebSocket data
|
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(data)
|
parsed = json.loads(data)
|
||||||
print("WS received JSON:", parsed)
|
print("WS received JSON:", parsed)
|
||||||
except Exception:
|
# Optional "to": 12-char hex MAC; rest is payload (sent with that address).
|
||||||
print("WS received raw:", data)
|
addr = parsed.pop("to", None)
|
||||||
|
payload = json.dumps(parsed) if parsed else data
|
||||||
# Forward JSON over ESPNow; split into multiple frames if > 250 bytes
|
await sender.send(payload, addr=addr)
|
||||||
try:
|
except json.JSONDecodeError:
|
||||||
|
# Not JSON: send raw with default address
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(data)
|
await sender.send(data)
|
||||||
chunks = split_espnow_message(parsed)
|
except Exception:
|
||||||
except (json.JSONDecodeError, ValueError):
|
try:
|
||||||
chunks = [data]
|
await ws.send(json.dumps({"error": "Send failed"}))
|
||||||
for i, chunk in enumerate(chunks):
|
except Exception:
|
||||||
if i > 0:
|
pass
|
||||||
await asyncio.sleep_ms(100)
|
|
||||||
await esp.send(chunk)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
try:
|
||||||
await ws.send(json.dumps({"error": "ESP-NOW send failed"}))
|
await ws.send(json.dumps({"error": "Send failed"}))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
@@ -122,25 +117,11 @@ async def main(port=80):
|
|||||||
|
|
||||||
server = asyncio.create_task(app.start_server(host="0.0.0.0", port=port))
|
server = asyncio.create_task(app.start_server(host="0.0.0.0", port=port))
|
||||||
|
|
||||||
#wdt = machine.WDT(timeout=10000)
|
|
||||||
#wdt.feed()
|
|
||||||
|
|
||||||
# Initialize heartbeat LED (XIAO ESP32S3 built-in LED on GPIO 21)
|
|
||||||
|
|
||||||
led = Pin(15, Pin.OUT)
|
|
||||||
|
|
||||||
|
|
||||||
led_state = False
|
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
gc.collect()
|
await asyncio.sleep(30)
|
||||||
for i in range(60):
|
|
||||||
#wdt.feed()
|
|
||||||
# Heartbeat: toggle LED every 500 ms
|
|
||||||
|
|
||||||
led.value(not led.value())
|
|
||||||
await asyncio.sleep_ms(500)
|
|
||||||
# cleanup before ending the application
|
# cleanup before ending the application
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
asyncio.run(main())
|
import os
|
||||||
|
port = int(os.environ.get("PORT", 80))
|
||||||
|
asyncio.run(main(port=port))
|
||||||
|
|||||||
54
src/models/device.py
Normal file
54
src/models/device.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from models.model import Model
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_address(addr):
|
||||||
|
"""Normalize 6-byte ESP32 address to 12-char lowercase hex (no colons)."""
|
||||||
|
if addr is None:
|
||||||
|
return None
|
||||||
|
s = str(addr).strip().lower().replace(":", "").replace("-", "")
|
||||||
|
if len(s) == 12 and all(c in "0123456789abcdef" for c in s):
|
||||||
|
return s
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class Device(Model):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def create(self, name="", address=None, default_pattern=None, tabs=None):
|
||||||
|
next_id = self.get_next_id()
|
||||||
|
addr = _normalize_address(address)
|
||||||
|
self[next_id] = {
|
||||||
|
"name": name,
|
||||||
|
"address": addr,
|
||||||
|
"default_pattern": default_pattern if default_pattern else None,
|
||||||
|
"tabs": list(tabs) if tabs else [],
|
||||||
|
}
|
||||||
|
self.save()
|
||||||
|
return next_id
|
||||||
|
|
||||||
|
def read(self, id):
|
||||||
|
id_str = str(id)
|
||||||
|
return self.get(id_str, None)
|
||||||
|
|
||||||
|
def update(self, id, data):
|
||||||
|
id_str = str(id)
|
||||||
|
if id_str not in self:
|
||||||
|
return False
|
||||||
|
if "address" in data and data["address"] is not None:
|
||||||
|
data = dict(data)
|
||||||
|
data["address"] = _normalize_address(data["address"])
|
||||||
|
self[id_str].update(data)
|
||||||
|
self.save()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def delete(self, id):
|
||||||
|
id_str = str(id)
|
||||||
|
if id_str not in self:
|
||||||
|
return False
|
||||||
|
self.pop(id_str)
|
||||||
|
self.save()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def list(self):
|
||||||
|
return list(self.keys())
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import network
|
|
||||||
|
|
||||||
import aioespnow
|
|
||||||
|
|
||||||
|
|
||||||
class ESPNow:
|
|
||||||
"""
|
|
||||||
Singleton ESPNow helper:
|
|
||||||
- Manages a single AIOESPNow instance
|
|
||||||
- Adds a single broadcast-like peer
|
|
||||||
- Exposes async send(data) to send to that peer.
|
|
||||||
"""
|
|
||||||
|
|
||||||
_instance = None
|
|
||||||
|
|
||||||
def __new__(cls, *args, **kwargs):
|
|
||||||
if cls._instance is None:
|
|
||||||
cls._instance = super().__new__(cls)
|
|
||||||
return cls._instance
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
if getattr(self, "_initialized", False):
|
|
||||||
return
|
|
||||||
|
|
||||||
# ESP-NOW requires a WiFi interface to be active (STA or AP). Activate STA
|
|
||||||
# so ESP-NOW has an interface to use; we don't need to connect to an AP.
|
|
||||||
try:
|
|
||||||
sta = network.WLAN(network.STA_IF)
|
|
||||||
sta.active(True)
|
|
||||||
except Exception as e:
|
|
||||||
print("ESPNow: STA active failed:", e)
|
|
||||||
|
|
||||||
self._esp = aioespnow.AIOESPNow()
|
|
||||||
self._esp.active(True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
self._esp.add_peer(b"\xff\xff\xff\xff\xff\xff")
|
|
||||||
except Exception:
|
|
||||||
# Ignore add_peer failures (e.g. duplicate)
|
|
||||||
pass
|
|
||||||
|
|
||||||
self._initialized = True
|
|
||||||
|
|
||||||
|
|
||||||
async def send(self, data):
|
|
||||||
"""
|
|
||||||
Async send to the broadcast peer.
|
|
||||||
- data: bytes or str (JSON)
|
|
||||||
"""
|
|
||||||
if isinstance(data, str):
|
|
||||||
payload = data.encode()
|
|
||||||
else:
|
|
||||||
payload = data
|
|
||||||
|
|
||||||
# Debug: show what we're sending and its size
|
|
||||||
try:
|
|
||||||
preview = payload.decode('utf-8')
|
|
||||||
except Exception:
|
|
||||||
preview = str(payload)
|
|
||||||
if len(preview) > 200:
|
|
||||||
preview = preview[:200] + "...(truncated)"
|
|
||||||
print("ESPNow.send len=", len(payload), "payload=", preview)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self._esp.asend(b"\xff\xff\xff\xff\xff\xff", payload)
|
|
||||||
except Exception as e:
|
|
||||||
print("ESPNow.send error:", e)
|
|
||||||
raise
|
|
||||||
|
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
# DB directory: project root / db (writable without root)
|
||||||
|
def _db_dir():
|
||||||
|
try:
|
||||||
|
# src/models/model.py -> project root
|
||||||
|
base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
return os.path.join(base, "db")
|
||||||
|
except Exception:
|
||||||
|
return "db"
|
||||||
|
|
||||||
class Model(dict):
|
class Model(dict):
|
||||||
def __new__(cls, *args, **kwargs):
|
def __new__(cls, *args, **kwargs):
|
||||||
@@ -13,13 +23,13 @@ class Model(dict):
|
|||||||
if hasattr(self, '_initialized'):
|
if hasattr(self, '_initialized'):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Create /db directory if it doesn't exist (MicroPython compatible)
|
db_dir = _db_dir()
|
||||||
try:
|
try:
|
||||||
os.mkdir("/db")
|
os.makedirs(db_dir, exist_ok=True)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass # Directory already exists, which is fine
|
pass
|
||||||
self.class_name = self.__class__.__name__
|
self.class_name = self.__class__.__name__
|
||||||
self.file = f"/db/{self.class_name.lower()}.json"
|
self.file = os.path.join(db_dir, f"{self.class_name.lower()}.json")
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
self.load() # Load settings from file during initialization
|
self.load() # Load settings from file during initialization
|
||||||
@@ -37,11 +47,11 @@ class Model(dict):
|
|||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
try:
|
try:
|
||||||
# Ensure directory exists
|
db_dir = os.path.dirname(self.file)
|
||||||
try:
|
try:
|
||||||
os.mkdir("/db")
|
os.makedirs(db_dir, exist_ok=True)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass # Directory already exists
|
pass
|
||||||
j = json.dumps(self)
|
j = json.dumps(self)
|
||||||
with open(self.file, 'w') as file:
|
with open(self.file, 'w') as file:
|
||||||
file.write(j)
|
file.write(j)
|
||||||
@@ -54,8 +64,7 @@ class Model(dict):
|
|||||||
print(f"{self.class_name} saved successfully to {self.file}")
|
print(f"{self.class_name} saved successfully to {self.file}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error saving {self.class_name} to {self.file}: {e}")
|
print(f"Error saving {self.class_name} to {self.file}: {e}")
|
||||||
import sys
|
traceback.print_exception(type(e), e, e.__traceback__)
|
||||||
sys.print_exception(e)
|
|
||||||
|
|
||||||
def load(self):
|
def load(self):
|
||||||
try:
|
try:
|
||||||
|
|||||||
12
src/models/serial.py
Normal file
12
src/models/serial.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
class Serial:
|
||||||
|
def __init__(self, port, baudrate):
|
||||||
|
self.port = port
|
||||||
|
self.baudrate = baudrate
|
||||||
|
self.uart = UART(1, baudrate, tx=Pin(21), rx=Pin(6))
|
||||||
|
|
||||||
|
def send(self, data):
|
||||||
|
self.uart.write(data)
|
||||||
|
|
||||||
|
def receive(self):
|
||||||
|
return self.uart.read()
|
||||||
|
|
||||||
66
src/models/transport.py
Normal file
66
src/models/transport.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
# Default: broadcast (6 bytes). Pi always sends 6-byte address + payload to ESP32.
|
||||||
|
BROADCAST_MAC = bytes.fromhex("ffffffffffff")
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_payload(data):
|
||||||
|
if isinstance(data, str):
|
||||||
|
return data.encode()
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return json.dumps(data).encode()
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_mac(addr):
|
||||||
|
"""Convert 12-char hex string or 6-byte bytes to 6-byte MAC."""
|
||||||
|
if addr is None or addr == b"":
|
||||||
|
return BROADCAST_MAC
|
||||||
|
if isinstance(addr, bytes) and len(addr) == 6:
|
||||||
|
return addr
|
||||||
|
if isinstance(addr, str) and len(addr) == 12:
|
||||||
|
return bytes.fromhex(addr)
|
||||||
|
return BROADCAST_MAC
|
||||||
|
|
||||||
|
|
||||||
|
async def _to_thread(func, *args):
|
||||||
|
to_thread = getattr(asyncio, "to_thread", None)
|
||||||
|
if to_thread:
|
||||||
|
return await to_thread(func, *args)
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
return await loop.run_in_executor(None, func, *args)
|
||||||
|
|
||||||
|
|
||||||
|
class SerialSender:
|
||||||
|
def __init__(self, port, baudrate, default_addr=None):
|
||||||
|
import serial
|
||||||
|
|
||||||
|
self._serial = serial.Serial(port, baudrate=baudrate, timeout=1)
|
||||||
|
self._default_addr = _parse_mac(default_addr)
|
||||||
|
|
||||||
|
async def send(self, data, addr=None):
|
||||||
|
mac = _parse_mac(addr) if addr is not None else self._default_addr
|
||||||
|
payload = _encode_payload(data)
|
||||||
|
await _to_thread(self._serial.write, mac + payload)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
_current_sender = None
|
||||||
|
|
||||||
|
|
||||||
|
def set_sender(sender):
|
||||||
|
global _current_sender
|
||||||
|
_current_sender = sender
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_sender():
|
||||||
|
return _current_sender
|
||||||
|
|
||||||
|
|
||||||
|
def get_sender(settings):
|
||||||
|
port = settings.get("serial_port", "/dev/ttyS0")
|
||||||
|
baudrate = settings.get("serial_baudrate", 912000)
|
||||||
|
default_addr = settings.get("serial_destination_mac", "ffffffffffff")
|
||||||
|
return SerialSender(port, baudrate, default_addr=default_addr)
|
||||||
39
src/p2p.py
39
src/p2p.py
@@ -1,39 +0,0 @@
|
|||||||
import network
|
|
||||||
import aioespnow
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
from time import sleep
|
|
||||||
|
|
||||||
|
|
||||||
class P2P:
|
|
||||||
def __init__(self):
|
|
||||||
network.WLAN(network.STA_IF).active(True)
|
|
||||||
self.broadcast = bytes.fromhex("ffffffffffff")
|
|
||||||
self.e = aioespnow.AIOESPNow()
|
|
||||||
self.e.active(True)
|
|
||||||
try:
|
|
||||||
self.e.add_peer(self.broadcast)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def send(self, data):
|
|
||||||
# Convert data to bytes if it's a string or dict
|
|
||||||
if isinstance(data, str):
|
|
||||||
payload = data.encode()
|
|
||||||
elif isinstance(data, dict):
|
|
||||||
payload = json.dumps(data).encode()
|
|
||||||
else:
|
|
||||||
payload = data # Assume it's already bytes
|
|
||||||
|
|
||||||
# Use asend for async sending - returns boolean indicating success
|
|
||||||
result = await self.e.asend(self.broadcast, payload)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
p = P2P()
|
|
||||||
await p.send(json.dumps({"dj": {"p": "on", "colors": ["#ff0000"]}}))
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
@@ -2,11 +2,23 @@ import json
|
|||||||
import os
|
import os
|
||||||
import binascii
|
import binascii
|
||||||
|
|
||||||
|
|
||||||
|
def _settings_path():
|
||||||
|
"""Path to settings.json in project root (writable without root)."""
|
||||||
|
try:
|
||||||
|
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
return os.path.join(base, "settings.json")
|
||||||
|
except Exception:
|
||||||
|
return "settings.json"
|
||||||
|
|
||||||
|
|
||||||
class Settings(dict):
|
class Settings(dict):
|
||||||
SETTINGS_FILE = "/settings.json"
|
SETTINGS_FILE = None # Set in __init__ from _settings_path()
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
if Settings.SETTINGS_FILE is None:
|
||||||
|
Settings.SETTINGS_FILE = _settings_path()
|
||||||
self.load() # Load settings from file during initialization
|
self.load() # Load settings from file during initialization
|
||||||
|
|
||||||
def generate_secret_key(self):
|
def generate_secret_key(self):
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const closeButton = document.getElementById('color-palette-close-btn');
|
const closeButton = document.getElementById('color-palette-close-btn');
|
||||||
const paletteContainer = document.getElementById('palette-container');
|
const paletteContainer = document.getElementById('palette-container');
|
||||||
const paletteNewColor = document.getElementById('palette-new-color');
|
const paletteNewColor = document.getElementById('palette-new-color');
|
||||||
const paletteAddButton = document.getElementById('palette-add-color-btn');
|
|
||||||
const profileNameDisplay = document.getElementById('palette-current-profile-name');
|
const profileNameDisplay = document.getElementById('palette-current-profile-name');
|
||||||
|
|
||||||
if (!paletteButton || !paletteModal || !paletteContainer) {
|
if (!paletteButton || !paletteModal || !paletteContainer) {
|
||||||
@@ -177,8 +176,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (closeButton) {
|
if (closeButton) {
|
||||||
closeButton.addEventListener('click', closeModal);
|
closeButton.addEventListener('click', closeModal);
|
||||||
}
|
}
|
||||||
if (paletteAddButton && paletteNewColor) {
|
if (paletteNewColor) {
|
||||||
paletteAddButton.addEventListener('click', async () => {
|
const addSelectedColor = async () => {
|
||||||
const color = paletteNewColor.value;
|
const color = paletteNewColor.value;
|
||||||
if (!color) {
|
if (!color) {
|
||||||
return;
|
return;
|
||||||
@@ -188,7 +187,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await savePalette([...currentPalette, color]);
|
await savePalette([...currentPalette, color]);
|
||||||
});
|
};
|
||||||
|
// Add when the picker closes (user confirms selection).
|
||||||
|
paletteNewColor.addEventListener('change', addSelectedColor);
|
||||||
}
|
}
|
||||||
paletteModal.addEventListener('click', (event) => {
|
paletteModal.addEventListener('click', (event) => {
|
||||||
if (event.target === paletteModal) {
|
if (event.target === paletteModal) {
|
||||||
|
|||||||
251
src/static/devices.js
Normal file
251
src/static/devices.js
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
// Device management: list, create, edit, delete (name and 6-byte address)
|
||||||
|
|
||||||
|
const HEX_BOX_COUNT = 12;
|
||||||
|
|
||||||
|
function makeHexAddressBoxes(container) {
|
||||||
|
if (!container || container.querySelector('.hex-addr-box')) return;
|
||||||
|
container.innerHTML = '';
|
||||||
|
for (let i = 0; i < HEX_BOX_COUNT; i++) {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.className = 'hex-addr-box';
|
||||||
|
input.maxLength = 1;
|
||||||
|
input.autocomplete = 'off';
|
||||||
|
input.setAttribute('data-index', i);
|
||||||
|
input.setAttribute('inputmode', 'numeric');
|
||||||
|
input.setAttribute('aria-label', `Hex digit ${i + 1}`);
|
||||||
|
input.addEventListener('input', (e) => {
|
||||||
|
const v = e.target.value.replace(/[^0-9a-fA-F]/g, '');
|
||||||
|
e.target.value = v;
|
||||||
|
if (v && e.target.nextElementSibling && e.target.nextElementSibling.classList.contains('hex-addr-box')) {
|
||||||
|
e.target.nextElementSibling.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Backspace' && !e.target.value && e.target.previousElementSibling) {
|
||||||
|
e.target.previousElementSibling.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
input.addEventListener('paste', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const pasted = (e.clipboardData.getData('text') || '').replace(/[^0-9a-fA-F]/g, '').slice(0, HEX_BOX_COUNT);
|
||||||
|
const boxes = container.querySelectorAll('.hex-addr-box');
|
||||||
|
for (let j = 0; j < pasted.length && j < boxes.length; j++) {
|
||||||
|
boxes[j].value = pasted[j];
|
||||||
|
}
|
||||||
|
if (pasted.length > 0) {
|
||||||
|
const nextIdx = Math.min(pasted.length, boxes.length - 1);
|
||||||
|
boxes[nextIdx].focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
container.appendChild(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAddressFromBoxes(container) {
|
||||||
|
if (!container) return '';
|
||||||
|
const boxes = container.querySelectorAll('.hex-addr-box');
|
||||||
|
return Array.from(boxes).map((b) => b.value).join('').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAddressToBoxes(container, addrStr) {
|
||||||
|
if (!container) return;
|
||||||
|
const s = (addrStr || '').replace(/[^0-9a-fA-F]/g, '').slice(0, HEX_BOX_COUNT);
|
||||||
|
const boxes = container.querySelectorAll('.hex-addr-box');
|
||||||
|
boxes.forEach((b, i) => {
|
||||||
|
b.value = s[i] || '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDevicesModal() {
|
||||||
|
const container = document.getElementById('devices-list-modal');
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = '<span class="muted-text">Loading...</span>';
|
||||||
|
try {
|
||||||
|
const response = await fetch('/devices', { headers: { Accept: 'application/json' } });
|
||||||
|
if (!response.ok) throw new Error('Failed to load devices');
|
||||||
|
const devices = await response.json();
|
||||||
|
renderDevicesList(devices || {});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('loadDevicesModal:', e);
|
||||||
|
container.innerHTML = '<span class="muted-text">Failed to load devices.</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDevicesList(devices) {
|
||||||
|
const container = document.getElementById('devices-list-modal');
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = '';
|
||||||
|
const ids = Object.keys(devices).filter((k) => devices[k] && typeof devices[k] === 'object');
|
||||||
|
if (ids.length === 0) {
|
||||||
|
const p = document.createElement('p');
|
||||||
|
p.className = 'muted-text';
|
||||||
|
p.textContent = 'No devices. Create one above.';
|
||||||
|
container.appendChild(p);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ids.forEach((devId) => {
|
||||||
|
const dev = devices[devId];
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'profiles-row';
|
||||||
|
row.style.display = 'flex';
|
||||||
|
row.style.alignItems = 'center';
|
||||||
|
row.style.gap = '0.5rem';
|
||||||
|
row.style.flexWrap = 'wrap';
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.textContent = (dev && dev.name) || devId;
|
||||||
|
label.style.flex = '1';
|
||||||
|
label.style.minWidth = '100px';
|
||||||
|
|
||||||
|
const meta = document.createElement('span');
|
||||||
|
meta.className = 'muted-text';
|
||||||
|
meta.style.fontSize = '0.85em';
|
||||||
|
const addr = (dev && dev.address) ? dev.address : '—';
|
||||||
|
meta.textContent = `Address: ${addr}`;
|
||||||
|
|
||||||
|
const editBtn = document.createElement('button');
|
||||||
|
editBtn.className = 'btn btn-secondary btn-small';
|
||||||
|
editBtn.textContent = 'Edit';
|
||||||
|
editBtn.addEventListener('click', () => openEditDeviceModal(devId, dev));
|
||||||
|
|
||||||
|
const deleteBtn = document.createElement('button');
|
||||||
|
deleteBtn.className = 'btn btn-secondary btn-small';
|
||||||
|
deleteBtn.textContent = 'Delete';
|
||||||
|
deleteBtn.addEventListener('click', async () => {
|
||||||
|
if (!confirm(`Delete device "${(dev && dev.name) || devId}"?`)) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/devices/${devId}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) await loadDevicesModal();
|
||||||
|
else {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
alert(data.error || 'Delete failed');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('Delete failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
row.appendChild(label);
|
||||||
|
row.appendChild(meta);
|
||||||
|
row.appendChild(editBtn);
|
||||||
|
row.appendChild(deleteBtn);
|
||||||
|
container.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDeviceModal(devId, dev) {
|
||||||
|
const modal = document.getElementById('edit-device-modal');
|
||||||
|
const idInput = document.getElementById('edit-device-id');
|
||||||
|
const nameInput = document.getElementById('edit-device-name');
|
||||||
|
const addressBoxes = document.getElementById('edit-device-address-boxes');
|
||||||
|
if (!modal || !idInput) return;
|
||||||
|
idInput.value = devId;
|
||||||
|
if (nameInput) nameInput.value = (dev && dev.name) || '';
|
||||||
|
setAddressToBoxes(addressBoxes, (dev && dev.address) || '');
|
||||||
|
modal.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDevice(name, address) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/devices', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, address: address || null }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (res.ok) {
|
||||||
|
await loadDevicesModal();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
alert(data.error || 'Failed to create device');
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('createDevice:', e);
|
||||||
|
alert('Failed to create device');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateDevice(devId, name, address) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/devices/${devId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, address: address || null }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (res.ok) {
|
||||||
|
await loadDevicesModal();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
alert(data.error || 'Failed to update device');
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('updateDevice:', e);
|
||||||
|
alert('Failed to update device');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
makeHexAddressBoxes(document.getElementById('new-device-address-boxes'));
|
||||||
|
makeHexAddressBoxes(document.getElementById('edit-device-address-boxes'));
|
||||||
|
|
||||||
|
const devicesBtn = document.getElementById('devices-btn');
|
||||||
|
const devicesModal = document.getElementById('devices-modal');
|
||||||
|
const devicesCloseBtn = document.getElementById('devices-close-btn');
|
||||||
|
const newName = document.getElementById('new-device-name');
|
||||||
|
const createBtn = document.getElementById('create-device-btn');
|
||||||
|
const editForm = document.getElementById('edit-device-form');
|
||||||
|
const editCloseBtn = document.getElementById('edit-device-close-btn');
|
||||||
|
const editDeviceModal = document.getElementById('edit-device-modal');
|
||||||
|
|
||||||
|
if (devicesBtn && devicesModal) {
|
||||||
|
devicesBtn.addEventListener('click', () => {
|
||||||
|
devicesModal.classList.add('active');
|
||||||
|
loadDevicesModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (devicesCloseBtn) {
|
||||||
|
devicesCloseBtn.addEventListener('click', () => devicesModal && devicesModal.classList.remove('active'));
|
||||||
|
}
|
||||||
|
const newAddressBoxes = document.getElementById('new-device-address-boxes');
|
||||||
|
const doCreate = async () => {
|
||||||
|
const name = (newName && newName.value.trim()) || '';
|
||||||
|
if (!name) {
|
||||||
|
alert('Device name is required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const address = newAddressBoxes ? getAddressFromBoxes(newAddressBoxes) : '';
|
||||||
|
const ok = await createDevice(name, address);
|
||||||
|
if (ok && newName) {
|
||||||
|
newName.value = '';
|
||||||
|
setAddressToBoxes(newAddressBoxes, '');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (createBtn) createBtn.addEventListener('click', doCreate);
|
||||||
|
if (newName) newName.addEventListener('keypress', (e) => { if (e.key === 'Enter') doCreate(); });
|
||||||
|
|
||||||
|
if (editForm) {
|
||||||
|
editForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const idInput = document.getElementById('edit-device-id');
|
||||||
|
const nameInput = document.getElementById('edit-device-name');
|
||||||
|
const addressBoxes = document.getElementById('edit-device-address-boxes');
|
||||||
|
const devId = idInput && idInput.value;
|
||||||
|
if (!devId) return;
|
||||||
|
const address = addressBoxes ? getAddressFromBoxes(addressBoxes) : '';
|
||||||
|
const ok = await updateDevice(
|
||||||
|
devId,
|
||||||
|
nameInput ? nameInput.value.trim() : '',
|
||||||
|
address
|
||||||
|
);
|
||||||
|
if (ok) editDeviceModal.classList.remove('active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (editCloseBtn) {
|
||||||
|
editCloseBtn.addEventListener('click', () => editDeviceModal && editDeviceModal.classList.remove('active'));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -2,6 +2,72 @@
|
|||||||
let espnowSocket = null;
|
let espnowSocket = null;
|
||||||
let espnowSocketReady = false;
|
let espnowSocketReady = false;
|
||||||
let espnowPendingMessages = [];
|
let espnowPendingMessages = [];
|
||||||
|
let currentProfileIdCache = null;
|
||||||
|
|
||||||
|
const getCurrentProfileId = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/profiles/current', { headers: { Accept: 'application/json' } });
|
||||||
|
if (!res.ok) return currentProfileIdCache ? String(currentProfileIdCache) : null;
|
||||||
|
const data = await res.json();
|
||||||
|
const id = data && (data.id || (data.profile && data.profile.id));
|
||||||
|
currentProfileIdCache = id ? String(id) : null;
|
||||||
|
return currentProfileIdCache;
|
||||||
|
} catch (_) {
|
||||||
|
return currentProfileIdCache ? String(currentProfileIdCache) : null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterPresetsForCurrentProfile = async (presetsObj) => {
|
||||||
|
const scoped = presetsObj && typeof presetsObj === 'object' ? presetsObj : {};
|
||||||
|
const currentProfileId = await getCurrentProfileId();
|
||||||
|
if (!currentProfileId) return scoped;
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(scoped).filter(([, preset]) => {
|
||||||
|
if (!preset || typeof preset !== 'object') return false;
|
||||||
|
if (!('profile_id' in preset)) return true; // Legacy records
|
||||||
|
return String(preset.profile_id) === String(currentProfileId);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentProfileData = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/profiles/current', { headers: { Accept: 'application/json' } });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return await res.json();
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentProfilePaletteColors = async () => {
|
||||||
|
const profileData = await getCurrentProfileData();
|
||||||
|
const profile = profileData && profileData.profile;
|
||||||
|
const paletteId = profile && (profile.palette_id || profile.paletteId);
|
||||||
|
if (!paletteId) return [];
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/palettes/${paletteId}`, { headers: { Accept: 'application/json' } });
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const pal = await res.json();
|
||||||
|
return Array.isArray(pal.colors) ? pal.colors : [];
|
||||||
|
} catch (_) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveColorsWithPaletteRefs = (colors, paletteRefs, paletteColors) => {
|
||||||
|
const baseColors = Array.isArray(colors) ? colors : [];
|
||||||
|
const refs = Array.isArray(paletteRefs) ? paletteRefs : [];
|
||||||
|
const pal = Array.isArray(paletteColors) ? paletteColors : [];
|
||||||
|
return baseColors.map((color, idx) => {
|
||||||
|
const refRaw = refs[idx];
|
||||||
|
const ref = Number.isInteger(refRaw) ? refRaw : parseInt(refRaw, 10);
|
||||||
|
if (Number.isInteger(ref) && ref >= 0 && ref < pal.length && pal[ref]) {
|
||||||
|
return pal[ref];
|
||||||
|
}
|
||||||
|
return color;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const getEspnowSocket = () => {
|
const getEspnowSocket = () => {
|
||||||
if (espnowSocket && (espnowSocket.readyState === WebSocket.OPEN || espnowSocket.readyState === WebSocket.CONNECTING)) {
|
if (espnowSocket && (espnowSocket.readyState === WebSocket.OPEN || espnowSocket.readyState === WebSocket.CONNECTING)) {
|
||||||
@@ -105,7 +171,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const presetPatternInput = document.getElementById('preset-pattern-input');
|
const presetPatternInput = document.getElementById('preset-pattern-input');
|
||||||
const presetColorsContainer = document.getElementById('preset-colors-container');
|
const presetColorsContainer = document.getElementById('preset-colors-container');
|
||||||
const presetNewColorInput = document.getElementById('preset-new-color');
|
const presetNewColorInput = document.getElementById('preset-new-color');
|
||||||
const presetAddColorButton = document.getElementById('preset-add-color-btn');
|
|
||||||
const presetBrightnessInput = document.getElementById('preset-brightness-input');
|
const presetBrightnessInput = document.getElementById('preset-brightness-input');
|
||||||
const presetDelayInput = document.getElementById('preset-delay-input');
|
const presetDelayInput = document.getElementById('preset-delay-input');
|
||||||
const presetDefaultButton = document.getElementById('preset-default-btn');
|
const presetDefaultButton = document.getElementById('preset-default-btn');
|
||||||
@@ -123,6 +188,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
let cachedPresets = {};
|
let cachedPresets = {};
|
||||||
let cachedPatterns = {};
|
let cachedPatterns = {};
|
||||||
let currentPresetColors = []; // Track colors for the current preset
|
let currentPresetColors = []; // Track colors for the current preset
|
||||||
|
let currentPresetPaletteRefs = []; // Palette index refs per color (null for direct colors)
|
||||||
|
|
||||||
// Function to get max colors for current pattern
|
// Function to get max colors for current pattern
|
||||||
const getMaxColors = () => {
|
const getMaxColors = () => {
|
||||||
@@ -158,7 +224,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
// Hide/show the actions (color picker and buttons)
|
// Hide/show the actions (color picker and buttons)
|
||||||
const colorActions = presetColorsContainer.nextElementSibling;
|
const colorActions = presetColorsContainer.nextElementSibling;
|
||||||
if (colorActions && (colorActions.querySelector('#preset-add-color-btn') || colorActions.querySelector('#preset-new-color'))) {
|
if (colorActions && colorActions.querySelector('#preset-new-color')) {
|
||||||
colorActions.style.display = shouldShow ? '' : 'none';
|
colorActions.style.display = shouldShow ? '' : 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,11 +238,24 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return parseInt(input.value, 10) || 0;
|
return parseInt(input.value, 10) || 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderPresetColors = (colors) => {
|
const renderPresetColors = (colors, paletteRefs) => {
|
||||||
if (!presetColorsContainer) return;
|
if (!presetColorsContainer) return;
|
||||||
|
|
||||||
presetColorsContainer.innerHTML = '';
|
presetColorsContainer.innerHTML = '';
|
||||||
currentPresetColors = colors || [];
|
currentPresetColors = Array.isArray(colors) ? colors.slice() : [];
|
||||||
|
if (Array.isArray(paletteRefs)) {
|
||||||
|
currentPresetPaletteRefs = currentPresetColors.map((_, i) => {
|
||||||
|
const refRaw = paletteRefs[i];
|
||||||
|
const ref = Number.isInteger(refRaw) ? refRaw : parseInt(refRaw, 10);
|
||||||
|
return Number.isInteger(ref) ? ref : null;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
currentPresetPaletteRefs = currentPresetColors.map((_, i) => {
|
||||||
|
const refRaw = currentPresetPaletteRefs[i];
|
||||||
|
const ref = Number.isInteger(refRaw) ? refRaw : parseInt(refRaw, 10);
|
||||||
|
return Number.isInteger(ref) ? ref : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Get max colors for current pattern
|
// Get max colors for current pattern
|
||||||
const maxColors = getMaxColors();
|
const maxColors = getMaxColors();
|
||||||
@@ -185,7 +264,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (currentPresetColors.length === 0) {
|
if (currentPresetColors.length === 0) {
|
||||||
const empty = document.createElement('p');
|
const empty = document.createElement('p');
|
||||||
empty.className = 'muted-text';
|
empty.className = 'muted-text';
|
||||||
empty.textContent = `No colors added. Click "Add Color" to add colors.${maxColorsText}`;
|
empty.textContent = `No colors added. Use the color picker to add colors.${maxColorsText}`;
|
||||||
presetColorsContainer.appendChild(empty);
|
presetColorsContainer.appendChild(empty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -208,6 +287,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
swatchWrapper.style.cssText = 'position: relative; display: inline-block;';
|
swatchWrapper.style.cssText = 'position: relative; display: inline-block;';
|
||||||
swatchWrapper.draggable = true;
|
swatchWrapper.draggable = true;
|
||||||
swatchWrapper.dataset.colorIndex = index;
|
swatchWrapper.dataset.colorIndex = index;
|
||||||
|
const refAtIndex = currentPresetPaletteRefs[index];
|
||||||
|
swatchWrapper.dataset.paletteRef = Number.isInteger(refAtIndex) ? String(refAtIndex) : '';
|
||||||
swatchWrapper.classList.add('draggable-color-swatch');
|
swatchWrapper.classList.add('draggable-color-swatch');
|
||||||
|
|
||||||
const swatch = document.createElement('div');
|
const swatch = document.createElement('div');
|
||||||
@@ -222,6 +303,31 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
transition: opacity 0.2s, transform 0.2s;
|
transition: opacity 0.2s, transform 0.2s;
|
||||||
`;
|
`;
|
||||||
swatch.title = `${color} - Drag to reorder`;
|
swatch.title = `${color} - Drag to reorder`;
|
||||||
|
|
||||||
|
if (Number.isInteger(refAtIndex)) {
|
||||||
|
const linkedBadge = document.createElement('span');
|
||||||
|
linkedBadge.textContent = 'P';
|
||||||
|
linkedBadge.title = `Linked to palette color #${refAtIndex + 1}`;
|
||||||
|
linkedBadge.style.cssText = `
|
||||||
|
position: absolute;
|
||||||
|
left: -6px;
|
||||||
|
top: -6px;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #3f51b5;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 11;
|
||||||
|
border: 1px solid rgba(255,255,255,0.35);
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.35);
|
||||||
|
`;
|
||||||
|
swatchWrapper.appendChild(linkedBadge);
|
||||||
|
}
|
||||||
|
|
||||||
// Color picker overlay
|
// Color picker overlay
|
||||||
const colorPicker = document.createElement('input');
|
const colorPicker = document.createElement('input');
|
||||||
@@ -239,7 +345,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
`;
|
`;
|
||||||
colorPicker.addEventListener('change', (e) => {
|
colorPicker.addEventListener('change', (e) => {
|
||||||
currentPresetColors[index] = e.target.value;
|
currentPresetColors[index] = e.target.value;
|
||||||
renderPresetColors(currentPresetColors);
|
// Manual picker edit breaks palette linkage for this slot.
|
||||||
|
currentPresetPaletteRefs[index] = null;
|
||||||
|
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
|
||||||
});
|
});
|
||||||
// Prevent color picker from interfering with drag
|
// Prevent color picker from interfering with drag
|
||||||
colorPicker.addEventListener('mousedown', (e) => {
|
colorPicker.addEventListener('mousedown', (e) => {
|
||||||
@@ -271,7 +379,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
removeBtn.addEventListener('click', (e) => {
|
removeBtn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
currentPresetColors.splice(index, 1);
|
currentPresetColors.splice(index, 1);
|
||||||
renderPresetColors(currentPresetColors);
|
currentPresetPaletteRefs.splice(index, 1);
|
||||||
|
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
|
||||||
});
|
});
|
||||||
// Prevent remove button from interfering with drag
|
// Prevent remove button from interfering with drag
|
||||||
removeBtn.addEventListener('mousedown', (e) => {
|
removeBtn.addEventListener('mousedown', (e) => {
|
||||||
@@ -326,12 +435,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const colorPicker = el.querySelector('input[type="color"]');
|
const colorPicker = el.querySelector('input[type="color"]');
|
||||||
return colorPicker ? colorPicker.value : null;
|
return colorPicker ? colorPicker.value : null;
|
||||||
}).filter(color => color !== null);
|
}).filter(color => color !== null);
|
||||||
|
const newRefOrder = colorElements.map((el) => {
|
||||||
|
const refRaw = el.dataset.paletteRef;
|
||||||
|
const ref = Number.isInteger(refRaw) ? refRaw : parseInt(refRaw, 10);
|
||||||
|
return Number.isInteger(ref) ? ref : null;
|
||||||
|
});
|
||||||
|
|
||||||
// Update current colors array
|
// Update current colors array
|
||||||
currentPresetColors = newColorOrder;
|
currentPresetColors = newColorOrder;
|
||||||
|
currentPresetPaletteRefs = newRefOrder;
|
||||||
|
|
||||||
// Re-render to update indices
|
// Re-render to update indices
|
||||||
renderPresetColors(currentPresetColors);
|
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
|
||||||
});
|
});
|
||||||
|
|
||||||
presetColorsContainer.appendChild(swatchContainer);
|
presetColorsContainer.appendChild(swatchContainer);
|
||||||
@@ -361,7 +476,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const patternName = preset.pattern || '';
|
const patternName = preset.pattern || '';
|
||||||
presetPatternInput.value = patternName;
|
presetPatternInput.value = patternName;
|
||||||
const colors = Array.isArray(preset.colors) ? preset.colors : [];
|
const colors = Array.isArray(preset.colors) ? preset.colors : [];
|
||||||
renderPresetColors(colors);
|
const paletteRefs = Array.isArray(preset.palette_refs) ? preset.palette_refs : [];
|
||||||
|
renderPresetColors(colors, paletteRefs);
|
||||||
presetBrightnessInput.value = preset.brightness || 0;
|
presetBrightnessInput.value = preset.brightness || 0;
|
||||||
presetDelayInput.value = preset.delay || 0;
|
presetDelayInput.value = preset.delay || 0;
|
||||||
|
|
||||||
@@ -424,6 +540,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
currentEditId = null;
|
currentEditId = null;
|
||||||
currentEditTabId = null;
|
currentEditTabId = null;
|
||||||
currentPresetColors = [];
|
currentPresetColors = [];
|
||||||
|
currentPresetPaletteRefs = [];
|
||||||
setFormValues({
|
setFormValues({
|
||||||
name: '',
|
name: '',
|
||||||
pattern: '',
|
pattern: '',
|
||||||
@@ -505,6 +622,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
name: presetNameInput ? presetNameInput.value.trim() : '',
|
name: presetNameInput ? presetNameInput.value.trim() : '',
|
||||||
pattern: presetPatternInput ? presetPatternInput.value.trim() : '',
|
pattern: presetPatternInput ? presetPatternInput.value.trim() : '',
|
||||||
colors: currentPresetColors || [],
|
colors: currentPresetColors || [],
|
||||||
|
palette_refs: currentPresetPaletteRefs || [],
|
||||||
// Use canonical field names expected by the device / API
|
// Use canonical field names expected by the device / API
|
||||||
brightness: presetBrightnessInput ? parseInt(presetBrightnessInput.value, 10) || 0 : 0,
|
brightness: presetBrightnessInput ? parseInt(presetBrightnessInput.value, 10) || 0 : 0,
|
||||||
delay: presetDelayInput ? parseInt(presetDelayInput.value, 10) || 0 : 0,
|
delay: presetDelayInput ? parseInt(presetDelayInput.value, 10) || 0 : 0,
|
||||||
@@ -633,9 +751,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const editButton = document.createElement('button');
|
const editButton = document.createElement('button');
|
||||||
editButton.className = 'btn btn-secondary btn-small';
|
editButton.className = 'btn btn-secondary btn-small';
|
||||||
editButton.textContent = 'Edit';
|
editButton.textContent = 'Edit';
|
||||||
editButton.addEventListener('click', () => {
|
editButton.addEventListener('click', async () => {
|
||||||
currentEditId = presetId;
|
currentEditId = presetId;
|
||||||
setFormValues(preset || {});
|
const paletteColors = await getCurrentProfilePaletteColors();
|
||||||
|
const presetForEditor = {
|
||||||
|
...(preset || {}),
|
||||||
|
colors: resolveColorsWithPaletteRefs(
|
||||||
|
(preset && preset.colors) || [],
|
||||||
|
(preset && preset.palette_refs) || [],
|
||||||
|
paletteColors,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
setFormValues(presetForEditor);
|
||||||
openEditor();
|
openEditor();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -698,7 +825,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
throw new Error('Failed to load presets');
|
throw new Error('Failed to load presets');
|
||||||
}
|
}
|
||||||
const presets = await response.json();
|
const presets = await response.json();
|
||||||
renderPresets(presets);
|
const filtered = await filterPresetsForCurrentProfile(presets);
|
||||||
|
renderPresets(filtered);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Load presets failed:', error);
|
console.error('Load presets failed:', error);
|
||||||
presetsList.innerHTML = '';
|
presetsList.innerHTML = '';
|
||||||
@@ -757,7 +885,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to load presets');
|
throw new Error('Failed to load presets');
|
||||||
}
|
}
|
||||||
const allPresets = await response.json();
|
const allPresetsRaw = await response.json();
|
||||||
|
const allPresets = await filterPresetsForCurrentProfile(allPresetsRaw);
|
||||||
|
|
||||||
// Load only the current tab's presets so we can avoid duplicates within this tab.
|
// Load only the current tab's presets so we can avoid duplicates within this tab.
|
||||||
let currentTabPresets = [];
|
let currentTabPresets = [];
|
||||||
@@ -946,12 +1075,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// Update color section visibility
|
// Update color section visibility
|
||||||
updateColorSectionVisibility();
|
updateColorSectionVisibility();
|
||||||
// Re-render colors to show updated max colors limit
|
// Re-render colors to show updated max colors limit
|
||||||
renderPresetColors(currentPresetColors);
|
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Add Color button handler
|
// Color picker auto-add handler
|
||||||
if (presetAddColorButton && presetNewColorInput) {
|
if (presetNewColorInput) {
|
||||||
presetAddColorButton.addEventListener('click', () => {
|
const tryAddSelectedColor = () => {
|
||||||
const color = presetNewColorInput.value;
|
const color = presetNewColorInput.value;
|
||||||
if (!color) return;
|
if (!color) return;
|
||||||
|
|
||||||
@@ -967,60 +1096,86 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentPresetColors.push(color);
|
currentPresetColors.push(color);
|
||||||
renderPresetColors(currentPresetColors);
|
currentPresetPaletteRefs.push(null);
|
||||||
});
|
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
|
||||||
|
};
|
||||||
|
// Add when the picker closes (user confirms selection).
|
||||||
|
presetNewColorInput.addEventListener('change', tryAddSelectedColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add from Palette button handler
|
|
||||||
if (presetAddFromPaletteButton) {
|
if (presetAddFromPaletteButton) {
|
||||||
presetAddFromPaletteButton.addEventListener('click', () => {
|
presetAddFromPaletteButton.addEventListener('click', async () => {
|
||||||
const openButton = document.getElementById('color-palette-btn');
|
try {
|
||||||
if (openButton) {
|
const paletteColors = await getCurrentProfilePaletteColors();
|
||||||
openButton.click();
|
if (!Array.isArray(paletteColors) || paletteColors.length === 0) {
|
||||||
}
|
alert('No profile palette colors available.');
|
||||||
const modal = document.getElementById('color-palette-modal');
|
return;
|
||||||
const modalList = document.getElementById('palette-container');
|
}
|
||||||
if (modal) {
|
|
||||||
modal.classList.add('active');
|
|
||||||
}
|
|
||||||
if (!modalList) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePick = (event) => {
|
const modal = document.createElement('div');
|
||||||
const row = event.target.closest('[data-color]');
|
modal.className = 'modal active';
|
||||||
if (!row) {
|
modal.innerHTML = `
|
||||||
return;
|
<div class="modal-content">
|
||||||
}
|
<h2>Pick Palette Color</h2>
|
||||||
const picked = row.dataset.color;
|
<div id="pick-palette-list" class="profiles-list" style="max-height: 300px; overflow-y: auto;"></div>
|
||||||
if (!picked) {
|
<div class="modal-actions">
|
||||||
return;
|
<button class="btn btn-secondary" id="pick-palette-close-btn">Close</button>
|
||||||
}
|
</div>
|
||||||
|
</div>
|
||||||
if (currentPresetColors.includes(picked)) {
|
`;
|
||||||
alert('This color is already in the list.');
|
document.body.appendChild(modal);
|
||||||
return;
|
|
||||||
}
|
const list = modal.querySelector('#pick-palette-list');
|
||||||
|
paletteColors.forEach((color, idx) => {
|
||||||
const maxColors = getMaxColors();
|
const row = document.createElement('div');
|
||||||
if (currentPresetColors.length >= maxColors) {
|
row.className = 'profiles-row';
|
||||||
alert(`This pattern allows a maximum of ${maxColors} color${maxColors !== 1 ? 's' : ''}.`);
|
row.style.display = 'flex';
|
||||||
if (modal) {
|
row.style.alignItems = 'center';
|
||||||
modal.classList.remove('active');
|
row.style.gap = '0.75rem';
|
||||||
|
row.dataset.paletteIndex = String(idx);
|
||||||
|
row.dataset.paletteColor = color;
|
||||||
|
row.innerHTML = `
|
||||||
|
<div style="width:28px;height:28px;border-radius:4px;border:1px solid #555;background:${color};"></div>
|
||||||
|
<span style="flex:1">${color}</span>
|
||||||
|
<button class="btn btn-primary btn-small" type="button">Use</button>
|
||||||
|
`;
|
||||||
|
list.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
const close = () => modal.remove();
|
||||||
|
modal.querySelector('#pick-palette-close-btn').addEventListener('click', close);
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) close();
|
||||||
|
});
|
||||||
|
|
||||||
|
list.addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('button');
|
||||||
|
if (!btn) return;
|
||||||
|
const row = e.target.closest('[data-palette-index]');
|
||||||
|
if (!row) return;
|
||||||
|
const color = row.dataset.paletteColor;
|
||||||
|
const ref = parseInt(row.dataset.paletteIndex, 10);
|
||||||
|
if (!color || !Number.isInteger(ref)) return;
|
||||||
|
|
||||||
|
if (currentPresetColors.includes(color) && currentPresetPaletteRefs.includes(ref)) {
|
||||||
|
alert('That palette color is already linked.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const maxColors = getMaxColors();
|
||||||
|
if (currentPresetColors.length >= maxColors) {
|
||||||
|
alert(`This pattern allows a maximum of ${maxColors} color${maxColors !== 1 ? 's' : ''}.`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
modalList.removeEventListener('click', handlePick);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPresetColors.push(picked);
|
|
||||||
renderPresetColors(currentPresetColors);
|
|
||||||
if (modal) {
|
|
||||||
modal.classList.remove('active');
|
|
||||||
}
|
|
||||||
modalList.removeEventListener('click', handlePick);
|
|
||||||
};
|
|
||||||
|
|
||||||
modalList.addEventListener('click', handlePick);
|
currentPresetColors.push(color);
|
||||||
|
currentPresetPaletteRefs.push(ref);
|
||||||
|
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to add from palette:', err);
|
||||||
|
alert('Failed to load palette colors.');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const presetSendButton = document.getElementById('preset-send-btn');
|
const presetSendButton = document.getElementById('preset-send-btn');
|
||||||
@@ -1136,7 +1291,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
currentEditId = presetId;
|
currentEditId = presetId;
|
||||||
currentEditTabId = tabId || null;
|
currentEditTabId = tabId || null;
|
||||||
await loadPatterns();
|
await loadPatterns();
|
||||||
setFormValues(preset);
|
const paletteColors = await getCurrentProfilePaletteColors();
|
||||||
|
setFormValues({
|
||||||
|
...(preset || {}),
|
||||||
|
colors: resolveColorsWithPaletteRefs(
|
||||||
|
(preset && preset.colors) || [],
|
||||||
|
(preset && preset.palette_refs) || [],
|
||||||
|
paletteColors,
|
||||||
|
),
|
||||||
|
});
|
||||||
openEditor();
|
openEditor();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1175,11 +1338,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// Build an ESPNow preset message for a single preset and optionally include a select
|
// Build an ESPNow preset message for a single preset and optionally include a select
|
||||||
// for the given device names, then send it via WebSocket.
|
// for the given device names, then send it via WebSocket.
|
||||||
// saveToDevice defaults to true.
|
// saveToDevice defaults to true.
|
||||||
const sendPresetViaEspNow = (presetId, preset, deviceNames, saveToDevice = true, setDefault = false) => {
|
const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice = true, setDefault = false) => {
|
||||||
try {
|
try {
|
||||||
const colors = Array.isArray(preset.colors) && preset.colors.length
|
const baseColors = Array.isArray(preset.colors) && preset.colors.length
|
||||||
? preset.colors
|
? preset.colors
|
||||||
: ['#FFFFFF'];
|
: ['#FFFFFF'];
|
||||||
|
const paletteColors = await getCurrentProfilePaletteColors();
|
||||||
|
const colors = resolveColorsWithPaletteRefs(baseColors, preset.palette_refs, paletteColors);
|
||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
v: '1',
|
v: '1',
|
||||||
@@ -1261,97 +1426,29 @@ try {
|
|||||||
|
|
||||||
// Store selected preset per tab
|
// Store selected preset per tab
|
||||||
const selectedPresets = {};
|
const selectedPresets = {};
|
||||||
|
// Run vs Edit for tab preset strip (in-memory only — each full page load starts in run mode)
|
||||||
|
let presetUiMode = 'run';
|
||||||
|
|
||||||
|
const getPresetUiMode = () => (presetUiMode === 'edit' ? 'edit' : 'run');
|
||||||
|
|
||||||
|
const setPresetUiMode = (mode) => {
|
||||||
|
presetUiMode = mode === 'edit' ? 'edit' : 'run';
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateUiModeToggleButtons = () => {
|
||||||
|
const mode = getPresetUiMode();
|
||||||
|
// Label is the mode you switch *to* (opposite of current)
|
||||||
|
const label = mode === 'edit' ? 'Run mode' : 'Edit mode';
|
||||||
|
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
|
||||||
|
btn.textContent = label;
|
||||||
|
btn.setAttribute('aria-pressed', mode === 'edit' ? 'true' : 'false');
|
||||||
|
btn.classList.toggle('ui-mode-toggle--edit', mode === 'edit');
|
||||||
|
});
|
||||||
|
document.body.classList.toggle('preset-ui-edit', mode === 'edit');
|
||||||
|
document.body.classList.toggle('preset-ui-run', mode === 'run');
|
||||||
|
};
|
||||||
// Track if we're currently dragging a preset
|
// Track if we're currently dragging a preset
|
||||||
let isDraggingPreset = false;
|
let isDraggingPreset = false;
|
||||||
// Context menu for tab presets
|
|
||||||
let presetContextMenu = null;
|
|
||||||
let presetContextTarget = null;
|
|
||||||
|
|
||||||
const ensurePresetContextMenu = () => {
|
|
||||||
if (presetContextMenu) {
|
|
||||||
return presetContextMenu;
|
|
||||||
}
|
|
||||||
const menu = document.createElement('div');
|
|
||||||
menu.id = 'preset-context-menu';
|
|
||||||
menu.style.cssText = `
|
|
||||||
position: fixed;
|
|
||||||
z-index: 2000;
|
|
||||||
background: #2e2e2e;
|
|
||||||
border: 1px solid #4a4a4a;
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow: 0 2px 6px rgba(0,0,0,0.6);
|
|
||||||
padding: 0.25rem 0;
|
|
||||||
min-width: 160px;
|
|
||||||
display: none;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const addItem = (label, action) => {
|
|
||||||
const item = document.createElement('button');
|
|
||||||
item.type = 'button';
|
|
||||||
item.textContent = label;
|
|
||||||
item.dataset.action = action;
|
|
||||||
item.style.cssText = `
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.4rem 0.75rem;
|
|
||||||
background: transparent;
|
|
||||||
color: #eee;
|
|
||||||
border: none;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
`;
|
|
||||||
item.addEventListener('mouseover', () => {
|
|
||||||
item.style.backgroundColor = '#3a3a3a';
|
|
||||||
});
|
|
||||||
item.addEventListener('mouseout', () => {
|
|
||||||
item.style.backgroundColor = 'transparent';
|
|
||||||
});
|
|
||||||
menu.appendChild(item);
|
|
||||||
};
|
|
||||||
|
|
||||||
addItem('Edit preset…', 'edit');
|
|
||||||
|
|
||||||
menu.addEventListener('click', async (e) => {
|
|
||||||
const btn = e.target.closest('button[data-action]');
|
|
||||||
if (!btn || !presetContextTarget) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { presetId } = presetContextTarget;
|
|
||||||
const action = btn.dataset.action;
|
|
||||||
hidePresetContextMenu();
|
|
||||||
if (action === 'edit') {
|
|
||||||
await editPresetFromTab(presetId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.body.appendChild(menu);
|
|
||||||
presetContextMenu = menu;
|
|
||||||
|
|
||||||
// Hide on outside click
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
if (!presetContextMenu) return;
|
|
||||||
if (e.target.closest('#preset-context-menu')) return;
|
|
||||||
hidePresetContextMenu();
|
|
||||||
});
|
|
||||||
|
|
||||||
return menu;
|
|
||||||
};
|
|
||||||
|
|
||||||
const showPresetContextMenu = (x, y, tabId, presetId, preset) => {
|
|
||||||
const menu = ensurePresetContextMenu();
|
|
||||||
presetContextTarget = { tabId, presetId, preset };
|
|
||||||
menu.style.left = `${x}px`;
|
|
||||||
menu.style.top = `${y}px`;
|
|
||||||
menu.style.display = 'block';
|
|
||||||
};
|
|
||||||
|
|
||||||
const hidePresetContextMenu = () => {
|
|
||||||
if (presetContextMenu) {
|
|
||||||
presetContextMenu.style.display = 'none';
|
|
||||||
}
|
|
||||||
presetContextTarget = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Function to convert 2D grid to flat array (for backward compatibility)
|
// Function to convert 2D grid to flat array (for backward compatibility)
|
||||||
const gridToArray = (presetsGrid) => {
|
const gridToArray = (presetsGrid) => {
|
||||||
@@ -1449,6 +1546,25 @@ const getDropTarget = (container, x, y) => {
|
|||||||
return closest.element;
|
return closest.element;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move dragged tile onto the drop target's slot.
|
||||||
|
* When moving down the list (fromIdx < toIdx), insertBefore(dragging, dropTarget) lands one index
|
||||||
|
* too early; use the next element sibling so the item occupies the target slot.
|
||||||
|
*/
|
||||||
|
const insertDraggingOntoTarget = (presetsList, dragging, dropTarget) => {
|
||||||
|
const siblings = [...presetsList.querySelectorAll('.draggable-preset')];
|
||||||
|
const fromIdx = siblings.indexOf(dragging);
|
||||||
|
const toIdx = siblings.indexOf(dropTarget);
|
||||||
|
if (fromIdx === -1 || toIdx === -1) return;
|
||||||
|
|
||||||
|
if (fromIdx < toIdx) {
|
||||||
|
const next = dropTarget.nextElementSibling;
|
||||||
|
presetsList.insertBefore(dragging, next);
|
||||||
|
} else {
|
||||||
|
presetsList.insertBefore(dragging, dropTarget);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Function to render presets for a specific tab in 2D grid
|
// Function to render presets for a specific tab in 2D grid
|
||||||
const renderTabPresets = async (tabId) => {
|
const renderTabPresets = async (tabId) => {
|
||||||
const presetsList = document.getElementById('presets-list-tab');
|
const presetsList = document.getElementById('presets-list-tab');
|
||||||
@@ -1482,47 +1598,74 @@ const renderTabPresets = async (tabId) => {
|
|||||||
if (!presetsResponse.ok) {
|
if (!presetsResponse.ok) {
|
||||||
throw new Error('Failed to load presets');
|
throw new Error('Failed to load presets');
|
||||||
}
|
}
|
||||||
const allPresets = await presetsResponse.json();
|
const allPresetsRaw = await presetsResponse.json();
|
||||||
|
const allPresets = await filterPresetsForCurrentProfile(allPresetsRaw);
|
||||||
|
const paletteColors = await getCurrentProfilePaletteColors();
|
||||||
|
|
||||||
presetsList.innerHTML = '';
|
presetsList.innerHTML = '';
|
||||||
|
presetsList.dataset.reorderTabId = tabId;
|
||||||
// Add drag and drop handlers to the container
|
|
||||||
presetsList.addEventListener('dragover', (e) => {
|
// Drag-and-drop on the list (wire once — re-render would duplicate listeners otherwise)
|
||||||
e.preventDefault();
|
if (!presetsList.dataset.dragWired) {
|
||||||
const dragging = presetsList.querySelector('.dragging');
|
presetsList.dataset.dragWired = '1';
|
||||||
if (!dragging) return;
|
// dragenter + dropEffect tell the browser this zone accepts a move (avoids ⊘ cursor)
|
||||||
|
presetsList.addEventListener('dragenter', (e) => {
|
||||||
const dropTarget = getDropTarget(presetsList, e.clientX, e.clientY);
|
if (getPresetUiMode() !== 'edit') return;
|
||||||
if (dropTarget && dropTarget !== dragging) {
|
e.preventDefault();
|
||||||
// Insert before drop target so the dragged item takes that cell's position
|
});
|
||||||
presetsList.insertBefore(dragging, dropTarget);
|
presetsList.addEventListener('dragover', (e) => {
|
||||||
}
|
if (getPresetUiMode() !== 'edit') return;
|
||||||
});
|
e.preventDefault();
|
||||||
|
try {
|
||||||
presetsList.addEventListener('drop', async (e) => {
|
e.dataTransfer.dropEffect = 'move';
|
||||||
e.preventDefault();
|
} catch (_) {}
|
||||||
const dragging = presetsList.querySelector('.dragging');
|
const dragging = presetsList.querySelector('.dragging');
|
||||||
if (!dragging) return;
|
if (!dragging) return;
|
||||||
|
|
||||||
// Get new grid layout from DOM
|
const dropTarget = getDropTarget(presetsList, e.clientX, e.clientY);
|
||||||
const presetElements = [...presetsList.querySelectorAll('.draggable-preset')];
|
// Keep dragover side-effect free; commit placement only on drop.
|
||||||
const presetIds = presetElements.map(el => el.dataset.presetId);
|
if (!dropTarget || dropTarget === dragging) {
|
||||||
|
delete presetsList.dataset.dropTargetId;
|
||||||
// Convert to 2D grid (3 columns)
|
return;
|
||||||
const newGrid = arrayToGrid(presetIds, 3);
|
}
|
||||||
|
presetsList.dataset.dropTargetId = dropTarget.dataset.presetId || '';
|
||||||
// Save new grid
|
});
|
||||||
try {
|
|
||||||
await savePresetGrid(tabId, newGrid);
|
presetsList.addEventListener('drop', async (e) => {
|
||||||
// Re-render to ensure consistency
|
if (getPresetUiMode() !== 'edit') return;
|
||||||
await renderTabPresets(tabId);
|
e.preventDefault();
|
||||||
} catch (error) {
|
const dragging = presetsList.querySelector('.dragging');
|
||||||
console.error('Failed to save preset grid:', error);
|
if (!dragging) return;
|
||||||
alert('Failed to save preset order. Please try again.');
|
const targetId = presetsList.dataset.dropTargetId;
|
||||||
// Re-render to restore original order
|
if (targetId) {
|
||||||
await renderTabPresets(tabId);
|
const dropTarget = presetsList.querySelector(`.draggable-preset[data-preset-id="${targetId}"]:not(.dragging)`);
|
||||||
}
|
if (dropTarget) {
|
||||||
});
|
insertDraggingOntoTarget(presetsList, dragging, dropTarget);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete presetsList.dataset.dropTargetId;
|
||||||
|
|
||||||
|
const saveId = presetsList.dataset.reorderTabId;
|
||||||
|
const presetElements = [...presetsList.querySelectorAll('.draggable-preset')];
|
||||||
|
const presetIds = presetElements.map((el) => el.dataset.presetId);
|
||||||
|
|
||||||
|
const newGrid = arrayToGrid(presetIds, 3);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!saveId) {
|
||||||
|
console.warn('No tab id for preset reorder save');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await savePresetGrid(saveId, newGrid);
|
||||||
|
await renderTabPresets(saveId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save preset grid:', error);
|
||||||
|
alert('Failed to save preset order. Please try again.');
|
||||||
|
const fallbackId = presetsList.dataset.reorderTabId;
|
||||||
|
if (fallbackId) await renderTabPresets(fallbackId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Get the currently selected preset for this tab
|
// Get the currently selected preset for this tab
|
||||||
const selectedPresetId = selectedPresets[tabId];
|
const selectedPresetId = selectedPresets[tabId];
|
||||||
@@ -1543,7 +1686,11 @@ const renderTabPresets = async (tabId) => {
|
|||||||
const preset = allPresets[presetId];
|
const preset = allPresets[presetId];
|
||||||
if (preset) {
|
if (preset) {
|
||||||
const isSelected = presetId === selectedPresetId;
|
const isSelected = presetId === selectedPresetId;
|
||||||
const wrapper = createPresetButton(presetId, preset, tabId, isSelected);
|
const displayPreset = {
|
||||||
|
...preset,
|
||||||
|
colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors),
|
||||||
|
};
|
||||||
|
const wrapper = createPresetButton(presetId, displayPreset, tabId, isSelected);
|
||||||
presetsList.appendChild(wrapper);
|
presetsList.appendChild(wrapper);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1555,15 +1702,22 @@ const renderTabPresets = async (tabId) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
|
const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
|
||||||
|
const uiMode = getPresetUiMode();
|
||||||
|
|
||||||
|
const row = document.createElement('div');
|
||||||
|
const canDrag = uiMode === 'edit';
|
||||||
|
row.className = `preset-tile-row preset-tile-row--${uiMode}${canDrag ? ' draggable-preset' : ''}`;
|
||||||
|
row.draggable = canDrag;
|
||||||
|
row.dataset.presetId = presetId;
|
||||||
|
|
||||||
const button = document.createElement('button');
|
const button = document.createElement('button');
|
||||||
button.className = 'pattern-button draggable-preset';
|
button.type = 'button';
|
||||||
button.draggable = true;
|
button.className = 'pattern-button preset-tile-main';
|
||||||
button.dataset.presetId = presetId;
|
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
button.classList.add('active');
|
button.classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
const colors = Array.isArray(preset.colors) ? preset.colors.filter(c => c) : [];
|
const colors = Array.isArray(preset.colors) ? preset.colors.filter((c) => c) : [];
|
||||||
const isRainbow = (preset.pattern || '').toLowerCase() === 'rainbow';
|
const isRainbow = (preset.pattern || '').toLowerCase() === 'rainbow';
|
||||||
const barColors = isRainbow
|
const barColors = isRainbow
|
||||||
? ['#FF0000', '#FF7F00', '#FFFF00', '#00FF00', '#0000FF', '#4B0082', '#8F00FF']
|
? ['#FF0000', '#FF7F00', '#FFFF00', '#00FF00', '#0000FF', '#4B0082', '#8F00FF']
|
||||||
@@ -1584,38 +1738,76 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
|
|||||||
presetNameLabel.className = 'pattern-button-label';
|
presetNameLabel.className = 'pattern-button-label';
|
||||||
button.appendChild(presetNameLabel);
|
button.appendChild(presetNameLabel);
|
||||||
|
|
||||||
button.addEventListener('click', (e) => {
|
button.addEventListener('click', () => {
|
||||||
if (isDraggingPreset) return;
|
if (isDraggingPreset) return;
|
||||||
const presetsList = document.getElementById('presets-list-tab');
|
const presetsListEl = document.getElementById('presets-list-tab');
|
||||||
if (presetsList) {
|
if (presetsListEl) {
|
||||||
presetsList.querySelectorAll('.pattern-button').forEach(btn => btn.classList.remove('active'));
|
presetsListEl.querySelectorAll('.pattern-button').forEach((btn) => btn.classList.remove('active'));
|
||||||
}
|
}
|
||||||
button.classList.add('active');
|
button.classList.add('active');
|
||||||
selectedPresets[tabId] = presetId;
|
selectedPresets[tabId] = presetId;
|
||||||
const section = button.closest('.presets-section');
|
const section = row.closest('.presets-section');
|
||||||
sendSelectForCurrentTabDevices(presetId, section);
|
sendSelectForCurrentTabDevices(presetId, section);
|
||||||
});
|
});
|
||||||
|
|
||||||
button.addEventListener('contextmenu', async (e) => {
|
if (canDrag) {
|
||||||
e.preventDefault();
|
row.addEventListener('dragstart', (e) => {
|
||||||
if (isDraggingPreset) return;
|
isDraggingPreset = true;
|
||||||
await editPresetFromTab(presetId, tabId, preset);
|
row.classList.add('dragging');
|
||||||
});
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/plain', presetId);
|
||||||
|
});
|
||||||
|
|
||||||
button.addEventListener('dragstart', (e) => {
|
row.addEventListener('dragend', () => {
|
||||||
isDraggingPreset = true;
|
row.classList.remove('dragging');
|
||||||
button.classList.add('dragging');
|
const presetsListEl = document.getElementById('presets-list-tab');
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
if (presetsListEl) {
|
||||||
e.dataTransfer.setData('text/plain', presetId);
|
delete presetsListEl.dataset.dropTargetId;
|
||||||
});
|
}
|
||||||
|
document.querySelectorAll('.draggable-preset').forEach((el) => el.classList.remove('drag-over'));
|
||||||
|
setTimeout(() => {
|
||||||
|
isDraggingPreset = false;
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
button.addEventListener('dragend', (e) => {
|
row.appendChild(button);
|
||||||
button.classList.remove('dragging');
|
|
||||||
document.querySelectorAll('.draggable-preset').forEach(el => el.classList.remove('drag-over'));
|
|
||||||
setTimeout(() => { isDraggingPreset = false; }, 100);
|
|
||||||
});
|
|
||||||
|
|
||||||
return button;
|
if (uiMode === 'edit') {
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'preset-tile-actions';
|
||||||
|
|
||||||
|
const editBtn = document.createElement('button');
|
||||||
|
editBtn.type = 'button';
|
||||||
|
editBtn.className = 'btn btn-secondary btn-small';
|
||||||
|
editBtn.textContent = 'Edit';
|
||||||
|
editBtn.title = 'Edit preset';
|
||||||
|
editBtn.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isDraggingPreset) return;
|
||||||
|
editPresetFromTab(presetId, tabId, preset);
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeBtn = document.createElement('button');
|
||||||
|
removeBtn.type = 'button';
|
||||||
|
removeBtn.className = 'btn btn-danger btn-small';
|
||||||
|
removeBtn.textContent = 'Remove';
|
||||||
|
removeBtn.title = 'Remove from this tab';
|
||||||
|
removeBtn.addEventListener('click', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isDraggingPreset) return;
|
||||||
|
if (!window.confirm('Remove this preset from this tab?')) return;
|
||||||
|
await removePresetFromTab(tabId, presetId);
|
||||||
|
});
|
||||||
|
|
||||||
|
actions.appendChild(editBtn);
|
||||||
|
actions.appendChild(removeBtn);
|
||||||
|
row.appendChild(actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return row;
|
||||||
};
|
};
|
||||||
|
|
||||||
const editPresetFromTab = async (presetId, tabId, existingPreset) => {
|
const editPresetFromTab = async (presetId, tabId, existingPreset) => {
|
||||||
@@ -1731,3 +1923,20 @@ document.body.addEventListener('htmx:afterSwap', (event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
updateUiModeToggleButtons();
|
||||||
|
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const next = getPresetUiMode() === 'edit' ? 'run' : 'edit';
|
||||||
|
setPresetUiMode(next);
|
||||||
|
updateUiModeToggleButtons();
|
||||||
|
const mainMenu = document.getElementById('main-menu-dropdown');
|
||||||
|
if (mainMenu) mainMenu.classList.remove('open');
|
||||||
|
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
|
||||||
|
if (leftPanel) {
|
||||||
|
renderTabPresets(leftPanel.dataset.tabId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const profilesCloseButton = document.getElementById("profiles-close-btn");
|
const profilesCloseButton = document.getElementById("profiles-close-btn");
|
||||||
const profilesList = document.getElementById("profiles-list");
|
const profilesList = document.getElementById("profiles-list");
|
||||||
const newProfileInput = document.getElementById("new-profile-name");
|
const newProfileInput = document.getElementById("new-profile-name");
|
||||||
|
const newProfileSeedDjInput = document.getElementById("new-profile-seed-dj");
|
||||||
const createProfileButton = document.getElementById("create-profile-btn");
|
const createProfileButton = document.getElementById("create-profile-btn");
|
||||||
|
|
||||||
if (!profilesButton || !profilesModal || !profilesList) {
|
if (!profilesButton || !profilesModal || !profilesList) {
|
||||||
@@ -19,6 +20,18 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
profilesModal.classList.remove("active");
|
profilesModal.classList.remove("active");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const refreshTabsForActiveProfile = async () => {
|
||||||
|
// Clear stale current tab so tab controller falls back to first tab of applied profile.
|
||||||
|
document.cookie = "current_tab=; path=/; max-age=0";
|
||||||
|
|
||||||
|
if (window.tabsManager && typeof window.tabsManager.loadTabs === "function") {
|
||||||
|
await window.tabsManager.loadTabs();
|
||||||
|
}
|
||||||
|
if (window.tabsManager && typeof window.tabsManager.loadTabsModal === "function") {
|
||||||
|
await window.tabsManager.loadTabsModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const renderProfiles = (profiles, currentProfileId) => {
|
const renderProfiles = (profiles, currentProfileId) => {
|
||||||
profilesList.innerHTML = "";
|
profilesList.innerHTML = "";
|
||||||
let entries = [];
|
let entries = [];
|
||||||
@@ -66,7 +79,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
throw new Error("Failed to apply profile");
|
throw new Error("Failed to apply profile");
|
||||||
}
|
}
|
||||||
await loadProfiles();
|
await loadProfiles();
|
||||||
document.body.dispatchEvent(new Event("tabs-updated"));
|
await refreshTabsForActiveProfile();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Apply profile failed:", error);
|
console.error("Apply profile failed:", error);
|
||||||
alert("Failed to apply profile.");
|
alert("Failed to apply profile.");
|
||||||
@@ -115,22 +128,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
headers: { Accept: "application/json" },
|
headers: { Accept: "application/json" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
document.cookie = "current_tab=; path=/; max-age=0";
|
|
||||||
await loadProfiles();
|
await loadProfiles();
|
||||||
if (typeof window.loadTabs === "function") {
|
await refreshTabsForActiveProfile();
|
||||||
await window.loadTabs();
|
|
||||||
}
|
|
||||||
if (typeof window.loadTabsModal === "function") {
|
|
||||||
await window.loadTabsModal();
|
|
||||||
}
|
|
||||||
const tabContent = document.getElementById("tab-content");
|
|
||||||
if (tabContent) {
|
|
||||||
tabContent.innerHTML = `
|
|
||||||
<div class="tab-content-placeholder">
|
|
||||||
Select a tab to get started
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Clone profile failed:", error);
|
console.error("Clone profile failed:", error);
|
||||||
alert("Failed to clone profile.");
|
alert("Failed to clone profile.");
|
||||||
@@ -210,7 +209,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const response = await fetch("/profiles", {
|
const response = await fetch("/profiles", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
seed_dj_tab: !!(newProfileSeedDjInput && newProfileSeedDjInput.checked),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Failed to create profile");
|
throw new Error("Failed to create profile");
|
||||||
@@ -236,23 +238,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
newProfileInput.value = "";
|
newProfileInput.value = "";
|
||||||
// Clear current tab and refresh the UI so the new profile starts empty.
|
if (newProfileSeedDjInput) {
|
||||||
document.cookie = "current_tab=; path=/; max-age=0";
|
newProfileSeedDjInput.checked = false;
|
||||||
|
}
|
||||||
await loadProfiles();
|
await loadProfiles();
|
||||||
if (typeof window.loadTabs === "function") {
|
await refreshTabsForActiveProfile();
|
||||||
await window.loadTabs();
|
|
||||||
}
|
|
||||||
if (typeof window.loadTabsModal === "function") {
|
|
||||||
await window.loadTabsModal();
|
|
||||||
}
|
|
||||||
const tabContent = document.getElementById("tab-content");
|
|
||||||
if (tabContent) {
|
|
||||||
tabContent.innerHTML = `
|
|
||||||
<div class="tab-content-placeholder">
|
|
||||||
Select a tab to get started
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Create profile failed:", error);
|
console.error("Create profile failed:", error);
|
||||||
alert("Failed to create profile.");
|
alert("Failed to create profile.");
|
||||||
|
|||||||
@@ -77,6 +77,11 @@ header h1 {
|
|||||||
background-color: #333;
|
background-color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Header/menu actions that should only appear in Edit mode */
|
||||||
|
body.preset-ui-run .edit-mode-only {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
padding: 0.45rem 0.9rem;
|
padding: 0.45rem 0.9rem;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -596,6 +601,52 @@ header h1 {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Preset tile: main button + optional edit/remove (Edit mode) */
|
||||||
|
.preset-tile-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: stretch;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-tile-row--run .preset-tile-actions {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-tile-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-tile-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0.15rem 0 0.15rem 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-tile-actions .btn {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0.15rem 0.35rem;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
line-height: 1.15;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-mode-toggle--edit {
|
||||||
|
background-color: #4a3f8f;
|
||||||
|
border: 1px solid #7b6fd6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-mode-toggle--edit:hover {
|
||||||
|
background-color: #5a4f9f;
|
||||||
|
}
|
||||||
|
|
||||||
/* Preset select buttons inside the tab grid */
|
/* Preset select buttons inside the tab grid */
|
||||||
#presets-list-tab .pattern-button {
|
#presets-list-tab .pattern-button {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
// Tab management JavaScript
|
// Tab management JavaScript
|
||||||
let currentTabId = null;
|
let currentTabId = null;
|
||||||
|
|
||||||
|
const isEditModeActive = () => {
|
||||||
|
const toggle = document.querySelector('.ui-mode-toggle');
|
||||||
|
return !!(toggle && toggle.getAttribute('aria-pressed') === 'true');
|
||||||
|
};
|
||||||
|
|
||||||
// Get current tab from cookie
|
// Get current tab from cookie
|
||||||
function getCurrentTabFromCookie() {
|
function getCurrentTabFromCookie() {
|
||||||
const cookies = document.cookie.split(';');
|
const cookies = document.cookie.split(';');
|
||||||
@@ -38,10 +43,12 @@ async function loadTabs() {
|
|||||||
|
|
||||||
// Load current tab content if available
|
// Load current tab content if available
|
||||||
if (currentTabId) {
|
if (currentTabId) {
|
||||||
loadTabContent(currentTabId);
|
await loadTabContent(currentTabId);
|
||||||
} else if (data.tab_order && data.tab_order.length > 0) {
|
} else if (data.tab_order && data.tab_order.length > 0) {
|
||||||
// Set first tab as current if none is set
|
// Set first tab as current if none is set
|
||||||
await setCurrentTab(data.tab_order[0]);
|
const firstTabId = data.tab_order[0];
|
||||||
|
await setCurrentTab(firstTabId);
|
||||||
|
await loadTabContent(firstTabId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load tabs:', error);
|
console.error('Failed to load tabs:', error);
|
||||||
@@ -62,6 +69,7 @@ function renderTabsList(tabs, tabOrder, currentTabId) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const editMode = isEditModeActive();
|
||||||
let html = '<div class="tabs-list">';
|
let html = '<div class="tabs-list">';
|
||||||
for (const tabId of tabOrder) {
|
for (const tabId of tabOrder) {
|
||||||
const tab = tabs[tabId];
|
const tab = tabs[tabId];
|
||||||
@@ -71,7 +79,7 @@ function renderTabsList(tabs, tabOrder, currentTabId) {
|
|||||||
html += `
|
html += `
|
||||||
<button class="tab-button ${activeClass}"
|
<button class="tab-button ${activeClass}"
|
||||||
data-tab-id="${tabId}"
|
data-tab-id="${tabId}"
|
||||||
title="Click to select, right-click to edit"
|
title="${editMode ? 'Click to select, right-click to edit' : 'Click to select'}"
|
||||||
onclick="selectTab('${tabId}')">
|
onclick="selectTab('${tabId}')">
|
||||||
${tabName}
|
${tabName}
|
||||||
</button>
|
</button>
|
||||||
@@ -106,6 +114,7 @@ function renderTabsListModal(tabs, tabOrder, currentTabId) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const editMode = isEditModeActive();
|
||||||
entries.forEach(([tabId, tab]) => {
|
entries.forEach(([tabId, tab]) => {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "profiles-row";
|
row.className = "profiles-row";
|
||||||
@@ -224,10 +233,12 @@ function renderTabsListModal(tabs, tabOrder, currentTabId) {
|
|||||||
|
|
||||||
row.appendChild(label);
|
row.appendChild(label);
|
||||||
row.appendChild(applyButton);
|
row.appendChild(applyButton);
|
||||||
row.appendChild(editButton);
|
|
||||||
row.appendChild(sendPresetsButton);
|
row.appendChild(sendPresetsButton);
|
||||||
row.appendChild(cloneButton);
|
if (editMode) {
|
||||||
row.appendChild(deleteButton);
|
row.appendChild(editButton);
|
||||||
|
row.appendChild(cloneButton);
|
||||||
|
row.appendChild(deleteButton);
|
||||||
|
}
|
||||||
container.appendChild(row);
|
container.appendChild(row);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -714,6 +725,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
// Right-click on a tab button in the main header bar to edit that tab
|
// Right-click on a tab button in the main header bar to edit that tab
|
||||||
document.addEventListener('contextmenu', async (event) => {
|
document.addEventListener('contextmenu', async (event) => {
|
||||||
|
if (!isEditModeActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const btn = event.target.closest('.tab-button');
|
const btn = event.target.closest('.tab-button');
|
||||||
if (!btn || !btn.dataset.tabId) {
|
if (!btn || !btn.dataset.tabId) {
|
||||||
return;
|
return;
|
||||||
@@ -796,11 +810,22 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
await sendProfilePresets();
|
await sendProfilePresets();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When run/edit mode toggles, refresh tabs UI so edit actions show/hide immediately.
|
||||||
|
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
await loadTabs();
|
||||||
|
if (tabsModal && tabsModal.classList.contains('active')) {
|
||||||
|
await loadTabsModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Export for use in other scripts
|
// Export for use in other scripts
|
||||||
window.tabsManager = {
|
window.tabsManager = {
|
||||||
loadTabs,
|
loadTabs,
|
||||||
|
loadTabsModal,
|
||||||
selectTab,
|
selectTab,
|
||||||
createTab,
|
createTab,
|
||||||
updateTab,
|
updateTab,
|
||||||
|
|||||||
@@ -15,24 +15,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button class="btn btn-secondary" id="tabs-btn">Tabs</button>
|
<button class="btn btn-secondary edit-mode-only" id="tabs-btn">Tabs</button>
|
||||||
<button class="btn btn-secondary" id="color-palette-btn">Color Palette</button>
|
<button class="btn btn-secondary edit-mode-only" id="color-palette-btn">Color Palette</button>
|
||||||
<button class="btn btn-secondary" id="presets-btn">Presets</button>
|
<button class="btn btn-secondary edit-mode-only" id="presets-btn">Presets</button>
|
||||||
<button class="btn btn-secondary" id="send-profile-presets-btn">Send Presets</button>
|
<button class="btn btn-secondary" id="send-profile-presets-btn">Send Presets</button>
|
||||||
<button class="btn btn-secondary" id="patterns-btn">Patterns</button>
|
<button class="btn btn-secondary edit-mode-only" id="patterns-btn">Patterns</button>
|
||||||
<button class="btn btn-secondary" id="profiles-btn">Profiles</button>
|
<button class="btn btn-secondary edit-mode-only" id="profiles-btn">Profiles</button>
|
||||||
<button class="btn btn-secondary" id="settings-btn">Settings</button>
|
<button class="btn btn-secondary" id="settings-btn">Settings</button>
|
||||||
<button class="btn btn-secondary" id="help-btn">Help</button>
|
<button class="btn btn-secondary" id="help-btn">Help</button>
|
||||||
|
<button type="button" class="btn btn-secondary ui-mode-toggle" id="ui-mode-toggle" aria-pressed="false" title="Switch preset strip mode — label is the mode you will switch to">Edit mode</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-menu-mobile">
|
<div class="header-menu-mobile">
|
||||||
<button class="btn btn-secondary" id="main-menu-btn">Menu</button>
|
<button class="btn btn-secondary" id="main-menu-btn">Menu</button>
|
||||||
<div id="main-menu-dropdown" class="main-menu-dropdown">
|
<div id="main-menu-dropdown" class="main-menu-dropdown">
|
||||||
<button type="button" data-target="tabs-btn">Tabs</button>
|
<button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
|
||||||
<button type="button" data-target="color-palette-btn">Color Palette</button>
|
<button type="button" class="edit-mode-only" data-target="tabs-btn">Tabs</button>
|
||||||
<button type="button" data-target="presets-btn">Presets</button>
|
<button type="button" class="edit-mode-only" data-target="color-palette-btn">Color Palette</button>
|
||||||
|
<button type="button" class="edit-mode-only" data-target="presets-btn">Presets</button>
|
||||||
<button type="button" data-target="send-profile-presets-btn">Send Presets</button>
|
<button type="button" data-target="send-profile-presets-btn">Send Presets</button>
|
||||||
<button type="button" data-target="patterns-btn">Patterns</button>
|
<button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</button>
|
||||||
<button type="button" data-target="profiles-btn">Profiles</button>
|
<button type="button" class="edit-mode-only" data-target="profiles-btn">Profiles</button>
|
||||||
<button type="button" data-target="settings-btn">Settings</button>
|
<button type="button" data-target="settings-btn">Settings</button>
|
||||||
<button type="button" data-target="help-btn">Help</button>
|
<button type="button" data-target="help-btn">Help</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,6 +94,12 @@
|
|||||||
<input type="text" id="new-profile-name" placeholder="Profile name">
|
<input type="text" id="new-profile-name" placeholder="Profile name">
|
||||||
<button class="btn btn-primary" id="create-profile-btn">Create</button>
|
<button class="btn btn-primary" id="create-profile-btn">Create</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group" style="margin-top: 0.5rem; margin-bottom: 0;">
|
||||||
|
<label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0;">
|
||||||
|
<input type="checkbox" id="new-profile-seed-dj">
|
||||||
|
DJ tab
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div id="profiles-list" class="profiles-list"></div>
|
<div id="profiles-list" class="profiles-list"></div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-secondary" id="profiles-close-btn">Close</button>
|
<button class="btn btn-secondary" id="profiles-close-btn">Close</button>
|
||||||
@@ -126,9 +134,8 @@
|
|||||||
<label>Colors</label>
|
<label>Colors</label>
|
||||||
<div id="preset-colors-container" class="preset-colors-container"></div>
|
<div id="preset-colors-container" class="preset-colors-container"></div>
|
||||||
<div class="profiles-actions">
|
<div class="profiles-actions">
|
||||||
<input type="color" id="preset-new-color" value="#ffffff">
|
<input type="color" id="preset-new-color" value="#ffffff" title="Choose color (auto-adds)">
|
||||||
<button class="btn btn-secondary btn-small" id="preset-add-color-btn">Add Color</button>
|
<button class="btn btn-secondary btn-small" id="preset-add-from-palette-btn">From Palette</button>
|
||||||
<button class="btn btn-secondary btn-small" id="preset-add-from-palette-btn">Add from Palette</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="profiles-actions">
|
<div class="profiles-actions">
|
||||||
<div class="preset-editor-field">
|
<div class="preset-editor-field">
|
||||||
@@ -204,7 +211,6 @@
|
|||||||
<div id="palette-container" class="profiles-list"></div>
|
<div id="palette-container" class="profiles-list"></div>
|
||||||
<div class="profiles-actions">
|
<div class="profiles-actions">
|
||||||
<input type="color" id="palette-new-color" value="#ffffff">
|
<input type="color" id="palette-new-color" value="#ffffff">
|
||||||
<button class="btn btn-primary" id="palette-add-color-btn">Add Color</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-secondary" id="color-palette-close-btn">Close</button>
|
<button class="btn btn-secondary" id="color-palette-close-btn">Close</button>
|
||||||
@@ -228,9 +234,10 @@
|
|||||||
<h3>Presets in a tab</h3>
|
<h3>Presets in a tab</h3>
|
||||||
<ul>
|
<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>Select preset</strong>: left-click a preset tile to select it and send a <code>select</code> message to all devices in the tab.</li>
|
||||||
<li><strong>Edit preset</strong>: right-click a preset tile and choose <strong>Edit preset…</strong>.</li>
|
<li><strong>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>Remove from tab</strong>: right-click a preset tile and choose <strong>Remove from this tab</strong> (the preset itself is not deleted, only its link from this tab).</li>
|
<li><strong>Edit preset</strong>: switch to <strong>Edit mode</strong> (menu button) and use <strong>Edit</strong> on each tile.</li>
|
||||||
<li><strong>Reorder presets</strong>: drag preset tiles to change their order; the new layout is saved automatically.</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>
|
</ul>
|
||||||
|
|
||||||
<h3>Presets, profiles & colors</h3>
|
<h3>Presets, profiles & colors</h3>
|
||||||
@@ -287,7 +294,7 @@
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="ap-password">AP Password</label>
|
<label for="ap-password">AP Password</label>
|
||||||
<input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)">
|
<input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)" data-bwignore>
|
||||||
<small>Leave empty for open network (min 8 characters if set)</small>
|
<small>Leave empty for open network (min 8 characters if set)</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -193,7 +193,7 @@
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="ap-password">AP Password</label>
|
<label for="ap-password">AP Password</label>
|
||||||
<input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)">
|
<input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)" data-bwignore>
|
||||||
<small>Leave empty for open network (min 8 characters if set)</small>
|
<small>Leave empty for open network (min 8 characters if set)</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,23 @@
|
|||||||
"""
|
"""
|
||||||
ESPNow message builder utility for LED driver communication.
|
Message builder for LED driver API communication.
|
||||||
|
|
||||||
This module provides utilities to build ESPNow messages according to the API specification.
|
Builds JSON messages according to the LED driver API specification
|
||||||
ESPNow has a 250-byte payload limit; messages larger than that must be split into multiple
|
for sending presets and select commands over the transport (e.g. serial).
|
||||||
frames.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
# ESPNow payload limit (bytes). Messages larger than this must be split.
|
|
||||||
ESPNOW_MAX_PAYLOAD_BYTES = 240
|
|
||||||
|
|
||||||
|
|
||||||
def build_message(presets=None, select=None, save=False, default=None):
|
def build_message(presets=None, select=None, save=False, default=None):
|
||||||
"""
|
"""
|
||||||
Build an ESPNow message according to the API specification.
|
Build an API message (presets and/or select) as a JSON string.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
presets: Dictionary mapping preset names to preset objects, or None
|
presets: Dictionary mapping preset names to preset objects, or None
|
||||||
select: Dictionary mapping device names to select lists, or None
|
select: Dictionary mapping device names to select lists, or None
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JSON string ready to send via ESPNow
|
JSON string ready to send over the transport
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
message = build_message(
|
message = build_message(
|
||||||
@@ -59,82 +55,6 @@ def build_message(presets=None, select=None, save=False, default=None):
|
|||||||
return json.dumps(message)
|
return json.dumps(message)
|
||||||
|
|
||||||
|
|
||||||
def split_espnow_message(msg_dict, max_bytes=None):
|
|
||||||
"""
|
|
||||||
Split a message dict into one or more JSON strings each within ESPNow payload limit.
|
|
||||||
If the message fits in max_bytes, returns a single-element list. Otherwise splits
|
|
||||||
"select" and/or "presets" into multiple messages (other keys like v, b, default, save
|
|
||||||
are included only in the first message).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
msg_dict: Full message as a dict (e.g. from json.loads).
|
|
||||||
max_bytes: Max payload size in bytes (default ESPNOW_MAX_PAYLOAD_BYTES).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of JSON strings, each <= max_bytes, to send in order.
|
|
||||||
"""
|
|
||||||
if max_bytes is None:
|
|
||||||
max_bytes = ESPNOW_MAX_PAYLOAD_BYTES
|
|
||||||
|
|
||||||
single = json.dumps(msg_dict)
|
|
||||||
if len(single) <= max_bytes:
|
|
||||||
return [single]
|
|
||||||
|
|
||||||
# Keys to attach only to the first message we emit
|
|
||||||
first_only = {k: msg_dict[k] for k in ("b", "default", "save") if k in msg_dict}
|
|
||||||
out = []
|
|
||||||
|
|
||||||
def emit(chunk_dict, is_first):
|
|
||||||
m = {"v": msg_dict.get("v", "1")}
|
|
||||||
if is_first and first_only:
|
|
||||||
m.update(first_only)
|
|
||||||
m.update(chunk_dict)
|
|
||||||
s = json.dumps(m)
|
|
||||||
if len(s) > max_bytes:
|
|
||||||
raise ValueError(f"Chunk still too large ({len(s)} > {max_bytes})")
|
|
||||||
out.append(s)
|
|
||||||
|
|
||||||
def chunk_dict(key, items_dict):
|
|
||||||
if not items_dict:
|
|
||||||
return
|
|
||||||
items = list(items_dict.items())
|
|
||||||
i = 0
|
|
||||||
first = True
|
|
||||||
while i < len(items):
|
|
||||||
chunk = {}
|
|
||||||
while i < len(items):
|
|
||||||
k, v = items[i]
|
|
||||||
trial = dict(chunk)
|
|
||||||
trial[k] = v
|
|
||||||
trial_msg = {"v": msg_dict.get("v", "1"), key: trial}
|
|
||||||
if first_only and first:
|
|
||||||
trial_msg.update(first_only)
|
|
||||||
if len(json.dumps(trial_msg)) <= max_bytes:
|
|
||||||
chunk[k] = v
|
|
||||||
i += 1
|
|
||||||
else:
|
|
||||||
if not chunk:
|
|
||||||
# Single entry too large; send as-is and hope receiver accepts
|
|
||||||
chunk[k] = v
|
|
||||||
i += 1
|
|
||||||
break
|
|
||||||
if chunk:
|
|
||||||
emit({key: chunk}, first)
|
|
||||||
first = False
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
|
|
||||||
if "select" in msg_dict:
|
|
||||||
chunk_dict("select", msg_dict["select"])
|
|
||||||
if "presets" in msg_dict:
|
|
||||||
chunk_dict("presets", msg_dict["presets"])
|
|
||||||
|
|
||||||
if not out:
|
|
||||||
# Fallback: emit one message even if over limit (receiver may reject)
|
|
||||||
out = [single]
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def build_select_message(device_name, preset_name, step=None):
|
def build_select_message(device_name, preset_name, step=None):
|
||||||
"""
|
"""
|
||||||
Build a select message for a single device.
|
Build a select message for a single device.
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import network
|
|
||||||
|
|
||||||
|
|
||||||
def ap(ssid, password, channel=None):
|
|
||||||
ap_if = network.WLAN(network.AP_IF)
|
|
||||||
ap_mac = ap_if.config('mac')
|
|
||||||
print(ssid)
|
|
||||||
ap_if.active(True)
|
|
||||||
if channel is not None:
|
|
||||||
ap_if.config(essid=ssid, password=password, channel=channel)
|
|
||||||
else:
|
|
||||||
ap_if.config(essid=ssid, password=password)
|
|
||||||
ap_if.active(False)
|
|
||||||
ap_if.active(True)
|
|
||||||
print(ap_if.ifconfig())
|
|
||||||
|
|
||||||
def get_mac():
|
|
||||||
ap_if = network.WLAN(network.AP_IF)
|
|
||||||
return ap_if.config('mac')
|
|
||||||
|
|
||||||
|
|
||||||
def get_ap_config():
|
|
||||||
"""Get current AP configuration."""
|
|
||||||
try:
|
|
||||||
ap_if = network.WLAN(network.AP_IF)
|
|
||||||
if ap_if.active():
|
|
||||||
config = ap_if.ifconfig()
|
|
||||||
return {
|
|
||||||
'ssid': ap_if.config('essid'),
|
|
||||||
'channel': ap_if.config('channel'),
|
|
||||||
'ip': config[0] if config else None,
|
|
||||||
'active': True
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
'ssid': None,
|
|
||||||
'channel': None,
|
|
||||||
'ip': None,
|
|
||||||
'active': False
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error getting AP config: {e}")
|
|
||||||
return None
|
|
||||||
@@ -12,6 +12,7 @@ from test_group import test_group
|
|||||||
from test_sequence import test_sequence
|
from test_sequence import test_sequence
|
||||||
from test_tab import test_tab
|
from test_tab import test_tab
|
||||||
from test_palette import test_palette
|
from test_palette import test_palette
|
||||||
|
from test_device import test_device
|
||||||
|
|
||||||
def run_all_tests():
|
def run_all_tests():
|
||||||
"""Run all model tests."""
|
"""Run all model tests."""
|
||||||
@@ -27,6 +28,7 @@ def run_all_tests():
|
|||||||
("Sequence", test_sequence),
|
("Sequence", test_sequence),
|
||||||
("Tab", test_tab),
|
("Tab", test_tab),
|
||||||
("Palette", test_palette),
|
("Palette", test_palette),
|
||||||
|
("Device", test_device),
|
||||||
]
|
]
|
||||||
|
|
||||||
passed = 0
|
passed = 0
|
||||||
|
|||||||
64
tests/models/test_device.py
Normal file
64
tests/models/test_device.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
from models.device import Device
|
||||||
|
import os
|
||||||
|
|
||||||
|
def test_device():
|
||||||
|
"""Test Device model CRUD operations."""
|
||||||
|
db_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "db")
|
||||||
|
device_file = os.path.join(db_dir, "device.json")
|
||||||
|
if os.path.exists(device_file):
|
||||||
|
os.remove(device_file)
|
||||||
|
|
||||||
|
devices = Device()
|
||||||
|
|
||||||
|
print("Testing create device")
|
||||||
|
device_id = devices.create("Test Device", address="aa:bb:cc:dd:ee:ff", default_pattern="on", tabs=["1", "2"])
|
||||||
|
print(f"Created device with ID: {device_id}")
|
||||||
|
assert device_id is not None
|
||||||
|
assert device_id in devices
|
||||||
|
|
||||||
|
print("\nTesting read device")
|
||||||
|
device = devices.read(device_id)
|
||||||
|
print(f"Read: {device}")
|
||||||
|
assert device is not None
|
||||||
|
assert device["name"] == "Test Device"
|
||||||
|
assert device["address"] == "aabbccddeeff"
|
||||||
|
assert device["default_pattern"] == "on"
|
||||||
|
assert device["tabs"] == ["1", "2"]
|
||||||
|
|
||||||
|
print("\nTesting address normalization")
|
||||||
|
devices.update(device_id, {"address": "11:22:33:44:55:66"})
|
||||||
|
updated = devices.read(device_id)
|
||||||
|
assert updated["address"] == "112233445566"
|
||||||
|
|
||||||
|
print("\nTesting update device")
|
||||||
|
update_data = {
|
||||||
|
"name": "Updated Device",
|
||||||
|
"default_pattern": "rainbow",
|
||||||
|
"tabs": ["1", "2", "3"],
|
||||||
|
}
|
||||||
|
result = devices.update(device_id, update_data)
|
||||||
|
assert result is True
|
||||||
|
updated = devices.read(device_id)
|
||||||
|
assert updated["name"] == "Updated Device"
|
||||||
|
assert updated["default_pattern"] == "rainbow"
|
||||||
|
assert len(updated["tabs"]) == 3
|
||||||
|
|
||||||
|
print("\nTesting list devices")
|
||||||
|
device_list = devices.list()
|
||||||
|
print(f"Device list: {device_list}")
|
||||||
|
assert device_id in device_list
|
||||||
|
|
||||||
|
print("\nTesting delete device")
|
||||||
|
deleted = devices.delete(device_id)
|
||||||
|
assert deleted is True
|
||||||
|
assert device_id not in devices
|
||||||
|
|
||||||
|
print("\nTesting read after delete")
|
||||||
|
device = devices.read(device_id)
|
||||||
|
assert device is None
|
||||||
|
|
||||||
|
print("\nAll device tests passed!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_device()
|
||||||
@@ -6,11 +6,13 @@ def test_model():
|
|||||||
# Create a test model class
|
# Create a test model class
|
||||||
class TestModel(Model):
|
class TestModel(Model):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Clean up any existing test file
|
# Clean up any existing test file (model uses db/<classname>.json)
|
||||||
if os.path.exists("TestModel.json"):
|
db_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "db")
|
||||||
os.remove("TestModel.json")
|
testmodel_file = os.path.join(db_dir, "testmodel.json")
|
||||||
|
if os.path.exists(testmodel_file):
|
||||||
|
os.remove(testmodel_file)
|
||||||
|
|
||||||
model = TestModel()
|
model = TestModel()
|
||||||
|
|
||||||
print("Testing get_next_id with empty model")
|
print("Testing get_next_id with empty model")
|
||||||
@@ -43,9 +45,9 @@ def test_model():
|
|||||||
assert hasattr(model2, 'set_defaults')
|
assert hasattr(model2, 'set_defaults')
|
||||||
|
|
||||||
# Clean up
|
# Clean up
|
||||||
if os.path.exists("TestModel.json"):
|
if os.path.exists(testmodel_file):
|
||||||
os.remove("TestModel.json")
|
os.remove(testmodel_file)
|
||||||
|
|
||||||
print("\nAll model base class tests passed!")
|
print("\nAll model base class tests passed!")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ from models.pallet import Palette
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
def test_palette():
|
def test_palette():
|
||||||
"""Test Palette model CRUD operations."""
|
"""Test Palette model CRUD operations.
|
||||||
# Clean up any existing test file
|
Palette stores a list of colors per ID; read() returns that list (or unwraps from dict).
|
||||||
if os.path.exists("Palette.json"):
|
"""
|
||||||
os.remove("Palette.json")
|
# Clean up any existing test file (model uses db/palette.json from project root)
|
||||||
|
db_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "db")
|
||||||
|
palette_file = os.path.join(db_dir, "palette.json")
|
||||||
|
if os.path.exists(palette_file):
|
||||||
|
os.remove(palette_file)
|
||||||
|
|
||||||
palettes = Palette()
|
palettes = Palette()
|
||||||
|
|
||||||
@@ -19,10 +23,12 @@ def test_palette():
|
|||||||
print("\nTesting read palette")
|
print("\nTesting read palette")
|
||||||
palette = palettes.read(palette_id)
|
palette = palettes.read(palette_id)
|
||||||
print(f"Read: {palette}")
|
print(f"Read: {palette}")
|
||||||
|
# read() returns list of colors (name is not stored)
|
||||||
assert palette is not None
|
assert palette is not None
|
||||||
assert palette["name"] == "test_palette"
|
assert isinstance(palette, list) or (isinstance(palette, dict) and "colors" in palette)
|
||||||
assert len(palette["colors"]) == 4
|
colors_read = palette if isinstance(palette, list) else palette.get("colors", [])
|
||||||
assert "#FF0000" in palette["colors"]
|
assert len(colors_read) == 4
|
||||||
|
assert "#FF0000" in colors_read
|
||||||
|
|
||||||
print("\nTesting update palette")
|
print("\nTesting update palette")
|
||||||
update_data = {
|
update_data = {
|
||||||
@@ -32,9 +38,9 @@ def test_palette():
|
|||||||
result = palettes.update(palette_id, update_data)
|
result = palettes.update(palette_id, update_data)
|
||||||
assert result is True
|
assert result is True
|
||||||
updated = palettes.read(palette_id)
|
updated = palettes.read(palette_id)
|
||||||
assert updated["name"] == "updated_palette"
|
updated_colors = updated if isinstance(updated, list) else (updated.get("colors") or [])
|
||||||
assert len(updated["colors"]) == 3
|
assert len(updated_colors) == 3
|
||||||
assert "#FF00FF" in updated["colors"]
|
assert "#FF00FF" in updated_colors
|
||||||
|
|
||||||
print("\nTesting list palettes")
|
print("\nTesting list palettes")
|
||||||
palette_list = palettes.list()
|
palette_list = palettes.list()
|
||||||
@@ -48,7 +54,8 @@ def test_palette():
|
|||||||
|
|
||||||
print("\nTesting read after delete")
|
print("\nTesting read after delete")
|
||||||
palette = palettes.read(palette_id)
|
palette = palettes.read(palette_id)
|
||||||
assert palette is None
|
# read() returns [] when id is missing (value or [])
|
||||||
|
assert palette == [] or palette is None
|
||||||
|
|
||||||
print("\nAll palette tests passed!")
|
print("\nAll palette tests passed!")
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ from models.profile import Profile
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
def test_profile():
|
def test_profile():
|
||||||
"""Test Profile model CRUD operations."""
|
"""Test Profile model CRUD operations.
|
||||||
# Clean up any existing test file
|
Profile create() sets name, type, tabs (list of tab IDs), scenes, palette_id.
|
||||||
if os.path.exists("Profile.json"):
|
"""
|
||||||
os.remove("Profile.json")
|
# Clean up any existing test file (model uses db/profile.json from project root)
|
||||||
|
db_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "db")
|
||||||
|
profile_file = os.path.join(db_dir, "profile.json")
|
||||||
|
if os.path.exists(profile_file):
|
||||||
|
os.remove(profile_file)
|
||||||
|
|
||||||
profiles = Profile()
|
profiles = Profile()
|
||||||
|
|
||||||
@@ -21,15 +25,13 @@ def test_profile():
|
|||||||
assert profile is not None
|
assert profile is not None
|
||||||
assert profile["name"] == "test_profile"
|
assert profile["name"] == "test_profile"
|
||||||
assert "tabs" in profile
|
assert "tabs" in profile
|
||||||
assert "palette" in profile
|
assert "palette_id" in profile
|
||||||
assert "tab_order" in profile
|
assert "type" in profile
|
||||||
|
|
||||||
print("\nTesting update profile")
|
print("\nTesting update profile")
|
||||||
update_data = {
|
update_data = {
|
||||||
"name": "updated_profile",
|
"name": "updated_profile",
|
||||||
"tabs": {"tab1": {"names": ["1"], "presets": []}},
|
"tabs": ["tab1"],
|
||||||
"palette": ["#FF0000", "#00FF00"],
|
|
||||||
"tab_order": ["tab1"]
|
|
||||||
}
|
}
|
||||||
result = profiles.update(profile_id, update_data)
|
result = profiles.update(profile_id, update_data)
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
"""
|
"""
|
||||||
Browser automation tests using Selenium.
|
Browser automation tests using Selenium.
|
||||||
Tests run against the device at 192.168.4.1 in an actual browser.
|
Tests run against the device at 192.168.4.1 in an actual browser.
|
||||||
|
|
||||||
|
On Pi OS Lite (no desktop) these tests are skipped unless headless Chromium
|
||||||
|
and chromedriver are installed (e.g. chromium-browser chromium-chromedriver).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
@@ -13,8 +16,8 @@ from selenium import webdriver
|
|||||||
from selenium.webdriver.common.by import By
|
from selenium.webdriver.common.by import By
|
||||||
from selenium.webdriver.support.ui import WebDriverWait
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
from selenium.webdriver.support import expected_conditions as EC
|
from selenium.webdriver.support import expected_conditions as EC
|
||||||
from selenium.webdriver.chrome.options import Options
|
from selenium.webdriver.chrome.options import Options as ChromeOptions
|
||||||
from selenium.webdriver.chrome.service import Service
|
from selenium.webdriver.firefox.options import Options as FirefoxOptions
|
||||||
from selenium.webdriver.common.action_chains import ActionChains
|
from selenium.webdriver.common.action_chains import ActionChains
|
||||||
from selenium.common.exceptions import TimeoutException, NoSuchElementException
|
from selenium.common.exceptions import TimeoutException, NoSuchElementException
|
||||||
|
|
||||||
@@ -33,24 +36,41 @@ class BrowserTest:
|
|||||||
self.created_presets: List[str] = []
|
self.created_presets: List[str] = []
|
||||||
|
|
||||||
def setup(self):
|
def setup(self):
|
||||||
"""Set up the browser driver."""
|
"""Set up the browser driver. Tries Chrome first, then Firefox."""
|
||||||
|
err_chrome, err_firefox = None, None
|
||||||
|
# Try Chrome first
|
||||||
try:
|
try:
|
||||||
chrome_options = Options()
|
opts = ChromeOptions()
|
||||||
if self.headless:
|
if self.headless:
|
||||||
chrome_options.add_argument('--headless')
|
opts.add_argument('--headless')
|
||||||
chrome_options.add_argument('--no-sandbox')
|
opts.add_argument('--no-sandbox')
|
||||||
chrome_options.add_argument('--disable-dev-shm-usage')
|
opts.add_argument('--disable-dev-shm-usage')
|
||||||
chrome_options.add_argument('--disable-gpu')
|
opts.add_argument('--disable-gpu')
|
||||||
chrome_options.add_argument('--window-size=1920,1080')
|
opts.add_argument('--window-size=1920,1080')
|
||||||
|
self.driver = webdriver.Chrome(options=opts)
|
||||||
self.driver = webdriver.Chrome(options=chrome_options)
|
|
||||||
self.driver.implicitly_wait(5)
|
self.driver.implicitly_wait(5)
|
||||||
print("✓ Browser started")
|
print("✓ Browser started (Chrome)")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"✗ Failed to start browser: {e}")
|
err_chrome = e
|
||||||
print(" Make sure Chrome and ChromeDriver are installed")
|
# Fallback to Firefox
|
||||||
return False
|
try:
|
||||||
|
opts = FirefoxOptions()
|
||||||
|
if self.headless:
|
||||||
|
opts.add_argument('--headless')
|
||||||
|
self.driver = webdriver.Firefox(options=opts)
|
||||||
|
self.driver.implicitly_wait(5)
|
||||||
|
print("✓ Browser started (Firefox)")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
err_firefox = e
|
||||||
|
print("✗ Failed to start browser.")
|
||||||
|
if err_chrome:
|
||||||
|
print(f" Chrome: {err_chrome}")
|
||||||
|
if err_firefox:
|
||||||
|
print(f" Firefox: {err_firefox}")
|
||||||
|
print(" On Raspberry Pi (aarch64), install: chromium-browser and chromium-chromedriver")
|
||||||
|
return False
|
||||||
|
|
||||||
def teardown(self):
|
def teardown(self):
|
||||||
"""Close the browser."""
|
"""Close the browser."""
|
||||||
@@ -209,46 +229,6 @@ class BrowserTest:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" ⚠ Cleanup error: {e}")
|
print(f" ⚠ Cleanup error: {e}")
|
||||||
|
|
||||||
def cleanup_test_data(self):
|
|
||||||
"""Clean up test data created during tests."""
|
|
||||||
try:
|
|
||||||
# Use requests to make API calls for cleanup
|
|
||||||
session = requests.Session()
|
|
||||||
|
|
||||||
# Delete created presets
|
|
||||||
for preset_id in self.created_presets:
|
|
||||||
try:
|
|
||||||
response = session.delete(f"{self.base_url}/presets/{preset_id}")
|
|
||||||
if response.status_code == 200:
|
|
||||||
print(f" ✓ Cleaned up preset: {preset_id}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ⚠ Failed to cleanup preset {preset_id}: {e}")
|
|
||||||
|
|
||||||
# Delete created tabs
|
|
||||||
for tab_id in self.created_tabs:
|
|
||||||
try:
|
|
||||||
response = session.delete(f"{self.base_url}/tabs/{tab_id}")
|
|
||||||
if response.status_code == 200:
|
|
||||||
print(f" ✓ Cleaned up tab: {tab_id}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ⚠ Failed to cleanup tab {tab_id}: {e}")
|
|
||||||
|
|
||||||
# Delete created profiles
|
|
||||||
for profile_id in self.created_profiles:
|
|
||||||
try:
|
|
||||||
response = session.delete(f"{self.base_url}/profiles/{profile_id}")
|
|
||||||
if response.status_code == 200:
|
|
||||||
print(f" ✓ Cleaned up profile: {profile_id}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ⚠ Failed to cleanup profile {profile_id}: {e}")
|
|
||||||
|
|
||||||
# Clear the lists
|
|
||||||
self.created_tabs.clear()
|
|
||||||
self.created_profiles.clear()
|
|
||||||
self.created_presets.clear()
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ⚠ Cleanup error: {e}")
|
|
||||||
|
|
||||||
def fill_input(self, by, value, text, timeout=10):
|
def fill_input(self, by, value, text, timeout=10):
|
||||||
"""Fill an input field."""
|
"""Fill an input field."""
|
||||||
try:
|
try:
|
||||||
@@ -553,7 +533,7 @@ def test_mobile_tab_presets_two_columns():
|
|||||||
container = bt.wait_for_element(By.ID, 'presets-list-tab', timeout=10)
|
container = bt.wait_for_element(By.ID, 'presets-list-tab', timeout=10)
|
||||||
assert container is not None, "presets-list-tab not found"
|
assert container is not None, "presets-list-tab not found"
|
||||||
|
|
||||||
tiles = bt.driver.find_elements(By.CSS_SELECTOR, '#presets-list-tab .draggable-preset')
|
tiles = bt.driver.find_elements(By.CSS_SELECTOR, '#presets-list-tab .preset-tile-row')
|
||||||
# Need at least 2 presets to make this meaningful
|
# Need at least 2 presets to make this meaningful
|
||||||
assert len(tiles) >= 2, "Fewer than 2 presets found for tab"
|
assert len(tiles) >= 2, "Fewer than 2 presets found for tab"
|
||||||
|
|
||||||
@@ -902,14 +882,20 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
|
|||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
# Test 5: Find presets in tab and test drag and drop
|
# Test 5: Find presets in tab and test drag and drop (Edit mode only)
|
||||||
total += 1
|
total += 1
|
||||||
try:
|
try:
|
||||||
# Wait for presets to load in the tab
|
# Wait for presets to load in the tab
|
||||||
presets_list_tab = browser.wait_for_element(By.ID, 'presets-list-tab', timeout=5)
|
presets_list_tab = browser.wait_for_element(By.ID, 'presets-list-tab', timeout=5)
|
||||||
if presets_list_tab:
|
if presets_list_tab:
|
||||||
time.sleep(1) # Wait for presets to render
|
time.sleep(1) # Wait for presets to render
|
||||||
|
|
||||||
|
# Reordering is only available in Edit mode (tiles get .draggable-preset)
|
||||||
|
mode_toggle = browser.wait_for_element(By.CSS_SELECTOR, '.ui-mode-toggle', timeout=5)
|
||||||
|
if mode_toggle and mode_toggle.get_attribute('aria-pressed') == 'false':
|
||||||
|
mode_toggle.click()
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
# Find draggable preset elements - wait a bit more for rendering
|
# Find draggable preset elements - wait a bit more for rendering
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
draggable_presets = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-tab .draggable-preset')
|
draggable_presets = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-tab .draggable-preset')
|
||||||
@@ -1005,11 +991,19 @@ def main():
|
|||||||
print("LED Controller Browser Tests")
|
print("LED Controller Browser Tests")
|
||||||
print(f"Testing against: {BASE_URL}")
|
print(f"Testing against: {BASE_URL}")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
|
||||||
|
# On Pi OS Lite there is no browser by default; skip with exit 0 instead of failing
|
||||||
|
browser = BrowserTest(headless=True)
|
||||||
|
if not browser.setup():
|
||||||
|
print("\nSkipped (Pi OS Lite / no browser). Install chromium-browser and")
|
||||||
|
print("chromium-chromedriver to run browser tests, or run on Pi OS with desktop.")
|
||||||
|
sys.exit(0)
|
||||||
|
browser.teardown()
|
||||||
|
|
||||||
browser = BrowserTest(headless=False) # Set to True for headless mode
|
browser = BrowserTest(headless=False) # Set to True for headless mode
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
# Run browser tests
|
# Run browser tests
|
||||||
results.append(("Browser Connection", test_browser_connection(browser)))
|
results.append(("Browser Connection", test_browser_connection(browser)))
|
||||||
results.append(("Tabs UI", test_tabs_ui(browser)))
|
results.append(("Tabs UI", test_tabs_ui(browser)))
|
||||||
|
|||||||
@@ -499,6 +499,7 @@ def test_static_files(client: TestClient) -> bool:
|
|||||||
'/static/tabs.js',
|
'/static/tabs.js',
|
||||||
'/static/presets.js',
|
'/static/presets.js',
|
||||||
'/static/profiles.js',
|
'/static/profiles.js',
|
||||||
|
'/static/devices.js',
|
||||||
]
|
]
|
||||||
|
|
||||||
for file_path in static_files:
|
for file_path in static_files:
|
||||||
|
|||||||
Reference in New Issue
Block a user