Compare commits
26 Commits
80ff216e54
...
v1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| b2077c0199 | |||
| 0fdc11c0b0 | |||
| 91bd78ab31 | |||
| 2be0640622 | |||
| 0e96223bf6 | |||
| d8b33923d5 | |||
| 4ce515be1c | |||
| f88bf03939 | |||
| 7cd4a91350 | |||
| d907ca37ad | |||
| 6c6ed22dbe | |||
| 00514f0525 | |||
| cf1d831b5a | |||
| fd37183400 | |||
| 5fdeb57b74 | |||
| 1576383d09 | |||
| 8503315bef | |||
| 928263fbd8 | |||
| 7e33f7db6a | |||
| e74ef6d64f | |||
| 3ed435824c | |||
| d7fabf58a4 | |||
| a7e921805a | |||
| c56739c5fa | |||
| fd52e40d17 | |||
| f48c8789c7 |
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
.venv
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Project specific
|
||||
*.log
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
4
Pipfile
4
Pipfile
@@ -9,6 +9,9 @@ pyserial = "*"
|
||||
esptool = "*"
|
||||
pyjwt = "*"
|
||||
watchfiles = "*"
|
||||
requests = "*"
|
||||
selenium = "*"
|
||||
adafruit-ampy = "*"
|
||||
|
||||
[dev-packages]
|
||||
|
||||
@@ -18,3 +21,4 @@ python_version = "3.12"
|
||||
[scripts]
|
||||
web = "python /home/pi/led-controller/tests/web.py"
|
||||
watch = "python -m watchfiles 'python tests/web.py' src tests"
|
||||
install = "pipenv install"
|
||||
449
Pipfile.lock
generated
449
Pipfile.lock
generated
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "24a0e63d49a769fb2bbc35d7d361aeb0c8563f2d65cbeb24acfae9e183d1c0ca"
|
||||
"sha256": "c963cd52164ac13fda5e6f3c5975bc14db6cea03ad4973de02ad91a0ab10d2ea"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
@@ -16,6 +16,14 @@
|
||||
]
|
||||
},
|
||||
"default": {
|
||||
"adafruit-ampy": {
|
||||
"hashes": [
|
||||
"sha256:4a74812226e53c17d01eb828633424bc4f4fe76b9499a7b35eba6fc2532635b7",
|
||||
"sha256:f4cba36f564096f2aafd173f7fbabb845365cc3bb3f41c37541edf98b58d3976"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.1.0"
|
||||
},
|
||||
"anyio": {
|
||||
"hashes": [
|
||||
"sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703",
|
||||
@@ -24,6 +32,22 @@
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==4.12.1"
|
||||
},
|
||||
"async-generator": {
|
||||
"hashes": [
|
||||
"sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b",
|
||||
"sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==1.10"
|
||||
},
|
||||
"attrs": {
|
||||
"hashes": [
|
||||
"sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11",
|
||||
"sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"
|
||||
],
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==25.4.0"
|
||||
},
|
||||
"bitarray": {
|
||||
"hashes": [
|
||||
"sha256:004d518fa410e6da43386d20e07b576a41eb417ac67abf9f30fa75e125697199",
|
||||
@@ -141,6 +165,14 @@
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==4.3.1"
|
||||
},
|
||||
"certifi": {
|
||||
"hashes": [
|
||||
"sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c",
|
||||
"sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"
|
||||
],
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==2026.1.4"
|
||||
},
|
||||
"cffi": {
|
||||
"hashes": [
|
||||
"sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb",
|
||||
@@ -231,6 +263,125 @@
|
||||
"markers": "python_full_version >= '3.9' and platform_python_implementation != 'PyPy'",
|
||||
"version": "==2.0.0"
|
||||
},
|
||||
"charset-normalizer": {
|
||||
"hashes": [
|
||||
"sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad",
|
||||
"sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93",
|
||||
"sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394",
|
||||
"sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89",
|
||||
"sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc",
|
||||
"sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86",
|
||||
"sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63",
|
||||
"sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d",
|
||||
"sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f",
|
||||
"sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8",
|
||||
"sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0",
|
||||
"sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505",
|
||||
"sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161",
|
||||
"sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af",
|
||||
"sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152",
|
||||
"sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318",
|
||||
"sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72",
|
||||
"sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4",
|
||||
"sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e",
|
||||
"sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3",
|
||||
"sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576",
|
||||
"sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c",
|
||||
"sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1",
|
||||
"sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8",
|
||||
"sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1",
|
||||
"sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2",
|
||||
"sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44",
|
||||
"sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26",
|
||||
"sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88",
|
||||
"sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016",
|
||||
"sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede",
|
||||
"sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf",
|
||||
"sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a",
|
||||
"sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc",
|
||||
"sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0",
|
||||
"sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84",
|
||||
"sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db",
|
||||
"sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1",
|
||||
"sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7",
|
||||
"sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed",
|
||||
"sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8",
|
||||
"sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133",
|
||||
"sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e",
|
||||
"sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef",
|
||||
"sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14",
|
||||
"sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2",
|
||||
"sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0",
|
||||
"sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d",
|
||||
"sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828",
|
||||
"sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f",
|
||||
"sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf",
|
||||
"sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6",
|
||||
"sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328",
|
||||
"sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090",
|
||||
"sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa",
|
||||
"sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381",
|
||||
"sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c",
|
||||
"sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb",
|
||||
"sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc",
|
||||
"sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a",
|
||||
"sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec",
|
||||
"sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc",
|
||||
"sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac",
|
||||
"sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e",
|
||||
"sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313",
|
||||
"sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569",
|
||||
"sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3",
|
||||
"sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d",
|
||||
"sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525",
|
||||
"sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894",
|
||||
"sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3",
|
||||
"sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9",
|
||||
"sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a",
|
||||
"sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9",
|
||||
"sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14",
|
||||
"sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25",
|
||||
"sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50",
|
||||
"sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf",
|
||||
"sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1",
|
||||
"sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3",
|
||||
"sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac",
|
||||
"sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e",
|
||||
"sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815",
|
||||
"sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c",
|
||||
"sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6",
|
||||
"sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6",
|
||||
"sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e",
|
||||
"sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4",
|
||||
"sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84",
|
||||
"sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69",
|
||||
"sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15",
|
||||
"sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191",
|
||||
"sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0",
|
||||
"sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897",
|
||||
"sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd",
|
||||
"sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2",
|
||||
"sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794",
|
||||
"sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d",
|
||||
"sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074",
|
||||
"sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3",
|
||||
"sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224",
|
||||
"sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838",
|
||||
"sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a",
|
||||
"sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d",
|
||||
"sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d",
|
||||
"sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f",
|
||||
"sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8",
|
||||
"sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490",
|
||||
"sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966",
|
||||
"sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9",
|
||||
"sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3",
|
||||
"sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e",
|
||||
"sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"
|
||||
],
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==3.4.4"
|
||||
},
|
||||
"click": {
|
||||
"hashes": [
|
||||
"sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a",
|
||||
@@ -241,71 +392,75 @@
|
||||
},
|
||||
"cryptography": {
|
||||
"hashes": [
|
||||
"sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217",
|
||||
"sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d",
|
||||
"sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc",
|
||||
"sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71",
|
||||
"sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971",
|
||||
"sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a",
|
||||
"sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926",
|
||||
"sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc",
|
||||
"sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d",
|
||||
"sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b",
|
||||
"sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20",
|
||||
"sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044",
|
||||
"sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3",
|
||||
"sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715",
|
||||
"sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4",
|
||||
"sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506",
|
||||
"sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f",
|
||||
"sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0",
|
||||
"sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683",
|
||||
"sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3",
|
||||
"sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21",
|
||||
"sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91",
|
||||
"sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c",
|
||||
"sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8",
|
||||
"sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df",
|
||||
"sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c",
|
||||
"sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb",
|
||||
"sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7",
|
||||
"sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04",
|
||||
"sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db",
|
||||
"sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459",
|
||||
"sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea",
|
||||
"sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914",
|
||||
"sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717",
|
||||
"sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9",
|
||||
"sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac",
|
||||
"sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32",
|
||||
"sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec",
|
||||
"sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1",
|
||||
"sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb",
|
||||
"sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac",
|
||||
"sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665",
|
||||
"sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e",
|
||||
"sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb",
|
||||
"sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5",
|
||||
"sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936",
|
||||
"sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de",
|
||||
"sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372",
|
||||
"sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54",
|
||||
"sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422",
|
||||
"sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849",
|
||||
"sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c",
|
||||
"sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963",
|
||||
"sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018"
|
||||
"sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa",
|
||||
"sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc",
|
||||
"sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da",
|
||||
"sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255",
|
||||
"sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2",
|
||||
"sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485",
|
||||
"sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0",
|
||||
"sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d",
|
||||
"sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616",
|
||||
"sha256:44cc0675b27cadb71bdbb96099cca1fa051cd11d2ade09e5cd3a2edb929ed947",
|
||||
"sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0",
|
||||
"sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908",
|
||||
"sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81",
|
||||
"sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc",
|
||||
"sha256:62217ba44bf81b30abaeda1488686a04a702a261e26f87db51ff61d9d3510abd",
|
||||
"sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b",
|
||||
"sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019",
|
||||
"sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7",
|
||||
"sha256:766330cce7416c92b5e90c3bb71b1b79521760cdcfc3a6a1a182d4c9fab23d2b",
|
||||
"sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973",
|
||||
"sha256:829c2b12bbc5428ab02d6b7f7e9bbfd53e33efd6672d21341f2177470171ad8b",
|
||||
"sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5",
|
||||
"sha256:8a15fb869670efa8f83cbffbc8753c1abf236883225aed74cd179b720ac9ec80",
|
||||
"sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef",
|
||||
"sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0",
|
||||
"sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b",
|
||||
"sha256:9b34d8ba84454641a6bf4d6762d15847ecbd85c1316c0a7984e6e4e9f748ec2e",
|
||||
"sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c",
|
||||
"sha256:9c2da296c8d3415b93e6053f5a728649a87a48ce084a9aaf51d6e46c87c7f2d2",
|
||||
"sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af",
|
||||
"sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4",
|
||||
"sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab",
|
||||
"sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82",
|
||||
"sha256:be8c01a7d5a55f9a47d1888162b76c8f49d62b234d88f0ff91a9fbebe32ffbc3",
|
||||
"sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59",
|
||||
"sha256:c236a44acfb610e70f6b3e1c3ca20ff24459659231ef2f8c48e879e2d32b73da",
|
||||
"sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061",
|
||||
"sha256:c92010b58a51196a5f41c3795190203ac52edfd5dc3ff99149b4659eba9d2085",
|
||||
"sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b",
|
||||
"sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263",
|
||||
"sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e",
|
||||
"sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829",
|
||||
"sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4",
|
||||
"sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c",
|
||||
"sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f",
|
||||
"sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095",
|
||||
"sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32",
|
||||
"sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976",
|
||||
"sha256:fdc3daab53b212472f1524d070735b2f0c214239df131903bae1d598016fa822"
|
||||
],
|
||||
"markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'",
|
||||
"version": "==46.0.3"
|
||||
"version": "==46.0.4"
|
||||
},
|
||||
"esptool": {
|
||||
"hashes": [
|
||||
"sha256:2ea9bcd7eb263d380a4fe0170856a10e4c65e3f38c757ebdc73584c8dd8322da"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==5.1.0"
|
||||
},
|
||||
"h11": {
|
||||
"hashes": [
|
||||
"sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1",
|
||||
"sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==0.16.0"
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
"sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea",
|
||||
@@ -314,6 +469,14 @@
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==3.11"
|
||||
},
|
||||
"importlib-metadata": {
|
||||
"hashes": [
|
||||
"sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb",
|
||||
"sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"
|
||||
],
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==8.7.1"
|
||||
},
|
||||
"intelhex": {
|
||||
"hashes": [
|
||||
"sha256:87cc5225657524ec6361354be928adfd56bcf2a3dcc646c40f8f094c39c07db4",
|
||||
@@ -343,8 +506,33 @@
|
||||
"sha256:6bb75774648091dad6833af4f86c5bf6505f8d7aec211380f9e6996c01d23cb5"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.4'",
|
||||
"version": "==1.27.0"
|
||||
},
|
||||
"mypy-extensions": {
|
||||
"hashes": [
|
||||
"sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505",
|
||||
"sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==1.1.0"
|
||||
},
|
||||
"outcome": {
|
||||
"hashes": [
|
||||
"sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8",
|
||||
"sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b"
|
||||
],
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==1.3.0.post0"
|
||||
},
|
||||
"packaging": {
|
||||
"hashes": [
|
||||
"sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4",
|
||||
"sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==26.0"
|
||||
},
|
||||
"platformdirs": {
|
||||
"hashes": [
|
||||
"sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda",
|
||||
@@ -355,11 +543,11 @@
|
||||
},
|
||||
"pycparser": {
|
||||
"hashes": [
|
||||
"sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2",
|
||||
"sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"
|
||||
"sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29",
|
||||
"sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"
|
||||
],
|
||||
"markers": "implementation_name != 'PyPy'",
|
||||
"version": "==2.23"
|
||||
"version": "==3.0"
|
||||
},
|
||||
"pygments": {
|
||||
"hashes": [
|
||||
@@ -371,11 +559,12 @@
|
||||
},
|
||||
"pyjwt": {
|
||||
"hashes": [
|
||||
"sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953",
|
||||
"sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"
|
||||
"sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623",
|
||||
"sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.10.1"
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==2.11.0"
|
||||
},
|
||||
"pyserial": {
|
||||
"hashes": [
|
||||
@@ -385,6 +574,22 @@
|
||||
"index": "pypi",
|
||||
"version": "==3.5"
|
||||
},
|
||||
"pysocks": {
|
||||
"hashes": [
|
||||
"sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299",
|
||||
"sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5",
|
||||
"sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"
|
||||
],
|
||||
"version": "==1.7.1"
|
||||
},
|
||||
"python-dotenv": {
|
||||
"hashes": [
|
||||
"sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6",
|
||||
"sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61"
|
||||
],
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==1.2.1"
|
||||
},
|
||||
"pyyaml": {
|
||||
"hashes": [
|
||||
"sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c",
|
||||
@@ -471,30 +676,111 @@
|
||||
],
|
||||
"version": "==1.7.0"
|
||||
},
|
||||
"requests": {
|
||||
"hashes": [
|
||||
"sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6",
|
||||
"sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==2.32.5"
|
||||
},
|
||||
"rich": {
|
||||
"hashes": [
|
||||
"sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4",
|
||||
"sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd"
|
||||
"sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69",
|
||||
"sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8"
|
||||
],
|
||||
"markers": "python_full_version >= '3.8.0'",
|
||||
"version": "==14.2.0"
|
||||
"version": "==14.3.2"
|
||||
},
|
||||
"rich-click": {
|
||||
"hashes": [
|
||||
"sha256:48120531493f1533828da80e13e839d471979ec8d7d0ca7b35f86a1379cc74b6",
|
||||
"sha256:9b195721a773b1acf0e16ff9ec68cef1e7d237e53471e6e3f7ade462f86c403a"
|
||||
"sha256:022997c1e30731995bdbc8ec2f82819340d42543237f033a003c7b1f843fc5dc",
|
||||
"sha256:2f99120fca78f536e07b114d3b60333bc4bb2a0969053b1250869bcdc1b5351b"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==1.9.5"
|
||||
"version": "==1.9.7"
|
||||
},
|
||||
"selenium": {
|
||||
"hashes": [
|
||||
"sha256:a88f5905d88ad0b84991c2386ea39e2bbde6d6c334be38df5842318ba98eaa8c",
|
||||
"sha256:c8823fc02e2c771d9ad9a0cf899cee7de1a57a6697e3d0b91f67566129f2b729"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==4.40.0"
|
||||
},
|
||||
"sniffio": {
|
||||
"hashes": [
|
||||
"sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2",
|
||||
"sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"
|
||||
],
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==1.3.1"
|
||||
},
|
||||
"sortedcontainers": {
|
||||
"hashes": [
|
||||
"sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88",
|
||||
"sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"
|
||||
],
|
||||
"version": "==2.4.0"
|
||||
},
|
||||
"trio": {
|
||||
"hashes": [
|
||||
"sha256:150f29ec923bcd51231e1d4c71c7006e65247d68759dd1c19af4ea815a25806b",
|
||||
"sha256:4ab65984ef8370b79a76659ec87aa3a30c5c7c83ff250b4de88c29a8ab6123c5"
|
||||
],
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==0.32.0"
|
||||
},
|
||||
"trio-typing": {
|
||||
"hashes": [
|
||||
"sha256:065ee684296d52a8ab0e2374666301aec36ee5747ac0e7a61f230250f8907ac3",
|
||||
"sha256:6d0e7ec9d837a2fe03591031a172533fbf4a1a95baf369edebfc51d5a49f0264"
|
||||
],
|
||||
"version": "==0.10.0"
|
||||
},
|
||||
"trio-websocket": {
|
||||
"hashes": [
|
||||
"sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae",
|
||||
"sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==0.12.2"
|
||||
},
|
||||
"types-certifi": {
|
||||
"hashes": [
|
||||
"sha256:72cf7798d165bc0b76e1c10dd1ea3097c7063c42c21d664523b928e88b554a4f",
|
||||
"sha256:b2d1e325e69f71f7c78e5943d410e650b4707bb0ef32e4ddf3da37f54176e88a"
|
||||
],
|
||||
"version": "==2021.10.8.3"
|
||||
},
|
||||
"types-urllib3": {
|
||||
"hashes": [
|
||||
"sha256:229b7f577c951b8c1b92c1bc2b2fdb0b49847bd2af6d1cc2a2e3dd340f3bda8f",
|
||||
"sha256:9683bbb7fb72e32bfe9d2be6e04875fbe1b3eeec3cbb4ea231435aa7fd6b4f0e"
|
||||
],
|
||||
"version": "==1.26.25.14"
|
||||
},
|
||||
"typing-extensions": {
|
||||
"hashes": [
|
||||
"sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466",
|
||||
"sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"
|
||||
],
|
||||
"markers": "python_version < '3.13'",
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==4.15.0"
|
||||
},
|
||||
"urllib3": {
|
||||
"extras": [
|
||||
"socks"
|
||||
],
|
||||
"hashes": [
|
||||
"sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed",
|
||||
"sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"
|
||||
],
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==2.6.3"
|
||||
},
|
||||
"watchfiles": {
|
||||
"hashes": [
|
||||
"sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c",
|
||||
@@ -608,7 +894,32 @@
|
||||
"sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==1.1.1"
|
||||
},
|
||||
"websocket-client": {
|
||||
"hashes": [
|
||||
"sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98",
|
||||
"sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef"
|
||||
],
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==1.9.0"
|
||||
},
|
||||
"wsproto": {
|
||||
"hashes": [
|
||||
"sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584",
|
||||
"sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294"
|
||||
],
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==1.3.2"
|
||||
},
|
||||
"zipp": {
|
||||
"hashes": [
|
||||
"sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e",
|
||||
"sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"
|
||||
],
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==3.23.0"
|
||||
}
|
||||
},
|
||||
"develop": {}
|
||||
|
||||
BIN
build_static/app.js.gz
Normal file
BIN
build_static/app.js.gz
Normal file
Binary file not shown.
37
build_static/styles.css
Normal file
37
build_static/styles.css
Normal file
@@ -0,0 +1,37 @@
|
||||
/* General tab styles */
|
||||
.tabs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 10px 20px;
|
||||
margin: 0 10px;
|
||||
cursor: pointer;
|
||||
background-color: #f1f1f1;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background-color: #ddd;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tab-pane {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-pane.active {
|
||||
display: block;
|
||||
}
|
||||
BIN
build_static/styles.css.gz
Normal file
BIN
build_static/styles.css.gz
Normal file
Binary file not shown.
@@ -1,39 +1,12 @@
|
||||
{
|
||||
"1": {
|
||||
"name": "Default Colors",
|
||||
"colors": [
|
||||
"#FF0000",
|
||||
"#00FF00",
|
||||
"#0000FF",
|
||||
"#FFFF00",
|
||||
"#FF00FF",
|
||||
"#00FFFF",
|
||||
"#FFFFFF",
|
||||
"#000000",
|
||||
"#FFA500",
|
||||
"#800080"
|
||||
]
|
||||
},
|
||||
"2": {
|
||||
"name": "Warm Colors",
|
||||
"colors": [
|
||||
"#FF6B6B",
|
||||
"#FF8E53",
|
||||
"#FFA07A",
|
||||
"#FFD700",
|
||||
"#FFA500",
|
||||
"#FF6347"
|
||||
]
|
||||
},
|
||||
"3": {
|
||||
"name": "Cool Colors",
|
||||
"colors": [
|
||||
"#4ECDC4",
|
||||
"#44A08D",
|
||||
"#96CEB4",
|
||||
"#A8E6CF",
|
||||
"#5F9EA0",
|
||||
"#4682B4"
|
||||
]
|
||||
}
|
||||
"1": [
|
||||
"#FF0000",
|
||||
"#00FF00",
|
||||
"#0000FF",
|
||||
"#FFFF00",
|
||||
"#FF00FF",
|
||||
"#00FFFF",
|
||||
"#FFFFFF",
|
||||
"#000000"
|
||||
]
|
||||
}
|
||||
|
||||
277
db/preset.json
277
db/preset.json
@@ -1 +1,276 @@
|
||||
{"1": {"name": "Warm White", "pattern": "on", "colors": ["#FFE5B4", "#FFDAB9", "#FFE4B5"], "brightness": 200, "delay": 100, "n1": 10, "n2": 10, "n3": 10, "n4": 10, "n5": 0, "n6": 0, "n7": 0, "n8": 0}, "2": {"name": "Rainbow", "pattern": "rainbow", "colors": ["#FF0000", "#FF7F00", "#FFFF00", "#00FF00", "#0000FF", "#4B0082", "#9400D3"], "brightness": 255, "delay": 50, "n1": 20, "n2": 15, "n3": 10, "n4": 5, "n5": 0, "n6": 0, "Step Rate": 20, "n7": 0, "n8": 0}, "3": {"name": "Pulse Red", "pattern": "pulse", "colors": ["#FF0000", "#CC0000", "#990000"], "brightness": 180, "delay": 200, "n1": 30, "n2": 20, "n3": 10, "n4": 5, "n5": 0, "n6": 0}}
|
||||
{
|
||||
"1": {
|
||||
"name": "on",
|
||||
"pattern": "on",
|
||||
"colors": [
|
||||
"#FFFFFF"
|
||||
],
|
||||
"brightness": 255,
|
||||
"delay": 100,
|
||||
"auto": true,
|
||||
"n1": 0,
|
||||
"n2": 0,
|
||||
"n3": 0,
|
||||
"n4": 0,
|
||||
"n5": 0,
|
||||
"n6": 0,
|
||||
"n7": 0,
|
||||
"n8": 0,
|
||||
"profile_id": "1"
|
||||
},
|
||||
"2": {
|
||||
"name": "off",
|
||||
"pattern": "off",
|
||||
"colors": [],
|
||||
"brightness": 0,
|
||||
"delay": 100,
|
||||
"auto": true,
|
||||
"n1": 0,
|
||||
"n2": 0,
|
||||
"n3": 0,
|
||||
"n4": 0,
|
||||
"n5": 0,
|
||||
"n6": 0,
|
||||
"n7": 0,
|
||||
"n8": 0,
|
||||
"profile_id": "1"
|
||||
},
|
||||
"3": {
|
||||
"name": "rainbow",
|
||||
"pattern": "rainbow",
|
||||
"colors": [],
|
||||
"brightness": 255,
|
||||
"delay": 100,
|
||||
"auto": true,
|
||||
"n1": 2,
|
||||
"n2": 0,
|
||||
"n3": 0,
|
||||
"n4": 0,
|
||||
"n5": 0,
|
||||
"n6": 0,
|
||||
"n7": 0,
|
||||
"n8": 0,
|
||||
"profile_id": "1"
|
||||
},
|
||||
"4": {
|
||||
"name": "transition",
|
||||
"pattern": "transition",
|
||||
"colors": [
|
||||
"#FF0000",
|
||||
"#00FF00",
|
||||
"#0000FF"
|
||||
],
|
||||
"brightness": 255,
|
||||
"delay": 500,
|
||||
"auto": true,
|
||||
"n1": 0,
|
||||
"n2": 0,
|
||||
"n3": 0,
|
||||
"n4": 0,
|
||||
"n5": 0,
|
||||
"n6": 0,
|
||||
"n7": 0,
|
||||
"n8": 0,
|
||||
"profile_id": "1"
|
||||
},
|
||||
"5": {
|
||||
"name": "chase",
|
||||
"pattern": "chase",
|
||||
"colors": [
|
||||
"#FF0000",
|
||||
"#0000FF"
|
||||
],
|
||||
"brightness": 255,
|
||||
"delay": 200,
|
||||
"auto": true,
|
||||
"n1": 5,
|
||||
"n2": 5,
|
||||
"n3": 1,
|
||||
"n4": 1,
|
||||
"n5": 0,
|
||||
"n6": 0,
|
||||
"n7": 0,
|
||||
"n8": 0,
|
||||
"profile_id": "1"
|
||||
},
|
||||
"6": {
|
||||
"name": "pulse",
|
||||
"pattern": "pulse",
|
||||
"colors": [
|
||||
"#00FF00"
|
||||
],
|
||||
"brightness": 255,
|
||||
"delay": 500,
|
||||
"auto": true,
|
||||
"n1": 1000,
|
||||
"n2": 500,
|
||||
"n3": 1000,
|
||||
"n4": 0,
|
||||
"n5": 0,
|
||||
"n6": 0,
|
||||
"n7": 0,
|
||||
"n8": 0,
|
||||
"profile_id": "1"
|
||||
},
|
||||
"7": {
|
||||
"name": "circle",
|
||||
"pattern": "circle",
|
||||
"colors": [
|
||||
"#FFA500",
|
||||
"#800080"
|
||||
],
|
||||
"brightness": 255,
|
||||
"delay": 200,
|
||||
"auto": true,
|
||||
"n1": 2,
|
||||
"n2": 10,
|
||||
"n3": 2,
|
||||
"n4": 5,
|
||||
"n5": 0,
|
||||
"n6": 0,
|
||||
"n7": 0,
|
||||
"n8": 0,
|
||||
"profile_id": "1"
|
||||
},
|
||||
"8": {
|
||||
"name": "blink",
|
||||
"pattern": "blink",
|
||||
"colors": [
|
||||
"#FF0000",
|
||||
"#00FF00",
|
||||
"#0000FF",
|
||||
"#FFFF00"
|
||||
],
|
||||
"brightness": 255,
|
||||
"delay": 1000,
|
||||
"auto": true,
|
||||
"n1": 0,
|
||||
"n2": 0,
|
||||
"n3": 0,
|
||||
"n4": 0,
|
||||
"n5": 0,
|
||||
"n6": 0,
|
||||
"n7": 0,
|
||||
"n8": 0,
|
||||
"profile_id": "1"
|
||||
},
|
||||
"9": {
|
||||
"name": "warm white",
|
||||
"pattern": "on",
|
||||
"colors": ["#FFF5E6"],
|
||||
"brightness": 200,
|
||||
"delay": 100,
|
||||
"auto": true,
|
||||
"n1": 0,
|
||||
"n2": 0,
|
||||
"n3": 0,
|
||||
"n4": 0,
|
||||
"n5": 0,
|
||||
"n6": 0,
|
||||
"n7": 0,
|
||||
"n8": 0,
|
||||
"profile_id": "1"
|
||||
},
|
||||
"10": {
|
||||
"name": "cool white",
|
||||
"pattern": "on",
|
||||
"colors": ["#E6F2FF"],
|
||||
"brightness": 200,
|
||||
"delay": 100,
|
||||
"auto": true,
|
||||
"n1": 0,
|
||||
"n2": 0,
|
||||
"n3": 0,
|
||||
"n4": 0,
|
||||
"n5": 0,
|
||||
"n6": 0,
|
||||
"n7": 0,
|
||||
"n8": 0,
|
||||
"profile_id": "1"
|
||||
},
|
||||
"11": {
|
||||
"name": "red",
|
||||
"pattern": "on",
|
||||
"colors": ["#FF0000"],
|
||||
"brightness": 255,
|
||||
"delay": 100,
|
||||
"auto": true,
|
||||
"n1": 0,
|
||||
"n2": 0,
|
||||
"n3": 0,
|
||||
"n4": 0,
|
||||
"n5": 0,
|
||||
"n6": 0,
|
||||
"n7": 0,
|
||||
"n8": 0,
|
||||
"profile_id": "1"
|
||||
},
|
||||
"12": {
|
||||
"name": "blue",
|
||||
"pattern": "on",
|
||||
"colors": ["#0000FF"],
|
||||
"brightness": 255,
|
||||
"delay": 100,
|
||||
"auto": true,
|
||||
"n1": 0,
|
||||
"n2": 0,
|
||||
"n3": 0,
|
||||
"n4": 0,
|
||||
"n5": 0,
|
||||
"n6": 0,
|
||||
"n7": 0,
|
||||
"n8": 0,
|
||||
"profile_id": "1"
|
||||
},
|
||||
"13": {
|
||||
"name": "rainbow slow",
|
||||
"pattern": "rainbow",
|
||||
"colors": [],
|
||||
"brightness": 255,
|
||||
"delay": 150,
|
||||
"auto": true,
|
||||
"n1": 1,
|
||||
"n2": 0,
|
||||
"n3": 0,
|
||||
"n4": 0,
|
||||
"n5": 0,
|
||||
"n6": 0,
|
||||
"n7": 0,
|
||||
"n8": 0,
|
||||
"profile_id": "1"
|
||||
},
|
||||
"14": {
|
||||
"name": "pulse slow",
|
||||
"pattern": "pulse",
|
||||
"colors": ["#FF6600"],
|
||||
"brightness": 255,
|
||||
"delay": 800,
|
||||
"auto": true,
|
||||
"n1": 2000,
|
||||
"n2": 1000,
|
||||
"n3": 2000,
|
||||
"n4": 0,
|
||||
"n5": 0,
|
||||
"n6": 0,
|
||||
"n7": 0,
|
||||
"n8": 0,
|
||||
"profile_id": "1"
|
||||
},
|
||||
"15": {
|
||||
"name": "blink red green",
|
||||
"pattern": "blink",
|
||||
"colors": ["#FF0000", "#00FF00"],
|
||||
"brightness": 255,
|
||||
"delay": 500,
|
||||
"auto": true,
|
||||
"n1": 0,
|
||||
"n2": 0,
|
||||
"n3": 0,
|
||||
"n4": 0,
|
||||
"n5": 0,
|
||||
"n6": 0,
|
||||
"n7": 0,
|
||||
"n8": 0,
|
||||
"profile_id": "1"
|
||||
}
|
||||
}
|
||||
@@ -1 +1,11 @@
|
||||
{"1": {"name": "Default", "tabs": ["1", "2"], "scenes": ["1", "2"], "palette": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"]}, "2": {"name": "test", "type": "tabs", "tabs": ["12", "13"], "scenes": [], "palette": ["#b93c3c", "#3cb961"], "color_palette": ["#b93c3c", "#3cb961"]}}
|
||||
{
|
||||
"1": {
|
||||
"name": "default",
|
||||
"type": "tabs",
|
||||
"tabs": [
|
||||
"1"
|
||||
],
|
||||
"scenes": [],
|
||||
"palette_id": "1"
|
||||
}
|
||||
}
|
||||
28
db/tab.json
28
db/tab.json
@@ -1 +1,27 @@
|
||||
{"1": {"name": "Main", "names": ["1", "2", "3"], "presets": [["1", "2", "3"]], "presets_flat": ["1", "2", "3"]}, "2": {"name": "Accent", "names": ["4", "5"], "presets": []}, "3": {"name": "", "names": [], "presets": []}, "4": {"name": "", "names": [], "presets": []}, "5": {"name": "", "names": [], "presets": []}, "6": {"name": "", "names": [], "presets": []}, "7": {"name": "", "names": [], "presets": []}, "8": {"name": "", "names": [], "presets": []}, "9": {"name": "", "names": [], "presets": []}, "10": {"name": "", "names": [], "presets": []}, "11": {"name": "", "names": [], "presets": []}, "12": {"name": "test2", "names": ["1"], "presets": [], "colors": ["#b93c3c", "#761e1e", "#ffffff"]}, "13": {"name": "test5", "names": ["1"], "presets": []}}
|
||||
{
|
||||
"1": {
|
||||
"name": "default",
|
||||
"names": [
|
||||
"a","b","c","d","e","f","g","h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "1", "2", "3", "4", "5", "6", "7", "8", "9", "0"
|
||||
],
|
||||
"presets": [
|
||||
[
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"8",
|
||||
"9",
|
||||
"10",
|
||||
"11",
|
||||
"12",
|
||||
"13",
|
||||
"14",
|
||||
"15"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
659
docs/API.md
659
docs/API.md
@@ -1,504 +1,263 @@
|
||||
# LED Controller API Specification
|
||||
# LED Driver ESPNow API Documentation
|
||||
|
||||
**Base URL:** `http://device-ip/` or `http://192.168.4.1/` (when in AP mode)
|
||||
**Protocol:** HTTP/1.1
|
||||
**Content-Type:** `application/json`
|
||||
This document describes the ESPNow message format for controlling LED driver devices.
|
||||
|
||||
## Presets API
|
||||
## Message Format
|
||||
|
||||
### GET /presets
|
||||
All messages are JSON objects sent via ESPNow with the following structure:
|
||||
|
||||
List all presets.
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"preset1": {
|
||||
"name": "preset1",
|
||||
"pattern": "on",
|
||||
"colors": [[255, 0, 0]],
|
||||
"delay": 100,
|
||||
"n1": 0,
|
||||
"n2": 0,
|
||||
"n3": 0,
|
||||
"n4": 0,
|
||||
"n5": 0,
|
||||
"n6": 0,
|
||||
"n7": 0,
|
||||
"n8": 0
|
||||
"v": "1",
|
||||
"presets": { ... },
|
||||
"select": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Version Field
|
||||
|
||||
- **`v`** (required): Message version, must be `"1"`. Messages with other versions are ignored.
|
||||
|
||||
## Presets
|
||||
|
||||
Presets define LED patterns with their configuration. Each preset has a name and contains pattern-specific settings.
|
||||
|
||||
### Preset Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"presets": {
|
||||
"preset_name": {
|
||||
"pattern": "pattern_type",
|
||||
"colors": ["#RRGGBB", ...],
|
||||
"delay": 100,
|
||||
"brightness": 127,
|
||||
"auto": true,
|
||||
"n1": 0,
|
||||
"n2": 0,
|
||||
"n3": 0,
|
||||
"n4": 0,
|
||||
"n5": 0,
|
||||
"n6": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /presets/{name}
|
||||
### Preset Fields
|
||||
|
||||
Get a specific preset by name.
|
||||
- **`pattern`** (required): Pattern type. Options:
|
||||
- `"off"` - Turn off all LEDs
|
||||
- `"on"` - Solid color
|
||||
- `"blink"` - Blinking pattern
|
||||
- `"rainbow"` - Rainbow color cycle
|
||||
- `"pulse"` - Pulse/fade pattern
|
||||
- `"transition"` - Color transition
|
||||
- `"chase"` - Chasing pattern
|
||||
- `"circle"` - Circle loading pattern
|
||||
|
||||
- **`colors`** (optional): Array of hex color strings (e.g., `"#FF0000"` for red). Default: `["#FFFFFF"]`
|
||||
- Colors are automatically converted from hex to RGB and reordered based on device color order setting
|
||||
- Supports multiple colors for patterns that use them
|
||||
|
||||
- **`delay`** (optional): Delay in milliseconds between pattern updates. Default: `100`
|
||||
|
||||
- **`brightness`** (optional): Brightness level (0-255). Default: `127`
|
||||
|
||||
- **`auto`** (optional): Auto mode flag. Default: `true`
|
||||
- `true`: Pattern runs continuously
|
||||
- `false`: Pattern advances one step per beat (manual mode)
|
||||
|
||||
- **`n1` through `n6`** (optional): Pattern-specific numeric parameters. Default: `0`
|
||||
- See pattern-specific documentation below
|
||||
|
||||
### Pattern-Specific Parameters
|
||||
|
||||
#### Rainbow
|
||||
- **`n1`**: Step increment (how many color wheel positions to advance per update). Default: `1`
|
||||
|
||||
#### Pulse
|
||||
- **`n1`**: Attack time in milliseconds (fade in)
|
||||
- **`n2`**: Hold time in milliseconds (full brightness)
|
||||
- **`n3`**: Decay time in milliseconds (fade out)
|
||||
- **`delay`**: Delay time in milliseconds (off between pulses)
|
||||
|
||||
#### Transition
|
||||
- **`delay`**: Transition duration in milliseconds
|
||||
|
||||
#### Chase
|
||||
- **`n1`**: Number of LEDs with first color
|
||||
- **`n2`**: Number of LEDs with second color
|
||||
- **`n3`**: Movement amount on even steps (can be negative)
|
||||
- **`n4`**: Movement amount on odd steps (can be negative)
|
||||
|
||||
#### Circle
|
||||
- **`n1`**: Head movement rate (LEDs per second)
|
||||
- **`n2`**: Maximum length
|
||||
- **`n3`**: Tail movement rate (LEDs per second)
|
||||
- **`n4`**: Minimum length
|
||||
|
||||
## Select Messages
|
||||
|
||||
Select messages control which preset is active on which device. The format uses a list to support step synchronization.
|
||||
|
||||
### Select Format
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"name": "preset1",
|
||||
"pattern": "on",
|
||||
"colors": [[255, 0, 0]],
|
||||
"delay": 100,
|
||||
"n1": 0,
|
||||
"n2": 0,
|
||||
"n3": 0,
|
||||
"n4": 0,
|
||||
"n5": 0,
|
||||
"n6": 0,
|
||||
"n7": 0,
|
||||
"n8": 0
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `404 Not Found`
|
||||
```json
|
||||
{
|
||||
"error": "Preset not found"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /presets
|
||||
|
||||
Create a new preset.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"name": "preset1",
|
||||
"pattern": "on",
|
||||
"colors": [[255, 0, 0]],
|
||||
"delay": 100,
|
||||
"n1": 0,
|
||||
"n2": 0,
|
||||
"n3": 0,
|
||||
"n4": 0,
|
||||
"n5": 0,
|
||||
"n6": 0,
|
||||
"n7": 0,
|
||||
"n8": 0
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `201 Created` - Returns the created preset
|
||||
|
||||
**Response:** `400 Bad Request`
|
||||
```json
|
||||
{
|
||||
"error": "Name is required"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `409 Conflict`
|
||||
```json
|
||||
{
|
||||
"error": "Preset already exists"
|
||||
}
|
||||
```
|
||||
|
||||
### PUT /presets/{name}
|
||||
|
||||
Update an existing preset.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"delay": 200,
|
||||
"colors": [[0, 255, 0]]
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `200 OK` - Returns the updated preset
|
||||
|
||||
**Response:** `404 Not Found`
|
||||
```json
|
||||
{
|
||||
"error": "Preset not found"
|
||||
}
|
||||
```
|
||||
|
||||
### DELETE /presets/{name}
|
||||
|
||||
Delete a preset.
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"message": "Preset deleted successfully"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `404 Not Found`
|
||||
```json
|
||||
{
|
||||
"error": "Preset not found"
|
||||
}
|
||||
```
|
||||
|
||||
## Profiles API
|
||||
|
||||
### GET /profiles
|
||||
|
||||
List all profiles.
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"profile1": {
|
||||
"name": "profile1",
|
||||
"description": "Profile description",
|
||||
"scenes": []
|
||||
"select": {
|
||||
"device_name": ["preset_name"],
|
||||
"device_name2": ["preset_name2", step_value]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /profiles/{name}
|
||||
### Select Fields
|
||||
|
||||
Get a specific profile by name.
|
||||
- **`select`**: Object mapping device names to selection lists
|
||||
- **Key**: Device name (as configured in device settings)
|
||||
- **Value**: List with one or two elements:
|
||||
- `["preset_name"]` - Select preset (uses default step behavior)
|
||||
- `["preset_name", step]` - Select preset with explicit step value (for synchronization)
|
||||
|
||||
**Response:** `200 OK`
|
||||
### Step Synchronization
|
||||
|
||||
The step value allows precise synchronization across multiple devices:
|
||||
|
||||
- **Without step**: `["preset_name"]`
|
||||
- If switching to different preset: step resets to 0
|
||||
- If selecting "off" pattern: step resets to 0
|
||||
- If selecting same preset (beat): step is preserved, pattern restarts
|
||||
|
||||
- **With step**: `["preset_name", 10]`
|
||||
- Explicitly sets step to the specified value
|
||||
- Useful for synchronizing multiple devices to the same step
|
||||
|
||||
### Beat Functionality
|
||||
|
||||
Calling `select()` again with the same preset name acts as a "beat" - it restarts the pattern generator:
|
||||
|
||||
- **Single-tick patterns** (rainbow, chase in manual mode): Advance one step per beat
|
||||
- **Multi-tick patterns** (pulse in manual mode): Run through full cycle per beat
|
||||
|
||||
Example beat sequence:
|
||||
```json
|
||||
{
|
||||
"name": "profile1",
|
||||
"description": "Profile description",
|
||||
"scenes": []
|
||||
}
|
||||
// Beat 1
|
||||
{"select": {"device1": ["rainbow_preset"]}}
|
||||
|
||||
// Beat 2 (same preset = beat)
|
||||
{"select": {"device1": ["rainbow_preset"]}}
|
||||
|
||||
// Beat 3
|
||||
{"select": {"device1": ["rainbow_preset"]}}
|
||||
```
|
||||
|
||||
**Response:** `404 Not Found`
|
||||
## Synchronization
|
||||
|
||||
### Using "off" Pattern
|
||||
|
||||
Selecting the "off" pattern resets the step counter to 0, providing a synchronization point:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Profile not found"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /profiles
|
||||
|
||||
Create a new profile.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"name": "profile1",
|
||||
"description": "Profile description",
|
||||
"scenes": []
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `201 Created` - Returns the created profile
|
||||
|
||||
**Response:** `400 Bad Request`
|
||||
```json
|
||||
{
|
||||
"error": "Name is required"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `409 Conflict`
|
||||
```json
|
||||
{
|
||||
"error": "Profile already exists"
|
||||
}
|
||||
```
|
||||
|
||||
### PUT /profiles/{name}
|
||||
|
||||
Update an existing profile.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"description": "Updated description"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `200 OK` - Returns the updated profile
|
||||
|
||||
**Response:** `404 Not Found`
|
||||
```json
|
||||
{
|
||||
"error": "Profile not found"
|
||||
}
|
||||
```
|
||||
|
||||
### DELETE /profiles/{name}
|
||||
|
||||
Delete a profile.
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"message": "Profile deleted successfully"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `404 Not Found`
|
||||
```json
|
||||
{
|
||||
"error": "Profile not found"
|
||||
}
|
||||
```
|
||||
|
||||
## Scenes API
|
||||
|
||||
### GET /scenes
|
||||
|
||||
List all scenes. Optionally filter by profile using query parameter.
|
||||
|
||||
**Query Parameters:**
|
||||
- `profile` (optional): Filter scenes by profile name
|
||||
|
||||
**Example:** `GET /scenes?profile=profile1`
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"profile1:scene1": {
|
||||
"name": "scene1",
|
||||
"profile_name": "profile1",
|
||||
"description": "Scene description",
|
||||
"transition_time": 0,
|
||||
"devices": [
|
||||
{"device_name": "device1", "preset_name": "preset1"},
|
||||
{"device_name": "device2", "preset_name": "preset2"}
|
||||
]
|
||||
"select": {
|
||||
"device1": ["off"],
|
||||
"device2": ["off"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /scenes/{profile_name}/{scene_name}
|
||||
After all devices are "off", switching to a pattern ensures they all start from step 0:
|
||||
|
||||
Get a specific scene.
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"name": "scene1",
|
||||
"profile_name": "profile1",
|
||||
"description": "Scene description",
|
||||
"transition_time": 0,
|
||||
"devices": [
|
||||
{"device_name": "device1", "preset_name": "preset1"},
|
||||
{"device_name": "device2", "preset_name": "preset2"}
|
||||
]
|
||||
"select": {
|
||||
"device1": ["rainbow_preset"],
|
||||
"device2": ["rainbow_preset"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `404 Not Found`
|
||||
### Using Step Parameter
|
||||
|
||||
For precise synchronization, use the step parameter:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Scene not found"
|
||||
"select": {
|
||||
"device1": ["rainbow_preset", 10],
|
||||
"device2": ["rainbow_preset", 10],
|
||||
"device3": ["rainbow_preset", 10]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### POST /scenes
|
||||
All devices will start at step 10 and advance together on subsequent beats.
|
||||
|
||||
Create a new scene.
|
||||
## Complete Example
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"name": "scene1",
|
||||
"profile_name": "profile1",
|
||||
"description": "Scene description",
|
||||
"transition_time": 0,
|
||||
"devices": [
|
||||
{"device_name": "device1", "preset_name": "preset1"},
|
||||
{"device_name": "device2", "preset_name": "preset2"}
|
||||
]
|
||||
"v": "1",
|
||||
"presets": {
|
||||
"red_blink": {
|
||||
"pattern": "blink",
|
||||
"colors": ["#FF0000"],
|
||||
"delay": 200,
|
||||
"brightness": 255,
|
||||
"auto": true
|
||||
},
|
||||
"rainbow_manual": {
|
||||
"pattern": "rainbow",
|
||||
"delay": 100,
|
||||
"n1": 2,
|
||||
"auto": false
|
||||
},
|
||||
"pulse_slow": {
|
||||
"pattern": "pulse",
|
||||
"colors": ["#00FF00"],
|
||||
"delay": 500,
|
||||
"n1": 1000,
|
||||
"n2": 500,
|
||||
"n3": 1000,
|
||||
"auto": false
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"device1": ["red_blink"],
|
||||
"device2": ["rainbow_manual", 0],
|
||||
"device3": ["pulse_slow"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `201 Created` - Returns the created scene
|
||||
## Message Processing
|
||||
|
||||
**Response:** `400 Bad Request`
|
||||
```json
|
||||
{
|
||||
"error": "Name is required"
|
||||
}
|
||||
```
|
||||
or
|
||||
```json
|
||||
{
|
||||
"error": "Profile name is required"
|
||||
}
|
||||
```
|
||||
1. **Version Check**: Messages with `v != "1"` are rejected
|
||||
2. **Preset Processing**: Presets are created or updated (upsert behavior)
|
||||
3. **Color Conversion**: Hex colors are converted to RGB tuples and reordered based on device color order
|
||||
4. **Selection**: Devices select their assigned preset, optionally with step value
|
||||
|
||||
**Response:** `409 Conflict`
|
||||
```json
|
||||
{
|
||||
"error": "Scene already exists"
|
||||
}
|
||||
```
|
||||
## Best Practices
|
||||
|
||||
### PUT /scenes/{profile_name}/{scene_name}
|
||||
1. **Always include version**: Set `"v": "1"` in all messages
|
||||
2. **Use "off" for sync**: Select "off" pattern to synchronize devices before starting patterns
|
||||
3. **Beats for manual mode**: Send select messages repeatedly with same preset name to advance manual patterns
|
||||
4. **Step for precision**: Use step parameter when exact synchronization is required
|
||||
5. **Color format**: Always use hex strings (`"#RRGGBB"`), conversion is automatic
|
||||
|
||||
Update an existing scene.
|
||||
## Error Handling
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"transition_time": 500,
|
||||
"description": "Updated description"
|
||||
}
|
||||
```
|
||||
- Invalid version: Message is ignored
|
||||
- Missing preset: Selection fails, device keeps current preset
|
||||
- Invalid pattern: Selection fails, device keeps current preset
|
||||
- Missing colors: Pattern uses default white color
|
||||
- Invalid step: Step value is used as-is (may cause unexpected behavior)
|
||||
|
||||
**Response:** `200 OK` - Returns the updated scene
|
||||
## Notes
|
||||
|
||||
**Response:** `404 Not Found`
|
||||
```json
|
||||
{
|
||||
"error": "Scene not found"
|
||||
}
|
||||
```
|
||||
|
||||
### DELETE /scenes/{profile_name}/{scene_name}
|
||||
|
||||
Delete a scene.
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"message": "Scene deleted successfully"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `404 Not Found`
|
||||
```json
|
||||
{
|
||||
"error": "Scene not found"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /scenes/{profile_name}/{scene_name}/devices
|
||||
|
||||
Add a device assignment to a scene.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"device_name": "device1",
|
||||
"preset_name": "preset1"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `200 OK` - Returns the updated scene
|
||||
|
||||
**Response:** `400 Bad Request`
|
||||
```json
|
||||
{
|
||||
"error": "Device name and preset name are required"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `404 Not Found`
|
||||
```json
|
||||
{
|
||||
"error": "Scene not found"
|
||||
}
|
||||
```
|
||||
|
||||
### DELETE /scenes/{profile_name}/{scene_name}/devices/{device_name}
|
||||
|
||||
Remove a device assignment from a scene.
|
||||
|
||||
**Response:** `200 OK` - Returns the updated scene
|
||||
|
||||
**Response:** `404 Not Found`
|
||||
```json
|
||||
{
|
||||
"error": "Scene not found"
|
||||
}
|
||||
```
|
||||
|
||||
## Patterns API
|
||||
|
||||
### GET /patterns
|
||||
|
||||
Get the list of available pattern names.
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
["on", "bl", "cl", "rb", "sb", "o"]
|
||||
```
|
||||
|
||||
### POST /patterns
|
||||
|
||||
Add a new pattern name to the list.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"name": "new_pattern"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `201 Created` - Returns the updated list of patterns
|
||||
```json
|
||||
["on", "bl", "cl", "rb", "sb", "o", "new_pattern"]
|
||||
```
|
||||
|
||||
**Response:** `400 Bad Request`
|
||||
```json
|
||||
{
|
||||
"error": "Name is required"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `409 Conflict`
|
||||
```json
|
||||
{
|
||||
"error": "Pattern already exists"
|
||||
}
|
||||
```
|
||||
|
||||
### DELETE /patterns/{name}
|
||||
|
||||
Remove a pattern name from the list.
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"message": "Pattern deleted successfully"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `404 Not Found`
|
||||
```json
|
||||
{
|
||||
"error": "Pattern not found"
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
All endpoints may return the following error responses:
|
||||
|
||||
**400 Bad Request** - Invalid request data
|
||||
```json
|
||||
{
|
||||
"error": "Error message"
|
||||
}
|
||||
```
|
||||
|
||||
**404 Not Found** - Resource not found
|
||||
```json
|
||||
{
|
||||
"error": "Resource not found"
|
||||
}
|
||||
```
|
||||
|
||||
**409 Conflict** - Resource already exists
|
||||
```json
|
||||
{
|
||||
"error": "Resource already exists"
|
||||
}
|
||||
```
|
||||
|
||||
**500 Internal Server Error** - Server error
|
||||
```json
|
||||
{
|
||||
"error": "Error message"
|
||||
}
|
||||
```
|
||||
- Colors are automatically converted from hex strings to RGB tuples
|
||||
- Color order reordering happens automatically based on device settings
|
||||
- Step counter wraps around (0-255 for rainbow, unbounded for others)
|
||||
- Manual mode patterns stop after one step/cycle, waiting for next beat
|
||||
- Auto mode patterns run continuously until changed
|
||||
|
||||
244
flash.sh
Executable file
244
flash.sh
Executable file
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
# Environment variables:
|
||||
# PORT - serial port (default: /dev/ttyUSB0)
|
||||
# BAUD - baud rate (default: 460800)
|
||||
# FIRMWARE - local path to firmware .bin
|
||||
# FW_URL - URL to download firmware if FIRMWARE not provided or missing
|
||||
|
||||
PORT=${PORT:-}
|
||||
BAUD=${BAUD:-460800}
|
||||
CHIP=${CHIP:-esp32} # esp32 | esp32c3
|
||||
|
||||
# Map chip-specific settings
|
||||
ESPT_CHIP="$CHIP"
|
||||
FLASH_OFFSET=0x1000
|
||||
DEFAULT_DOWNLOAD_PAGE="https://micropython.org/download/ESP32/"
|
||||
BOARD_ID="ESP32_GENERIC"
|
||||
BOARD_PAGE="https://micropython.org/download/${BOARD_ID}/"
|
||||
case "$CHIP" in
|
||||
esp32c3)
|
||||
ESPT_CHIP="esp32c3"
|
||||
FLASH_OFFSET=0x0
|
||||
DEFAULT_DOWNLOAD_PAGE="https://micropython.org/download/ESP32C3/"
|
||||
BOARD_ID="ESP32_GENERIC_C3"
|
||||
BOARD_PAGE="https://micropython.org/download/${BOARD_ID}/"
|
||||
;;
|
||||
esp32)
|
||||
ESPT_CHIP="esp32"
|
||||
FLASH_OFFSET=0x1000
|
||||
DEFAULT_DOWNLOAD_PAGE="https://micropython.org/download/ESP32/"
|
||||
BOARD_ID="ESP32_GENERIC"
|
||||
BOARD_PAGE="https://micropython.org/download/${BOARD_ID}/"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported CHIP: $CHIP (supported: esp32, esp32c3)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Download-only mode: fetch the appropriate firmware and exit
|
||||
if [ -n "${DOWNLOAD_ONLY:-}" ]; then
|
||||
# Prefer resolving latest if nothing provided
|
||||
if [ -z "${FIRMWARE:-}" ] && [ -z "${FW_URL:-}" ]; then
|
||||
LATEST=1
|
||||
fi
|
||||
if ! resolve_firmware; then
|
||||
echo "Failed to resolve firmware for CHIP=$CHIP" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$FIRMWARE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Helper: resolve the latest firmware URL for a given board pattern with multiple fallbacks
|
||||
resolve_latest_url() {
|
||||
board_pattern="$1" # e.g., ESP32_GENERIC_C3-.*\.bin
|
||||
# Candidate pages to try in order
|
||||
pages="${BOARD_PAGE} ${DOWNLOAD_PAGE:-$DEFAULT_DOWNLOAD_PAGE} https://micropython.org/download/ https://micropython.org/resources/firmware/"
|
||||
for page in $pages; do
|
||||
echo "Trying to resolve latest from $page" >&2
|
||||
html=$(curl -fsSL -H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36' -e 'https://micropython.org/download/' "$page" || true)
|
||||
[ -z "$html" ] && continue
|
||||
# Prefer matching the board pattern
|
||||
url=$(printf "%s" "$html" \
|
||||
| sed -n 's/.*href=\"\([^\"]*\.bin\)\".*/\1/p' \
|
||||
| grep -E "$board_pattern" \
|
||||
| head -n1)
|
||||
if [ -n "$url" ]; then
|
||||
case "$url" in
|
||||
http*) echo "$url"; return 0 ;;
|
||||
/*) echo "https://micropython.org$url"; return 0 ;;
|
||||
*) echo "$page$url"; return 0 ;;
|
||||
esac
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# If LATEST is set and neither FIRMWARE nor FW_URL are provided, auto-detect latest URL
|
||||
if [ -n "${LATEST:-}" ] && [ -z "${FIRMWARE:-}" ] && [ -z "${FW_URL:-}" ]; then
|
||||
# Default board identifiers for each chip
|
||||
case "$CHIP" in
|
||||
esp32c3) BOARD_ID="ESP32_GENERIC_C3" ;;
|
||||
esp32) BOARD_ID="ESP32_GENERIC" ;;
|
||||
*) BOARD_ID="ESP32_GENERIC" ;;
|
||||
esac
|
||||
pattern="${BOARD_ID}-.*\\.bin"
|
||||
echo "Resolving latest firmware for $BOARD_ID"
|
||||
if FW_URL=$(resolve_latest_url "$pattern"); then
|
||||
export FW_URL
|
||||
echo "Latest firmware resolved to: $FW_URL"
|
||||
else
|
||||
echo "Failed to resolve latest firmware for pattern $pattern" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Resolve firmware path, downloading if needed
|
||||
resolve_firmware() {
|
||||
if [ -z "${FIRMWARE:-}" ]; then
|
||||
if [ -n "${FW_URL:-}" ] || [ -n "${LATEST:-}" ]; then
|
||||
# If FW_URL still unset, resolve latest using board-specific pattern
|
||||
if [ -z "${FW_URL:-}" ]; then
|
||||
case "$CHIP" in
|
||||
esp32c3) BOARD_ID="ESP32_GENERIC_C3" ;;
|
||||
esp32) BOARD_ID="ESP32_GENERIC" ;;
|
||||
*) BOARD_ID="ESP32_GENERIC" ;;
|
||||
esac
|
||||
pattern="${BOARD_ID}-.*\\.bin"
|
||||
echo "Resolving latest firmware for $BOARD_ID"
|
||||
if ! FW_URL=$(resolve_latest_url "$pattern"); then
|
||||
echo "Failed to resolve latest firmware for pattern $pattern" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
mkdir -p .cache
|
||||
FIRMWARE=".cache/$(basename "$FW_URL")"
|
||||
if [ ! -f "$FIRMWARE" ]; then
|
||||
echo "Downloading firmware from $FW_URL to $FIRMWARE"
|
||||
curl -L --fail -o "$FIRMWARE" "$FW_URL"
|
||||
else
|
||||
echo "Firmware already downloaded at $FIRMWARE"
|
||||
fi
|
||||
else
|
||||
# Default fallback: fetch latest using board-specific pattern
|
||||
case "$CHIP" in
|
||||
esp32c3) BOARD_ID="ESP32_GENERIC_C3" ;;
|
||||
esp32) BOARD_ID="ESP32_GENERIC" ;;
|
||||
*) BOARD_ID="ESP32_GENERIC" ;;
|
||||
esac
|
||||
pattern="${BOARD_ID}-.*\\.bin"
|
||||
echo "No FIRMWARE or FW_URL specified. Auto-fetching latest for $BOARD_ID"
|
||||
if ! FW_URL=$(resolve_latest_url "$pattern"); then
|
||||
echo "Failed to resolve latest firmware for pattern $pattern" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p .cache
|
||||
FIRMWARE=".cache/$(basename "$FW_URL")"
|
||||
if [ ! -f "$FIRMWARE" ]; then
|
||||
echo "Downloading firmware from $FW_URL to $FIRMWARE"
|
||||
curl -L --fail -o "$FIRMWARE" "$FW_URL"
|
||||
else
|
||||
echo "Firmware already downloaded at $FIRMWARE"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
if [ ! -f "$FIRMWARE" ]; then
|
||||
if [ -n "${FW_URL:-}" ]; then
|
||||
mkdir -p "$(dirname "$FIRMWARE")"
|
||||
echo "Firmware not found at $FIRMWARE. Downloading from $FW_URL"
|
||||
curl -L --fail -o "$FIRMWARE" "$FW_URL"
|
||||
else
|
||||
echo "Firmware file not found: $FIRMWARE. Provide FW_URL to download automatically." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Auto-detect PORT if not specified
|
||||
if [ -z "$PORT" ]; then
|
||||
candidates="$(ls /dev/tty/ACM* /dev/tty/USB* 2>/dev/null || true)"
|
||||
# Some systems expose without /dev/tty/ prefix patterns; try common Linux paths
|
||||
[ -z "$candidates" ] && candidates="$(ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null || true)"
|
||||
# Prefer ACM (often for C3) then USB
|
||||
PORT=$(printf "%s\n" $candidates | grep -E "/dev/ttyACM[0-9]+" | head -n1 || true)
|
||||
[ -z "$PORT" ] && PORT=$(printf "%s\n" $candidates | grep -E "/dev/ttyUSB[0-9]+" | head -n1 || true)
|
||||
if [ -z "$PORT" ]; then
|
||||
echo "No serial port detected. Connect the board and set PORT=/dev/ttyACM0 (or /dev/ttyUSB0)." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Auto-detected PORT=$PORT"
|
||||
fi
|
||||
|
||||
# Preflight: ensure port exists
|
||||
if [ ! -e "$PORT" ]; then
|
||||
echo "Port $PORT does not exist. Detected candidates:" >&2
|
||||
ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ESPL="python -m esptool"
|
||||
|
||||
detect_chip() {
|
||||
# Try to detect actual connected chip using esptool and override if needed
|
||||
out=$($ESPL --port "$PORT" --baud "$BAUD" chip_id 2>&1 || true)
|
||||
case "$out" in
|
||||
*"ESP32-C3"*) DETECTED_CHIP=esp32c3 ;;
|
||||
*"ESP32"*) DETECTED_CHIP=esp32 ;;
|
||||
*) DETECTED_CHIP="" ;;
|
||||
esac
|
||||
if [ -n "$DETECTED_CHIP" ] && [ "$DETECTED_CHIP" != "$ESPT_CHIP" ]; then
|
||||
echo "Detected chip $DETECTED_CHIP differs from requested $ESPT_CHIP. Using detected chip."
|
||||
ESPT_CHIP="$DETECTED_CHIP"
|
||||
case "$ESPT_CHIP" in
|
||||
esp32c3) FLASH_OFFSET=0x0 ;;
|
||||
esp32) FLASH_OFFSET=0x1000 ;;
|
||||
esac
|
||||
fi
|
||||
}
|
||||
|
||||
detect_chip
|
||||
|
||||
# Now that we know the actual chip, resolve the correct firmware for it
|
||||
resolve_firmware
|
||||
|
||||
# Validate firmware matches detected chip; if not, auto-correct by fetching the right image
|
||||
EXPECTED_BOARD_ID="ESP32_GENERIC"
|
||||
case "$ESPT_CHIP" in
|
||||
esp32c3) EXPECTED_BOARD_ID="ESP32_GENERIC_C3" ;;
|
||||
esp32) EXPECTED_BOARD_ID="ESP32_GENERIC" ;;
|
||||
|
||||
esac
|
||||
|
||||
FW_BASENAME="$(basename "$FIRMWARE")"
|
||||
case "$FW_BASENAME" in
|
||||
${EXPECTED_BOARD_ID}-*.bin) : ;; # ok
|
||||
*)
|
||||
echo "Firmware $FW_BASENAME does not match detected chip ($ESPT_CHIP). Fetching correct image for $EXPECTED_BOARD_ID..."
|
||||
pattern="${EXPECTED_BOARD_ID}-.*\\.bin"
|
||||
if ! FW_URL=$(resolve_latest_url "$pattern"); then
|
||||
echo "Failed to resolve a firmware matching $EXPECTED_BOARD_ID" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p .cache
|
||||
FIRMWARE=".cache/$(basename "$FW_URL")"
|
||||
if [ ! -f "$FIRMWARE" ]; then
|
||||
echo "Downloading firmware from $FW_URL to $FIRMWARE"
|
||||
curl -L --fail -o "$FIRMWARE" "$FW_URL"
|
||||
else
|
||||
echo "Firmware already downloaded at $FIRMWARE"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
$ESPL --chip "$ESPT_CHIP" --port "$PORT" --baud "$BAUD" erase_flash
|
||||
|
||||
echo "Writing firmware $FIRMWARE to $FLASH_OFFSET..."
|
||||
$ESPL --chip "$ESPT_CHIP" --port "$PORT" --baud "$BAUD" write_flash -z "$FLASH_OFFSET" "$FIRMWARE"
|
||||
|
||||
echo "Done."
|
||||
|
||||
|
||||
4
install.sh
Executable file
4
install.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install script - runs pipenv install
|
||||
|
||||
pipenv install "$@"
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,4 +1,25 @@
|
||||
import jwt
|
||||
try:
|
||||
import jwt
|
||||
HAS_JWT = True
|
||||
except ImportError:
|
||||
HAS_JWT = False
|
||||
try:
|
||||
import ubinascii
|
||||
except ImportError:
|
||||
import binascii as ubinascii
|
||||
try:
|
||||
import uhashlib as hashlib
|
||||
except ImportError:
|
||||
import hashlib
|
||||
try:
|
||||
import uhmac as hmac
|
||||
except ImportError:
|
||||
try:
|
||||
import hmac
|
||||
except ImportError:
|
||||
hmac = None
|
||||
import json
|
||||
|
||||
from microdot.microdot import invoke_handler
|
||||
from microdot.helpers import wraps
|
||||
|
||||
@@ -125,16 +146,61 @@ class Session:
|
||||
return response
|
||||
|
||||
def encode(self, payload, secret_key=None):
|
||||
return jwt.encode(payload, secret_key or self.secret_key,
|
||||
algorithm='HS256')
|
||||
"""Encode session data using JWT if available, otherwise use simple HMAC."""
|
||||
if HAS_JWT:
|
||||
return jwt.encode(payload, secret_key or self.secret_key,
|
||||
algorithm='HS256')
|
||||
else:
|
||||
# Simple encoding for MicroPython: base64(json) + HMAC signature
|
||||
key = (secret_key or self.secret_key).encode() if isinstance(secret_key or self.secret_key, str) else (secret_key or self.secret_key)
|
||||
payload_json = json.dumps(payload)
|
||||
payload_b64 = ubinascii.b2a_base64(payload_json.encode()).decode().strip()
|
||||
|
||||
# Create HMAC signature
|
||||
if hmac:
|
||||
# Use hmac module if available
|
||||
h = hmac.new(key, payload_json.encode(), hashlib.sha256)
|
||||
else:
|
||||
# Fallback: simple SHA256(key + message)
|
||||
h = hashlib.sha256(key + payload_json.encode())
|
||||
signature = ubinascii.b2a_base64(h.digest()).decode().strip()
|
||||
|
||||
return f"{payload_b64}.{signature}"
|
||||
|
||||
def decode(self, session, secret_key=None):
|
||||
try:
|
||||
payload = jwt.decode(session, secret_key or self.secret_key,
|
||||
algorithms=['HS256'])
|
||||
except jwt.exceptions.PyJWTError: # pragma: no cover
|
||||
return {}
|
||||
return payload
|
||||
"""Decode session data using JWT if available, otherwise use simple HMAC."""
|
||||
if HAS_JWT:
|
||||
try:
|
||||
payload = jwt.decode(session, secret_key or self.secret_key,
|
||||
algorithms=['HS256'])
|
||||
except jwt.exceptions.PyJWTError: # pragma: no cover
|
||||
return {}
|
||||
return payload
|
||||
else:
|
||||
try:
|
||||
# Simple decoding for MicroPython
|
||||
if '.' not in session:
|
||||
return {}
|
||||
|
||||
payload_b64, signature = session.rsplit('.', 1)
|
||||
payload_json = ubinascii.a2b_base64(payload_b64).decode()
|
||||
|
||||
# Verify HMAC signature
|
||||
key = (secret_key or self.secret_key).encode() if isinstance(secret_key or self.secret_key, str) else (secret_key or self.secret_key)
|
||||
if hmac:
|
||||
# Use hmac module if available
|
||||
h = hmac.new(key, payload_json.encode(), hashlib.sha256)
|
||||
else:
|
||||
# Fallback: simple SHA256(key + message)
|
||||
h = hashlib.sha256(key + payload_json.encode())
|
||||
expected_signature = ubinascii.b2a_base64(h.digest()).decode().strip()
|
||||
|
||||
if signature != expected_signature:
|
||||
return {}
|
||||
|
||||
return json.loads(payload_json)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def with_session(f):
|
||||
|
||||
23
msg.json
Normal file
23
msg.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"g":{
|
||||
"df": {
|
||||
"pt": "on",
|
||||
"cl": ["#ff0000"],
|
||||
"br": 200,
|
||||
"n1": 10,
|
||||
"n2": 10,
|
||||
"n3": 10,
|
||||
"n4": 10,
|
||||
"n5": 10,
|
||||
"n6": 10,
|
||||
"dl": 100
|
||||
},
|
||||
"dj": {
|
||||
"pt": "blink",
|
||||
"cl": ["#00ff00"],
|
||||
"dl": 500
|
||||
}
|
||||
},
|
||||
"sv": true,
|
||||
"st": 0
|
||||
}
|
||||
21
run_web.py
21
run_web.py
@@ -87,6 +87,8 @@ async def run_local():
|
||||
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
|
||||
@@ -94,9 +96,15 @@ async def run_local():
|
||||
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')
|
||||
@@ -105,6 +113,8 @@ async def run_local():
|
||||
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('/')
|
||||
@@ -112,6 +122,17 @@ async def run_local():
|
||||
"""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):
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -8,14 +8,18 @@ palettes = Palette()
|
||||
@controller.get('')
|
||||
async def list_palettes(request):
|
||||
"""List all palettes."""
|
||||
return json.dumps(palettes), 200, {'Content-Type': 'application/json'}
|
||||
data = {}
|
||||
for pid in palettes.list():
|
||||
colors = palettes.read(pid)
|
||||
data[pid] = colors
|
||||
return json.dumps(data), 200, {'Content-Type': 'application/json'}
|
||||
|
||||
@controller.get('/<id>')
|
||||
async def get_palette(request, id):
|
||||
"""Get a specific palette by ID."""
|
||||
palette = palettes.read(id)
|
||||
if palette:
|
||||
return json.dumps(palette), 200, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"colors": palette, "id": str(id)}), 200, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "Palette not found"}), 404
|
||||
|
||||
@controller.post('')
|
||||
@@ -23,12 +27,14 @@ async def create_palette(request):
|
||||
"""Create a new palette."""
|
||||
try:
|
||||
data = request.json or {}
|
||||
name = data.get("name", "")
|
||||
colors = data.get("colors", None)
|
||||
palette_id = palettes.create(name, colors)
|
||||
if data:
|
||||
palettes.update(palette_id, data)
|
||||
return json.dumps(palettes.read(palette_id)), 201, {'Content-Type': 'application/json'}
|
||||
# Palette no longer needs a name; only colors are stored.
|
||||
palette_id = palettes.create("", colors)
|
||||
palette = palettes.read(palette_id) or {}
|
||||
# Include the ID in the response payload so clients can link it.
|
||||
palette_with_id = {"id": str(palette_id)}
|
||||
palette_with_id.update(palette)
|
||||
return json.dumps(palette_with_id), 201, {'Content-Type': 'application/json'}
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@@ -36,9 +42,15 @@ async def create_palette(request):
|
||||
async def update_palette(request, id):
|
||||
"""Update an existing palette."""
|
||||
try:
|
||||
data = request.json
|
||||
data = request.json or {}
|
||||
# Ignore any name field; only colors are relevant.
|
||||
if "name" in data:
|
||||
data.pop("name", None)
|
||||
if palettes.update(id, data):
|
||||
return json.dumps(palettes.read(id)), 200, {'Content-Type': 'application/json'}
|
||||
palette = palettes.read(id) or {}
|
||||
palette_with_id = {"id": str(id)}
|
||||
palette_with_id.update(palette)
|
||||
return json.dumps(palette_with_id), 200, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "Palette not found"}), 404
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@@ -1,40 +1,92 @@
|
||||
from microdot import Microdot
|
||||
from microdot.session import with_session
|
||||
from models.preset import Preset
|
||||
from models.profile import Profile
|
||||
from models.espnow import ESPNow
|
||||
from util.espnow_message import build_message, build_preset_dict, ESPNOW_MAX_PAYLOAD_BYTES
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
controller = Microdot()
|
||||
presets = Preset()
|
||||
profiles = Profile()
|
||||
|
||||
def get_current_profile_id(session=None):
|
||||
"""Get the current active profile ID from session or fallback to first."""
|
||||
profile_list = profiles.list()
|
||||
session_profile = None
|
||||
if session is not None:
|
||||
session_profile = session.get('current_profile')
|
||||
if session_profile and session_profile in profile_list:
|
||||
return session_profile
|
||||
if profile_list:
|
||||
return profile_list[0]
|
||||
return None
|
||||
|
||||
@controller.get('')
|
||||
async def list_presets(request):
|
||||
"""List all presets."""
|
||||
return json.dumps(presets), 200, {'Content-Type': 'application/json'}
|
||||
@with_session
|
||||
async def list_presets(request, session):
|
||||
"""List presets for the current profile."""
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
if not current_profile_id:
|
||||
return json.dumps({}), 200, {'Content-Type': 'application/json'}
|
||||
scoped = {
|
||||
pid: pdata for pid, pdata in presets.items()
|
||||
if isinstance(pdata, dict) and str(pdata.get("profile_id")) == str(current_profile_id)
|
||||
}
|
||||
return json.dumps(scoped), 200, {'Content-Type': 'application/json'}
|
||||
|
||||
@controller.get('/<id>')
|
||||
async def get_preset(request, id):
|
||||
"""Get a specific preset by ID."""
|
||||
@with_session
|
||||
async def get_preset(request, id, session):
|
||||
"""Get a specific preset by ID (current profile only)."""
|
||||
preset = presets.read(id)
|
||||
if preset:
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
if preset and str(preset.get("profile_id")) == str(current_profile_id):
|
||||
return json.dumps(preset), 200, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "Preset not found"}), 404
|
||||
|
||||
@controller.post('')
|
||||
async def create_preset(request):
|
||||
"""Create a new preset."""
|
||||
@with_session
|
||||
async def create_preset(request, session):
|
||||
"""Create a new preset for the current profile."""
|
||||
try:
|
||||
data = request.json
|
||||
preset_id = presets.create()
|
||||
try:
|
||||
data = request.json or {}
|
||||
except Exception:
|
||||
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
if not current_profile_id:
|
||||
return json.dumps({"error": "No profile available"}), 404
|
||||
preset_id = presets.create(current_profile_id)
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
data = dict(data)
|
||||
data["profile_id"] = str(current_profile_id)
|
||||
if presets.update(preset_id, data):
|
||||
return json.dumps(presets.read(preset_id)), 201, {'Content-Type': 'application/json'}
|
||||
preset_data = presets.read(preset_id)
|
||||
return json.dumps({preset_id: preset_data}), 201, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "Failed to create preset"}), 400
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@controller.put('/<id>')
|
||||
async def update_preset(request, id):
|
||||
"""Update an existing preset."""
|
||||
@with_session
|
||||
async def update_preset(request, id, session):
|
||||
"""Update an existing preset (current profile only)."""
|
||||
try:
|
||||
data = request.json
|
||||
preset = presets.read(id)
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
|
||||
return json.dumps({"error": "Preset not found"}), 404
|
||||
try:
|
||||
data = request.json or {}
|
||||
except Exception:
|
||||
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
data = dict(data)
|
||||
data["profile_id"] = str(current_profile_id)
|
||||
if presets.update(id, data):
|
||||
return json.dumps(presets.read(id)), 200, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "Preset not found"}), 404
|
||||
@@ -42,8 +94,111 @@ async def update_preset(request, id):
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@controller.delete('/<id>')
|
||||
async def delete_preset(request, id):
|
||||
"""Delete a preset."""
|
||||
@with_session
|
||||
async def delete_preset(request, id, session):
|
||||
"""Delete a preset (current profile only)."""
|
||||
preset = presets.read(id)
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
|
||||
return json.dumps({"error": "Preset not found"}), 404
|
||||
if presets.delete(id):
|
||||
return json.dumps({"message": "Preset deleted successfully"}), 200
|
||||
return json.dumps({"error": "Preset not found"}), 404
|
||||
|
||||
|
||||
@controller.post('/send')
|
||||
@with_session
|
||||
async def send_presets(request, session):
|
||||
"""
|
||||
Send one or more presets over ESPNow.
|
||||
|
||||
Body JSON:
|
||||
{"preset_ids": ["1", "2", ...]} or {"ids": ["1", "2", ...]}
|
||||
|
||||
The controller:
|
||||
- looks up each preset in the Preset model
|
||||
- converts them to API-compliant format
|
||||
- splits into <= 240-byte ESPNow messages
|
||||
- sends each message to all configured ESPNow peers.
|
||||
"""
|
||||
try:
|
||||
data = request.json or {}
|
||||
except Exception:
|
||||
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
|
||||
|
||||
preset_ids = data.get('preset_ids') or data.get('ids')
|
||||
if not isinstance(preset_ids, list) or not preset_ids:
|
||||
return json.dumps({"error": "preset_ids must be a non-empty list"}), 400, {'Content-Type': 'application/json'}
|
||||
save_flag = data.get('save', True)
|
||||
save_flag = bool(save_flag)
|
||||
default_id = data.get('default')
|
||||
|
||||
# Build API-compliant preset map keyed by preset ID, include name
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
presets_by_name = {}
|
||||
for pid in preset_ids:
|
||||
preset_data = presets.read(str(pid))
|
||||
if not preset_data:
|
||||
continue
|
||||
if str(preset_data.get("profile_id")) != str(current_profile_id):
|
||||
continue
|
||||
preset_key = str(pid)
|
||||
preset_payload = build_preset_dict(preset_data)
|
||||
preset_payload["name"] = preset_data.get("name", "")
|
||||
presets_by_name[preset_key] = preset_payload
|
||||
|
||||
if not presets_by_name:
|
||||
return json.dumps({"error": "No matching presets found"}), 404, {'Content-Type': 'application/json'}
|
||||
|
||||
if default_id is not None and str(default_id) not in presets_by_name:
|
||||
default_id = None
|
||||
|
||||
# Use shared ESPNow singleton
|
||||
esp = ESPNow()
|
||||
|
||||
async def send_chunk(chunk_presets):
|
||||
# Include save flag so the led-driver can persist when desired.
|
||||
msg = build_message(presets=chunk_presets, save=save_flag, default=default_id)
|
||||
await esp.send(msg)
|
||||
|
||||
MAX_BYTES = ESPNOW_MAX_PAYLOAD_BYTES
|
||||
SEND_DELAY_MS = 100
|
||||
entries = list(presets_by_name.items())
|
||||
total_presets = len(entries)
|
||||
messages_sent = 0
|
||||
|
||||
batch = {}
|
||||
last_msg = None
|
||||
for name, preset_obj in entries:
|
||||
test_batch = dict(batch)
|
||||
test_batch[name] = preset_obj
|
||||
test_msg = build_message(presets=test_batch, save=save_flag, default=default_id)
|
||||
size = len(test_msg)
|
||||
|
||||
if size <= MAX_BYTES or not batch:
|
||||
batch = test_batch
|
||||
last_msg = test_msg
|
||||
else:
|
||||
try:
|
||||
await send_chunk(batch)
|
||||
except Exception:
|
||||
return json.dumps({"error": "ESP-NOW send failed"}), 503, {'Content-Type': 'application/json'}
|
||||
await asyncio.sleep_ms(SEND_DELAY_MS)
|
||||
messages_sent += 1
|
||||
batch = {name: preset_obj}
|
||||
last_msg = build_message(presets=batch, save=save_flag, default=default_id)
|
||||
|
||||
if batch:
|
||||
try:
|
||||
await send_chunk(batch)
|
||||
except Exception:
|
||||
return json.dumps({"error": "ESP-NOW send failed"}), 503, {'Content-Type': 'application/json'}
|
||||
await asyncio.sleep_ms(SEND_DELAY_MS)
|
||||
messages_sent += 1
|
||||
|
||||
return json.dumps({
|
||||
"message": "Presets sent via ESPNow",
|
||||
"presets_sent": total_presets,
|
||||
"messages_sent": messages_sent
|
||||
}), 200, {'Content-Type': 'application/json'}
|
||||
|
||||
|
||||
@@ -1,15 +1,41 @@
|
||||
from microdot import Microdot
|
||||
from microdot.session import with_session
|
||||
from models.profile import Profile
|
||||
from models.tab import Tab
|
||||
from models.preset import Preset
|
||||
import json
|
||||
|
||||
controller = Microdot()
|
||||
profiles = Profile()
|
||||
tabs = Tab()
|
||||
presets = Preset()
|
||||
|
||||
@controller.get('')
|
||||
async def list_profiles(request):
|
||||
"""List all profiles."""
|
||||
return json.dumps(profiles), 200, {'Content-Type': 'application/json'}
|
||||
@with_session
|
||||
async def list_profiles(request, session):
|
||||
"""List all profiles with current profile info."""
|
||||
profile_list = profiles.list()
|
||||
current_id = session.get('current_profile')
|
||||
if current_id and current_id not in profile_list:
|
||||
current_id = None
|
||||
|
||||
# If no current profile in session, use first one
|
||||
if not current_id and profile_list:
|
||||
current_id = profile_list[0]
|
||||
session['current_profile'] = str(current_id)
|
||||
session.save()
|
||||
|
||||
# Build profiles object
|
||||
profiles_data = {}
|
||||
for profile_id in profile_list:
|
||||
profile_data = profiles.read(profile_id)
|
||||
if profile_data:
|
||||
profiles_data[profile_id] = profile_data
|
||||
|
||||
return json.dumps({
|
||||
"profiles": profiles_data,
|
||||
"current_profile_id": current_id
|
||||
}), 200, {'Content-Type': 'application/json'}
|
||||
|
||||
@controller.get('/current')
|
||||
@with_session
|
||||
@@ -17,6 +43,8 @@ async def get_current_profile(request, session):
|
||||
"""Get the current profile ID from session (or fallback)."""
|
||||
profile_list = profiles.list()
|
||||
current_id = session.get('current_profile')
|
||||
if current_id and current_id not in profile_list:
|
||||
current_id = None
|
||||
if not current_id and profile_list:
|
||||
current_id = profile_list[0]
|
||||
session['current_profile'] = str(current_id)
|
||||
@@ -27,8 +55,13 @@ async def get_current_profile(request, session):
|
||||
return json.dumps({"error": "No profile available"}), 404
|
||||
|
||||
@controller.get('/<id>')
|
||||
async def get_profile(request, id):
|
||||
@with_session
|
||||
async def get_profile(request, id, session):
|
||||
"""Get a specific profile by ID."""
|
||||
# Handle 'current' as a special case
|
||||
if id == 'current':
|
||||
return await get_current_profile(request, session)
|
||||
|
||||
profile = profiles.read(id)
|
||||
if profile:
|
||||
return json.dumps(profile), 200, {'Content-Type': 'application/json'}
|
||||
@@ -53,7 +86,120 @@ async def create_profile(request):
|
||||
profile_id = profiles.create(name)
|
||||
if data:
|
||||
profiles.update(profile_id, data)
|
||||
return json.dumps(profiles.read(profile_id)), 201, {'Content-Type': 'application/json'}
|
||||
profile_data = profiles.read(profile_id)
|
||||
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@controller.post('/<id>/clone')
|
||||
async def clone_profile(request, id):
|
||||
"""Clone an existing profile along with its tabs and palette."""
|
||||
try:
|
||||
source = profiles.read(id)
|
||||
if not source:
|
||||
return json.dumps({"error": "Profile not found"}), 404
|
||||
|
||||
data = request.json or {}
|
||||
source_name = source.get("name") or f"Profile {id}"
|
||||
new_name = data.get("name") or source_name
|
||||
profile_type = source.get("type", "tabs")
|
||||
|
||||
def allocate_id(model, cache):
|
||||
if "next" not in cache:
|
||||
max_id = max((int(k) for k in model.keys() if str(k).isdigit()), default=0)
|
||||
cache["next"] = max_id + 1
|
||||
next_id = str(cache["next"])
|
||||
cache["next"] += 1
|
||||
return next_id
|
||||
|
||||
def map_preset_container(value, id_map, preset_cache, new_profile_id, new_presets):
|
||||
if isinstance(value, list):
|
||||
return [map_preset_container(v, id_map, preset_cache, new_profile_id, new_presets) for v in value]
|
||||
if value is None:
|
||||
return None
|
||||
preset_id = str(value)
|
||||
if preset_id in id_map:
|
||||
return id_map[preset_id]
|
||||
preset_data = presets.read(preset_id)
|
||||
if not preset_data:
|
||||
return None
|
||||
new_preset_id = allocate_id(presets, preset_cache)
|
||||
clone_data = dict(preset_data)
|
||||
clone_data["profile_id"] = str(new_profile_id)
|
||||
new_presets[new_preset_id] = clone_data
|
||||
id_map[preset_id] = new_preset_id
|
||||
return new_preset_id
|
||||
|
||||
# Prepare new IDs without writing until everything is ready.
|
||||
profile_cache = {}
|
||||
palette_cache = {}
|
||||
tab_cache = {}
|
||||
preset_cache = {}
|
||||
|
||||
new_profile_id = allocate_id(profiles, profile_cache)
|
||||
new_palette_id = allocate_id(profiles._palette_model, palette_cache)
|
||||
|
||||
# Clone palette colors into the new profile's palette
|
||||
src_palette_id = source.get("palette_id")
|
||||
palette_colors = []
|
||||
if src_palette_id:
|
||||
try:
|
||||
palette_colors = profiles._palette_model.read(src_palette_id)
|
||||
except Exception:
|
||||
palette_colors = []
|
||||
|
||||
# Clone tabs and presets used by those tabs
|
||||
source_tabs = source.get("tabs")
|
||||
if not isinstance(source_tabs, list) or len(source_tabs) == 0:
|
||||
source_tabs = source.get("tab_order", [])
|
||||
source_tabs = source_tabs or []
|
||||
cloned_tab_ids = []
|
||||
preset_id_map = {}
|
||||
new_tabs = {}
|
||||
new_presets = {}
|
||||
for tab_id in source_tabs:
|
||||
tab = tabs.read(tab_id)
|
||||
if not tab:
|
||||
continue
|
||||
tab_name = tab.get("name") or f"Tab {tab_id}"
|
||||
clone_name = tab_name
|
||||
mapped_presets = map_preset_container(tab.get("presets"), preset_id_map, preset_cache, new_profile_id, new_presets)
|
||||
clone_id = allocate_id(tabs, tab_cache)
|
||||
clone_data = {
|
||||
"name": clone_name,
|
||||
"names": tab.get("names") or [],
|
||||
"presets": mapped_presets if mapped_presets is not None else []
|
||||
}
|
||||
extra = {k: v for k, v in tab.items() if k not in ("name", "names", "presets")}
|
||||
if "presets_flat" in extra:
|
||||
extra["presets_flat"] = map_preset_container(extra.get("presets_flat"), preset_id_map, preset_cache, new_profile_id, new_presets)
|
||||
if extra:
|
||||
clone_data.update(extra)
|
||||
new_tabs[clone_id] = clone_data
|
||||
cloned_tab_ids.append(clone_id)
|
||||
|
||||
new_profile_data = {
|
||||
"name": new_name,
|
||||
"type": profile_type,
|
||||
"tabs": cloned_tab_ids,
|
||||
"scenes": list(source.get("scenes", [])) if isinstance(source.get("scenes", []), list) else [],
|
||||
"palette_id": str(new_palette_id),
|
||||
}
|
||||
|
||||
# Commit all changes and save once per model.
|
||||
profiles._palette_model[str(new_palette_id)] = list(palette_colors) if palette_colors else []
|
||||
for pid, pdata in new_presets.items():
|
||||
presets[pid] = pdata
|
||||
for tid, tdata in new_tabs.items():
|
||||
tabs[tid] = tdata
|
||||
profiles[str(new_profile_id)] = new_profile_data
|
||||
|
||||
profiles._palette_model.save()
|
||||
presets.save()
|
||||
tabs.save()
|
||||
profiles.save()
|
||||
|
||||
return json.dumps({new_profile_id: new_profile_data}), 201, {'Content-Type': 'application/json'}
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
|
||||
80
src/controllers/settings.py
Normal file
80
src/controllers/settings.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from microdot import Microdot, send_file
|
||||
from settings import Settings
|
||||
import util.wifi as wifi
|
||||
import json
|
||||
|
||||
controller = Microdot()
|
||||
settings = Settings()
|
||||
|
||||
@controller.get('')
|
||||
async def get_settings(request):
|
||||
"""Get all settings."""
|
||||
# Settings is already a dict subclass; avoid dict() wrapper which can
|
||||
# trigger MicroPython's "dict update sequence has wrong length" quirk.
|
||||
return json.dumps(settings), 200, {'Content-Type': 'application/json'}
|
||||
|
||||
@controller.get('/wifi/ap')
|
||||
async def get_ap_config(request):
|
||||
"""Get Access Point configuration."""
|
||||
config = wifi.get_ap_config()
|
||||
if config:
|
||||
# Also get saved settings
|
||||
config['saved_ssid'] = settings.get('wifi_ap_ssid')
|
||||
config['saved_password'] = settings.get('wifi_ap_password')
|
||||
config['saved_channel'] = settings.get('wifi_ap_channel')
|
||||
return json.dumps(config), 200, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "Failed to get AP config"}), 500
|
||||
|
||||
@controller.post('/wifi/ap')
|
||||
async def configure_ap(request):
|
||||
"""Configure Access Point."""
|
||||
try:
|
||||
data = request.json
|
||||
ssid = data.get('ssid')
|
||||
password = data.get('password', '')
|
||||
channel = data.get('channel')
|
||||
|
||||
if not ssid:
|
||||
return json.dumps({"error": "SSID is required"}), 400
|
||||
|
||||
# Validate channel (1-11 for 2.4GHz)
|
||||
if channel is not None:
|
||||
channel = int(channel)
|
||||
if channel < 1 or channel > 11:
|
||||
return json.dumps({"error": "Channel must be between 1 and 11"}), 400
|
||||
|
||||
# Save to settings
|
||||
settings['wifi_ap_ssid'] = ssid
|
||||
settings['wifi_ap_password'] = password
|
||||
if channel is not None:
|
||||
settings['wifi_ap_channel'] = channel
|
||||
settings.save()
|
||||
|
||||
# Configure AP
|
||||
wifi.ap(ssid, password, channel)
|
||||
|
||||
return json.dumps({
|
||||
"message": "AP configured successfully",
|
||||
"ssid": ssid,
|
||||
"channel": channel
|
||||
}), 200, {'Content-Type': 'application/json'}
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 500
|
||||
|
||||
@controller.put('/settings')
|
||||
async def update_settings(request):
|
||||
"""Update general settings."""
|
||||
try:
|
||||
data = request.json
|
||||
for key, value in data.items():
|
||||
settings[key] = value
|
||||
settings.save()
|
||||
return json.dumps({"message": "Settings updated successfully"}), 200, {'Content-Type': 'application/json'}
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 500
|
||||
|
||||
@controller.get('/page')
|
||||
async def settings_page(request):
|
||||
"""Serve the settings page."""
|
||||
return send_file('templates/settings.html')
|
||||
|
||||
@@ -33,13 +33,13 @@ def get_profile_tab_order(profile_id):
|
||||
return []
|
||||
|
||||
def get_current_tab_id(request, session=None):
|
||||
"""Get the current tab ID from session."""
|
||||
if session:
|
||||
current_tab = session.get('current_tab')
|
||||
if current_tab:
|
||||
return current_tab
|
||||
"""Get the current tab ID from cookie."""
|
||||
# Read from cookie first
|
||||
current_tab = request.cookies.get('current_tab')
|
||||
if current_tab:
|
||||
return current_tab
|
||||
|
||||
# Fallback to first tab in current profile if no session
|
||||
# Fallback to first tab in current profile
|
||||
profile_id = get_current_profile_id(session)
|
||||
if profile_id:
|
||||
profile = profiles.read(profile_id)
|
||||
@@ -50,16 +50,8 @@ def get_current_tab_id(request, session=None):
|
||||
return tabs_list[0]
|
||||
return None
|
||||
|
||||
@controller.get('')
|
||||
async def list_tabs(request):
|
||||
"""List all tabs."""
|
||||
return json.dumps(tabs), 200, {'Content-Type': 'application/json'}
|
||||
|
||||
# HTML Fragment endpoints for htmx - must be before /<id> route
|
||||
@controller.get('/list-fragment')
|
||||
@with_session
|
||||
async def tabs_list_fragment(request, session):
|
||||
"""Return HTML fragment for the tabs list."""
|
||||
def _render_tabs_list_fragment(request, session):
|
||||
"""Helper function to render tabs list HTML fragment."""
|
||||
profile_id = get_current_profile_id(session)
|
||||
# #region agent log
|
||||
try:
|
||||
@@ -69,7 +61,7 @@ async def tabs_list_fragment(request, session):
|
||||
"sessionId": "debug-session",
|
||||
"runId": "tabs-pre-fix",
|
||||
"hypothesisId": "H1",
|
||||
"location": "src/controllers/tab.py:tabs_list_fragment",
|
||||
"location": "src/controllers/tab.py:_render_tabs_list_fragment",
|
||||
"message": "tabs list fragment",
|
||||
"data": {
|
||||
"profile_id": profile_id,
|
||||
@@ -106,49 +98,18 @@ async def tabs_list_fragment(request, session):
|
||||
html += '</div>'
|
||||
return html, 200, {'Content-Type': 'text/html'}
|
||||
|
||||
@controller.get('/create-form-fragment')
|
||||
async def create_tab_form_fragment(request):
|
||||
"""Return the create tab form HTML fragment."""
|
||||
html = '''
|
||||
<h2>Add New Tab</h2>
|
||||
<form hx-post="/tabs"
|
||||
hx-target="#tabs-list"
|
||||
hx-swap="innerHTML"
|
||||
hx-headers='{"Accept": "text/html"}'
|
||||
hx-on::after-request="if(event.detail.successful) { document.getElementById('add-tab-modal').classList.remove('active'); document.body.dispatchEvent(new Event('tabs-updated')); }">
|
||||
<label>Tab Name:</label>
|
||||
<input type="text" name="name" placeholder="Enter tab name" required>
|
||||
<label>Device IDs (comma-separated):</label>
|
||||
<input type="text" name="ids" placeholder="1,2,3" value="1">
|
||||
<div class="modal-actions">
|
||||
<button type="submit" class="btn btn-primary">Add</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="document.getElementById('add-tab-modal').classList.remove('active')">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
'''
|
||||
return html, 200, {'Content-Type': 'text/html'}
|
||||
|
||||
@controller.get('/current')
|
||||
@with_session
|
||||
async def get_current_tab(request, session):
|
||||
"""Get the current tab from session."""
|
||||
current_tab_id = get_current_tab_id(request, session)
|
||||
if not current_tab_id:
|
||||
accept_header = request.headers.get('Accept', '')
|
||||
wants_html = 'text/html' in accept_header
|
||||
if wants_html:
|
||||
return '<div class="error">No current tab set</div>', 404, {'Content-Type': 'text/html'}
|
||||
return json.dumps({"error": "No current tab set"}), 404
|
||||
|
||||
return await tab_content_fragment.__wrapped__(request, session, current_tab_id)
|
||||
|
||||
@controller.get('/<id>/content-fragment')
|
||||
@with_session
|
||||
async def tab_content_fragment(request, session, id):
|
||||
"""Return HTML fragment for tab content."""
|
||||
def _render_tab_content_fragment(request, session, id):
|
||||
"""Helper function to render tab content HTML fragment."""
|
||||
# Handle 'current' as a special case
|
||||
if id == 'current':
|
||||
return await get_current_tab(request, session)
|
||||
current_tab_id = get_current_tab_id(request, session)
|
||||
if not current_tab_id:
|
||||
accept_header = request.headers.get('Accept', '')
|
||||
wants_html = 'text/html' in accept_header
|
||||
if wants_html:
|
||||
return '<div class="error">No current tab set</div>', 404, {'Content-Type': 'text/html'}
|
||||
return json.dumps({"error": "No current tab set"}), 404
|
||||
id = current_tab_id
|
||||
|
||||
tab = tabs.read(id)
|
||||
if not tab:
|
||||
@@ -167,9 +128,7 @@ async def tab_content_fragment(request, session, id):
|
||||
html = (
|
||||
'<div class="presets-section" data-tab-id="' + str(id) + '">'
|
||||
'<h3>Presets</h3>'
|
||||
'<div class="profiles-actions" style="margin-bottom: 1rem;">'
|
||||
'<button class="btn btn-primary" id="preset-add-btn-tab">Add Preset</button>'
|
||||
'</div>'
|
||||
'<div class="profiles-actions" style="margin-bottom: 1rem;"></div>'
|
||||
'<div id="presets-list-tab" class="presets-list">'
|
||||
'<!-- Presets will be loaded here -->'
|
||||
'</div>'
|
||||
@@ -177,6 +136,62 @@ async def tab_content_fragment(request, session, id):
|
||||
)
|
||||
return html, 200, {'Content-Type': 'text/html'}
|
||||
|
||||
@controller.get('')
|
||||
@with_session
|
||||
async def list_tabs(request, session):
|
||||
"""List all tabs with current tab info."""
|
||||
profile_id = get_current_profile_id(session)
|
||||
current_tab_id = get_current_tab_id(request, session)
|
||||
|
||||
# Get tab order for current profile
|
||||
tab_order = get_profile_tab_order(profile_id) if profile_id else []
|
||||
|
||||
# Build tabs list with metadata
|
||||
tabs_data = {}
|
||||
for tab_id in tabs.list():
|
||||
tab_data = tabs.read(tab_id)
|
||||
if tab_data:
|
||||
tabs_data[tab_id] = tab_data
|
||||
|
||||
return json.dumps({
|
||||
"tabs": tabs_data,
|
||||
"tab_order": tab_order,
|
||||
"current_tab_id": current_tab_id,
|
||||
"profile_id": profile_id
|
||||
}), 200, {'Content-Type': 'application/json'}
|
||||
|
||||
# Get current tab - returns JSON with tab data and content info
|
||||
@controller.get('/current')
|
||||
@with_session
|
||||
async def get_current_tab(request, session):
|
||||
"""Get the current tab from session."""
|
||||
current_tab_id = get_current_tab_id(request, session)
|
||||
if not current_tab_id:
|
||||
return json.dumps({"error": "No current tab set", "tab": None, "tab_id": None}), 404
|
||||
|
||||
tab = tabs.read(current_tab_id)
|
||||
if tab:
|
||||
return json.dumps({
|
||||
"tab": tab,
|
||||
"tab_id": current_tab_id
|
||||
}), 200, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "Tab not found", "tab": None, "tab_id": None}), 404
|
||||
|
||||
@controller.post('/<id>/set-current')
|
||||
async def set_current_tab(request, id):
|
||||
"""Set a tab as the current tab in cookie."""
|
||||
tab = tabs.read(id)
|
||||
if not tab:
|
||||
return json.dumps({"error": "Tab not found"}), 404
|
||||
|
||||
# Set cookie with current tab
|
||||
response_data = json.dumps({"message": "Current tab set", "tab_id": id})
|
||||
response = response_data, 200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Set-Cookie': f'current_tab={id}; Path=/; Max-Age=31536000' # 1 year expiry
|
||||
}
|
||||
return response
|
||||
|
||||
@controller.get('/<id>')
|
||||
async def get_tab(request, id):
|
||||
"""Get a specific tab by ID."""
|
||||
@@ -198,84 +213,60 @@ async def update_tab(request, id):
|
||||
|
||||
@controller.delete('/<id>')
|
||||
@with_session
|
||||
async def delete_tab(request, id, session):
|
||||
async def delete_tab(request, session, id):
|
||||
"""Delete a tab."""
|
||||
# Check if this is an htmx request (wants HTML fragment)
|
||||
accept_header = request.headers.get('Accept', '')
|
||||
wants_html = 'text/html' in accept_header
|
||||
|
||||
# Handle 'current' tab ID
|
||||
if id == 'current':
|
||||
current_tab_id = get_current_tab_id(request, session)
|
||||
if current_tab_id:
|
||||
id = current_tab_id
|
||||
else:
|
||||
if wants_html:
|
||||
return '<div class="error">No current tab to delete</div>', 404, {'Content-Type': 'text/html'}
|
||||
return json.dumps({"error": "No current tab to delete"}), 404
|
||||
|
||||
if tabs.delete(id):
|
||||
# Remove from profile's tabs
|
||||
profile_id = get_current_profile_id(session)
|
||||
if profile_id:
|
||||
profile = profiles.read(profile_id)
|
||||
if profile:
|
||||
# Support both "tabs" (new) and "tab_order" (old) format
|
||||
tabs_list = profile.get('tabs', profile.get('tab_order', []))
|
||||
if id in tabs_list:
|
||||
tabs_list.remove(id)
|
||||
profile['tabs'] = tabs_list
|
||||
# Remove old tab_order if it exists
|
||||
if 'tab_order' in profile:
|
||||
del profile['tab_order']
|
||||
profiles.update(profile_id, profile)
|
||||
try:
|
||||
# Handle 'current' tab ID
|
||||
if id == 'current':
|
||||
current_tab_id = get_current_tab_id(request, session)
|
||||
if current_tab_id:
|
||||
id = current_tab_id
|
||||
else:
|
||||
return json.dumps({"error": "No current tab to delete"}), 404
|
||||
|
||||
# Clear session if the deleted tab was the current tab
|
||||
current_tab_id = get_current_tab_id(request, session)
|
||||
if current_tab_id == id:
|
||||
if 'current_tab' in session:
|
||||
session.pop('current_tab', None)
|
||||
session.save()
|
||||
|
||||
if wants_html:
|
||||
return await tabs_list_fragment.__wrapped__(request, session)
|
||||
else:
|
||||
if tabs.delete(id):
|
||||
# Remove from profile's tabs
|
||||
profile_id = get_current_profile_id(session)
|
||||
if profile_id:
|
||||
profile = profiles.read(profile_id)
|
||||
if profile:
|
||||
# Support both "tabs" (new) and "tab_order" (old) format
|
||||
tabs_list = profile.get('tabs', profile.get('tab_order', []))
|
||||
if id in tabs_list:
|
||||
tabs_list.remove(id)
|
||||
profile['tabs'] = tabs_list
|
||||
# Remove old tab_order if it exists
|
||||
if 'tab_order' in profile:
|
||||
del profile['tab_order']
|
||||
profiles.update(profile_id, profile)
|
||||
|
||||
# Clear cookie if the deleted tab was the current tab
|
||||
current_tab_id = get_current_tab_id(request, session)
|
||||
if current_tab_id == id:
|
||||
response_data = json.dumps({"message": "Tab deleted successfully"})
|
||||
response = response_data, 200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Set-Cookie': 'current_tab=; Path=/; Max-Age=0' # Clear cookie
|
||||
}
|
||||
return response
|
||||
|
||||
return json.dumps({"message": "Tab deleted successfully"}), 200, {'Content-Type': 'application/json'}
|
||||
|
||||
if wants_html:
|
||||
return '<div class="error">Tab not found</div>', 404, {'Content-Type': 'text/html'}
|
||||
return json.dumps({"error": "Tab not found"}), 404
|
||||
|
||||
return json.dumps({"error": "Tab not found"}), 404
|
||||
except Exception as e:
|
||||
import sys
|
||||
try:
|
||||
sys.print_exception(e)
|
||||
except:
|
||||
pass
|
||||
return json.dumps({"error": str(e)}), 500, {'Content-Type': 'application/json'}
|
||||
|
||||
@controller.post('')
|
||||
@with_session
|
||||
async def create_tab(request, session):
|
||||
"""Create a new tab."""
|
||||
# Check if this is an htmx request (wants HTML fragment)
|
||||
accept_header = request.headers.get('Accept', '')
|
||||
wants_html = 'text/html' in accept_header
|
||||
# #region agent log
|
||||
try:
|
||||
os.makedirs('/home/pi/led-controller/.cursor', exist_ok=True)
|
||||
with open('/home/pi/led-controller/.cursor/debug.log', 'a') as _log:
|
||||
_log.write(json.dumps({
|
||||
"sessionId": "debug-session",
|
||||
"runId": "tabs-pre-fix",
|
||||
"hypothesisId": "H3",
|
||||
"location": "src/controllers/tab.py:create_tab_htmx",
|
||||
"message": "create tab with session",
|
||||
"data": {
|
||||
"wants_html": wants_html,
|
||||
"has_form": bool(request.form),
|
||||
"accept": accept_header
|
||||
},
|
||||
"timestamp": int(time.time() * 1000)
|
||||
}) + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
# #endregion
|
||||
|
||||
try:
|
||||
# Handle form data (htmx) or JSON
|
||||
# Handle form data or JSON
|
||||
if request.form:
|
||||
name = request.form.get('name', '').strip()
|
||||
ids_str = request.form.get('ids', '1').strip()
|
||||
@@ -288,8 +279,6 @@ async def create_tab(request, session):
|
||||
preset_ids = data.get("presets", None)
|
||||
|
||||
if not name:
|
||||
if wants_html:
|
||||
return '<div class="error">Tab name cannot be empty</div>', 400, {'Content-Type': 'text/html'}
|
||||
return json.dumps({"error": "Tab name cannot be empty"}), 400
|
||||
|
||||
tab_id = tabs.create(name, names, preset_ids)
|
||||
@@ -308,36 +297,50 @@ async def create_tab(request, session):
|
||||
if 'tab_order' in profile:
|
||||
del profile['tab_order']
|
||||
profiles.update(profile_id, profile)
|
||||
# #region agent log
|
||||
try:
|
||||
os.makedirs('/home/pi/led-controller/.cursor', exist_ok=True)
|
||||
with open('/home/pi/led-controller/.cursor/debug.log', 'a') as _log:
|
||||
_log.write(json.dumps({
|
||||
"sessionId": "debug-session",
|
||||
"runId": "tabs-pre-fix",
|
||||
"hypothesisId": "H4",
|
||||
"location": "src/controllers/tab.py:create_tab_htmx",
|
||||
"message": "tab created and profile updated",
|
||||
"data": {
|
||||
"tab_id": tab_id,
|
||||
"profile_id": profile_id,
|
||||
"profile_tabs": tabs_list if profile_id and profile else None
|
||||
},
|
||||
"timestamp": int(time.time() * 1000)
|
||||
}) + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
# #endregion
|
||||
|
||||
if wants_html:
|
||||
# Return HTML fragment for tabs list
|
||||
return await tabs_list_fragment.__wrapped__(request, session)
|
||||
else:
|
||||
# Return JSON response
|
||||
return json.dumps(tabs.read(tab_id)), 201, {'Content-Type': 'application/json'}
|
||||
# Return JSON response with tab ID
|
||||
tab_data = tabs.read(tab_id)
|
||||
return json.dumps({tab_id: tab_data}), 201, {'Content-Type': 'application/json'}
|
||||
except Exception as e:
|
||||
import sys
|
||||
sys.print_exception(e)
|
||||
if wants_html:
|
||||
return f'<div class="error">Error: {str(e)}</div>', 400, {'Content-Type': 'text/html'}
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@controller.post('/<id>/clone')
|
||||
@with_session
|
||||
async def clone_tab(request, session, id):
|
||||
"""Clone an existing tab and add it to the current profile."""
|
||||
try:
|
||||
source = tabs.read(id)
|
||||
if not source:
|
||||
return json.dumps({"error": "Tab not found"}), 404
|
||||
|
||||
data = request.json or {}
|
||||
source_name = source.get("name") or f"Tab {id}"
|
||||
new_name = data.get("name") or f"{source_name} Copy"
|
||||
clone_id = tabs.create(new_name, source.get("names"), source.get("presets"))
|
||||
extra = {k: v for k, v in source.items() if k not in ("name", "names", "presets")}
|
||||
if extra:
|
||||
tabs.update(clone_id, extra)
|
||||
|
||||
profile_id = get_current_profile_id(session)
|
||||
if profile_id:
|
||||
profile = profiles.read(profile_id)
|
||||
if profile:
|
||||
tabs_list = profile.get('tabs', profile.get('tab_order', []))
|
||||
if clone_id not in tabs_list:
|
||||
tabs_list.append(clone_id)
|
||||
profile['tabs'] = tabs_list
|
||||
if 'tab_order' in profile:
|
||||
del profile['tab_order']
|
||||
profiles.update(profile_id, profile)
|
||||
|
||||
tab_data = tabs.read(clone_id)
|
||||
return json.dumps({clone_id: tab_data}), 201, {'Content-Type': 'application/json'}
|
||||
except Exception as e:
|
||||
import sys
|
||||
try:
|
||||
sys.print_exception(e)
|
||||
except:
|
||||
pass
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
71
src/main.py
71
src/main.py
@@ -1,13 +1,14 @@
|
||||
import asyncio
|
||||
from settings import Settings
|
||||
import gc
|
||||
import json
|
||||
import machine
|
||||
from machine import Pin
|
||||
from microdot import Microdot, send_file
|
||||
from microdot.websocket import with_websocket
|
||||
from microdot.session import Session
|
||||
from settings import Settings
|
||||
|
||||
import aioespnow
|
||||
import network
|
||||
import controllers.preset as preset
|
||||
import controllers.profile as profile
|
||||
import controllers.group as group
|
||||
@@ -16,18 +17,18 @@ 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
|
||||
from models.espnow import ESPNow
|
||||
from util.espnow_message import split_espnow_message
|
||||
|
||||
|
||||
async def main(port=80):
|
||||
settings = Settings()
|
||||
print(settings)
|
||||
print("Starting")
|
||||
|
||||
network.WLAN(network.STA_IF).active(True)
|
||||
|
||||
|
||||
e = aioespnow.AIOESPNow()
|
||||
e.active(True)
|
||||
e.add_peer(b"\xbb\xbb\xbb\xbb\xbb\xbb")
|
||||
# Initialize ESPNow singleton (config + peers)
|
||||
esp = ESPNow()
|
||||
|
||||
app = Microdot()
|
||||
|
||||
@@ -56,6 +57,7 @@ async def main(port=80):
|
||||
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('/')
|
||||
@@ -63,6 +65,17 @@ async def main(port=80):
|
||||
"""Serve the main web UI."""
|
||||
return send_file('templates/index.html')
|
||||
|
||||
# Serve settings page
|
||||
@app.route('/settings')
|
||||
def settings_page(request):
|
||||
"""Serve the settings page."""
|
||||
return send_file('templates/settings.html')
|
||||
|
||||
# Favicon: avoid 404 in browser console (no file needed)
|
||||
@app.route('/favicon.ico')
|
||||
def favicon(request):
|
||||
return '', 204
|
||||
|
||||
# Static file route
|
||||
@app.route("/static/<path:path>")
|
||||
def static_handler(request, path):
|
||||
@@ -77,9 +90,31 @@ async def main(port=80):
|
||||
async def ws(request, ws):
|
||||
while True:
|
||||
data = await ws.receive()
|
||||
print(data)
|
||||
if data:
|
||||
await e.asend(b"\xbb\xbb\xbb\xbb\xbb\xbb", data)
|
||||
print(data)
|
||||
# Debug: log incoming WebSocket data
|
||||
try:
|
||||
parsed = json.loads(data)
|
||||
print("WS received JSON:", parsed)
|
||||
except Exception:
|
||||
print("WS received raw:", data)
|
||||
|
||||
# Forward JSON over ESPNow; split into multiple frames if > 250 bytes
|
||||
try:
|
||||
try:
|
||||
parsed = json.loads(data)
|
||||
chunks = split_espnow_message(parsed)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
chunks = [data]
|
||||
for i, chunk in enumerate(chunks):
|
||||
if i > 0:
|
||||
await asyncio.sleep_ms(100)
|
||||
await esp.send(chunk)
|
||||
except Exception:
|
||||
try:
|
||||
await ws.send(json.dumps({"error": "ESP-NOW send failed"}))
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
break
|
||||
|
||||
@@ -87,13 +122,23 @@ async def main(port=80):
|
||||
|
||||
server = asyncio.create_task(app.start_server(host="0.0.0.0", port=port))
|
||||
|
||||
wdt = machine.WDT(timeout=10000)
|
||||
wdt.feed()
|
||||
#wdt = machine.WDT(timeout=10000)
|
||||
#wdt.feed()
|
||||
|
||||
# Initialize heartbeat LED (XIAO ESP32S3 built-in LED on GPIO 21)
|
||||
|
||||
led = Pin(15, Pin.OUT)
|
||||
|
||||
|
||||
led_state = False
|
||||
|
||||
while True:
|
||||
gc.collect()
|
||||
for i in range(60):
|
||||
wdt.feed()
|
||||
#wdt.feed()
|
||||
# Heartbeat: toggle LED every 500 ms
|
||||
|
||||
led.value(not led.value())
|
||||
await asyncio.sleep_ms(500)
|
||||
# cleanup before ending the application
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
69
src/models/espnow.py
Normal file
69
src/models/espnow.py
Normal file
@@ -0,0 +1,69 @@
|
||||
import network
|
||||
|
||||
import aioespnow
|
||||
|
||||
|
||||
class ESPNow:
|
||||
"""
|
||||
Singleton ESPNow helper:
|
||||
- Manages a single AIOESPNow instance
|
||||
- Adds a single broadcast-like peer
|
||||
- Exposes async send(data) to send to that peer.
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if getattr(self, "_initialized", False):
|
||||
return
|
||||
|
||||
# ESP-NOW requires a WiFi interface to be active (STA or AP). Activate STA
|
||||
# so ESP-NOW has an interface to use; we don't need to connect to an AP.
|
||||
try:
|
||||
sta = network.WLAN(network.STA_IF)
|
||||
sta.active(True)
|
||||
except Exception as e:
|
||||
print("ESPNow: STA active failed:", e)
|
||||
|
||||
self._esp = aioespnow.AIOESPNow()
|
||||
self._esp.active(True)
|
||||
|
||||
try:
|
||||
self._esp.add_peer(b"\xff\xff\xff\xff\xff\xff")
|
||||
except Exception:
|
||||
# Ignore add_peer failures (e.g. duplicate)
|
||||
pass
|
||||
|
||||
self._initialized = True
|
||||
|
||||
|
||||
async def send(self, data):
|
||||
"""
|
||||
Async send to the broadcast peer.
|
||||
- data: bytes or str (JSON)
|
||||
"""
|
||||
if isinstance(data, str):
|
||||
payload = data.encode()
|
||||
else:
|
||||
payload = data
|
||||
|
||||
# Debug: show what we're sending and its size
|
||||
try:
|
||||
preview = payload.decode('utf-8')
|
||||
except Exception:
|
||||
preview = str(payload)
|
||||
if len(preview) > 200:
|
||||
preview = preview[:200] + "...(truncated)"
|
||||
print("ESPNow.send len=", len(payload), "payload=", preview)
|
||||
|
||||
try:
|
||||
await self._esp.asend(b"\xff\xff\xff\xff\xff\xff", payload)
|
||||
except Exception as e:
|
||||
print("ESPNow.send error:", e)
|
||||
raise
|
||||
|
||||
@@ -45,6 +45,12 @@ class Model(dict):
|
||||
j = json.dumps(self)
|
||||
with open(self.file, 'w') as file:
|
||||
file.write(j)
|
||||
file.flush() # Ensure data is written to buffer
|
||||
# Try to sync filesystem if available (MicroPython)
|
||||
try:
|
||||
os.sync()
|
||||
except (AttributeError, OSError):
|
||||
pass # os.sync() not available on all platforms
|
||||
print(f"{self.class_name} saved successfully to {self.file}")
|
||||
except Exception as e:
|
||||
print(f"Error saving {self.class_name} to {self.file}: {e}")
|
||||
@@ -53,11 +59,46 @@ class Model(dict):
|
||||
|
||||
def load(self):
|
||||
try:
|
||||
with open(self.file, 'r') as file:
|
||||
loaded_settings = json.load(file)
|
||||
self.update(loaded_settings)
|
||||
# Check if file exists first
|
||||
try:
|
||||
with open(self.file, 'r') as file:
|
||||
content = file.read().strip()
|
||||
except OSError:
|
||||
# File doesn't exist
|
||||
raise
|
||||
|
||||
if not content:
|
||||
# Empty file
|
||||
loaded_settings = {}
|
||||
else:
|
||||
# Parse JSON content
|
||||
loaded_settings = json.loads(content)
|
||||
|
||||
# Verify it's a dictionary
|
||||
if not isinstance(loaded_settings, dict):
|
||||
raise ValueError(f"File does not contain a dictionary, got {type(loaded_settings)}")
|
||||
|
||||
# Clear and update with loaded data
|
||||
# Clear first
|
||||
self.clear()
|
||||
# Manually copy items to avoid any update() method issues
|
||||
for key, value in loaded_settings.items():
|
||||
self[key] = value
|
||||
print(f"{self.class_name} loaded successfully.")
|
||||
except Exception as e:
|
||||
print(f"Error loading {self.class_name}")
|
||||
except OSError as e:
|
||||
# File doesn't exist yet - this is normal on first run
|
||||
# Create an empty file with defaults
|
||||
self.set_defaults()
|
||||
self.save()
|
||||
print(f"{self.class_name} initialized (new file created).")
|
||||
except ValueError:
|
||||
# JSON parsing error - file exists but is corrupted
|
||||
# Note: MicroPython uses ValueError for JSON errors, not JSONDecodeError
|
||||
print(f"Error loading {self.class_name}: Invalid JSON format. Resetting to defaults.")
|
||||
self.set_defaults()
|
||||
self.save()
|
||||
except Exception:
|
||||
# Other unexpected errors - avoid trying to format exception to prevent further errors
|
||||
print(f"Error loading {self.class_name}. Resetting to defaults.")
|
||||
self.set_defaults()
|
||||
self.save()
|
||||
|
||||
@@ -6,22 +6,30 @@ class Palette(Model):
|
||||
|
||||
def create(self, name="", colors=None):
|
||||
next_id = self.get_next_id()
|
||||
self[next_id] = {
|
||||
"name": name,
|
||||
"colors": colors if colors else []
|
||||
}
|
||||
# Store palette as a simple list of colors; name is ignored.
|
||||
self[next_id] = list(colors) if colors else []
|
||||
self.save()
|
||||
return next_id
|
||||
|
||||
def read(self, id):
|
||||
id_str = str(id)
|
||||
return self.get(id_str, None)
|
||||
value = self.get(id_str, None)
|
||||
# Backwards compatibility: if stored as {"colors": [...]}, unwrap.
|
||||
if isinstance(value, dict) and "colors" in value:
|
||||
return value.get("colors") or []
|
||||
# Otherwise, expect a list of colors.
|
||||
return value or []
|
||||
|
||||
def update(self, id, data):
|
||||
id_str = str(id)
|
||||
if id_str not in self:
|
||||
return False
|
||||
self[id_str].update(data)
|
||||
# Accept either {"colors": [...]} or a raw list.
|
||||
if isinstance(data, dict):
|
||||
colors = data.get("colors", [])
|
||||
else:
|
||||
colors = data
|
||||
self[id_str] = list(colors) if colors else []
|
||||
self.save()
|
||||
return True
|
||||
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
from models.model import Model
|
||||
from models.profile import Profile
|
||||
|
||||
class Preset(Model):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# Backfill profile ownership for existing presets.
|
||||
try:
|
||||
profiles = Profile()
|
||||
profile_list = profiles.list()
|
||||
default_profile_id = profile_list[0] if profile_list else None
|
||||
changed = False
|
||||
for preset_id, preset_data in list(self.items()):
|
||||
if isinstance(preset_data, dict) and "profile_id" not in preset_data:
|
||||
if default_profile_id is not None:
|
||||
preset_data["profile_id"] = str(default_profile_id)
|
||||
changed = True
|
||||
if changed:
|
||||
self.save()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def create(self):
|
||||
def create(self, profile_id=None):
|
||||
next_id = self.get_next_id()
|
||||
self[next_id] = {
|
||||
"name": "",
|
||||
@@ -20,6 +36,7 @@ class Preset(Model):
|
||||
"n6": 0,
|
||||
"n7": 0,
|
||||
"n8": 0,
|
||||
"profile_id": str(profile_id) if profile_id is not None else None,
|
||||
}
|
||||
self.save()
|
||||
return next_id
|
||||
|
||||
@@ -1,21 +1,45 @@
|
||||
from models.model import Model
|
||||
from models.pallet import Palette
|
||||
|
||||
|
||||
class Profile(Model):
|
||||
def __init__(self):
|
||||
"""Profile model.
|
||||
|
||||
Each profile owns a single, unique palette stored in the Palette model.
|
||||
The profile stores a `palette_id` that points to its palette; any legacy
|
||||
inline `palette` arrays are migrated to a dedicated Palette entry.
|
||||
"""
|
||||
super().__init__()
|
||||
self._palette_model = Palette()
|
||||
|
||||
# Migrate legacy inline palettes to separate Palette entries.
|
||||
changed = False
|
||||
for pid, pdata in list(self.items()):
|
||||
if isinstance(pdata, dict):
|
||||
if "palette" in pdata and "palette_id" not in pdata:
|
||||
colors = pdata.get("palette") or []
|
||||
palette_id = self._palette_model.create(colors=colors)
|
||||
pdata.pop("palette", None)
|
||||
pdata["palette_id"] = str(palette_id)
|
||||
changed = True
|
||||
if changed:
|
||||
self.save()
|
||||
|
||||
def create(self, name="", profile_type="tabs"):
|
||||
"""
|
||||
Create a new profile.
|
||||
"""Create a new profile and its own empty palette.
|
||||
|
||||
profile_type: "tabs" or "scenes" (ignoring scenes for now)
|
||||
"""
|
||||
next_id = self.get_next_id()
|
||||
# Create a unique palette for this profile.
|
||||
palette_id = self._palette_model.create(colors=[])
|
||||
self[next_id] = {
|
||||
"name": name,
|
||||
"type": profile_type, # "tabs" or "scenes"
|
||||
"tabs": [], # Array of tab IDs
|
||||
"scenes": [], # Array of scene IDs (for future use)
|
||||
"palette": []
|
||||
"palette_id": str(palette_id),
|
||||
}
|
||||
self.save()
|
||||
return next_id
|
||||
|
||||
@@ -9,7 +9,8 @@ class Tab(Model):
|
||||
self[next_id] = {
|
||||
"name": name,
|
||||
"names": names if names else [],
|
||||
"presets": presets if presets else []
|
||||
"presets": presets if presets else [],
|
||||
"default_preset": None
|
||||
}
|
||||
self.save()
|
||||
return next_id
|
||||
|
||||
0
src/profile.py
Normal file
0
src/profile.py
Normal file
@@ -12,6 +12,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
let currentProfileId = null;
|
||||
let currentPaletteId = null;
|
||||
let currentPalette = [];
|
||||
let currentProfileName = null;
|
||||
|
||||
@@ -84,7 +85,27 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return;
|
||||
}
|
||||
|
||||
currentPalette = profile.palette || profile.color_palette || [];
|
||||
// Prefer palette_id-based storage; fall back to legacy inline palette.
|
||||
currentPaletteId = profile.palette_id || profile.paletteId || null;
|
||||
if (currentPaletteId) {
|
||||
try {
|
||||
const palResponse = await fetch(`/palettes/${currentPaletteId}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (palResponse.ok) {
|
||||
const palData = await palResponse.json();
|
||||
currentPalette = (palData.colors) || [];
|
||||
} else {
|
||||
currentPalette = [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load palette by id:', e);
|
||||
currentPalette = [];
|
||||
}
|
||||
} else {
|
||||
// Legacy: palette stored directly on profile
|
||||
currentPalette = profile.palette || profile.color_palette || [];
|
||||
}
|
||||
renderPalette();
|
||||
} catch (error) {
|
||||
console.error('Failed to load palette:', error);
|
||||
@@ -99,17 +120,42 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/profiles/current', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
palette: newPalette,
|
||||
color_palette: newPalette,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save palette');
|
||||
// Ensure we have a palette ID for this profile.
|
||||
if (!currentPaletteId) {
|
||||
const createResponse = await fetch('/palettes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ colors: newPalette }),
|
||||
});
|
||||
if (!createResponse.ok) {
|
||||
throw new Error('Failed to create palette');
|
||||
}
|
||||
const pal = await createResponse.json();
|
||||
currentPaletteId = pal.id || Object.keys(pal)[0];
|
||||
|
||||
// Link the new palette to the current profile.
|
||||
const linkResponse = await fetch('/profiles/current', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
palette_id: currentPaletteId,
|
||||
}),
|
||||
});
|
||||
if (!linkResponse.ok) {
|
||||
throw new Error('Failed to link palette to profile');
|
||||
}
|
||||
} else {
|
||||
// Update existing palette colors
|
||||
const updateResponse = await fetch(`/palettes/${currentPaletteId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ colors: newPalette }),
|
||||
});
|
||||
if (!updateResponse.ok) {
|
||||
throw new Error('Failed to save palette');
|
||||
}
|
||||
}
|
||||
|
||||
currentPalette = newPalette;
|
||||
renderPalette();
|
||||
} catch (error) {
|
||||
|
||||
200
src/static/help.js
Normal file
200
src/static/help.js
Normal file
@@ -0,0 +1,200 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Help modal
|
||||
const helpBtn = document.getElementById('help-btn');
|
||||
const helpModal = document.getElementById('help-modal');
|
||||
const helpCloseBtn = document.getElementById('help-close-btn');
|
||||
const mainMenuBtn = document.getElementById('main-menu-btn');
|
||||
const mainMenuDropdown = document.getElementById('main-menu-dropdown');
|
||||
|
||||
if (helpBtn && helpModal) {
|
||||
helpBtn.addEventListener('click', () => {
|
||||
helpModal.classList.add('active');
|
||||
});
|
||||
}
|
||||
|
||||
if (helpCloseBtn && helpModal) {
|
||||
helpCloseBtn.addEventListener('click', () => {
|
||||
helpModal.classList.remove('active');
|
||||
});
|
||||
}
|
||||
|
||||
if (helpModal) {
|
||||
helpModal.addEventListener('click', (event) => {
|
||||
if (event.target === helpModal) {
|
||||
helpModal.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Mobile main menu: forward clicks to existing header buttons
|
||||
if (mainMenuBtn && mainMenuDropdown) {
|
||||
mainMenuBtn.addEventListener('click', () => {
|
||||
mainMenuDropdown.classList.toggle('open');
|
||||
});
|
||||
|
||||
mainMenuDropdown.addEventListener('click', (event) => {
|
||||
const target = event.target;
|
||||
if (target && target.matches('button[data-target]')) {
|
||||
const id = target.getAttribute('data-target');
|
||||
const realBtn = document.getElementById(id);
|
||||
if (realBtn) {
|
||||
realBtn.click();
|
||||
}
|
||||
mainMenuDropdown.classList.remove('open');
|
||||
}
|
||||
});
|
||||
|
||||
// Close menu when clicking outside
|
||||
document.addEventListener('click', (event) => {
|
||||
if (!mainMenuDropdown.contains(event.target) && event.target !== mainMenuBtn) {
|
||||
mainMenuDropdown.classList.remove('open');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Settings modal wiring (reusing existing settings endpoints).
|
||||
const settingsButton = document.getElementById('settings-btn');
|
||||
const settingsModal = document.getElementById('settings-modal');
|
||||
const settingsCloseButton = document.getElementById('settings-close-btn');
|
||||
|
||||
const showSettingsMessage = (text, type = 'success') => {
|
||||
const messageEl = document.getElementById('settings-message');
|
||||
if (!messageEl) return;
|
||||
messageEl.textContent = text;
|
||||
messageEl.className = `message ${type} show`;
|
||||
setTimeout(() => {
|
||||
messageEl.classList.remove('show');
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
async function loadDeviceSettings() {
|
||||
try {
|
||||
const response = await fetch('/settings');
|
||||
const data = await response.json();
|
||||
const nameInput = document.getElementById('device-name-input');
|
||||
if (nameInput && data && typeof data === 'object') {
|
||||
nameInput.value = data.device_name || 'led-controller';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading device settings:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAPStatus() {
|
||||
try {
|
||||
const response = await fetch('/settings/wifi/ap');
|
||||
const config = await response.json();
|
||||
const statusEl = document.getElementById('ap-status');
|
||||
if (!statusEl) return;
|
||||
if (config.active) {
|
||||
statusEl.innerHTML = `
|
||||
<h4>AP Status: <span class="status-connected">Active</span></h4>
|
||||
<p><strong>SSID:</strong> ${config.ssid || 'N/A'}</p>
|
||||
<p><strong>Channel:</strong> ${config.channel || 'Auto'}</p>
|
||||
<p><strong>IP Address:</strong> ${config.ip || 'N/A'}</p>
|
||||
`;
|
||||
} else {
|
||||
statusEl.innerHTML = `
|
||||
<h4>AP Status: <span class="status-disconnected">Inactive</span></h4>
|
||||
<p>Access Point is not currently active</p>
|
||||
`;
|
||||
}
|
||||
if (config.saved_ssid) document.getElementById('ap-ssid').value = config.saved_ssid;
|
||||
if (config.saved_channel) document.getElementById('ap-channel').value = config.saved_channel;
|
||||
} catch (error) {
|
||||
console.error('Error loading AP status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (settingsButton && settingsModal) {
|
||||
settingsButton.addEventListener('click', () => {
|
||||
settingsModal.classList.add('active');
|
||||
// Load current WiFi status/config when opening
|
||||
loadDeviceSettings();
|
||||
loadAPStatus();
|
||||
});
|
||||
}
|
||||
|
||||
if (settingsCloseButton && settingsModal) {
|
||||
settingsCloseButton.addEventListener('click', () => {
|
||||
settingsModal.classList.remove('active');
|
||||
});
|
||||
}
|
||||
|
||||
if (settingsModal) {
|
||||
settingsModal.addEventListener('click', (event) => {
|
||||
if (event.target === settingsModal) {
|
||||
settingsModal.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const deviceForm = document.getElementById('device-form');
|
||||
if (deviceForm) {
|
||||
deviceForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const nameInput = document.getElementById('device-name-input');
|
||||
const deviceName = nameInput ? nameInput.value.trim() : '';
|
||||
if (!deviceName) {
|
||||
showSettingsMessage('Device name is required', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/settings/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ device_name: deviceName }),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (response.ok) {
|
||||
showSettingsMessage('Device name saved. It will be used on next restart.', 'success');
|
||||
} else {
|
||||
showSettingsMessage(`Error: ${result.error || 'Failed to save device name'}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showSettingsMessage(`Error: ${error.message}`, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
const apForm = document.getElementById('ap-form');
|
||||
if (apForm) {
|
||||
apForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = {
|
||||
ssid: document.getElementById('ap-ssid').value,
|
||||
password: document.getElementById('ap-password').value,
|
||||
channel: document.getElementById('ap-channel').value || null,
|
||||
};
|
||||
|
||||
if (formData.password && formData.password.length > 0 && formData.password.length < 8) {
|
||||
showSettingsMessage('AP password must be at least 8 characters', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.channel) {
|
||||
formData.channel = parseInt(formData.channel, 10);
|
||||
if (formData.channel < 1 || formData.channel > 11) {
|
||||
showSettingsMessage('Channel must be between 1 and 11', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/settings/wifi/ap', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (response.ok) {
|
||||
showSettingsMessage('Access Point configured successfully!', 'success');
|
||||
setTimeout(loadAPStatus, 1000);
|
||||
} else {
|
||||
showSettingsMessage(`Error: ${result.error || 'Failed to configure AP'}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showSettingsMessage(`Error: ${error.message}`, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,7 +26,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
if (Array.isArray(profiles)) {
|
||||
entries = profiles.map((profileId) => [profileId, {}]);
|
||||
} else if (profiles && typeof profiles === "object") {
|
||||
entries = Object.entries(profiles);
|
||||
// Make sure we're iterating over profile entries, not metadata
|
||||
entries = Object.entries(profiles).filter(([key]) => {
|
||||
// Skip metadata keys like 'current_profile_id' if they exist
|
||||
return key !== 'current_profile_id' && key !== 'profiles';
|
||||
});
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
@@ -69,6 +73,70 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
});
|
||||
|
||||
const cloneButton = document.createElement("button");
|
||||
cloneButton.className = "btn btn-secondary btn-small";
|
||||
cloneButton.textContent = "Clone";
|
||||
cloneButton.addEventListener("click", async () => {
|
||||
const baseName = (profile && profile.name) || profileId;
|
||||
const suggested = `${baseName}`;
|
||||
const name = prompt("New profile name:", suggested);
|
||||
if (name === null) {
|
||||
return;
|
||||
}
|
||||
const trimmed = String(name).trim();
|
||||
if (!trimmed) {
|
||||
alert("Profile name cannot be empty.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/profiles/${profileId}/clone`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({ name: trimmed }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to clone profile");
|
||||
}
|
||||
const data = await response.json().catch(() => null);
|
||||
let newProfileId = null;
|
||||
if (data && typeof data === "object") {
|
||||
if (data.id) {
|
||||
newProfileId = String(data.id);
|
||||
} else {
|
||||
const ids = Object.keys(data);
|
||||
if (ids.length > 0) {
|
||||
newProfileId = String(ids[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (newProfileId) {
|
||||
await fetch(`/profiles/${newProfileId}/apply`, {
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
}
|
||||
document.cookie = "current_tab=; path=/; max-age=0";
|
||||
await loadProfiles();
|
||||
if (typeof window.loadTabs === "function") {
|
||||
await window.loadTabs();
|
||||
}
|
||||
if (typeof window.loadTabsModal === "function") {
|
||||
await window.loadTabsModal();
|
||||
}
|
||||
const tabContent = document.getElementById("tab-content");
|
||||
if (tabContent) {
|
||||
tabContent.innerHTML = `
|
||||
<div class="tab-content-placeholder">
|
||||
Select a tab to get started
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Clone profile failed:", error);
|
||||
alert("Failed to clone profile.");
|
||||
}
|
||||
});
|
||||
|
||||
const deleteButton = document.createElement("button");
|
||||
deleteButton.className = "btn btn-danger btn-small";
|
||||
deleteButton.textContent = "Delete";
|
||||
@@ -94,6 +162,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
row.appendChild(label);
|
||||
row.appendChild(applyButton);
|
||||
row.appendChild(cloneButton);
|
||||
row.appendChild(deleteButton);
|
||||
profilesList.appendChild(row);
|
||||
});
|
||||
@@ -113,19 +182,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load profiles");
|
||||
}
|
||||
const profiles = await response.json();
|
||||
let currentProfileId = null;
|
||||
try {
|
||||
const currentResponse = await fetch("/profiles/current", {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (currentResponse.ok) {
|
||||
const currentData = await currentResponse.json();
|
||||
currentProfileId = currentData.id || null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to load current profile:", error);
|
||||
}
|
||||
const data = await response.json();
|
||||
// Handle both old format (just profiles object) and new format (with current_profile_id)
|
||||
const profiles = data.profiles || data;
|
||||
const currentProfileId = data.current_profile_id || null;
|
||||
renderProfiles(profiles, currentProfileId);
|
||||
} catch (error) {
|
||||
console.error("Load profiles failed:", error);
|
||||
@@ -155,8 +215,44 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to create profile");
|
||||
}
|
||||
const data = await response.json().catch(() => null);
|
||||
let newProfileId = null;
|
||||
if (data && typeof data === "object") {
|
||||
if (data.id) {
|
||||
newProfileId = String(data.id);
|
||||
} else {
|
||||
const ids = Object.keys(data);
|
||||
if (ids.length > 0) {
|
||||
newProfileId = String(ids[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newProfileId) {
|
||||
await fetch(`/profiles/${newProfileId}/apply`, {
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
newProfileInput.value = "";
|
||||
// Clear current tab and refresh the UI so the new profile starts empty.
|
||||
document.cookie = "current_tab=; path=/; max-age=0";
|
||||
await loadProfiles();
|
||||
if (typeof window.loadTabs === "function") {
|
||||
await window.loadTabs();
|
||||
}
|
||||
if (typeof window.loadTabsModal === "function") {
|
||||
await window.loadTabsModal();
|
||||
}
|
||||
const tabContent = document.getElementById("tab-content");
|
||||
if (tabContent) {
|
||||
tabContent.innerHTML = `
|
||||
<div class="tab-content-placeholder">
|
||||
Select a tab to get started
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Create profile failed:", error);
|
||||
alert("Failed to create profile.");
|
||||
|
||||
@@ -20,25 +20,65 @@ body {
|
||||
|
||||
header {
|
||||
background-color: #1a1a1a;
|
||||
padding: 1rem 2rem;
|
||||
padding: 0.75rem 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 2px solid #4a4a4a;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.header-menu-mobile {
|
||||
display: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.main-menu-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
background-color: #1a1a1a;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 4px;
|
||||
padding: 0.25rem 0;
|
||||
display: none;
|
||||
min-width: 160px;
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
.main-menu-dropdown.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.main-menu-dropdown button {
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
text-align: left;
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.main-menu-dropdown button:hover {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
padding: 0.45rem 0.9rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
@@ -87,15 +127,22 @@ header h1 {
|
||||
}
|
||||
|
||||
.tabs-container {
|
||||
background-color: #1a1a1a;
|
||||
border-bottom: 2px solid #4a4a4a;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: transparent;
|
||||
padding: 0.5rem 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tabs-list {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.25rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
@@ -121,10 +168,27 @@ header h1 {
|
||||
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
display: block;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0.5rem 1rem 1rem;
|
||||
}
|
||||
|
||||
.presets-toolbar {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tab-brightness-group {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
padding: 1rem;
|
||||
gap: 1rem;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.25rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.tab-brightness-group label {
|
||||
white-space: nowrap;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.left-panel {
|
||||
@@ -356,6 +420,149 @@ header h1 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Make the presets area fill available vertical space; no border around presets */
|
||||
.presets-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: hidden;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Tab preset selecting area: 3 columns, vertical scroll only */
|
||||
#presets-list-tab {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-auto-rows: 5rem;
|
||||
column-gap: 0.3rem;
|
||||
row-gap: 0.3rem;
|
||||
align-content: start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Settings modal layout */
|
||||
.settings-section {
|
||||
background-color: #1a1a1a;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
border: 1px solid #4a4a4a;
|
||||
}
|
||||
|
||||
.settings-section h3 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: #fff;
|
||||
border-bottom: 1px solid #4a4a4a;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #ccc;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="password"],
|
||||
.form-group input[type="number"],
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
background-color: #2e2e2e;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
color: #888;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.status-info {
|
||||
background-color: #2e2e2e;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.status-info h3,
|
||||
.status-info h4 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.status-info p {
|
||||
color: #aaa;
|
||||
margin: 0.25rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status-connected {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.status-disconnected {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-full {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background-color: #1b5e20;
|
||||
color: #4caf50;
|
||||
border: 1px solid #4caf50;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background-color: #5e1b1b;
|
||||
color: #f44336;
|
||||
border: 1px solid #f44336;
|
||||
}
|
||||
|
||||
.message.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.patterns-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -363,21 +570,38 @@ header h1 {
|
||||
}
|
||||
|
||||
.presets-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pattern-button {
|
||||
padding: 0.75rem;
|
||||
height: 5rem;
|
||||
padding: 0 0.5rem;
|
||||
background-color: #3a3a3a;
|
||||
color: white;
|
||||
border: none;
|
||||
border: 3px solid #000;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
text-align: left;
|
||||
transition: background-color 0.2s;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Preset select buttons inside the tab grid */
|
||||
#presets-list-tab .pattern-button {
|
||||
display: flex;
|
||||
}
|
||||
.pattern-button .pattern-button-label {
|
||||
text-shadow: 0 0 2px rgba(0,0,0,0.8), 0 1px 2px rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
.pattern-button:hover {
|
||||
@@ -387,10 +611,28 @@ header h1 {
|
||||
.pattern-button.active {
|
||||
background-color: #6a5acd;
|
||||
color: white;
|
||||
border-color: #ffffff;
|
||||
}
|
||||
.pattern-button.active[style*="background-image"] {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.pattern-button.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -3px;
|
||||
border-radius: 7px;
|
||||
padding: 3px;
|
||||
pointer-events: none;
|
||||
background: #ffffff;
|
||||
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
||||
mask-composite: exclude;
|
||||
}
|
||||
|
||||
.pattern-button.default-preset {
|
||||
border: 2px solid #6a5acd;
|
||||
/* No border; active state shows selection */
|
||||
}
|
||||
|
||||
.color-palette {
|
||||
@@ -489,7 +731,7 @@ header h1 {
|
||||
background-color: #2e2e2e;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
min-width: 400px;
|
||||
min-width: 320px;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
@@ -546,3 +788,265 @@ header h1 {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
|
||||
/* Mobile-friendly layout */
|
||||
@media (max-width: 800px) {
|
||||
header {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
} header h1 {
|
||||
font-size: 1.1rem;
|
||||
} /* On mobile, hide header buttons; all actions (including Tabs) are in the Menu dropdown */
|
||||
.header-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-menu-mobile {
|
||||
display: block;
|
||||
margin-top: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
}
|
||||
|
||||
.tabs-container {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.left-panel {
|
||||
flex: 1;
|
||||
border-right: none;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
padding-left: 0;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Hide the "Presets for ..." heading to save space on mobile */
|
||||
.presets-section h3 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
min-width: 280px;
|
||||
max-width: 95vw;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Styles moved from inline <style> in templates/index.html */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0,0,0,0.7);
|
||||
}
|
||||
.modal.active {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal-content {
|
||||
background-color: #2e2e2e;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
min-width: 400px;
|
||||
max-width: 600px;
|
||||
}
|
||||
.modal-content label {
|
||||
display: block;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.modal-content input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
background-color: #3a3a3a;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
}
|
||||
.profiles-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.profiles-actions input[type="text"] {
|
||||
flex: 1;
|
||||
}
|
||||
.profiles-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.profiles-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background-color: #3a3a3a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
/* Hide any text content in palette rows - only show color swatches */
|
||||
#palette-container .profiles-row {
|
||||
font-size: 0; /* Hide any text nodes */
|
||||
}
|
||||
#palette-container .profiles-row > * {
|
||||
font-size: 1rem; /* Restore font size for buttons */
|
||||
}
|
||||
#palette-container .profiles-row > span:not(.btn),
|
||||
#palette-container .profiles-row > label,
|
||||
#palette-container .profiles-row::before,
|
||||
#palette-container .profiles-row::after {
|
||||
display: none !important;
|
||||
content: none !important;
|
||||
}
|
||||
/* Preset colors container */
|
||||
#preset-colors-container {
|
||||
min-height: 80px;
|
||||
padding: 0.5rem;
|
||||
background-color: #2a2a2a;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
#preset-colors-container .muted-text {
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
.muted-text {
|
||||
text-align: center;
|
||||
color: #888;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.error {
|
||||
color: #d32f2f;
|
||||
padding: 0.5rem;
|
||||
background-color: #3a1a1a;
|
||||
border-radius: 4px;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
/* Drag and drop styles for presets */
|
||||
.draggable-preset {
|
||||
cursor: move;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
}
|
||||
.draggable-preset.dragging {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
.draggable-preset:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
/* Drag and drop styles for color swatches */
|
||||
.draggable-color-swatch {
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
}
|
||||
.draggable-color-swatch.dragging-color {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
.draggable-color-swatch.drag-over-color {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.color-swatches-container {
|
||||
min-height: 80px;
|
||||
}
|
||||
/* Presets list: 3 columns and vertical scroll (defined above); mobile same */
|
||||
@media (max-width: 800px) {
|
||||
#presets-list-tab {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
/* Help modal readability */
|
||||
#help-modal .modal-content {
|
||||
max-width: 720px;
|
||||
line-height: 1.6;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
#help-modal .modal-content h2 {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
#help-modal .modal-content h3 {
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.4rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
#help-modal .modal-content p {
|
||||
text-align: left;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
#help-modal .modal-content ul {
|
||||
margin-top: 0.25rem;
|
||||
margin-left: 1.25rem;
|
||||
padding-left: 0;
|
||||
text-align: left;
|
||||
}
|
||||
#help-modal .modal-content li {
|
||||
margin: 0.2rem 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
#help-modal .muted-text {
|
||||
text-align: left;
|
||||
color: #bbb;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Tab content placeholder (no tab selected) */
|
||||
.tab-content-placeholder {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
/* Preset editor: color actions row */
|
||||
#preset-editor-modal .preset-colors-container + .profiles-actions {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Preset editor: brightness/delay field wrappers */
|
||||
.preset-editor-field {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Settings modal */
|
||||
#settings-modal .modal-content {
|
||||
max-width: 900px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}#settings-modal .modal-content > p.muted-text {
|
||||
margin-bottom: 1rem;
|
||||
}#settings-modal .settings-section.ap-settings-section {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
809
src/static/tabs.js
Normal file
809
src/static/tabs.js
Normal file
@@ -0,0 +1,809 @@
|
||||
// Tab management JavaScript
|
||||
let currentTabId = null;
|
||||
|
||||
// Get current tab from cookie
|
||||
function getCurrentTabFromCookie() {
|
||||
const cookies = document.cookie.split(';');
|
||||
for (let cookie of cookies) {
|
||||
const [name, value] = cookie.trim().split('=');
|
||||
if (name === 'current_tab') {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Load tabs list
|
||||
async function loadTabs() {
|
||||
try {
|
||||
const response = await fetch('/tabs');
|
||||
const data = await response.json();
|
||||
|
||||
// Get current tab from cookie first, then from server response
|
||||
const cookieTabId = getCurrentTabFromCookie();
|
||||
const serverCurrent = data.current_tab_id;
|
||||
const tabs = data.tabs || {};
|
||||
const tabIds = Object.keys(tabs);
|
||||
|
||||
let candidateId = cookieTabId || serverCurrent || null;
|
||||
// If the candidate doesn't exist anymore (e.g. after DB reset), fall back to first tab.
|
||||
if (candidateId && !tabIds.includes(String(candidateId))) {
|
||||
candidateId = tabIds.length > 0 ? tabIds[0] : null;
|
||||
// Clear stale cookie
|
||||
document.cookie = 'current_tab=; path=/; max-age=0';
|
||||
}
|
||||
|
||||
currentTabId = candidateId;
|
||||
renderTabsList(data.tabs, data.tab_order, currentTabId);
|
||||
|
||||
// Load current tab content if available
|
||||
if (currentTabId) {
|
||||
loadTabContent(currentTabId);
|
||||
} else if (data.tab_order && data.tab_order.length > 0) {
|
||||
// Set first tab as current if none is set
|
||||
await setCurrentTab(data.tab_order[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load tabs:', error);
|
||||
const container = document.getElementById('tabs-list');
|
||||
if (container) {
|
||||
container.innerHTML = '<div class="error">Failed to load tabs</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render tabs list in the main UI
|
||||
function renderTabsList(tabs, tabOrder, currentTabId) {
|
||||
const container = document.getElementById('tabs-list');
|
||||
if (!container) return;
|
||||
|
||||
if (!tabOrder || tabOrder.length === 0) {
|
||||
container.innerHTML = '<div class="muted-text">No tabs available</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="tabs-list">';
|
||||
for (const tabId of tabOrder) {
|
||||
const tab = tabs[tabId];
|
||||
if (tab) {
|
||||
const activeClass = tabId === currentTabId ? 'active' : '';
|
||||
const tabName = tab.name || `Tab ${tabId}`;
|
||||
html += `
|
||||
<button class="tab-button ${activeClass}"
|
||||
data-tab-id="${tabId}"
|
||||
title="Click to select, right-click to edit"
|
||||
onclick="selectTab('${tabId}')">
|
||||
${tabName}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// Render tabs list in modal (like profiles)
|
||||
function renderTabsListModal(tabs, tabOrder, currentTabId) {
|
||||
const container = document.getElementById('tabs-list-modal');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = "";
|
||||
let entries = [];
|
||||
|
||||
if (Array.isArray(tabOrder)) {
|
||||
entries = tabOrder.map((tabId) => [tabId, tabs[tabId] || {}]);
|
||||
} else if (tabs && typeof tabs === "object") {
|
||||
entries = Object.entries(tabs).filter(([key]) => {
|
||||
return key !== 'current_tab_id' && key !== 'tabs' && key !== 'tab_order';
|
||||
});
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "muted-text";
|
||||
empty.textContent = "No tabs found.";
|
||||
container.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
entries.forEach(([tabId, tab]) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "profiles-row";
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.textContent = (tab && tab.name) || tabId;
|
||||
if (String(tabId) === String(currentTabId)) {
|
||||
label.textContent = `✓ ${label.textContent}`;
|
||||
label.style.fontWeight = "bold";
|
||||
label.style.color = "#FFD700";
|
||||
}
|
||||
|
||||
const applyButton = document.createElement("button");
|
||||
applyButton.className = "btn btn-secondary btn-small";
|
||||
applyButton.textContent = "Select";
|
||||
applyButton.addEventListener("click", async () => {
|
||||
await selectTab(tabId);
|
||||
document.getElementById('tabs-modal').classList.remove('active');
|
||||
});
|
||||
|
||||
const editButton = document.createElement("button");
|
||||
editButton.className = "btn btn-secondary btn-small";
|
||||
editButton.textContent = "Edit";
|
||||
editButton.addEventListener("click", () => {
|
||||
openEditTabModal(tabId, tab);
|
||||
});
|
||||
|
||||
const sendPresetsButton = document.createElement("button");
|
||||
sendPresetsButton.className = "btn btn-secondary btn-small";
|
||||
sendPresetsButton.textContent = "Send Presets";
|
||||
sendPresetsButton.addEventListener("click", async () => {
|
||||
await sendTabPresets(tabId);
|
||||
});
|
||||
|
||||
const cloneButton = document.createElement("button");
|
||||
cloneButton.className = "btn btn-secondary btn-small";
|
||||
cloneButton.textContent = "Clone";
|
||||
cloneButton.addEventListener("click", async () => {
|
||||
const baseName = (tab && tab.name) || tabId;
|
||||
const suggested = `${baseName} Copy`;
|
||||
const name = prompt("New tab name:", suggested);
|
||||
if (name === null) {
|
||||
return;
|
||||
}
|
||||
const trimmed = String(name).trim();
|
||||
if (!trimmed) {
|
||||
alert("Tab name cannot be empty.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/tabs/${tabId}/clone`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ name: trimmed }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: "Failed to clone tab" }));
|
||||
throw new Error(errorData.error || "Failed to clone tab");
|
||||
}
|
||||
const data = await response.json().catch(() => null);
|
||||
let newTabId = null;
|
||||
if (data && typeof data === "object") {
|
||||
if (data.id) {
|
||||
newTabId = String(data.id);
|
||||
} else {
|
||||
const ids = Object.keys(data);
|
||||
if (ids.length > 0) {
|
||||
newTabId = String(ids[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
await loadTabsModal();
|
||||
if (newTabId) {
|
||||
await selectTab(newTabId);
|
||||
} else {
|
||||
await loadTabs();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Clone tab failed:", error);
|
||||
alert("Failed to clone tab: " + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
const deleteButton = document.createElement("button");
|
||||
deleteButton.className = "btn btn-danger btn-small";
|
||||
deleteButton.textContent = "Delete";
|
||||
deleteButton.addEventListener("click", async () => {
|
||||
const confirmed = confirm(`Delete tab "${label.textContent}"?`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/tabs/${tabId}`, {
|
||||
method: "DELETE",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: "Failed to delete tab" }));
|
||||
throw new Error(errorData.error || "Failed to delete tab");
|
||||
}
|
||||
// Clear cookie if deleted tab was current
|
||||
if (tabId === currentTabId) {
|
||||
document.cookie = 'current_tab=; path=/; max-age=0';
|
||||
currentTabId = null;
|
||||
}
|
||||
await loadTabsModal();
|
||||
await loadTabs(); // Reload main tabs list
|
||||
} catch (error) {
|
||||
console.error("Delete tab failed:", error);
|
||||
alert("Failed to delete tab: " + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
row.appendChild(label);
|
||||
row.appendChild(applyButton);
|
||||
row.appendChild(editButton);
|
||||
row.appendChild(sendPresetsButton);
|
||||
row.appendChild(cloneButton);
|
||||
row.appendChild(deleteButton);
|
||||
container.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// Load tabs in modal
|
||||
async function loadTabsModal() {
|
||||
const container = document.getElementById('tabs-list-modal');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = "";
|
||||
const loading = document.createElement("p");
|
||||
loading.className = "muted-text";
|
||||
loading.textContent = "Loading tabs...";
|
||||
container.appendChild(loading);
|
||||
|
||||
try {
|
||||
const response = await fetch("/tabs", {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load tabs");
|
||||
}
|
||||
const data = await response.json();
|
||||
const tabs = data.tabs || data;
|
||||
const currentTabId = getCurrentTabFromCookie() || data.current_tab_id || null;
|
||||
renderTabsListModal(tabs, data.tab_order || [], currentTabId);
|
||||
} catch (error) {
|
||||
console.error("Load tabs failed:", error);
|
||||
container.innerHTML = "";
|
||||
const errorMessage = document.createElement("p");
|
||||
errorMessage.className = "muted-text";
|
||||
errorMessage.textContent = "Failed to load tabs.";
|
||||
container.appendChild(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// Select a tab
|
||||
async function selectTab(tabId) {
|
||||
// Update active state
|
||||
document.querySelectorAll('.tab-button').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
const btn = document.querySelector(`[data-tab-id="${tabId}"]`);
|
||||
if (btn) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
|
||||
// Set as current tab
|
||||
await setCurrentTab(tabId);
|
||||
// Load tab content
|
||||
loadTabContent(tabId);
|
||||
}
|
||||
|
||||
// Set current tab in cookie
|
||||
async function setCurrentTab(tabId) {
|
||||
try {
|
||||
const response = await fetch(`/tabs/${tabId}/set-current`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
currentTabId = tabId;
|
||||
// Also set cookie on client side
|
||||
document.cookie = `current_tab=${tabId}; path=/; max-age=31536000`;
|
||||
} else {
|
||||
console.error('Failed to set current tab:', data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error setting current tab:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load tab content
|
||||
async function loadTabContent(tabId) {
|
||||
const container = document.getElementById('tab-content');
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/tabs/${tabId}`);
|
||||
const tab = await response.json();
|
||||
|
||||
if (tab.error) {
|
||||
container.innerHTML = `<div class="error">${tab.error}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Render tab content (presets section)
|
||||
const tabName = tab.name || `Tab ${tabId}`;
|
||||
const deviceNames = Array.isArray(tab.names) ? tab.names.join(',') : '';
|
||||
container.innerHTML = `
|
||||
<div class="presets-section" data-tab-id="${tabId}" data-device-names="${deviceNames}">
|
||||
<div class="profiles-actions presets-toolbar" style="margin-bottom: 1rem;">
|
||||
<div class="tab-brightness-group">
|
||||
<label for="tab-brightness-slider">Brightness</label>
|
||||
<input type="range" id="tab-brightness-slider" min="0" max="255" value="255">
|
||||
</div>
|
||||
</div>
|
||||
<div id="presets-list-tab" class="presets-list">
|
||||
<!-- Presets will be loaded here by presets.js -->
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Wire up per-tab brightness slider to send global brightness via ESPNow.
|
||||
const brightnessSlider = container.querySelector('#tab-brightness-slider');
|
||||
let brightnessSendTimeout = null;
|
||||
if (brightnessSlider) {
|
||||
brightnessSlider.addEventListener('input', (e) => {
|
||||
const val = parseInt(e.target.value, 10) || 0;
|
||||
if (brightnessSendTimeout) {
|
||||
clearTimeout(brightnessSendTimeout);
|
||||
}
|
||||
brightnessSendTimeout = setTimeout(() => {
|
||||
if (typeof window.sendEspnowRaw === 'function') {
|
||||
try {
|
||||
window.sendEspnowRaw({ v: '1', b: val });
|
||||
} catch (err) {
|
||||
console.error('Failed to send brightness via ESPNow:', err);
|
||||
}
|
||||
}
|
||||
}, 150);
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger presets loading if the function exists
|
||||
if (typeof renderTabPresets === 'function') {
|
||||
renderTabPresets(tabId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load tab content:', error);
|
||||
container.innerHTML = '<div class="error">Failed to load tab content</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Send all presets used by a tab via the /presets/send HTTP endpoint.
|
||||
async function sendTabPresets(tabId) {
|
||||
try {
|
||||
// Load tab data to determine which presets are used
|
||||
const tabResponse = await fetch(`/tabs/${tabId}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!tabResponse.ok) {
|
||||
alert('Failed to load tab to send presets.');
|
||||
return;
|
||||
}
|
||||
const tabData = await tabResponse.json();
|
||||
|
||||
// Extract preset IDs from tab (supports grid, flat, and legacy formats)
|
||||
let presetIds = [];
|
||||
if (Array.isArray(tabData.presets_flat)) {
|
||||
presetIds = tabData.presets_flat;
|
||||
} else if (Array.isArray(tabData.presets)) {
|
||||
if (tabData.presets.length && typeof tabData.presets[0] === 'string') {
|
||||
// Flat array of IDs
|
||||
presetIds = tabData.presets;
|
||||
} else if (tabData.presets.length && Array.isArray(tabData.presets[0])) {
|
||||
// 2D grid
|
||||
presetIds = tabData.presets.flat();
|
||||
}
|
||||
}
|
||||
presetIds = (presetIds || []).filter(Boolean);
|
||||
|
||||
if (!presetIds.length) {
|
||||
alert('This tab has no presets to send.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Call server-side ESPNow sender with just the IDs; it handles chunking.
|
||||
const payload = { preset_ids: presetIds };
|
||||
if (tabData.default_preset) {
|
||||
payload.default = tabData.default_preset;
|
||||
}
|
||||
const response = await fetch('/presets/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const msg = (data && data.error) || 'Failed to send presets.';
|
||||
alert(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
const sent = typeof data.presets_sent === 'number' ? data.presets_sent : presetIds.length;
|
||||
const messages = typeof data.messages_sent === 'number' ? data.messages_sent : '?';
|
||||
alert(`Sent ${sent} preset(s) in ${messages} ESPNow message(s).`);
|
||||
} catch (error) {
|
||||
console.error('Failed to send tab presets:', error);
|
||||
alert('Failed to send tab presets.');
|
||||
}
|
||||
}
|
||||
|
||||
// Send all presets used by all tabs in the current profile via /presets/send.
|
||||
async function sendProfilePresets() {
|
||||
try {
|
||||
// Load current profile to get its tabs
|
||||
const profileRes = await fetch('/profiles/current', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!profileRes.ok) {
|
||||
alert('Failed to load current profile.');
|
||||
return;
|
||||
}
|
||||
const profileData = await profileRes.json();
|
||||
const profile = profileData.profile || {};
|
||||
let tabList = null;
|
||||
if (Array.isArray(profile.tabs)) {
|
||||
tabList = profile.tabs;
|
||||
} else if (profile.tabs) {
|
||||
tabList = [profile.tabs];
|
||||
}
|
||||
if (!tabList || tabList.length === 0) {
|
||||
if (Array.isArray(profile.tab_order)) {
|
||||
tabList = profile.tab_order;
|
||||
} else if (profile.tab_order) {
|
||||
tabList = [profile.tab_order];
|
||||
} else {
|
||||
tabList = [];
|
||||
}
|
||||
}
|
||||
if (!tabList || tabList.length === 0) {
|
||||
console.warn('sendProfilePresets: no tabs found', {
|
||||
profileData,
|
||||
profile,
|
||||
});
|
||||
}
|
||||
|
||||
if (!tabList.length) {
|
||||
alert('Current profile has no tabs to send presets for.');
|
||||
return;
|
||||
}
|
||||
|
||||
let totalSent = 0;
|
||||
let totalMessages = 0;
|
||||
let tabsWithPresets = 0;
|
||||
|
||||
for (const tabId of tabList) {
|
||||
try {
|
||||
const tabResp = await fetch(`/tabs/${tabId}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!tabResp.ok) {
|
||||
continue;
|
||||
}
|
||||
const tabData = await tabResp.json();
|
||||
let presetIds = [];
|
||||
if (Array.isArray(tabData.presets_flat)) {
|
||||
presetIds = tabData.presets_flat;
|
||||
} else if (Array.isArray(tabData.presets)) {
|
||||
if (tabData.presets.length && typeof tabData.presets[0] === 'string') {
|
||||
presetIds = tabData.presets;
|
||||
} else if (tabData.presets.length && Array.isArray(tabData.presets[0])) {
|
||||
presetIds = tabData.presets.flat();
|
||||
}
|
||||
}
|
||||
presetIds = (presetIds || []).filter(Boolean);
|
||||
if (!presetIds.length) {
|
||||
continue;
|
||||
}
|
||||
tabsWithPresets += 1;
|
||||
const payload = { preset_ids: presetIds };
|
||||
if (tabData.default_preset) {
|
||||
payload.default = tabData.default_preset;
|
||||
}
|
||||
const response = await fetch('/presets/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const msg = (data && data.error) || `Failed to send presets for tab ${tabId}.`;
|
||||
console.warn(msg);
|
||||
continue;
|
||||
}
|
||||
totalSent += typeof data.presets_sent === 'number' ? data.presets_sent : presetIds.length;
|
||||
totalMessages += typeof data.messages_sent === 'number' ? data.messages_sent : 0;
|
||||
} catch (e) {
|
||||
console.error('Failed to send profile presets for tab:', tabId, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!tabsWithPresets) {
|
||||
alert('No presets to send for the current profile.');
|
||||
return;
|
||||
}
|
||||
|
||||
const messagesLabel = totalMessages ? totalMessages : '?';
|
||||
alert(`Sent ${totalSent} preset(s) across ${tabsWithPresets} tab(s) in ${messagesLabel} ESPNow message(s).`);
|
||||
} catch (error) {
|
||||
console.error('Failed to send profile presets:', error);
|
||||
alert('Failed to send profile presets.');
|
||||
}
|
||||
}
|
||||
|
||||
// Populate the "Add presets to this tab" list: only presets NOT already in the tab, each with a Select button.
|
||||
async function populateEditTabPresetsList(tabId) {
|
||||
const listEl = document.getElementById('edit-tab-presets-list');
|
||||
if (!listEl) return;
|
||||
listEl.innerHTML = '<span class="muted-text">Loading…</span>';
|
||||
try {
|
||||
const tabRes = await fetch(`/tabs/${tabId}`, { headers: { Accept: 'application/json' } });
|
||||
if (!tabRes.ok) {
|
||||
listEl.innerHTML = '<span class="muted-text">Failed to load presets.</span>';
|
||||
return;
|
||||
}
|
||||
const tabData = await tabRes.json();
|
||||
let inTabIds = [];
|
||||
if (Array.isArray(tabData.presets_flat)) {
|
||||
inTabIds = tabData.presets_flat;
|
||||
} else if (Array.isArray(tabData.presets)) {
|
||||
if (tabData.presets.length && typeof tabData.presets[0] === 'string') {
|
||||
inTabIds = tabData.presets;
|
||||
} else if (tabData.presets.length && Array.isArray(tabData.presets[0])) {
|
||||
inTabIds = tabData.presets.flat();
|
||||
}
|
||||
}
|
||||
const presetsRes = await fetch('/presets', { headers: { Accept: 'application/json' } });
|
||||
const allPresets = presetsRes.ok ? await presetsRes.json() : {};
|
||||
const allIds = Object.keys(allPresets);
|
||||
const availableToAdd = allIds.filter(id => !inTabIds.includes(id));
|
||||
listEl.innerHTML = '';
|
||||
if (availableToAdd.length === 0) {
|
||||
listEl.innerHTML = '<span class="muted-text">No presets to add. All presets are already in this tab.</span>';
|
||||
return;
|
||||
}
|
||||
for (const presetId of availableToAdd) {
|
||||
const preset = allPresets[presetId] || {};
|
||||
const name = preset.name || presetId;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'profiles-row';
|
||||
row.style.display = 'flex';
|
||||
row.style.alignItems = 'center';
|
||||
row.style.justifyContent = 'space-between';
|
||||
row.style.gap = '0.5rem';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = name;
|
||||
const selectBtn = document.createElement('button');
|
||||
selectBtn.type = 'button';
|
||||
selectBtn.className = 'btn btn-primary btn-small';
|
||||
selectBtn.textContent = 'Select';
|
||||
selectBtn.addEventListener('click', async () => {
|
||||
if (typeof window.addPresetToTab === 'function') {
|
||||
await window.addPresetToTab(presetId, tabId);
|
||||
await populateEditTabPresetsList(tabId);
|
||||
}
|
||||
});
|
||||
row.appendChild(label);
|
||||
row.appendChild(selectBtn);
|
||||
listEl.appendChild(row);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('populateEditTabPresetsList:', e);
|
||||
listEl.innerHTML = '<span class="muted-text">Failed to load presets.</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// Open edit tab modal
|
||||
function openEditTabModal(tabId, tab) {
|
||||
const modal = document.getElementById('edit-tab-modal');
|
||||
const idInput = document.getElementById('edit-tab-id');
|
||||
const nameInput = document.getElementById('edit-tab-name');
|
||||
const idsInput = document.getElementById('edit-tab-ids');
|
||||
|
||||
if (idInput) idInput.value = tabId;
|
||||
if (nameInput) nameInput.value = tab ? (tab.name || '') : '';
|
||||
if (idsInput) idsInput.value = tab && tab.names ? tab.names.join(', ') : '1';
|
||||
|
||||
if (modal) modal.classList.add('active');
|
||||
populateEditTabPresetsList(tabId);
|
||||
}
|
||||
|
||||
// Update an existing tab
|
||||
async function updateTab(tabId, name, ids) {
|
||||
try {
|
||||
const names = ids ? ids.split(',').map(id => id.trim()) : ['1'];
|
||||
const response = await fetch(`/tabs/${tabId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
names: names
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
// Reload tabs list
|
||||
await loadTabsModal();
|
||||
await loadTabs();
|
||||
// Close modal
|
||||
document.getElementById('edit-tab-modal').classList.remove('active');
|
||||
return true;
|
||||
} else {
|
||||
alert(`Error: ${data.error || 'Failed to update tab'}`);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update tab:', error);
|
||||
alert('Failed to update tab');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new tab
|
||||
async function createTab(name, ids) {
|
||||
try {
|
||||
const names = ids ? ids.split(',').map(id => id.trim()) : ['1'];
|
||||
const response = await fetch('/tabs', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
names: names
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
// Reload tabs list
|
||||
await loadTabsModal();
|
||||
await loadTabs();
|
||||
// Select the new tab
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
const newTabId = Object.keys(data)[0];
|
||||
await selectTab(newTabId);
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
alert(`Error: ${data.error || 'Failed to create tab'}`);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create tab:', error);
|
||||
alert('Failed to create tab');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadTabs();
|
||||
|
||||
// Set up tabs modal
|
||||
const tabsButton = document.getElementById('tabs-btn');
|
||||
const tabsModal = document.getElementById('tabs-modal');
|
||||
const tabsCloseButton = document.getElementById('tabs-close-btn');
|
||||
const newTabNameInput = document.getElementById('new-tab-name');
|
||||
const newTabIdsInput = document.getElementById('new-tab-ids');
|
||||
const createTabButton = document.getElementById('create-tab-btn');
|
||||
|
||||
if (tabsButton && tabsModal) {
|
||||
tabsButton.addEventListener('click', () => {
|
||||
tabsModal.classList.add('active');
|
||||
loadTabsModal();
|
||||
});
|
||||
}
|
||||
|
||||
if (tabsCloseButton) {
|
||||
tabsCloseButton.addEventListener('click', () => {
|
||||
tabsModal.classList.remove('active');
|
||||
});
|
||||
}
|
||||
|
||||
if (tabsModal) {
|
||||
tabsModal.addEventListener('click', (event) => {
|
||||
if (event.target === tabsModal) {
|
||||
tabsModal.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Right-click on a tab button in the main header bar to edit that tab
|
||||
document.addEventListener('contextmenu', async (event) => {
|
||||
const btn = event.target.closest('.tab-button');
|
||||
if (!btn || !btn.dataset.tabId) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const tabId = btn.dataset.tabId;
|
||||
try {
|
||||
const response = await fetch(`/tabs/${tabId}`);
|
||||
if (response.ok) {
|
||||
const tab = await response.json();
|
||||
openEditTabModal(tabId, tab);
|
||||
} else {
|
||||
alert('Failed to load tab for editing');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load tab:', error);
|
||||
alert('Failed to load tab for editing');
|
||||
}
|
||||
});
|
||||
|
||||
// Set up create tab
|
||||
const createTabHandler = async () => {
|
||||
if (!newTabNameInput) return;
|
||||
const name = newTabNameInput.value.trim();
|
||||
const ids = (newTabIdsInput && newTabIdsInput.value.trim()) || '1';
|
||||
|
||||
if (name) {
|
||||
await createTab(name, ids);
|
||||
if (newTabNameInput) newTabNameInput.value = '';
|
||||
if (newTabIdsInput) newTabIdsInput.value = '1';
|
||||
}
|
||||
};
|
||||
|
||||
if (createTabButton) {
|
||||
createTabButton.addEventListener('click', createTabHandler);
|
||||
}
|
||||
|
||||
if (newTabNameInput) {
|
||||
newTabNameInput.addEventListener('keypress', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
createTabHandler();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Set up edit tab form
|
||||
const editTabForm = document.getElementById('edit-tab-form');
|
||||
if (editTabForm) {
|
||||
editTabForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const idInput = document.getElementById('edit-tab-id');
|
||||
const nameInput = document.getElementById('edit-tab-name');
|
||||
const idsInput = document.getElementById('edit-tab-ids');
|
||||
|
||||
const tabId = idInput ? idInput.value : null;
|
||||
const name = nameInput ? nameInput.value.trim() : '';
|
||||
const ids = idsInput ? idsInput.value.trim() : '1';
|
||||
|
||||
if (tabId && name) {
|
||||
await updateTab(tabId, name, ids);
|
||||
editTabForm.reset();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Close edit modal when clicking outside
|
||||
const editTabModal = document.getElementById('edit-tab-modal');
|
||||
if (editTabModal) {
|
||||
editTabModal.addEventListener('click', (event) => {
|
||||
if (event.target === editTabModal) {
|
||||
editTabModal.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Profile-wide "Send Presets" button in header
|
||||
const sendProfilePresetsBtn = document.getElementById('send-profile-presets-btn');
|
||||
if (sendProfilePresetsBtn) {
|
||||
sendProfilePresetsBtn.addEventListener('click', async () => {
|
||||
await sendProfilePresets();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Export for use in other scripts
|
||||
window.tabsManager = {
|
||||
loadTabs,
|
||||
selectTab,
|
||||
createTab,
|
||||
updateTab,
|
||||
openEditTabModal,
|
||||
getCurrentTabId: () => currentTabId
|
||||
};
|
||||
@@ -5,88 +5,82 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LED Controller - Tab Mode</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<header>
|
||||
<h1>LED Controller - Tab Mode</h1>
|
||||
<div class="tabs-container">
|
||||
<div id="tabs-list">
|
||||
Loading tabs...
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-primary"
|
||||
hx-get="/tabs/create-form-fragment"
|
||||
hx-target="#add-tab-modal .modal-content"
|
||||
hx-swap="innerHTML"
|
||||
onclick="document.getElementById('add-tab-modal').classList.add('active')">
|
||||
+ Add Tab
|
||||
</button>
|
||||
<button class="btn btn-secondary" id="edit-tab-btn">Edit Tab</button>
|
||||
<button class="btn btn-danger"
|
||||
hx-delete="/tabs/current"
|
||||
hx-target="#tabs-list"
|
||||
hx-swap="innerHTML"
|
||||
hx-headers='{"Accept": "text/html"}'
|
||||
hx-confirm="Are you sure you want to delete this tab?">
|
||||
Delete Tab
|
||||
</button>
|
||||
<button class="btn btn-secondary" id="tabs-btn">Tabs</button>
|
||||
<button class="btn btn-secondary" id="color-palette-btn">Color Palette</button>
|
||||
<button class="btn btn-secondary" id="presets-btn">Presets</button>
|
||||
<button class="btn btn-secondary" id="send-profile-presets-btn">Send Presets</button>
|
||||
<button class="btn btn-secondary" id="patterns-btn">Patterns</button>
|
||||
<button class="btn btn-secondary" id="profiles-btn">Profiles</button>
|
||||
<button class="btn btn-secondary" id="settings-btn">Settings</button>
|
||||
<button class="btn btn-secondary" id="help-btn">Help</button>
|
||||
</div>
|
||||
<div class="header-menu-mobile">
|
||||
<button class="btn btn-secondary" id="main-menu-btn">Menu</button>
|
||||
<div id="main-menu-dropdown" class="main-menu-dropdown">
|
||||
<button type="button" data-target="tabs-btn">Tabs</button>
|
||||
<button type="button" data-target="color-palette-btn">Color Palette</button>
|
||||
<button type="button" data-target="presets-btn">Presets</button>
|
||||
<button type="button" data-target="send-profile-presets-btn">Send Presets</button>
|
||||
<button type="button" data-target="patterns-btn">Patterns</button>
|
||||
<button type="button" data-target="profiles-btn">Profiles</button>
|
||||
<button type="button" data-target="settings-btn">Settings</button>
|
||||
<button type="button" data-target="help-btn">Help</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="main-content">
|
||||
<div class="tabs-container">
|
||||
<div id="tabs-list"
|
||||
hx-get="/tabs/list-fragment"
|
||||
hx-trigger="load, tabs-updated from:body"
|
||||
hx-swap="innerHTML">
|
||||
Loading tabs...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-content"
|
||||
class="tab-content"
|
||||
hx-get="/tabs/current"
|
||||
hx-trigger="load, tabs-updated from:body"
|
||||
hx-swap="innerHTML"
|
||||
hx-headers='{"Accept": "text/html"}'>
|
||||
<div style="padding: 2rem; text-align: center; color: #aaa;">
|
||||
<div id="tab-content" class="tab-content">
|
||||
<div class="tab-content-placeholder">
|
||||
Select a tab to get started
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Tab Modal -->
|
||||
<div id="add-tab-modal" class="modal">
|
||||
<!-- Tabs Modal -->
|
||||
<div id="tabs-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Add New Tab</h2>
|
||||
<form hx-post="/tabs"
|
||||
hx-target="#tabs-list"
|
||||
hx-swap="innerHTML"
|
||||
hx-headers='{"Accept": "text/html"}'
|
||||
hx-on::after-request="if(event.detail.successful) { document.getElementById('add-tab-modal').classList.remove('active'); document.body.dispatchEvent(new Event('tabs-updated')); }">
|
||||
<label>Tab Name:</label>
|
||||
<input type="text" name="name" placeholder="Enter tab name" required>
|
||||
<label>Device IDs (comma-separated):</label>
|
||||
<input type="text" name="ids" placeholder="1,2,3" value="1">
|
||||
<div class="modal-actions">
|
||||
<button type="submit" class="btn btn-primary">Add</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="document.getElementById('add-tab-modal').classList.remove('active')">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
<h2>Tabs</h2>
|
||||
<div class="profiles-actions">
|
||||
<input type="text" id="new-tab-name" placeholder="Tab name">
|
||||
<input type="text" id="new-tab-ids" placeholder="Device IDs (1,2,3)" value="1">
|
||||
<button class="btn btn-primary" id="create-tab-btn">Create</button>
|
||||
</div>
|
||||
<div id="tabs-list-modal" class="profiles-list"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" id="tabs-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Tab Modal (placeholder for now) -->
|
||||
<!-- Edit Tab Modal -->
|
||||
<div id="edit-tab-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Edit Tab</h2>
|
||||
<p>Edit functionality coming soon...</p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" onclick="document.getElementById('edit-tab-modal').classList.remove('active')">Close</button>
|
||||
</div>
|
||||
<form id="edit-tab-form">
|
||||
<input type="hidden" id="edit-tab-id">
|
||||
<div class="modal-actions" style="margin-bottom: 1rem;">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-tab-modal').classList.remove('active')">Close</button>
|
||||
</div>
|
||||
<label>Tab Name:</label>
|
||||
<input type="text" id="edit-tab-name" placeholder="Enter tab name" required>
|
||||
<label>Device IDs (comma-separated):</label>
|
||||
<input type="text" id="edit-tab-ids" placeholder="1,2,3" required>
|
||||
<label style="margin-top: 1rem;">Add presets to this tab</label>
|
||||
<div id="edit-tab-presets-list" class="profiles-list" style="max-height: 200px; overflow-y: auto; margin-bottom: 1rem;"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -131,14 +125,20 @@
|
||||
</div>
|
||||
<label>Colors</label>
|
||||
<div id="preset-colors-container" class="preset-colors-container"></div>
|
||||
<div class="profiles-actions" style="margin-top: 0.5rem;">
|
||||
<div class="profiles-actions">
|
||||
<input type="color" id="preset-new-color" value="#ffffff">
|
||||
<button class="btn btn-secondary btn-small" id="preset-add-color-btn">Add Color</button>
|
||||
<button class="btn btn-secondary btn-small" id="preset-add-from-palette-btn">Add from Palette</button>
|
||||
</div>
|
||||
<div class="profiles-actions">
|
||||
<input type="number" id="preset-brightness-input" placeholder="Brightness" min="0" max="255" value="0">
|
||||
<input type="number" id="preset-delay-input" placeholder="Delay" min="0" max="10000" value="0">
|
||||
<div class="preset-editor-field">
|
||||
<label for="preset-brightness-input">Brightness (0–255)</label>
|
||||
<input type="number" id="preset-brightness-input" placeholder="Brightness" min="0" max="255" value="0">
|
||||
</div>
|
||||
<div class="preset-editor-field">
|
||||
<label for="preset-delay-input">Delay (ms)</label>
|
||||
<input type="number" id="preset-delay-input" placeholder="Delay" min="0" max="10000" value="0">
|
||||
</div>
|
||||
</div>
|
||||
<div class="n-params-grid">
|
||||
<div class="n-param-group">
|
||||
@@ -175,7 +175,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" id="preset-save-btn">Save</button>
|
||||
<button class="btn btn-secondary" id="preset-send-btn">Try</button>
|
||||
<button class="btn btn-secondary" id="preset-default-btn">Default</button>
|
||||
<button class="btn btn-primary" id="preset-save-btn">Save & Send</button>
|
||||
<button class="btn btn-danger" id="preset-remove-from-tab-btn">Remove from Tab</button>
|
||||
<button class="btn btn-secondary" id="preset-clear-btn">Clear</button>
|
||||
<button class="btn btn-secondary" id="preset-editor-close-btn">Close</button>
|
||||
</div>
|
||||
@@ -209,145 +212,106 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0,0,0,0.7);
|
||||
}
|
||||
.modal.active {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal-content {
|
||||
background-color: #2e2e2e;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
min-width: 400px;
|
||||
max-width: 600px;
|
||||
}
|
||||
.modal-content label {
|
||||
display: block;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.modal-content input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
background-color: #3a3a3a;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
}
|
||||
.profiles-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.profiles-actions input[type="text"] {
|
||||
flex: 1;
|
||||
}
|
||||
.profiles-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.profiles-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background-color: #3a3a3a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
/* Hide any text content in palette rows - only show color swatches */
|
||||
#palette-container .profiles-row {
|
||||
font-size: 0; /* Hide any text nodes */
|
||||
}
|
||||
#palette-container .profiles-row > * {
|
||||
font-size: 1rem; /* Restore font size for buttons */
|
||||
}
|
||||
#palette-container .profiles-row > span:not(.btn),
|
||||
#palette-container .profiles-row > label,
|
||||
#palette-container .profiles-row::before,
|
||||
#palette-container .profiles-row::after {
|
||||
display: none !important;
|
||||
content: none !important;
|
||||
}
|
||||
/* Preset colors container */
|
||||
#preset-colors-container {
|
||||
min-height: 80px;
|
||||
padding: 0.5rem;
|
||||
background-color: #2a2a2a;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
#preset-colors-container .muted-text {
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
.muted-text {
|
||||
text-align: center;
|
||||
color: #888;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.error {
|
||||
color: #d32f2f;
|
||||
padding: 0.5rem;
|
||||
background-color: #3a1a1a;
|
||||
border-radius: 4px;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
/* Drag and drop styles for presets */
|
||||
.draggable-preset {
|
||||
cursor: move;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
}
|
||||
.draggable-preset.dragging {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
.draggable-preset:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
/* Drag and drop styles for color swatches */
|
||||
.draggable-color-swatch {
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
}
|
||||
.draggable-color-swatch.dragging-color {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
.draggable-color-swatch.drag-over-color {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.color-swatches-container {
|
||||
min-height: 80px;
|
||||
}
|
||||
/* Ensure presets list uses grid layout */
|
||||
#presets-list-tab {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
<!-- Help Modal -->
|
||||
<div id="help-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Help</h2>
|
||||
<p class="muted-text">How to use the LED controller UI.</p>
|
||||
|
||||
<h3>Tabs & devices</h3>
|
||||
<ul>
|
||||
<li><strong>Select tab</strong>: left-click a tab button in the top bar.</li>
|
||||
<li><strong>Edit tab</strong>: right-click a tab button, or click <strong>Edit</strong> in the Tabs modal.</li>
|
||||
<li><strong>Send all presets</strong>: open the <strong>Tabs</strong> menu and click <strong>Send Presets</strong> next to the tab to push every preset used in that tab to all devices.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Presets in a tab</h3>
|
||||
<ul>
|
||||
<li><strong>Select preset</strong>: left-click a preset tile to select it and send a <code>select</code> message to all devices in the tab.</li>
|
||||
<li><strong>Edit preset</strong>: right-click a preset tile and choose <strong>Edit preset…</strong>.</li>
|
||||
<li><strong>Remove from tab</strong>: right-click a preset tile and choose <strong>Remove from this tab</strong> (the preset itself is not deleted, only its link from this tab).</li>
|
||||
<li><strong>Reorder presets</strong>: drag preset tiles to change their order; the new layout is saved automatically.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Presets, profiles & colors</h3>
|
||||
<ul>
|
||||
<li><strong>Presets</strong>: use the <strong>Presets</strong> button in the header to create and manage reusable presets.</li>
|
||||
<li><strong>Profiles</strong>: use <strong>Profiles</strong> to save and recall groups of settings.</li>
|
||||
<li><strong>Color Palette</strong>: use <strong>Color Palette</strong> to build a reusable set of colors you can pull into presets.</li>
|
||||
</ul>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" id="help-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<div id="settings-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Device Settings</h2>
|
||||
<p class="muted-text">Configure WiFi Access Point and device settings.</p>
|
||||
|
||||
<div id="settings-message" class="message"></div>
|
||||
|
||||
<!-- Device Name -->
|
||||
<div class="settings-section">
|
||||
<h3>Device</h3>
|
||||
<form id="device-form">
|
||||
<div class="form-group">
|
||||
<label for="device-name-input">Device Name</label>
|
||||
<input type="text" id="device-name-input" name="device_name" placeholder="e.g. led-controller" required>
|
||||
<small>This name may be used for mDNS (e.g. <code>name.local</code>) and UI display.</small>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button type="submit" class="btn btn-primary btn-full">Save Name</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- WiFi Access Point Settings -->
|
||||
<div class="settings-section ap-settings-section">
|
||||
<h3>WiFi Access Point</h3>
|
||||
|
||||
<div id="ap-status" class="status-info">
|
||||
<h4>AP Status</h4>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
|
||||
<form id="ap-form">
|
||||
<div class="form-group">
|
||||
<label for="ap-ssid">AP SSID (Network Name)</label>
|
||||
<input type="text" id="ap-ssid" name="ssid" placeholder="Enter AP name" required>
|
||||
<small>The name of the WiFi access point this device creates</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ap-password">AP Password</label>
|
||||
<input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)">
|
||||
<small>Leave empty for open network (min 8 characters if set)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ap-channel">Channel (1-11)</label>
|
||||
<input type="number" id="ap-channel" name="channel" min="1" max="11" placeholder="Auto">
|
||||
<small>WiFi channel (1-11 for 2.4GHz). Leave empty for auto.</small>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button type="submit" class="btn btn-primary btn-full">Configure AP</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" id="settings-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Styles moved to /static/style.css -->
|
||||
<script src="/static/tabs.js"></script>
|
||||
<script src="/static/help.js"></script>
|
||||
<script src="/static/color_palette.js"></script>
|
||||
<script src="/static/profiles.js"></script>
|
||||
<script src="/static/tab_palette.js"></script>
|
||||
|
||||
309
src/templates/settings.html
Normal file
309
src/templates/settings.html
Normal file
@@ -0,0 +1,309 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LED Controller - Settings</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<style>
|
||||
.settings-container {
|
||||
padding: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.settings-header h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.settings-header p {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
background-color: #1a1a1a;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid #4a4a4a;
|
||||
}
|
||||
|
||||
.settings-section h2 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #fff;
|
||||
border-bottom: 2px solid #4a4a4a;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #ccc;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="password"],
|
||||
.form-group input[type="number"],
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background-color: #2e2e2e;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #5a5a5a;
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
color: #888;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.status-info {
|
||||
background-color: #2e2e2e;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 4px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.status-info h3 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.status-info p {
|
||||
color: #aaa;
|
||||
margin: 0.25rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status-connected {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.status-disconnected {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-full {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin-bottom: 1rem;
|
||||
color: #aaa;
|
||||
text-decoration: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
background-color: #2e2e2e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background-color: #1b5e20;
|
||||
color: #4caf50;
|
||||
border: 1px solid #4caf50;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background-color: #5e1b1b;
|
||||
color: #f44336;
|
||||
border: 1px solid #f44336;
|
||||
}
|
||||
|
||||
.message.show {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<div class="settings-container">
|
||||
<a href="/" class="back-link">← Back to Dashboard</a>
|
||||
|
||||
<div class="settings-header">
|
||||
<h1>Device Settings</h1>
|
||||
<p>Configure WiFi Access Point settings</p>
|
||||
</div>
|
||||
|
||||
<div id="message" class="message"></div>
|
||||
|
||||
<!-- WiFi Access Point Settings -->
|
||||
<div class="settings-section">
|
||||
<h2>WiFi Access Point Settings</h2>
|
||||
|
||||
<div id="ap-status" class="status-info">
|
||||
<h3>AP Status</h3>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
|
||||
<form id="ap-form">
|
||||
<div class="form-group">
|
||||
<label for="ap-ssid">AP SSID (Network Name)</label>
|
||||
<input type="text" id="ap-ssid" name="ssid" placeholder="Enter AP name" required>
|
||||
<small>The name of the WiFi access point this device creates</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ap-password">AP Password</label>
|
||||
<input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)">
|
||||
<small>Leave empty for open network (min 8 characters if set)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ap-channel">Channel (1-11)</label>
|
||||
<input type="number" id="ap-channel" name="channel" min="1" max="11" placeholder="Auto">
|
||||
<small>WiFi channel (1-11 for 2.4GHz). Leave empty for auto.</small>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button type="submit" class="btn btn-primary btn-full">Configure AP</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Show message helper
|
||||
function showMessage(text, type = 'success') {
|
||||
const messageEl = document.getElementById('message');
|
||||
messageEl.textContent = text;
|
||||
messageEl.className = `message ${type} show`;
|
||||
setTimeout(() => {
|
||||
messageEl.classList.remove('show');
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Load AP status and config
|
||||
async function loadAPStatus() {
|
||||
try {
|
||||
const response = await fetch('/settings/wifi/ap');
|
||||
const config = await response.json();
|
||||
|
||||
const statusEl = document.getElementById('ap-status');
|
||||
if (config.active) {
|
||||
statusEl.innerHTML = `
|
||||
<h3>AP Status: <span class="status-connected">Active</span></h3>
|
||||
<p><strong>SSID:</strong> ${config.ssid || 'N/A'}</p>
|
||||
<p><strong>Channel:</strong> ${config.channel || 'Auto'}</p>
|
||||
<p><strong>IP Address:</strong> ${config.ip || 'N/A'}</p>
|
||||
`;
|
||||
} else {
|
||||
statusEl.innerHTML = `
|
||||
<h3>AP Status: <span class="status-disconnected">Inactive</span></h3>
|
||||
<p>Access Point is not currently active</p>
|
||||
`;
|
||||
}
|
||||
|
||||
// Load saved values
|
||||
if (config.saved_ssid) document.getElementById('ap-ssid').value = config.saved_ssid;
|
||||
if (config.saved_channel) document.getElementById('ap-channel').value = config.saved_channel;
|
||||
} catch (error) {
|
||||
console.error('Error loading AP status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// AP form submission
|
||||
document.getElementById('ap-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = {
|
||||
ssid: document.getElementById('ap-ssid').value,
|
||||
password: document.getElementById('ap-password').value,
|
||||
channel: document.getElementById('ap-channel').value || null
|
||||
};
|
||||
|
||||
// Validate password length if provided
|
||||
if (formData.password && formData.password.length > 0 && formData.password.length < 8) {
|
||||
showMessage('AP password must be at least 8 characters', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert channel to number if provided
|
||||
if (formData.channel) {
|
||||
formData.channel = parseInt(formData.channel);
|
||||
if (formData.channel < 1 || formData.channel > 11) {
|
||||
showMessage('Channel must be between 1 and 11', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/settings/wifi/ap', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showMessage('Access Point configured successfully!', 'success');
|
||||
setTimeout(loadAPStatus, 1000);
|
||||
} else {
|
||||
showMessage(`Error: ${result.error || 'Failed to configure AP'}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage(`Error: ${error.message}`, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Load all data on page load
|
||||
loadAPStatus();
|
||||
|
||||
// Refresh status every 10 seconds
|
||||
setInterval(loadAPStatus, 10000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
80
src/util/README.md
Normal file
80
src/util/README.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# ESPNow Message Builder
|
||||
|
||||
This utility module provides functions to build ESPNow messages according to the LED Driver API specification.
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Message Building
|
||||
|
||||
```python
|
||||
from util.espnow_message import build_message, build_preset_dict, build_select_dict
|
||||
|
||||
# Build a message with presets and select
|
||||
presets = {
|
||||
"red_blink": build_preset_dict({
|
||||
"pattern": "blink",
|
||||
"colors": ["#FF0000"],
|
||||
"delay": 200,
|
||||
"brightness": 255,
|
||||
"auto": True
|
||||
})
|
||||
}
|
||||
|
||||
select = build_select_dict({
|
||||
"device1": "red_blink"
|
||||
})
|
||||
|
||||
message = build_message(presets=presets, select=select)
|
||||
# Result: {"v": "1", "presets": {...}, "select": {...}}
|
||||
```
|
||||
|
||||
### Building Select Messages with Step Synchronization
|
||||
|
||||
```python
|
||||
from util.espnow_message import build_message, build_select_dict
|
||||
|
||||
# Select with step for synchronization
|
||||
select = build_select_dict(
|
||||
{"device1": "rainbow_preset", "device2": "rainbow_preset"},
|
||||
step_mapping={"device1": 10, "device2": 10}
|
||||
)
|
||||
|
||||
message = build_message(select=select)
|
||||
# Result: {"v": "1", "select": {"device1": ["rainbow_preset", 10], "device2": ["rainbow_preset", 10]}}
|
||||
```
|
||||
|
||||
### Converting Presets
|
||||
|
||||
```python
|
||||
from util.espnow_message import build_preset_dict, build_presets_dict
|
||||
|
||||
# Single preset
|
||||
preset = build_preset_dict({
|
||||
"name": "my_preset",
|
||||
"pattern": "rainbow",
|
||||
"colors": ["#FF0000", "#00FF00"], # Can be hex strings or RGB tuples
|
||||
"delay": 100,
|
||||
"brightness": 127,
|
||||
"auto": False,
|
||||
"n1": 2
|
||||
})
|
||||
|
||||
# Multiple presets
|
||||
presets_data = {
|
||||
"preset1": {"pattern": "on", "colors": ["#FF0000"]},
|
||||
"preset2": {"pattern": "blink", "colors": ["#00FF00"]}
|
||||
}
|
||||
presets = build_presets_dict(presets_data)
|
||||
```
|
||||
|
||||
## API Specification
|
||||
|
||||
See `docs/API.md` for the complete ESPNow API specification.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Version Field**: All messages include `"v": "1"` for version tracking
|
||||
- **Preset Format**: Presets use hex color strings (`#RRGGBB`), not RGB tuples
|
||||
- **Select Format**: Select values are always lists: `["preset_name"]` or `["preset_name", step]`
|
||||
- **Color Conversion**: Automatically converts RGB tuples to hex strings
|
||||
- **Default Values**: Provides sensible defaults for missing fields
|
||||
274
src/util/espnow_message.py
Normal file
274
src/util/espnow_message.py
Normal file
@@ -0,0 +1,274 @@
|
||||
"""
|
||||
ESPNow message builder utility for LED driver communication.
|
||||
|
||||
This module provides utilities to build ESPNow messages according to the API specification.
|
||||
ESPNow has a 250-byte payload limit; messages larger than that must be split into multiple
|
||||
frames.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
# ESPNow payload limit (bytes). Messages larger than this must be split.
|
||||
ESPNOW_MAX_PAYLOAD_BYTES = 240
|
||||
|
||||
|
||||
def build_message(presets=None, select=None, save=False, default=None):
|
||||
"""
|
||||
Build an ESPNow message according to the API specification.
|
||||
|
||||
Args:
|
||||
presets: Dictionary mapping preset names to preset objects, or None
|
||||
select: Dictionary mapping device names to select lists, or None
|
||||
|
||||
Returns:
|
||||
JSON string ready to send via ESPNow
|
||||
|
||||
Example:
|
||||
message = build_message(
|
||||
presets={
|
||||
"red_blink": {
|
||||
"pattern": "blink",
|
||||
"colors": ["#FF0000"],
|
||||
"delay": 200,
|
||||
"brightness": 255,
|
||||
"auto": True
|
||||
}
|
||||
},
|
||||
select={
|
||||
"device1": ["red_blink"]
|
||||
}
|
||||
)
|
||||
"""
|
||||
message = {
|
||||
"v": "1"
|
||||
}
|
||||
|
||||
if presets:
|
||||
message["presets"] = presets
|
||||
# When sending presets, optionally include a save flag so the
|
||||
# led-driver can persist them.
|
||||
if save:
|
||||
message["save"] = True
|
||||
|
||||
if select:
|
||||
message["select"] = select
|
||||
|
||||
if default is not None:
|
||||
message["default"] = default
|
||||
|
||||
return json.dumps(message)
|
||||
|
||||
|
||||
def split_espnow_message(msg_dict, max_bytes=None):
|
||||
"""
|
||||
Split a message dict into one or more JSON strings each within ESPNow payload limit.
|
||||
If the message fits in max_bytes, returns a single-element list. Otherwise splits
|
||||
"select" and/or "presets" into multiple messages (other keys like v, b, default, save
|
||||
are included only in the first message).
|
||||
|
||||
Args:
|
||||
msg_dict: Full message as a dict (e.g. from json.loads).
|
||||
max_bytes: Max payload size in bytes (default ESPNOW_MAX_PAYLOAD_BYTES).
|
||||
|
||||
Returns:
|
||||
List of JSON strings, each <= max_bytes, to send in order.
|
||||
"""
|
||||
if max_bytes is None:
|
||||
max_bytes = ESPNOW_MAX_PAYLOAD_BYTES
|
||||
|
||||
single = json.dumps(msg_dict)
|
||||
if len(single) <= max_bytes:
|
||||
return [single]
|
||||
|
||||
# Keys to attach only to the first message we emit
|
||||
first_only = {k: msg_dict[k] for k in ("b", "default", "save") if k in msg_dict}
|
||||
out = []
|
||||
|
||||
def emit(chunk_dict, is_first):
|
||||
m = {"v": msg_dict.get("v", "1")}
|
||||
if is_first and first_only:
|
||||
m.update(first_only)
|
||||
m.update(chunk_dict)
|
||||
s = json.dumps(m)
|
||||
if len(s) > max_bytes:
|
||||
raise ValueError(f"Chunk still too large ({len(s)} > {max_bytes})")
|
||||
out.append(s)
|
||||
|
||||
def chunk_dict(key, items_dict):
|
||||
if not items_dict:
|
||||
return
|
||||
items = list(items_dict.items())
|
||||
i = 0
|
||||
first = True
|
||||
while i < len(items):
|
||||
chunk = {}
|
||||
while i < len(items):
|
||||
k, v = items[i]
|
||||
trial = dict(chunk)
|
||||
trial[k] = v
|
||||
trial_msg = {"v": msg_dict.get("v", "1"), key: trial}
|
||||
if first_only and first:
|
||||
trial_msg.update(first_only)
|
||||
if len(json.dumps(trial_msg)) <= max_bytes:
|
||||
chunk[k] = v
|
||||
i += 1
|
||||
else:
|
||||
if not chunk:
|
||||
# Single entry too large; send as-is and hope receiver accepts
|
||||
chunk[k] = v
|
||||
i += 1
|
||||
break
|
||||
if chunk:
|
||||
emit({key: chunk}, first)
|
||||
first = False
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
if "select" in msg_dict:
|
||||
chunk_dict("select", msg_dict["select"])
|
||||
if "presets" in msg_dict:
|
||||
chunk_dict("presets", msg_dict["presets"])
|
||||
|
||||
if not out:
|
||||
# Fallback: emit one message even if over limit (receiver may reject)
|
||||
out = [single]
|
||||
return out
|
||||
|
||||
|
||||
def build_select_message(device_name, preset_name, step=None):
|
||||
"""
|
||||
Build a select message for a single device.
|
||||
|
||||
Args:
|
||||
device_name: Name of the device
|
||||
preset_name: Name of the preset to select
|
||||
step: Optional step value for synchronization
|
||||
|
||||
Returns:
|
||||
Dictionary with select field ready to use in build_message
|
||||
|
||||
Example:
|
||||
select = build_select_message("device1", "rainbow_preset", step=10)
|
||||
message = build_message(select=select)
|
||||
"""
|
||||
select_list = [preset_name]
|
||||
if step is not None:
|
||||
select_list.append(step)
|
||||
|
||||
return {device_name: select_list}
|
||||
|
||||
|
||||
def build_preset_dict(preset_data):
|
||||
"""
|
||||
Convert preset data to API-compliant format.
|
||||
|
||||
Args:
|
||||
preset_data: Dictionary with preset fields (may include name, pattern, colors, etc.)
|
||||
|
||||
Returns:
|
||||
Dictionary with preset in API-compliant format (without name field)
|
||||
|
||||
Example:
|
||||
preset = build_preset_dict({
|
||||
"name": "red_blink",
|
||||
"pattern": "blink",
|
||||
"colors": ["#FF0000"],
|
||||
"delay": 200,
|
||||
"brightness": 255,
|
||||
"auto": True,
|
||||
"n1": 0,
|
||||
"n2": 0,
|
||||
"n3": 0,
|
||||
"n4": 0,
|
||||
"n5": 0,
|
||||
"n6": 0
|
||||
})
|
||||
"""
|
||||
# Ensure colors are in hex format
|
||||
colors = preset_data.get("colors", preset_data.get("c", ["#FFFFFF"]))
|
||||
if colors:
|
||||
# Convert RGB tuples to hex strings if needed
|
||||
if isinstance(colors[0], list) and len(colors[0]) == 3:
|
||||
# RGB tuple format [r, g, b]
|
||||
colors = [f"#{r:02x}{g:02x}{b:02x}" for r, g, b in colors]
|
||||
elif not isinstance(colors[0], str):
|
||||
# Handle other formats - convert to hex
|
||||
colors = ["#FFFFFF"]
|
||||
# Ensure all colors start with #
|
||||
colors = [c if c.startswith("#") else f"#{c}" for c in colors]
|
||||
else:
|
||||
colors = ["#FFFFFF"]
|
||||
|
||||
# Build payload using the short keys expected by led-driver
|
||||
preset = {
|
||||
"p": preset_data.get("pattern", preset_data.get("p", "off")),
|
||||
"c": colors,
|
||||
"d": preset_data.get("delay", preset_data.get("d", 100)),
|
||||
"b": preset_data.get("brightness", preset_data.get("b", preset_data.get("br", 127))),
|
||||
"a": preset_data.get("auto", preset_data.get("a", True)),
|
||||
"n1": preset_data.get("n1", 0),
|
||||
"n2": preset_data.get("n2", 0),
|
||||
"n3": preset_data.get("n3", 0),
|
||||
"n4": preset_data.get("n4", 0),
|
||||
"n5": preset_data.get("n5", 0),
|
||||
"n6": preset_data.get("n6", 0)
|
||||
}
|
||||
|
||||
return preset
|
||||
|
||||
|
||||
def build_presets_dict(presets_data):
|
||||
"""
|
||||
Convert multiple presets to API-compliant format.
|
||||
|
||||
Args:
|
||||
presets_data: Dictionary mapping preset names to preset data
|
||||
|
||||
Returns:
|
||||
Dictionary mapping preset names to API-compliant preset objects
|
||||
|
||||
Example:
|
||||
presets = build_presets_dict({
|
||||
"red_blink": {
|
||||
"pattern": "blink",
|
||||
"colors": ["#FF0000"],
|
||||
"delay": 200
|
||||
},
|
||||
"blue_pulse": {
|
||||
"pattern": "pulse",
|
||||
"colors": ["#0000FF"],
|
||||
"delay": 100
|
||||
}
|
||||
})
|
||||
"""
|
||||
result = {}
|
||||
for preset_name, preset_data in presets_data.items():
|
||||
result[preset_name] = build_preset_dict(preset_data)
|
||||
return result
|
||||
|
||||
|
||||
def build_select_dict(device_preset_mapping, step_mapping=None):
|
||||
"""
|
||||
Build a select dictionary mapping device names to select lists.
|
||||
|
||||
Args:
|
||||
device_preset_mapping: Dictionary mapping device names to preset names
|
||||
step_mapping: Optional dictionary mapping device names to step values
|
||||
|
||||
Returns:
|
||||
Dictionary with select field ready to use in build_message
|
||||
|
||||
Example:
|
||||
select = build_select_dict(
|
||||
{"device1": "rainbow_preset", "device2": "pulse_preset"},
|
||||
step_mapping={"device1": 10}
|
||||
)
|
||||
message = build_message(select=select)
|
||||
"""
|
||||
select = {}
|
||||
for device_name, preset_name in device_preset_mapping.items():
|
||||
select_list = [preset_name]
|
||||
if step_mapping and device_name in step_mapping:
|
||||
select_list.append(step_mapping[device_name])
|
||||
select[device_name] = select_list
|
||||
return select
|
||||
@@ -1,34 +1,15 @@
|
||||
import network
|
||||
from time import sleep
|
||||
|
||||
def connect(ssid, password, ip, gateway):
|
||||
if ssid is None or password is None:
|
||||
print("Missing ssid or password")
|
||||
return None
|
||||
try:
|
||||
sta_if = network.WLAN(network.STA_IF)
|
||||
if ip is not None and gateway is not None:
|
||||
sta_if.ifconfig((ip, '255.255.255.0', gateway, '1.1.1.1'))
|
||||
if not sta_if.isconnected():
|
||||
print('connecting to network...')
|
||||
sta_if.active(True)
|
||||
sta_if.connect(ssid, password)
|
||||
sleep(0.1)
|
||||
if sta_if.isconnected():
|
||||
return sta_if.ifconfig()
|
||||
return None
|
||||
return sta_if.ifconfig()
|
||||
except Exception as e:
|
||||
print(f"Failed to connect to wifi {e}")
|
||||
return None
|
||||
|
||||
|
||||
def ap(ssid, password):
|
||||
def ap(ssid, password, channel=None):
|
||||
ap_if = network.WLAN(network.AP_IF)
|
||||
ap_mac = ap_if.config('mac')
|
||||
print(ssid)
|
||||
ap_if.active(True)
|
||||
ap_if.config(essid=ssid, password=password)
|
||||
if channel is not None:
|
||||
ap_if.config(essid=ssid, password=password, channel=channel)
|
||||
else:
|
||||
ap_if.config(essid=ssid, password=password)
|
||||
ap_if.active(False)
|
||||
ap_if.active(True)
|
||||
print(ap_if.ifconfig())
|
||||
@@ -36,3 +17,26 @@ def ap(ssid, password):
|
||||
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
|
||||
|
||||
79
tests/README.md
Normal file
79
tests/README.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Tests
|
||||
|
||||
This directory contains tests for the LED Controller project.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
- `test_endpoints.py` - HTTP endpoint tests that mimic web browser requests (runs against 192.168.4.1)
|
||||
- `test_ws.py` - WebSocket tests
|
||||
- `test_p2p.py` - ESP-NOW P2P tests
|
||||
- `models/` - Model unit tests
|
||||
- `web.py` - Local development web server
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Browser Tests (Real Browser Automation)
|
||||
|
||||
Tests the web interface in an actual browser using Selenium:
|
||||
|
||||
```bash
|
||||
python tests/test_browser.py
|
||||
```
|
||||
|
||||
These tests:
|
||||
- Open a real Chrome browser
|
||||
- Navigate to the device at 192.168.4.1
|
||||
- Interact with UI elements (buttons, forms, modals)
|
||||
- Test complete user workflows
|
||||
- Verify visual elements and interactions
|
||||
|
||||
**Requirements:**
|
||||
```bash
|
||||
pip install selenium
|
||||
# Also need ChromeDriver installed and in PATH
|
||||
# Download from: https://chromedriver.chromium.org/
|
||||
```
|
||||
|
||||
### Endpoint Tests (Browser-like HTTP)
|
||||
|
||||
Tests HTTP endpoints by making requests to the device at 192.168.4.1:
|
||||
|
||||
```bash
|
||||
python tests/test_endpoints.py
|
||||
```
|
||||
|
||||
These tests:
|
||||
- Mimic web browser requests with proper headers
|
||||
- Handle cookies for session management
|
||||
- Test all CRUD operations (GET, POST, PUT, DELETE)
|
||||
- Verify responses and status codes
|
||||
|
||||
**Requirements:**
|
||||
```bash
|
||||
pip install requests
|
||||
```
|
||||
|
||||
### WebSocket Tests
|
||||
|
||||
```bash
|
||||
python tests/test_ws.py
|
||||
```
|
||||
|
||||
**Requirements:**
|
||||
```bash
|
||||
pip install websockets
|
||||
```
|
||||
|
||||
### Model Tests
|
||||
|
||||
```bash
|
||||
python tests/models/run_all.py
|
||||
```
|
||||
|
||||
### Local Development Server
|
||||
|
||||
Run the local development server (port 5000):
|
||||
|
||||
```bash
|
||||
python tests/web.py
|
||||
```
|
||||
Binary file not shown.
1041
tests/test_browser.py
Normal file
1041
tests/test_browser.py
Normal file
File diff suppressed because it is too large
Load Diff
563
tests/test_endpoints.py
Normal file
563
tests/test_endpoints.py
Normal file
@@ -0,0 +1,563 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Endpoint tests that mimic web browser requests.
|
||||
Tests run against the device at 192.168.4.1
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
import sys
|
||||
from typing import Dict, Optional
|
||||
|
||||
# Base URL for the device
|
||||
BASE_URL = "http://192.168.4.1"
|
||||
|
||||
class TestClient:
|
||||
"""HTTP client that mimics a web browser with cookie support."""
|
||||
|
||||
def __init__(self, base_url: str = BASE_URL):
|
||||
self.base_url = base_url
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
|
||||
'Accept': 'application/json',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
})
|
||||
|
||||
def get(self, path: str, **kwargs) -> requests.Response:
|
||||
"""GET request."""
|
||||
url = f"{self.base_url}{path}"
|
||||
return self.session.get(url, **kwargs)
|
||||
|
||||
def post(self, path: str, data: Optional[Dict] = None, json_data: Optional[Dict] = None, **kwargs) -> requests.Response:
|
||||
"""POST request."""
|
||||
url = f"{self.base_url}{path}"
|
||||
if json_data:
|
||||
return self.session.post(url, json=json_data, **kwargs)
|
||||
return self.session.post(url, data=data, **kwargs)
|
||||
|
||||
def put(self, path: str, json_data: Optional[Dict] = None, **kwargs) -> requests.Response:
|
||||
"""PUT request."""
|
||||
url = f"{self.base_url}{path}"
|
||||
return self.session.put(url, json=json_data, **kwargs)
|
||||
|
||||
def delete(self, path: str, **kwargs) -> requests.Response:
|
||||
"""DELETE request."""
|
||||
url = f"{self.base_url}{path}"
|
||||
return self.session.delete(url, **kwargs)
|
||||
|
||||
def set_cookie(self, name: str, value: str):
|
||||
"""Set a cookie manually."""
|
||||
self.session.cookies.set(name, value, domain='192.168.4.1', path='/')
|
||||
|
||||
def get_cookie(self, name: str) -> Optional[str]:
|
||||
"""Get a cookie value."""
|
||||
return self.session.cookies.get(name)
|
||||
|
||||
def test_connection(client: TestClient) -> bool:
|
||||
"""Test basic connection to the server."""
|
||||
print("Testing connection...")
|
||||
try:
|
||||
response = client.get('/')
|
||||
if response.status_code == 200:
|
||||
print("✓ Connection successful")
|
||||
return True
|
||||
else:
|
||||
print(f"✗ Connection failed: {response.status_code}")
|
||||
return False
|
||||
except requests.exceptions.ConnectionError:
|
||||
print(f"✗ Cannot connect to {BASE_URL}")
|
||||
print(" Make sure the device is running and accessible at 192.168.4.1")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"✗ Connection error: {e}")
|
||||
return False
|
||||
|
||||
def test_tabs(client: TestClient) -> bool:
|
||||
"""Test tabs endpoints."""
|
||||
print("\n=== Testing Tabs Endpoints ===")
|
||||
passed = 0
|
||||
total = 0
|
||||
|
||||
# Test 1: List tabs
|
||||
total += 1
|
||||
try:
|
||||
response = client.get('/tabs')
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
print(f"✓ GET /tabs - Found {len(data.get('tabs', {}))} tabs")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ GET /tabs - Status: {response.status_code}")
|
||||
except Exception as e:
|
||||
print(f"✗ GET /tabs - Error: {e}")
|
||||
|
||||
# Test 2: Create tab
|
||||
total += 1
|
||||
try:
|
||||
tab_data = {
|
||||
"name": "Test Tab",
|
||||
"names": ["1", "2"]
|
||||
}
|
||||
response = client.post('/tabs', json_data=tab_data)
|
||||
if response.status_code == 201:
|
||||
created_tab = response.json()
|
||||
# Response format: {tab_id: {tab_data}}
|
||||
if isinstance(created_tab, dict):
|
||||
# Get the first key which should be the tab ID
|
||||
tab_id = next(iter(created_tab.keys())) if created_tab else None
|
||||
else:
|
||||
tab_id = None
|
||||
print(f"✓ POST /tabs - Created tab: {tab_id}")
|
||||
passed += 1
|
||||
|
||||
# Test 3: Get specific tab
|
||||
if tab_id:
|
||||
total += 1
|
||||
response = client.get(f'/tabs/{tab_id}')
|
||||
if response.status_code == 200:
|
||||
print(f"✓ GET /tabs/{tab_id} - Retrieved tab")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ GET /tabs/{tab_id} - Status: {response.status_code}")
|
||||
|
||||
# Test 4: Set current tab
|
||||
total += 1
|
||||
response = client.post(f'/tabs/{tab_id}/set-current')
|
||||
if response.status_code == 200:
|
||||
print(f"✓ POST /tabs/{tab_id}/set-current - Set current tab")
|
||||
# Check cookie was set
|
||||
cookie = client.get_cookie('current_tab')
|
||||
if cookie == tab_id:
|
||||
print(f" ✓ Cookie 'current_tab' set to {tab_id}")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ POST /tabs/{tab_id}/set-current - Status: {response.status_code}")
|
||||
|
||||
# Test 5: Get current tab
|
||||
total += 1
|
||||
response = client.get('/tabs/current')
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get('tab_id') == tab_id:
|
||||
print(f"✓ GET /tabs/current - Current tab is {tab_id}")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ GET /tabs/current - Wrong tab ID")
|
||||
else:
|
||||
print(f"✗ GET /tabs/current - Status: {response.status_code}")
|
||||
|
||||
# Test 6: Update tab (edit functionality)
|
||||
total += 1
|
||||
update_data = {
|
||||
"name": "Updated Test Tab",
|
||||
"names": ["1", "2", "3"] # Update device IDs too
|
||||
}
|
||||
response = client.put(f'/tabs/{tab_id}', json_data=update_data)
|
||||
if response.status_code == 200:
|
||||
updated = response.json()
|
||||
if updated.get('name') == "Updated Test Tab" and updated.get('names') == ["1", "2", "3"]:
|
||||
print(f"✓ PUT /tabs/{tab_id} - Updated tab (name and device IDs)")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ PUT /tabs/{tab_id} - Update didn't work correctly")
|
||||
print(f" Expected name='Updated Test Tab', got '{updated.get('name')}'")
|
||||
print(f" Expected names=['1','2','3'], got {updated.get('names')}")
|
||||
else:
|
||||
print(f"✗ PUT /tabs/{tab_id} - Status: {response.status_code}, Response: {response.text}")
|
||||
|
||||
# Test 6b: Verify update persisted
|
||||
total += 1
|
||||
response = client.get(f'/tabs/{tab_id}')
|
||||
if response.status_code == 200:
|
||||
verified = response.json()
|
||||
if verified.get('name') == "Updated Test Tab":
|
||||
print(f"✓ GET /tabs/{tab_id} - Verified update persisted")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ GET /tabs/{tab_id} - Update didn't persist")
|
||||
else:
|
||||
print(f"✗ GET /tabs/{tab_id} - Status: {response.status_code}")
|
||||
|
||||
# Test 7: Delete tab
|
||||
total += 1
|
||||
response = client.delete(f'/tabs/{tab_id}')
|
||||
if response.status_code == 200:
|
||||
print(f"✓ DELETE /tabs/{tab_id} - Deleted tab")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ DELETE /tabs/{tab_id} - Status: {response.status_code}")
|
||||
else:
|
||||
print(f"✗ POST /tabs - Status: {response.status_code}, Response: {response.text}")
|
||||
except Exception as e:
|
||||
print(f"✗ POST /tabs - Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print(f"\nTabs tests: {passed}/{total} passed")
|
||||
return passed == total
|
||||
|
||||
def test_profiles(client: TestClient) -> bool:
|
||||
"""Test profiles endpoints."""
|
||||
print("\n=== Testing Profiles Endpoints ===")
|
||||
passed = 0
|
||||
total = 0
|
||||
|
||||
# Test 1: List profiles
|
||||
total += 1
|
||||
try:
|
||||
response = client.get('/profiles')
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
profiles = data.get('profiles', {})
|
||||
current_id = data.get('current_profile_id')
|
||||
print(f"✓ GET /profiles - Found {len(profiles)} profiles, current: {current_id}")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ GET /profiles - Status: {response.status_code}")
|
||||
except Exception as e:
|
||||
print(f"✗ GET /profiles - Error: {e}")
|
||||
|
||||
# Test 2: Get current profile
|
||||
total += 1
|
||||
try:
|
||||
response = client.get('/profiles/current')
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
print(f"✓ GET /profiles/current - Current profile: {data.get('id')}")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ GET /profiles/current - Status: {response.status_code}")
|
||||
except Exception as e:
|
||||
print(f"✗ GET /profiles/current - Error: {e}")
|
||||
|
||||
# Test 3: Create profile
|
||||
total += 1
|
||||
try:
|
||||
profile_data = {"name": "Test Profile"}
|
||||
response = client.post('/profiles', json_data=profile_data)
|
||||
if response.status_code == 201:
|
||||
created = response.json()
|
||||
# Response format: {profile_id: {profile_data}}
|
||||
if isinstance(created, dict):
|
||||
profile_id = next(iter(created.keys())) if created else None
|
||||
else:
|
||||
profile_id = None
|
||||
print(f"✓ POST /profiles - Created profile: {profile_id}")
|
||||
passed += 1
|
||||
|
||||
# Test 4: Apply profile
|
||||
if profile_id:
|
||||
total += 1
|
||||
response = client.post(f'/profiles/{profile_id}/apply')
|
||||
if response.status_code == 200:
|
||||
print(f"✓ POST /profiles/{profile_id}/apply - Applied profile")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ POST /profiles/{profile_id}/apply - Status: {response.status_code}")
|
||||
|
||||
# Test 5: Delete profile
|
||||
total += 1
|
||||
response = client.delete(f'/profiles/{profile_id}')
|
||||
if response.status_code == 200:
|
||||
print(f"✓ DELETE /profiles/{profile_id} - Deleted profile")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ DELETE /profiles/{profile_id} - Status: {response.status_code}")
|
||||
else:
|
||||
print(f"✗ POST /profiles - Status: {response.status_code}")
|
||||
except Exception as e:
|
||||
print(f"✗ POST /profiles - Error: {e}")
|
||||
|
||||
print(f"\nProfiles tests: {passed}/{total} passed")
|
||||
return passed == total
|
||||
|
||||
def test_presets(client: TestClient) -> bool:
|
||||
"""Test presets endpoints."""
|
||||
print("\n=== Testing Presets Endpoints ===")
|
||||
passed = 0
|
||||
total = 0
|
||||
|
||||
# Test 1: List presets
|
||||
total += 1
|
||||
try:
|
||||
response = client.get('/presets')
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
preset_count = len(data) if isinstance(data, dict) else 0
|
||||
print(f"✓ GET /presets - Found {preset_count} presets")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ GET /presets - Status: {response.status_code}")
|
||||
except Exception as e:
|
||||
print(f"✗ GET /presets - Error: {e}")
|
||||
|
||||
# Test 2: Create preset
|
||||
total += 1
|
||||
try:
|
||||
preset_data = {
|
||||
"name": "Test Preset",
|
||||
"pattern": "on",
|
||||
"colors": ["#ff0000"],
|
||||
"brightness": 200
|
||||
}
|
||||
response = client.post('/presets', json_data=preset_data)
|
||||
if response.status_code == 201:
|
||||
created = response.json()
|
||||
# Response format: {preset_id: {preset_data}}
|
||||
if isinstance(created, dict):
|
||||
preset_id = next(iter(created.keys())) if created else None
|
||||
else:
|
||||
preset_id = None
|
||||
print(f"✓ POST /presets - Created preset: {preset_id}")
|
||||
passed += 1
|
||||
|
||||
# Test 3: Get specific preset
|
||||
if preset_id:
|
||||
total += 1
|
||||
response = client.get(f'/presets/{preset_id}')
|
||||
if response.status_code == 200:
|
||||
print(f"✓ GET /presets/{preset_id} - Retrieved preset")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ GET /presets/{preset_id} - Status: {response.status_code}")
|
||||
|
||||
# Test 4: Update preset
|
||||
total += 1
|
||||
update_data = {"brightness": 150}
|
||||
response = client.put(f'/presets/{preset_id}', json_data=update_data)
|
||||
if response.status_code == 200:
|
||||
print(f"✓ PUT /presets/{preset_id} - Updated preset")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ PUT /presets/{preset_id} - Status: {response.status_code}")
|
||||
|
||||
# Test 5: Send preset via /presets/send
|
||||
total += 1
|
||||
try:
|
||||
send_body = {"preset_ids": [preset_id]}
|
||||
response = client.post('/presets/send', json_data=send_body)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
sent = data.get('presets_sent')
|
||||
print(f"✓ POST /presets/send - Sent presets (presets_sent={sent})")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ POST /presets/send - Status: {response.status_code}, Response: {response.text}")
|
||||
except Exception as e:
|
||||
print(f"✗ POST /presets/send - Error: {e}")
|
||||
|
||||
# Test 6: Delete preset
|
||||
total += 1
|
||||
response = client.delete(f'/presets/{preset_id}')
|
||||
if response.status_code == 200:
|
||||
print(f"✓ DELETE /presets/{preset_id} - Deleted preset")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ DELETE /presets/{preset_id} - Status: {response.status_code}")
|
||||
else:
|
||||
print(f"✗ POST /presets - Status: {response.status_code}")
|
||||
except Exception as e:
|
||||
print(f"✗ POST /presets - Error: {e}")
|
||||
|
||||
print(f"\nPresets tests: {passed}/{total} passed")
|
||||
return passed == total
|
||||
|
||||
def test_patterns(client: TestClient) -> bool:
|
||||
"""Test patterns endpoints."""
|
||||
print("\n=== Testing Patterns Endpoints ===")
|
||||
passed = 0
|
||||
total = 0
|
||||
|
||||
# Test 1: List patterns
|
||||
total += 1
|
||||
try:
|
||||
response = client.get('/patterns')
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
pattern_count = len(data) if isinstance(data, dict) else 0
|
||||
print(f"✓ GET /patterns - Found {pattern_count} patterns")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ GET /patterns - Status: {response.status_code}")
|
||||
except Exception as e:
|
||||
print(f"✗ GET /patterns - Error: {e}")
|
||||
|
||||
# Test 2: Get pattern definitions
|
||||
total += 1
|
||||
try:
|
||||
response = client.get('/patterns/definitions')
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
print(f"✓ GET /patterns/definitions - Retrieved definitions")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ GET /patterns/definitions - Status: {response.status_code}")
|
||||
except Exception as e:
|
||||
print(f"✗ GET /patterns/definitions - Error: {e}")
|
||||
|
||||
print(f"\nPatterns tests: {passed}/{total} passed")
|
||||
return passed == total
|
||||
|
||||
def test_tab_edit_workflow(client: TestClient) -> bool:
|
||||
"""Test complete tab edit workflow like a browser would."""
|
||||
print("\n=== Testing Tab Edit Workflow ===")
|
||||
passed = 0
|
||||
total = 0
|
||||
|
||||
# Step 1: Create a tab to edit
|
||||
total += 1
|
||||
try:
|
||||
tab_data = {
|
||||
"name": "Tab to Edit",
|
||||
"names": ["1"]
|
||||
}
|
||||
response = client.post('/tabs', json_data=tab_data)
|
||||
if response.status_code == 201:
|
||||
created = response.json()
|
||||
if isinstance(created, dict):
|
||||
tab_id = next(iter(created.keys())) if created else None
|
||||
else:
|
||||
tab_id = None
|
||||
|
||||
if tab_id:
|
||||
print(f"✓ Created tab {tab_id} for editing")
|
||||
passed += 1
|
||||
|
||||
# Step 2: Get the tab to verify initial state
|
||||
total += 1
|
||||
response = client.get(f'/tabs/{tab_id}')
|
||||
if response.status_code == 200:
|
||||
original_tab = response.json()
|
||||
print(f"✓ Retrieved tab - Name: '{original_tab.get('name')}', IDs: {original_tab.get('names')}")
|
||||
passed += 1
|
||||
|
||||
# Step 3: Edit the tab (simulate browser edit form submission)
|
||||
total += 1
|
||||
edit_data = {
|
||||
"name": "Edited Tab Name",
|
||||
"names": ["2", "3", "4"]
|
||||
}
|
||||
response = client.put(f'/tabs/{tab_id}', json_data=edit_data)
|
||||
if response.status_code == 200:
|
||||
edited = response.json()
|
||||
if edited.get('name') == "Edited Tab Name" and edited.get('names') == ["2", "3", "4"]:
|
||||
print(f"✓ PUT /tabs/{tab_id} - Successfully edited tab")
|
||||
print(f" New name: '{edited.get('name')}'")
|
||||
print(f" New device IDs: {edited.get('names')}")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ PUT /tabs/{tab_id} - Edit didn't work correctly")
|
||||
print(f" Got: {edited}")
|
||||
else:
|
||||
print(f"✗ PUT /tabs/{tab_id} - Status: {response.status_code}, Response: {response.text}")
|
||||
|
||||
# Step 4: Verify edit persisted by getting the tab again
|
||||
total += 1
|
||||
response = client.get(f'/tabs/{tab_id}')
|
||||
if response.status_code == 200:
|
||||
verified = response.json()
|
||||
if verified.get('name') == "Edited Tab Name" and verified.get('names') == ["2", "3", "4"]:
|
||||
print(f"✓ GET /tabs/{tab_id} - Verified edit persisted")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ GET /tabs/{tab_id} - Edit didn't persist")
|
||||
print(f" Expected name='Edited Tab Name', got '{verified.get('name')}'")
|
||||
print(f" Expected names=['2','3','4'], got {verified.get('names')}")
|
||||
else:
|
||||
print(f"✗ GET /tabs/{tab_id} - Status: {response.status_code}")
|
||||
|
||||
# Step 5: Clean up - delete the test tab
|
||||
total += 1
|
||||
response = client.delete(f'/tabs/{tab_id}')
|
||||
if response.status_code == 200:
|
||||
print(f"✓ DELETE /tabs/{tab_id} - Cleaned up test tab")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ DELETE /tabs/{tab_id} - Status: {response.status_code}")
|
||||
else:
|
||||
print(f"✗ Failed to extract tab ID from create response")
|
||||
else:
|
||||
print(f"✗ POST /tabs - Status: {response.status_code}, Response: {response.text}")
|
||||
except Exception as e:
|
||||
print(f"✗ Tab edit workflow - Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print(f"\nTab edit workflow tests: {passed}/{total} passed")
|
||||
return passed == total
|
||||
|
||||
def test_static_files(client: TestClient) -> bool:
|
||||
"""Test static file serving."""
|
||||
print("\n=== Testing Static Files ===")
|
||||
passed = 0
|
||||
total = 0
|
||||
|
||||
static_files = [
|
||||
'/static/style.css',
|
||||
'/static/app.js',
|
||||
'/static/tabs.js',
|
||||
'/static/presets.js',
|
||||
'/static/profiles.js',
|
||||
]
|
||||
|
||||
for file_path in static_files:
|
||||
total += 1
|
||||
try:
|
||||
response = client.get(file_path)
|
||||
if response.status_code == 200:
|
||||
print(f"✓ GET {file_path} - Retrieved")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ GET {file_path} - Status: {response.status_code}")
|
||||
except Exception as e:
|
||||
print(f"✗ GET {file_path} - Error: {e}")
|
||||
|
||||
print(f"\nStatic files tests: {passed}/{total} passed")
|
||||
return passed == total
|
||||
|
||||
def main():
|
||||
"""Run all endpoint tests."""
|
||||
print("=" * 60)
|
||||
print("LED Controller Endpoint Tests")
|
||||
print(f"Testing against: {BASE_URL}")
|
||||
print("=" * 60)
|
||||
|
||||
client = TestClient()
|
||||
|
||||
# Test connection first
|
||||
if not test_connection(client):
|
||||
print("\n✗ Cannot connect to device. Exiting.")
|
||||
sys.exit(1)
|
||||
|
||||
results = []
|
||||
|
||||
# Run all tests
|
||||
results.append(("Tabs", test_tabs(client)))
|
||||
results.append(("Tab Edit Workflow", test_tab_edit_workflow(client)))
|
||||
results.append(("Profiles", test_profiles(client)))
|
||||
results.append(("Presets", test_presets(client)))
|
||||
results.append(("Patterns", test_patterns(client)))
|
||||
results.append(("Static Files", test_static_files(client)))
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 60)
|
||||
print("Test Summary")
|
||||
print("=" * 60)
|
||||
all_passed = True
|
||||
for name, passed in results:
|
||||
status = "✓ PASS" if passed else "✗ FAIL"
|
||||
print(f"{status} - {name}")
|
||||
if not passed:
|
||||
all_passed = False
|
||||
|
||||
print("=" * 60)
|
||||
if all_passed:
|
||||
print("✓ All tests passed!")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("✗ Some tests failed")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user