refactor(api): migrate server to fastapi and uvicorn
Replace the Microdot-only entrypoint with a CombinedASGI app that handles FastAPI routes (audio API, websocket, dev live-reload) while delegating the rest to Microdot. Suppress noisy /__dev/ access logs during live-reload polling. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
7
Pipfile
7
Pipfile
@@ -14,9 +14,12 @@ requests = "*"
|
|||||||
selenium = "*"
|
selenium = "*"
|
||||||
adafruit-ampy = "*"
|
adafruit-ampy = "*"
|
||||||
microdot = "*"
|
microdot = "*"
|
||||||
|
fastapi = "*"
|
||||||
websockets = "*"
|
websockets = "*"
|
||||||
|
httpx = "*"
|
||||||
numpy = "*"
|
numpy = "*"
|
||||||
sounddevice = "*"
|
sounddevice = "*"
|
||||||
|
uvicorn = {extras = ["standard"], version = "*"}
|
||||||
|
|
||||||
[dev-packages]
|
[dev-packages]
|
||||||
pytest = "*"
|
pytest = "*"
|
||||||
@@ -27,8 +30,8 @@ python_version = "3.11"
|
|||||||
[scripts]
|
[scripts]
|
||||||
web = "python tests/web.py"
|
web = "python tests/web.py"
|
||||||
watch = "python -m watchfiles \"python tests/web.py\" src tests"
|
watch = "python -m watchfiles \"python tests/web.py\" src tests"
|
||||||
run = "sh -c 'cd src && python main.py'"
|
run = "sh -c 'cd src && uvicorn fastapi_app:app --host 0.0.0.0 --port \"${PORT:-80}\"'"
|
||||||
dev = "python -m watchfiles \"sh -c 'cd src && LED_CONTROLLER_LIVE_RELOAD=1 python main.py'\" src"
|
dev = "sh -c 'cd src && LED_CONTROLLER_LIVE_RELOAD=1 uvicorn fastapi_app:app --host 0.0.0.0 --port \"${PORT:-80}\" --reload --reload-dir . --reload-include \"**/*.html\" --reload-include \"**/*.css\" --reload-include \"**/*.js\" --reload-exclude \"**/db/**\" --reload-exclude \"**/settings.json\" --reload-exclude \"**/settings.json.*\"'"
|
||||||
test = "python -m pytest"
|
test = "python -m pytest"
|
||||||
test-browser = "sh -c 'python tests/web.py > /tmp/led-controller-web.log 2>&1 & pid=$!; trap \"kill $pid\" EXIT; sleep 2; LED_CONTROLLER_RUN_BROWSER_TESTS=1 LED_CONTROLLER_DEVICE_IP=http://127.0.0.1:5000 python -m pytest tests/test_browser.py'"
|
test-browser = "sh -c 'python tests/web.py > /tmp/led-controller-web.log 2>&1 & pid=$!; trap \"kill $pid\" EXIT; sleep 2; LED_CONTROLLER_RUN_BROWSER_TESTS=1 LED_CONTROLLER_DEVICE_IP=http://127.0.0.1:5000 python -m pytest tests/test_browser.py'"
|
||||||
test-browser-device = "sh -c 'LED_CONTROLLER_RUN_BROWSER_TESTS=1 python -m pytest tests/test_browser.py'"
|
test-browser-device = "sh -c 'LED_CONTROLLER_RUN_BROWSER_TESTS=1 python -m pytest tests/test_browser.py'"
|
||||||
|
|||||||
336
Pipfile.lock
generated
336
Pipfile.lock
generated
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"_meta": {
|
"_meta": {
|
||||||
"hash": {
|
"hash": {
|
||||||
"sha256": "2387dc49cb5166bfd75072d96e74e8fdac3b60afccba34d8bdca3d9da1552b80"
|
"sha256": "b6783f8de9b1f98387fccab1d9fed190f2acbf66f0f188e43fe91f28a6950ad1"
|
||||||
},
|
},
|
||||||
"pipfile-spec": 6,
|
"pipfile-spec": 6,
|
||||||
"requires": {
|
"requires": {
|
||||||
@@ -24,6 +24,22 @@
|
|||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==1.1.0"
|
"version": "==1.1.0"
|
||||||
},
|
},
|
||||||
|
"annotated-doc": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320",
|
||||||
|
"sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.8'",
|
||||||
|
"version": "==0.0.4"
|
||||||
|
},
|
||||||
|
"annotated-types": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53",
|
||||||
|
"sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.8'",
|
||||||
|
"version": "==0.7.0"
|
||||||
|
},
|
||||||
"anyio": {
|
"anyio": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708",
|
"sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708",
|
||||||
@@ -455,11 +471,20 @@
|
|||||||
},
|
},
|
||||||
"esptool": {
|
"esptool": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:9c355b7d6331cc92979cc710ae5c41f59830d1ea29ec24c467c6005a092c06d6"
|
"sha256:0a077cb3ee8e60e223882c06ab7dae9b3686816c2547904d7472a42e6284e7de"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"markers": "python_version >= '3.10'",
|
"markers": "python_version >= '3.10'",
|
||||||
"version": "==5.2.0"
|
"version": "==5.3.0"
|
||||||
|
},
|
||||||
|
"fastapi": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620",
|
||||||
|
"sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"markers": "python_version >= '3.10'",
|
||||||
|
"version": "==0.136.3"
|
||||||
},
|
},
|
||||||
"h11": {
|
"h11": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -469,13 +494,85 @@
|
|||||||
"markers": "python_version >= '3.8'",
|
"markers": "python_version >= '3.8'",
|
||||||
"version": "==0.16.0"
|
"version": "==0.16.0"
|
||||||
},
|
},
|
||||||
|
"httpcore": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55",
|
||||||
|
"sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.8'",
|
||||||
|
"version": "==1.0.9"
|
||||||
|
},
|
||||||
|
"httptools": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683",
|
||||||
|
"sha256:0ea897f0c729581ebf72131a438a7932d9b14efef72d75ada966700cac3caaeb",
|
||||||
|
"sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b",
|
||||||
|
"sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527",
|
||||||
|
"sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124",
|
||||||
|
"sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca",
|
||||||
|
"sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081",
|
||||||
|
"sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c",
|
||||||
|
"sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77",
|
||||||
|
"sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09",
|
||||||
|
"sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f",
|
||||||
|
"sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085",
|
||||||
|
"sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376",
|
||||||
|
"sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5",
|
||||||
|
"sha256:5d7fa4ba7292c1139c0526f0b5aad507c6263c948206ea1b1cbca015c8af1b62",
|
||||||
|
"sha256:5eb911c515b96ee44bbd861e42cbefc488681d450545b1d02127f6136e3a86f5",
|
||||||
|
"sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8",
|
||||||
|
"sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681",
|
||||||
|
"sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999",
|
||||||
|
"sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1",
|
||||||
|
"sha256:7b71e7d7031928c650e1006e6c03e911bf967f7c69c011d37d541c3e7bf55005",
|
||||||
|
"sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d",
|
||||||
|
"sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d",
|
||||||
|
"sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d",
|
||||||
|
"sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d",
|
||||||
|
"sha256:9878eb2785ba5eb70631ad269b37976f73d647955e26c91d490eb8a4edfda4ba",
|
||||||
|
"sha256:9fc1644f415372cec4f8a5be3a64183737398f10dbb1263602a036427fe75247",
|
||||||
|
"sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745",
|
||||||
|
"sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07",
|
||||||
|
"sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b",
|
||||||
|
"sha256:a6f21e2a3b0067bbe7f67e34cfd16276af556e5e52f4c7503be0cb5f90e905e4",
|
||||||
|
"sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2",
|
||||||
|
"sha256:b205e5f5523fa039679da0dfe5a10132b2a4abeae6a86fdd1ddc035f7f836557",
|
||||||
|
"sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d",
|
||||||
|
"sha256:bf3b6f807c8541503cecfbb8a8dffb385640d0d96102f3d112aa8740f9b7c826",
|
||||||
|
"sha256:c08ffe3e79756e0963cbc8fe410139f38a5884874b6f2e17761bef6563fdcd9b",
|
||||||
|
"sha256:c0d726cc107fceb7d45f978483b4b70dd8caa836f5914d3434bb18628eb73813",
|
||||||
|
"sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0",
|
||||||
|
"sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150",
|
||||||
|
"sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e",
|
||||||
|
"sha256:da684f2e1aa2ee9bdcb083f3f3a68c5956750b375bc5df864d3a5f0c42a40b77",
|
||||||
|
"sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568",
|
||||||
|
"sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6",
|
||||||
|
"sha256:df31ef5494f406ab6cf827b7e64a22841c6e2d654100e6a116ea15b46d02d5e8",
|
||||||
|
"sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b",
|
||||||
|
"sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7",
|
||||||
|
"sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168",
|
||||||
|
"sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a",
|
||||||
|
"sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0",
|
||||||
|
"sha256:fe2a4c95aeba2209434e7b31172da572846cae8ca0bf1e7013e61b99fbbf5e72"
|
||||||
|
],
|
||||||
|
"version": "==0.8.0"
|
||||||
|
},
|
||||||
|
"httpx": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc",
|
||||||
|
"sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"markers": "python_version >= '3.8'",
|
||||||
|
"version": "==0.28.1"
|
||||||
|
},
|
||||||
"idna": {
|
"idna": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5",
|
"sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2",
|
||||||
"sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d"
|
"sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"
|
||||||
],
|
],
|
||||||
"markers": "python_version >= '3.9'",
|
"markers": "python_version >= '3.9'",
|
||||||
"version": "==3.16"
|
"version": "==3.18"
|
||||||
},
|
},
|
||||||
"intelhex": {
|
"intelhex": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -607,11 +704,11 @@
|
|||||||
},
|
},
|
||||||
"platformdirs": {
|
"platformdirs": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a",
|
"sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7",
|
||||||
"sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917"
|
"sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a"
|
||||||
],
|
],
|
||||||
"markers": "python_version >= '3.10'",
|
"markers": "python_version >= '3.10'",
|
||||||
"version": "==4.9.6"
|
"version": "==4.10.0"
|
||||||
},
|
},
|
||||||
"pycparser": {
|
"pycparser": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -621,6 +718,140 @@
|
|||||||
"markers": "implementation_name != 'PyPy'",
|
"markers": "implementation_name != 'PyPy'",
|
||||||
"version": "==3.0"
|
"version": "==3.0"
|
||||||
},
|
},
|
||||||
|
"pydantic": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba",
|
||||||
|
"sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.9'",
|
||||||
|
"version": "==2.13.4"
|
||||||
|
},
|
||||||
|
"pydantic-core": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0",
|
||||||
|
"sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262",
|
||||||
|
"sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda",
|
||||||
|
"sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0",
|
||||||
|
"sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e",
|
||||||
|
"sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b",
|
||||||
|
"sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594",
|
||||||
|
"sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29",
|
||||||
|
"sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2",
|
||||||
|
"sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c",
|
||||||
|
"sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d",
|
||||||
|
"sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398",
|
||||||
|
"sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d",
|
||||||
|
"sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3",
|
||||||
|
"sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f",
|
||||||
|
"sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb",
|
||||||
|
"sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7",
|
||||||
|
"sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5",
|
||||||
|
"sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9",
|
||||||
|
"sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462",
|
||||||
|
"sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4",
|
||||||
|
"sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b",
|
||||||
|
"sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d",
|
||||||
|
"sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df",
|
||||||
|
"sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2",
|
||||||
|
"sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0",
|
||||||
|
"sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519",
|
||||||
|
"sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd",
|
||||||
|
"sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7",
|
||||||
|
"sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac",
|
||||||
|
"sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6",
|
||||||
|
"sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565",
|
||||||
|
"sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898",
|
||||||
|
"sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb",
|
||||||
|
"sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928",
|
||||||
|
"sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6",
|
||||||
|
"sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3",
|
||||||
|
"sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a",
|
||||||
|
"sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596",
|
||||||
|
"sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987",
|
||||||
|
"sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e",
|
||||||
|
"sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d",
|
||||||
|
"sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712",
|
||||||
|
"sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008",
|
||||||
|
"sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd",
|
||||||
|
"sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1",
|
||||||
|
"sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be",
|
||||||
|
"sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea",
|
||||||
|
"sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292",
|
||||||
|
"sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33",
|
||||||
|
"sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3",
|
||||||
|
"sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4",
|
||||||
|
"sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b",
|
||||||
|
"sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826",
|
||||||
|
"sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac",
|
||||||
|
"sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7",
|
||||||
|
"sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d",
|
||||||
|
"sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf",
|
||||||
|
"sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4",
|
||||||
|
"sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc",
|
||||||
|
"sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15",
|
||||||
|
"sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3",
|
||||||
|
"sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b",
|
||||||
|
"sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914",
|
||||||
|
"sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04",
|
||||||
|
"sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c",
|
||||||
|
"sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b",
|
||||||
|
"sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9",
|
||||||
|
"sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce",
|
||||||
|
"sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4",
|
||||||
|
"sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a",
|
||||||
|
"sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f",
|
||||||
|
"sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424",
|
||||||
|
"sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894",
|
||||||
|
"sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9",
|
||||||
|
"sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76",
|
||||||
|
"sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201",
|
||||||
|
"sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb",
|
||||||
|
"sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109",
|
||||||
|
"sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4",
|
||||||
|
"sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848",
|
||||||
|
"sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526",
|
||||||
|
"sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0",
|
||||||
|
"sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01",
|
||||||
|
"sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458",
|
||||||
|
"sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e",
|
||||||
|
"sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba",
|
||||||
|
"sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a",
|
||||||
|
"sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39",
|
||||||
|
"sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c",
|
||||||
|
"sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000",
|
||||||
|
"sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b",
|
||||||
|
"sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf",
|
||||||
|
"sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4",
|
||||||
|
"sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd",
|
||||||
|
"sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28",
|
||||||
|
"sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9",
|
||||||
|
"sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30",
|
||||||
|
"sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983",
|
||||||
|
"sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1",
|
||||||
|
"sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76",
|
||||||
|
"sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5",
|
||||||
|
"sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4",
|
||||||
|
"sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7",
|
||||||
|
"sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c",
|
||||||
|
"sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066",
|
||||||
|
"sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3",
|
||||||
|
"sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02",
|
||||||
|
"sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89",
|
||||||
|
"sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50",
|
||||||
|
"sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76",
|
||||||
|
"sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49",
|
||||||
|
"sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b",
|
||||||
|
"sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d",
|
||||||
|
"sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7",
|
||||||
|
"sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4",
|
||||||
|
"sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c",
|
||||||
|
"sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e",
|
||||||
|
"sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff",
|
||||||
|
"sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.9'",
|
||||||
|
"version": "==2.46.4"
|
||||||
|
},
|
||||||
"pygments": {
|
"pygments": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f",
|
"sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f",
|
||||||
@@ -775,11 +1006,11 @@
|
|||||||
},
|
},
|
||||||
"rich-click": {
|
"rich-click": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:022997c1e30731995bdbc8ec2f82819340d42543237f033a003c7b1f843fc5dc",
|
"sha256:12873865396e6927835d4eabb1cc3996edcd65b7ac9b2391a29eca4f335a2f93",
|
||||||
"sha256:2f99120fca78f536e07b114d3b60333bc4bb2a0969053b1250869bcdc1b5351b"
|
"sha256:4008f921da88b5d91646c134ec881c1500e5a6b3f093e90e8f29400e09608371"
|
||||||
],
|
],
|
||||||
"markers": "python_version >= '3.8'",
|
"markers": "python_version >= '3.8'",
|
||||||
"version": "==1.9.7"
|
"version": "==1.9.8"
|
||||||
},
|
},
|
||||||
"selenium": {
|
"selenium": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -818,6 +1049,14 @@
|
|||||||
"markers": "python_version >= '3.7'",
|
"markers": "python_version >= '3.7'",
|
||||||
"version": "==0.5.5"
|
"version": "==0.5.5"
|
||||||
},
|
},
|
||||||
|
"starlette": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89",
|
||||||
|
"sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.10'",
|
||||||
|
"version": "==1.2.1"
|
||||||
|
},
|
||||||
"tibs": {
|
"tibs": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:01ea5258bdf942d21560dc07d532082cd04f07cfef65fedd58ae84f7d0d2562a",
|
"sha256:01ea5258bdf942d21560dc07d532082cd04f07cfef65fedd58ae84f7d0d2562a",
|
||||||
@@ -879,6 +1118,14 @@
|
|||||||
"markers": "python_version >= '3.9'",
|
"markers": "python_version >= '3.9'",
|
||||||
"version": "==4.15.0"
|
"version": "==4.15.0"
|
||||||
},
|
},
|
||||||
|
"typing-inspection": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7",
|
||||||
|
"sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.9'",
|
||||||
|
"version": "==0.4.2"
|
||||||
|
},
|
||||||
"urllib3": {
|
"urllib3": {
|
||||||
"extras": [
|
"extras": [
|
||||||
"socks"
|
"socks"
|
||||||
@@ -890,6 +1137,71 @@
|
|||||||
"markers": "python_version >= '3.10'",
|
"markers": "python_version >= '3.10'",
|
||||||
"version": "==2.7.0"
|
"version": "==2.7.0"
|
||||||
},
|
},
|
||||||
|
"uvicorn": {
|
||||||
|
"extras": [
|
||||||
|
"standard"
|
||||||
|
],
|
||||||
|
"hashes": [
|
||||||
|
"sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f",
|
||||||
|
"sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.10'",
|
||||||
|
"version": "==0.49.0"
|
||||||
|
},
|
||||||
|
"uvloop": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:017bd46f9e7b78e81606329d07141d3da446f8798c6baeec124260e22c262772",
|
||||||
|
"sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e",
|
||||||
|
"sha256:05e4b5f86e621cf3927631789999e697e58f0d2d32675b67d9ca9eb0bca55743",
|
||||||
|
"sha256:0ae676de143db2b2f60a9696d7eca5bb9d0dd6cc3ac3dad59a8ae7e95f9e1b54",
|
||||||
|
"sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec",
|
||||||
|
"sha256:17d4e97258b0172dfa107b89aa1eeba3016f4b1974ce85ca3ef6a66b35cbf659",
|
||||||
|
"sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8",
|
||||||
|
"sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad",
|
||||||
|
"sha256:286322a90bea1f9422a470d5d2ad82d38080be0a29c4dd9b3e6384320a4d11e7",
|
||||||
|
"sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35",
|
||||||
|
"sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289",
|
||||||
|
"sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142",
|
||||||
|
"sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77",
|
||||||
|
"sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733",
|
||||||
|
"sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd",
|
||||||
|
"sha256:4a968a72422a097b09042d5fa2c5c590251ad484acf910a651b4b620acd7f193",
|
||||||
|
"sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74",
|
||||||
|
"sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0",
|
||||||
|
"sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6",
|
||||||
|
"sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473",
|
||||||
|
"sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21",
|
||||||
|
"sha256:55502bc2c653ed2e9692e8c55cb95b397d33f9f2911e929dc97c4d6b26d04242",
|
||||||
|
"sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705",
|
||||||
|
"sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702",
|
||||||
|
"sha256:57df59d8b48feb0e613d9b1f5e57b7532e97cbaf0d61f7aa9aa32221e84bc4b6",
|
||||||
|
"sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f",
|
||||||
|
"sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e",
|
||||||
|
"sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d",
|
||||||
|
"sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370",
|
||||||
|
"sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4",
|
||||||
|
"sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792",
|
||||||
|
"sha256:80eee091fe128e425177fbd82f8635769e2f32ec9daf6468286ec57ec0313efa",
|
||||||
|
"sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079",
|
||||||
|
"sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2",
|
||||||
|
"sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86",
|
||||||
|
"sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6",
|
||||||
|
"sha256:b45649628d816c030dba3c80f8e2689bab1c89518ed10d426036cdc47874dfc4",
|
||||||
|
"sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3",
|
||||||
|
"sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21",
|
||||||
|
"sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c",
|
||||||
|
"sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e",
|
||||||
|
"sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25",
|
||||||
|
"sha256:c3e5c6727a57cb6558592a95019e504f605d1c54eb86463ee9f7a2dbd411c820",
|
||||||
|
"sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9",
|
||||||
|
"sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88",
|
||||||
|
"sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2",
|
||||||
|
"sha256:ea721dd3203b809039fcc2983f14608dae82b212288b346e0bfe46ec2fab0b7c",
|
||||||
|
"sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c",
|
||||||
|
"sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42"
|
||||||
|
],
|
||||||
|
"version": "==0.22.1"
|
||||||
|
},
|
||||||
"watchfiles": {
|
"watchfiles": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9",
|
"sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9",
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ LED controller web app for managing profiles, **zones**, presets, and colour pal
|
|||||||
## Run
|
## Run
|
||||||
|
|
||||||
- One-time setup for port 80 without root: `sudo scripts/setup-port80.sh`
|
- One-time setup for port 80 without root: `sudo scripts/setup-port80.sh`
|
||||||
- Start app: `pipenv run run` (override listen port with the **`PORT`** environment variable)
|
- Start app: `pipenv run run` (FastAPI + uvicorn; override listen port with **`PORT`**)
|
||||||
- Dev watcher (auto-restart on `src/` changes): `pipenv run dev`
|
- Dev mode (uvicorn **`--reload`** on `src/` + browser refresh via `dev-live-reload.js`): `pipenv run dev`
|
||||||
- Regenerate **`docs/help.pdf`** from **`docs/help.md`**: `pipenv run help-pdf` (requires **pandoc** and **chromium** on the host)
|
- Regenerate **`docs/help.pdf`** from **`docs/help.md`**: `pipenv run help-pdf` (requires **pandoc** and **chromium** on the host)
|
||||||
|
|
||||||
## UI modes
|
## UI modes
|
||||||
|
|||||||
@@ -13,4 +13,8 @@ if [ -n "${pids}" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
cd "$ROOT_DIR/src"
|
cd "$ROOT_DIR/src"
|
||||||
exec python main.py
|
exec env LED_CONTROLLER_LIVE_RELOAD=1 python -m uvicorn fastapi_app:app \
|
||||||
|
--host 0.0.0.0 --port "$PORT" --reload --reload-dir . \
|
||||||
|
--reload-include '**/*.html' --reload-include '**/*.css' --reload-include '**/*.js' \
|
||||||
|
--reload-exclude '**/db/**' --reload-exclude '**/settings.json' \
|
||||||
|
--reload-exclude '**/settings.json.*'
|
||||||
|
|||||||
@@ -35,4 +35,4 @@ if [ -z "$PYTHON" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
cd "$ROOT/src"
|
cd "$ROOT/src"
|
||||||
exec "$PYTHON" -u main.py
|
exec "$PYTHON" -u -m uvicorn fastapi_app:app --host 0.0.0.0 --port "$PORT"
|
||||||
|
|||||||
298
src/app_factory.py
Normal file
298
src/app_factory.py
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
"""Application factory: Microdot routes and shared runtime startup."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from microdot import Microdot, send_file
|
||||||
|
from microdot.session import Session
|
||||||
|
from settings import WIFI_CHANNEL_DEFAULT, get_settings
|
||||||
|
|
||||||
|
import controllers.preset as preset
|
||||||
|
import controllers.profile as profile
|
||||||
|
import controllers.group as group
|
||||||
|
import controllers.sequence as sequence
|
||||||
|
import controllers.zone as zone
|
||||||
|
import controllers.palette as palette
|
||||||
|
import controllers.scene as scene
|
||||||
|
import controllers.pattern as pattern
|
||||||
|
import controllers.settings as settings_controller
|
||||||
|
import controllers.device as device_controller
|
||||||
|
import controllers.led_tool as led_tool_controller
|
||||||
|
import controllers.wifi_bridge as wifi_bridge_controller
|
||||||
|
from models.transport import (
|
||||||
|
BridgeSerialTransport,
|
||||||
|
BridgeWsTransport,
|
||||||
|
get_bridge,
|
||||||
|
set_bridge,
|
||||||
|
)
|
||||||
|
from models.device import Device
|
||||||
|
from models.bridge_serial_client import init_bridge_serial_client
|
||||||
|
from models.bridge_ws_client import init_bridge_client
|
||||||
|
from util.espnow_registry import handle_bridge_uplink
|
||||||
|
from util.bridge_runtime import set_bridge_uplink_handler
|
||||||
|
from util.audio_detector import AudioBeatDetector
|
||||||
|
|
||||||
|
|
||||||
|
def live_reload_enabled() -> bool:
|
||||||
|
v = os.environ.get("LED_CONTROLLER_LIVE_RELOAD", "").strip().lower()
|
||||||
|
return v not in ("", "0", "false", "no")
|
||||||
|
|
||||||
|
|
||||||
|
_dev_build_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def dev_build_id() -> Optional[str]:
|
||||||
|
global _dev_build_id
|
||||||
|
if not live_reload_enabled():
|
||||||
|
return None
|
||||||
|
if _dev_build_id is None:
|
||||||
|
_dev_build_id = secrets.token_hex(12)
|
||||||
|
return _dev_build_id
|
||||||
|
|
||||||
|
|
||||||
|
def dev_client_revision() -> Optional[str]:
|
||||||
|
"""Revision of static/template assets (changes when UI files are saved)."""
|
||||||
|
if not live_reload_enabled():
|
||||||
|
return None
|
||||||
|
base = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
parts: list[str] = []
|
||||||
|
for sub in ("static", "templates"):
|
||||||
|
root = os.path.join(base, sub)
|
||||||
|
if not os.path.isdir(root):
|
||||||
|
continue
|
||||||
|
for dirpath, _, files in os.walk(root):
|
||||||
|
for name in sorted(files):
|
||||||
|
if not name.endswith((".js", ".css", ".html")):
|
||||||
|
continue
|
||||||
|
path = os.path.join(dirpath, name)
|
||||||
|
try:
|
||||||
|
st = os.stat(path)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
parts.append(f"{path}:{st.st_mtime_ns}:{st.st_size}")
|
||||||
|
if not parts:
|
||||||
|
return "0"
|
||||||
|
digest = hashlib.sha256("\n".join(parts).encode("utf-8")).hexdigest()
|
||||||
|
return digest[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def create_microdot_app(*, inject_live_reload: bool = False) -> Microdot:
|
||||||
|
"""Build the Microdot app with mounted controllers and static routes."""
|
||||||
|
settings = get_settings()
|
||||||
|
app = Microdot()
|
||||||
|
|
||||||
|
secret_key = settings.get(
|
||||||
|
"session_secret_key", "led-controller-secret-key-change-in-production"
|
||||||
|
)
|
||||||
|
Session(app, secret_key=secret_key)
|
||||||
|
|
||||||
|
app.mount(preset.controller, "/presets")
|
||||||
|
app.mount(profile.controller, "/profiles")
|
||||||
|
app.mount(group.controller, "/groups")
|
||||||
|
app.mount(sequence.controller, "/sequences")
|
||||||
|
app.mount(zone.controller, "/zones")
|
||||||
|
app.mount(palette.controller, "/palettes")
|
||||||
|
app.mount(scene.controller, "/scenes")
|
||||||
|
app.mount(pattern.controller, "/patterns")
|
||||||
|
app.mount(settings_controller.controller, "/settings")
|
||||||
|
app.mount(wifi_bridge_controller.controller, "/settings/wifi")
|
||||||
|
app.mount(device_controller.controller, "/devices")
|
||||||
|
app.mount(led_tool_controller.controller, "/led-tool")
|
||||||
|
|
||||||
|
build_id = dev_build_id() if inject_live_reload else None
|
||||||
|
if build_id:
|
||||||
|
|
||||||
|
@app.route("/__dev/build-id")
|
||||||
|
def dev_build_id_route(request):
|
||||||
|
_ = request
|
||||||
|
return (
|
||||||
|
build_id,
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"Content-Type": "text/plain; charset=utf-8",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index(request):
|
||||||
|
_ = request
|
||||||
|
if build_id:
|
||||||
|
try:
|
||||||
|
with open("templates/index.html", encoding="utf-8") as f:
|
||||||
|
html = f.read()
|
||||||
|
tag = '<script src="/static/dev-live-reload.js" defer></script>'
|
||||||
|
if "</body>" in html:
|
||||||
|
html = html.replace("</body>", tag + "\n</body>", 1)
|
||||||
|
return html, 200, {"Content-Type": "text/html; charset=utf-8"}
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return send_file("templates/index.html")
|
||||||
|
|
||||||
|
@app.route("/favicon.ico")
|
||||||
|
def favicon(request):
|
||||||
|
_ = request
|
||||||
|
return "", 204
|
||||||
|
|
||||||
|
@app.route("/static/<path:path>")
|
||||||
|
def static_handler(request, path):
|
||||||
|
if ".." in path:
|
||||||
|
return "Not found", 404
|
||||||
|
return send_file("static/" + path)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
class AppRuntime:
|
||||||
|
"""Holds long-lived services started with the HTTP server."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.settings = get_settings()
|
||||||
|
self.audio_detector = AudioBeatDetector()
|
||||||
|
self.bridge = None
|
||||||
|
|
||||||
|
async def startup(self, *, test_mode: bool = False) -> None:
|
||||||
|
set_bridge_uplink_handler(handle_bridge_uplink)
|
||||||
|
|
||||||
|
if test_mode:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.bridge = get_bridge(self.settings)
|
||||||
|
set_bridge(self.bridge)
|
||||||
|
|
||||||
|
bridge_mode = str(self.settings.get("bridge_transport") or "wifi").strip().lower()
|
||||||
|
if bridge_mode == "wifi":
|
||||||
|
ws_url = str(self.settings.get("bridge_ws_url") or "").strip()
|
||||||
|
if ws_url:
|
||||||
|
try:
|
||||||
|
ch = int(self.settings.get("wifi_channel", WIFI_CHANNEL_DEFAULT))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
ch = WIFI_CHANNEL_DEFAULT
|
||||||
|
ws_client = init_bridge_client(ws_url, wifi_channel=ch)
|
||||||
|
ws_client.set_uplink_handler(handle_bridge_uplink)
|
||||||
|
ws_client.start()
|
||||||
|
set_bridge(BridgeWsTransport())
|
||||||
|
elif bridge_mode == "serial":
|
||||||
|
serial_port = str(self.settings.get("bridge_serial_port") or "").strip()
|
||||||
|
if serial_port:
|
||||||
|
baud = 115200
|
||||||
|
for prof in self.settings.get("bridges") or []:
|
||||||
|
if not isinstance(prof, dict):
|
||||||
|
continue
|
||||||
|
if str(prof.get("transport") or "").strip().lower() != "serial":
|
||||||
|
continue
|
||||||
|
if str(prof.get("serial_port") or "").strip() != serial_port:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
baud = int(prof.get("serial_baudrate") or baud)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
baud = int(self.settings.get("bridge_serial_baudrate") or baud)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
serial_client = init_bridge_serial_client(serial_port, baudrate=baud)
|
||||||
|
serial_client.set_uplink_handler(handle_bridge_uplink)
|
||||||
|
serial_client.start()
|
||||||
|
set_bridge(BridgeSerialTransport())
|
||||||
|
|
||||||
|
try:
|
||||||
|
from util import audio_detector as audio_detector_module
|
||||||
|
|
||||||
|
audio_detector_module.set_shared_beat_detector(self.audio_detector)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[startup] audio detector shared registration skipped: {e!r}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from util.audio_run_persist import coerce_audio_device, read_audio_run_state
|
||||||
|
|
||||||
|
persisted = read_audio_run_state()
|
||||||
|
if persisted.get("enabled"):
|
||||||
|
sel = persisted.get("device_select") or persisted.get("device")
|
||||||
|
dev = coerce_audio_device(sel)
|
||||||
|
self.audio_detector.start(device=dev)
|
||||||
|
print("[startup] audio beat detector started from saved run state")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[startup] audio auto-start skipped: {e!r}")
|
||||||
|
|
||||||
|
from util import beat_driver_route
|
||||||
|
|
||||||
|
beat_driver_route.set_beat_route_main_loop(asyncio.get_running_loop())
|
||||||
|
from util import sequence_playback as seq_pb
|
||||||
|
|
||||||
|
seq_pb.ensure_beat_consumer_started()
|
||||||
|
Device()
|
||||||
|
|
||||||
|
async def shutdown(self) -> None:
|
||||||
|
try:
|
||||||
|
self.audio_detector.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from util import sequence_playback as seq_pb
|
||||||
|
|
||||||
|
seq_pb.stop()
|
||||||
|
for attr in ("_pending_beat_task", "_sim_beat_task"):
|
||||||
|
t = getattr(seq_pb, attr, None)
|
||||||
|
if t is not None and not t.done():
|
||||||
|
t.cancel()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def audio_status_payload(
|
||||||
|
audio_detector: AudioBeatDetector, settings: Any
|
||||||
|
) -> dict:
|
||||||
|
from util import beat_driver_route
|
||||||
|
from util import sequence_playback
|
||||||
|
from util.audio_run_persist import read_audio_run_state
|
||||||
|
|
||||||
|
st = audio_detector.status()
|
||||||
|
st["sequence"] = sequence_playback.playback_status()
|
||||||
|
st["manual_beat_stride"] = beat_driver_route.manual_beat_stride_status()
|
||||||
|
seq = st.get("sequence")
|
||||||
|
beat_readout = ""
|
||||||
|
if isinstance(seq, dict) and str(seq.get("beat_readout") or "").strip():
|
||||||
|
beat_readout = str(seq.get("beat_readout") or "").strip()
|
||||||
|
elif st.get("running"):
|
||||||
|
mb = st.get("manual_beat_stride")
|
||||||
|
if isinstance(mb, dict) and mb.get("active"):
|
||||||
|
try:
|
||||||
|
n = int(mb.get("stride_n") or 1)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
n = 1
|
||||||
|
n = max(1, min(64, n))
|
||||||
|
try:
|
||||||
|
bi = int(mb.get("beat_in_stride") or 1)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
bi = 1
|
||||||
|
pos = min(n, max(1, bi))
|
||||||
|
beat_readout = f"{pos}/{n}"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
bs = int(st.get("beat_seq") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
bs = 0
|
||||||
|
if bs > 0:
|
||||||
|
beat_readout = str(bs)
|
||||||
|
st["beat_readout"] = beat_readout
|
||||||
|
st["beat_phase_ms"] = int(settings.get("audio_beat_phase_ms") or 0)
|
||||||
|
try:
|
||||||
|
st["input_volume"] = int(settings.get("audio_input_volume") or 100)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
st["input_volume"] = 100
|
||||||
|
st["input_volume"] = max(0, min(200, st["input_volume"]))
|
||||||
|
seq_wait = str(settings.get("sequence_switch_wait") or "beat").strip().lower()
|
||||||
|
if seq_wait not in ("beat", "downbeat"):
|
||||||
|
seq_wait = "beat"
|
||||||
|
st["sequence_switch_wait"] = seq_wait
|
||||||
|
st["audio_run"] = read_audio_run_state()
|
||||||
|
return st
|
||||||
251
src/fastapi_app.py
Normal file
251
src/fastapi_app.py
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
"""FastAPI entrypoint; Microdot controllers run behind an ASGI bridge."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
|
from fastapi.responses import JSONResponse, PlainTextResponse
|
||||||
|
|
||||||
|
from app_factory import (
|
||||||
|
AppRuntime,
|
||||||
|
audio_status_payload,
|
||||||
|
create_microdot_app,
|
||||||
|
live_reload_enabled,
|
||||||
|
)
|
||||||
|
from microdot_asgi import MicrodotASGI
|
||||||
|
from models.transport import get_current_bridge
|
||||||
|
|
||||||
|
|
||||||
|
_runtime: Optional[AppRuntime] = None
|
||||||
|
_microdot_app = None
|
||||||
|
_test_mode = False
|
||||||
|
|
||||||
|
|
||||||
|
class _SuppressDevAccessLogFilter(logging.Filter):
|
||||||
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
|
return "/__dev/" not in record.getMessage()
|
||||||
|
|
||||||
|
|
||||||
|
logging.getLogger("uvicorn.access").addFilter(_SuppressDevAccessLogFilter())
|
||||||
|
|
||||||
|
|
||||||
|
def _bridge():
|
||||||
|
return get_current_bridge()
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _lifespan(app: FastAPI):
|
||||||
|
global _runtime
|
||||||
|
_runtime = AppRuntime()
|
||||||
|
await _runtime.startup(test_mode=_test_mode)
|
||||||
|
if live_reload_enabled() and not _test_mode:
|
||||||
|
print(
|
||||||
|
"[dev] LED_CONTROLLER_LIVE_RELOAD: browser refreshes when uvicorn reloads"
|
||||||
|
)
|
||||||
|
yield
|
||||||
|
await _runtime.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
def _create_fastapi() -> FastAPI:
|
||||||
|
api = FastAPI(title="LED Controller", lifespan=_lifespan)
|
||||||
|
|
||||||
|
@api.get("/__dev/build-id", response_class=PlainTextResponse)
|
||||||
|
async def dev_build_id_route():
|
||||||
|
from app_factory import dev_build_id as current_build_id
|
||||||
|
|
||||||
|
bid = current_build_id()
|
||||||
|
if not bid:
|
||||||
|
return PlainTextResponse("", status_code=404)
|
||||||
|
return PlainTextResponse(
|
||||||
|
bid,
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.get("/__dev/client-rev", response_class=PlainTextResponse)
|
||||||
|
async def dev_client_rev_route():
|
||||||
|
from app_factory import dev_client_revision
|
||||||
|
|
||||||
|
rev = dev_client_revision()
|
||||||
|
if not rev:
|
||||||
|
return PlainTextResponse("", status_code=404)
|
||||||
|
return PlainTextResponse(
|
||||||
|
rev,
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.get("/api/audio/devices")
|
||||||
|
async def audio_devices():
|
||||||
|
if _runtime is None:
|
||||||
|
return JSONResponse({"error": "not ready"}, status_code=503)
|
||||||
|
try:
|
||||||
|
return {
|
||||||
|
"devices": _runtime.audio_detector.list_input_devices(),
|
||||||
|
"diagnostics": _runtime.audio_detector.diagnostics(),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=500)
|
||||||
|
|
||||||
|
@api.post("/api/audio/start")
|
||||||
|
async def audio_start(payload: dict | None = None):
|
||||||
|
if _runtime is None:
|
||||||
|
return JSONResponse({"ok": False, "error": "not ready"}, status_code=503)
|
||||||
|
body = payload if isinstance(payload, dict) else {}
|
||||||
|
device = body.get("device", None)
|
||||||
|
if device in ("", None):
|
||||||
|
device = None
|
||||||
|
device_select = str(body.get("device_select") or "").strip()
|
||||||
|
if not device_select and device not in ("", None):
|
||||||
|
device_select = str(device).strip()
|
||||||
|
try:
|
||||||
|
from util.pulse_audio_devices import resolve_capture_device
|
||||||
|
|
||||||
|
device = resolve_capture_device(device)
|
||||||
|
_runtime.audio_detector.start(device=device)
|
||||||
|
from util.audio_run_persist import write_audio_run_state
|
||||||
|
|
||||||
|
write_audio_run_state(
|
||||||
|
enabled=True,
|
||||||
|
device=device,
|
||||||
|
device_override=str(body.get("device_override") or ""),
|
||||||
|
device_select=device_select,
|
||||||
|
)
|
||||||
|
return {"ok": True, "status": _runtime.audio_detector.status()}
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
|
||||||
|
|
||||||
|
@api.put("/api/audio/device")
|
||||||
|
async def audio_set_device(payload: dict | None = None):
|
||||||
|
if _runtime is None:
|
||||||
|
return JSONResponse({"ok": False, "error": "not ready"}, status_code=503)
|
||||||
|
body = payload if isinstance(payload, dict) else {}
|
||||||
|
device_select = str(body.get("device_select") or "").strip()
|
||||||
|
device_override = str(body.get("device_override") or "").strip()
|
||||||
|
raw = device_override if device_override else device_select
|
||||||
|
device = raw if raw else None
|
||||||
|
from util.audio_run_persist import read_audio_run_state, write_audio_run_state
|
||||||
|
|
||||||
|
prev = read_audio_run_state()
|
||||||
|
write_audio_run_state(
|
||||||
|
enabled=bool(prev.get("enabled")),
|
||||||
|
device=device if raw else None,
|
||||||
|
device_override=device_override,
|
||||||
|
device_select=device_select,
|
||||||
|
)
|
||||||
|
return {"ok": True, "audio_run": read_audio_run_state()}
|
||||||
|
|
||||||
|
@api.post("/api/audio/stop")
|
||||||
|
async def audio_stop():
|
||||||
|
if _runtime is None:
|
||||||
|
return JSONResponse({"ok": False, "error": "not ready"}, status_code=503)
|
||||||
|
_runtime.audio_detector.stop()
|
||||||
|
from util.audio_run_persist import write_audio_run_state
|
||||||
|
|
||||||
|
write_audio_run_state(enabled=False)
|
||||||
|
return {"ok": True, "status": _runtime.audio_detector.status()}
|
||||||
|
|
||||||
|
@api.post("/api/audio/reset")
|
||||||
|
async def audio_reset():
|
||||||
|
if _runtime is None:
|
||||||
|
return JSONResponse({"ok": False, "error": "not ready"}, status_code=503)
|
||||||
|
ok = _runtime.audio_detector.reset_tracking()
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": "Audio detector is not running"},
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
return {"ok": True, "status": _runtime.audio_detector.status()}
|
||||||
|
|
||||||
|
@api.post("/api/audio/anchor-bar")
|
||||||
|
async def audio_anchor_bar():
|
||||||
|
if _runtime is None:
|
||||||
|
return JSONResponse({"ok": False, "error": "not ready"}, status_code=503)
|
||||||
|
ok = _runtime.audio_detector.anchor_bar_phase()
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": "Audio detector is not running"},
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
return {"ok": True, "status": _runtime.audio_detector.status()}
|
||||||
|
|
||||||
|
@api.get("/api/audio/status")
|
||||||
|
async def audio_status():
|
||||||
|
if _runtime is None:
|
||||||
|
return JSONResponse({"error": "not ready"}, status_code=503)
|
||||||
|
return {"status": audio_status_payload(_runtime.audio_detector, _runtime.settings)}
|
||||||
|
|
||||||
|
@api.websocket("/ws")
|
||||||
|
async def ws_endpoint(websocket: WebSocket):
|
||||||
|
await websocket.accept()
|
||||||
|
bridge = _bridge()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await websocket.receive()
|
||||||
|
if data.get("type") == "websocket.disconnect":
|
||||||
|
break
|
||||||
|
if "bytes" in data and data["bytes"] is not None:
|
||||||
|
await bridge.send(bytes(data["bytes"]))
|
||||||
|
continue
|
||||||
|
text = data.get("text")
|
||||||
|
if text is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed = json.loads(text)
|
||||||
|
addr = parsed.pop("to", None)
|
||||||
|
await bridge.send(parsed, addr=addr)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
await websocket.send_text(json.dumps({"error": "Send failed"}))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return api
|
||||||
|
|
||||||
|
|
||||||
|
class CombinedASGI:
|
||||||
|
"""Route FastAPI-only paths first; delegate the rest to Microdot."""
|
||||||
|
|
||||||
|
_FASTAPI_PREFIXES = ("/api/", "/__dev/")
|
||||||
|
|
||||||
|
def __init__(self, fastapi_app: FastAPI, microdot_asgi: MicrodotASGI):
|
||||||
|
self.fastapi_app = fastapi_app
|
||||||
|
self.microdot_asgi = microdot_asgi
|
||||||
|
|
||||||
|
async def __call__(self, scope: dict, receive: Any, send: Any) -> None:
|
||||||
|
stype = scope.get("type")
|
||||||
|
if stype == "lifespan":
|
||||||
|
await self.fastapi_app(scope, receive, send)
|
||||||
|
return
|
||||||
|
if stype == "websocket":
|
||||||
|
if scope.get("path") == "/ws":
|
||||||
|
await self.fastapi_app(scope, receive, send)
|
||||||
|
return
|
||||||
|
await send({"type": "websocket.close", "code": 1000})
|
||||||
|
return
|
||||||
|
if stype == "http":
|
||||||
|
path = scope.get("path") or ""
|
||||||
|
if path.startswith(self._FASTAPI_PREFIXES):
|
||||||
|
await self.fastapi_app(scope, receive, send)
|
||||||
|
return
|
||||||
|
await self.microdot_asgi(scope, receive, send)
|
||||||
|
|
||||||
|
|
||||||
|
def create_application(*, test_mode: bool = False) -> CombinedASGI:
|
||||||
|
global _microdot_app, _test_mode
|
||||||
|
_test_mode = test_mode
|
||||||
|
_microdot_app = create_microdot_app(inject_live_reload=live_reload_enabled())
|
||||||
|
fastapi_app = _create_fastapi()
|
||||||
|
return CombinedASGI(fastapi_app, MicrodotASGI(_microdot_app))
|
||||||
|
|
||||||
|
|
||||||
|
app = create_application()
|
||||||
456
src/main.py
456
src/main.py
@@ -1,456 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import errno
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import secrets
|
|
||||||
import signal
|
|
||||||
from microdot import Microdot, send_file
|
|
||||||
from microdot.websocket import with_websocket
|
|
||||||
from microdot.session import Session
|
|
||||||
from settings import WIFI_CHANNEL_DEFAULT, get_settings
|
|
||||||
|
|
||||||
import controllers.preset as preset
|
|
||||||
import controllers.profile as profile
|
|
||||||
import controllers.group as group
|
|
||||||
import controllers.sequence as sequence
|
|
||||||
import controllers.zone as zone
|
|
||||||
import controllers.palette as palette
|
|
||||||
import controllers.scene as scene
|
|
||||||
import controllers.pattern as pattern
|
|
||||||
import controllers.settings as settings_controller
|
|
||||||
import controllers.device as device_controller
|
|
||||||
import controllers.led_tool as led_tool_controller
|
|
||||||
from models.transport import (
|
|
||||||
get_bridge,
|
|
||||||
set_bridge,
|
|
||||||
get_current_bridge,
|
|
||||||
BridgeSerialTransport,
|
|
||||||
BridgeWsTransport,
|
|
||||||
)
|
|
||||||
from models.device import Device
|
|
||||||
from models.bridge_serial_client import init_bridge_serial_client
|
|
||||||
from models.bridge_ws_client import init_bridge_client
|
|
||||||
from util.espnow_registry import handle_bridge_uplink
|
|
||||||
from util.bridge_runtime import set_bridge_uplink_handler
|
|
||||||
import controllers.wifi_bridge as wifi_bridge_controller
|
|
||||||
from util.audio_detector import AudioBeatDetector
|
|
||||||
|
|
||||||
|
|
||||||
def _live_reload_enabled() -> bool:
|
|
||||||
v = os.environ.get("LED_CONTROLLER_LIVE_RELOAD", "").strip().lower()
|
|
||||||
return v not in ("", "0", "false", "no")
|
|
||||||
|
|
||||||
|
|
||||||
async def main(port=80):
|
|
||||||
settings = get_settings()
|
|
||||||
print(settings)
|
|
||||||
print("Starting")
|
|
||||||
|
|
||||||
set_bridge_uplink_handler(handle_bridge_uplink)
|
|
||||||
|
|
||||||
bridge = get_bridge(settings)
|
|
||||||
set_bridge(bridge)
|
|
||||||
|
|
||||||
bridge_mode = str(settings.get("bridge_transport") or "wifi").strip().lower()
|
|
||||||
if bridge_mode == "wifi":
|
|
||||||
ws_url = str(settings.get("bridge_ws_url") or "").strip()
|
|
||||||
if ws_url:
|
|
||||||
try:
|
|
||||||
ch = int(settings.get("wifi_channel", WIFI_CHANNEL_DEFAULT))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
ch = WIFI_CHANNEL_DEFAULT
|
|
||||||
ws_client = init_bridge_client(ws_url, wifi_channel=ch)
|
|
||||||
ws_client.set_uplink_handler(handle_bridge_uplink)
|
|
||||||
ws_client.start()
|
|
||||||
set_bridge(BridgeWsTransport())
|
|
||||||
elif bridge_mode == "serial":
|
|
||||||
serial_port = str(settings.get("bridge_serial_port") or "").strip()
|
|
||||||
if serial_port:
|
|
||||||
baud = 115200
|
|
||||||
for prof in settings.get("bridges") or []:
|
|
||||||
if not isinstance(prof, dict):
|
|
||||||
continue
|
|
||||||
if str(prof.get("transport") or "").strip().lower() != "serial":
|
|
||||||
continue
|
|
||||||
if str(prof.get("serial_port") or "").strip() != serial_port:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
baud = int(prof.get("serial_baudrate") or baud)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
baud = int(settings.get("bridge_serial_baudrate") or baud)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
serial_client = init_bridge_serial_client(serial_port, baudrate=baud)
|
|
||||||
serial_client.set_uplink_handler(handle_bridge_uplink)
|
|
||||||
serial_client.start()
|
|
||||||
set_bridge(BridgeSerialTransport())
|
|
||||||
|
|
||||||
app = Microdot()
|
|
||||||
audio_detector = AudioBeatDetector()
|
|
||||||
try:
|
|
||||||
from util import audio_detector as audio_detector_module
|
|
||||||
|
|
||||||
audio_detector_module.set_shared_beat_detector(audio_detector)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[startup] audio detector shared registration skipped: {e!r}")
|
|
||||||
try:
|
|
||||||
from util.audio_run_persist import coerce_audio_device, read_audio_run_state
|
|
||||||
|
|
||||||
persisted = read_audio_run_state()
|
|
||||||
if persisted.get("enabled"):
|
|
||||||
sel = persisted.get("device_select") or persisted.get("device")
|
|
||||||
dev = coerce_audio_device(sel)
|
|
||||||
audio_detector.start(device=dev)
|
|
||||||
print("[startup] audio beat detector started from saved run state")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[startup] audio auto-start skipped: {e!r}")
|
|
||||||
from util import beat_driver_route
|
|
||||||
|
|
||||||
beat_driver_route.set_beat_route_main_loop(asyncio.get_running_loop())
|
|
||||||
from util import sequence_playback as seq_pb
|
|
||||||
|
|
||||||
seq_pb.ensure_beat_consumer_started()
|
|
||||||
|
|
||||||
# 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
|
|
||||||
# Verify controllers are Microdot instances before mounting
|
|
||||||
controllers_to_mount = [
|
|
||||||
('/presets', preset, 'preset'),
|
|
||||||
('/profiles', profile, 'profile'),
|
|
||||||
('/groups', group, 'group'),
|
|
||||||
('/sequences', sequence, 'sequence'),
|
|
||||||
('/zones', zone, 'zone'),
|
|
||||||
('/palettes', palette, 'palette'),
|
|
||||||
('/scenes', scene, 'scene'),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Mount model controllers as subroutes
|
|
||||||
app.mount(preset.controller, '/presets')
|
|
||||||
app.mount(profile.controller, '/profiles')
|
|
||||||
app.mount(group.controller, '/groups')
|
|
||||||
app.mount(sequence.controller, '/sequences')
|
|
||||||
app.mount(zone.controller, '/zones')
|
|
||||||
app.mount(palette.controller, '/palettes')
|
|
||||||
app.mount(scene.controller, '/scenes')
|
|
||||||
app.mount(pattern.controller, '/patterns')
|
|
||||||
app.mount(settings_controller.controller, '/settings')
|
|
||||||
app.mount(wifi_bridge_controller.controller, '/settings/wifi')
|
|
||||||
app.mount(device_controller.controller, '/devices')
|
|
||||||
app.mount(led_tool_controller.controller, '/led-tool')
|
|
||||||
|
|
||||||
live_reload = _live_reload_enabled()
|
|
||||||
dev_build_id = secrets.token_hex(12) if live_reload else None
|
|
||||||
if live_reload:
|
|
||||||
print(
|
|
||||||
"[dev] LED_CONTROLLER_LIVE_RELOAD: browser refreshes when the server process restarts"
|
|
||||||
)
|
|
||||||
|
|
||||||
if dev_build_id:
|
|
||||||
|
|
||||||
@app.route("/__dev/build-id")
|
|
||||||
def dev_build_id_route(request):
|
|
||||||
_ = request
|
|
||||||
return (
|
|
||||||
dev_build_id,
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
"Content-Type": "text/plain; charset=utf-8",
|
|
||||||
"Cache-Control": "no-store",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Serve index.html at root (cwd is src/ when run via pipenv run run)
|
|
||||||
@app.route("/")
|
|
||||||
def index(request):
|
|
||||||
"""Serve the main web UI."""
|
|
||||||
if dev_build_id:
|
|
||||||
try:
|
|
||||||
with open("templates/index.html", encoding="utf-8") as f:
|
|
||||||
html = f.read()
|
|
||||||
tag = '<script src="/static/dev-live-reload.js" defer></script>'
|
|
||||||
if "</body>" in html:
|
|
||||||
html = html.replace("</body>", tag + "\n</body>", 1)
|
|
||||||
return html, 200, {"Content-Type": "text/html; charset=utf-8"}
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
return send_file("templates/index.html")
|
|
||||||
|
|
||||||
# Favicon: avoid 404 in browser console (no file needed)
|
|
||||||
@app.route('/favicon.ico')
|
|
||||||
def favicon(request):
|
|
||||||
return '', 204
|
|
||||||
|
|
||||||
@app.route('/api/audio/devices')
|
|
||||||
async def audio_devices(request):
|
|
||||||
_ = request
|
|
||||||
try:
|
|
||||||
return {
|
|
||||||
"devices": audio_detector.list_input_devices(),
|
|
||||||
"diagnostics": audio_detector.diagnostics(),
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
return {"error": str(e)}, 500
|
|
||||||
|
|
||||||
@app.route('/api/audio/start', methods=['POST'])
|
|
||||||
async def audio_start(request):
|
|
||||||
payload = request.json if isinstance(request.json, dict) else {}
|
|
||||||
device = payload.get("device", None)
|
|
||||||
if device in ("", None):
|
|
||||||
device = None
|
|
||||||
device_select = str(payload.get("device_select") or "").strip()
|
|
||||||
if not device_select and device not in ("", None):
|
|
||||||
device_select = str(device).strip()
|
|
||||||
try:
|
|
||||||
from util.pulse_audio_devices import resolve_capture_device
|
|
||||||
|
|
||||||
device = resolve_capture_device(device)
|
|
||||||
audio_detector.start(device=device)
|
|
||||||
from util.audio_run_persist import write_audio_run_state
|
|
||||||
|
|
||||||
write_audio_run_state(
|
|
||||||
enabled=True,
|
|
||||||
device=device,
|
|
||||||
device_override=str(payload.get("device_override") or ""),
|
|
||||||
device_select=device_select,
|
|
||||||
)
|
|
||||||
return {"ok": True, "status": audio_detector.status()}
|
|
||||||
except Exception as e:
|
|
||||||
return {"ok": False, "error": str(e)}, 500
|
|
||||||
|
|
||||||
@app.route('/api/audio/device', methods=['PUT'])
|
|
||||||
async def audio_set_device(request):
|
|
||||||
"""Save preferred input device without toggling run state."""
|
|
||||||
payload = request.json if isinstance(request.json, dict) else {}
|
|
||||||
device_select = str(payload.get("device_select") or "").strip()
|
|
||||||
device_override = str(payload.get("device_override") or "").strip()
|
|
||||||
raw = device_override if device_override else device_select
|
|
||||||
device = raw if raw else None
|
|
||||||
from util.audio_run_persist import read_audio_run_state, write_audio_run_state
|
|
||||||
|
|
||||||
prev = read_audio_run_state()
|
|
||||||
write_audio_run_state(
|
|
||||||
enabled=bool(prev.get("enabled")),
|
|
||||||
device=device if raw else None,
|
|
||||||
device_override=device_override,
|
|
||||||
device_select=device_select,
|
|
||||||
)
|
|
||||||
return {"ok": True, "audio_run": read_audio_run_state()}
|
|
||||||
|
|
||||||
@app.route('/api/audio/stop', methods=['POST'])
|
|
||||||
async def audio_stop(request):
|
|
||||||
_ = request
|
|
||||||
audio_detector.stop()
|
|
||||||
from util.audio_run_persist import write_audio_run_state
|
|
||||||
|
|
||||||
write_audio_run_state(enabled=False)
|
|
||||||
return {"ok": True, "status": audio_detector.status()}
|
|
||||||
|
|
||||||
@app.route('/api/audio/reset', methods=['POST'])
|
|
||||||
async def audio_reset(request):
|
|
||||||
"""Clear beat/BPM tracking state without stopping the detector."""
|
|
||||||
_ = request
|
|
||||||
ok = audio_detector.reset_tracking()
|
|
||||||
if not ok:
|
|
||||||
return {"ok": False, "error": "Audio detector is not running"}, 409
|
|
||||||
return {"ok": True, "status": audio_detector.status()}
|
|
||||||
|
|
||||||
@app.route('/api/audio/anchor-bar', methods=['POST'])
|
|
||||||
async def audio_anchor_bar(request):
|
|
||||||
"""Mark the current moment as bar beat 1 (downbeat)."""
|
|
||||||
_ = request
|
|
||||||
ok = audio_detector.anchor_bar_phase()
|
|
||||||
if not ok:
|
|
||||||
return {"ok": False, "error": "Audio detector is not running"}, 409
|
|
||||||
return {"ok": True, "status": audio_detector.status()}
|
|
||||||
|
|
||||||
@app.route('/api/audio/status')
|
|
||||||
async def audio_status(request):
|
|
||||||
_ = request
|
|
||||||
from util import beat_driver_route
|
|
||||||
from util import sequence_playback
|
|
||||||
|
|
||||||
st = audio_detector.status()
|
|
||||||
st["sequence"] = sequence_playback.playback_status()
|
|
||||||
st["manual_beat_stride"] = beat_driver_route.manual_beat_stride_status()
|
|
||||||
seq = st.get("sequence")
|
|
||||||
beat_readout = ""
|
|
||||||
if isinstance(seq, dict) and str(seq.get("beat_readout") or "").strip():
|
|
||||||
beat_readout = str(seq.get("beat_readout") or "").strip()
|
|
||||||
elif st.get("running"):
|
|
||||||
mb = st.get("manual_beat_stride")
|
|
||||||
if isinstance(mb, dict) and mb.get("active"):
|
|
||||||
try:
|
|
||||||
n = int(mb.get("stride_n") or 1)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
n = 1
|
|
||||||
n = max(1, min(64, n))
|
|
||||||
try:
|
|
||||||
bi = int(mb.get("beat_in_stride") or 1)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
bi = 1
|
|
||||||
pos = min(n, max(1, bi))
|
|
||||||
beat_readout = f"{pos}/{n}"
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
bs = int(st.get("beat_seq") or 0)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
bs = 0
|
|
||||||
if bs > 0:
|
|
||||||
beat_readout = str(bs)
|
|
||||||
st["beat_readout"] = beat_readout
|
|
||||||
from util.audio_run_persist import read_audio_run_state
|
|
||||||
|
|
||||||
st["beat_phase_ms"] = int(settings.get("audio_beat_phase_ms") or 0)
|
|
||||||
try:
|
|
||||||
st["input_volume"] = int(settings.get("audio_input_volume") or 100)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
st["input_volume"] = 100
|
|
||||||
st["input_volume"] = max(0, min(200, st["input_volume"]))
|
|
||||||
seq_wait = str(settings.get("sequence_switch_wait") or "beat").strip().lower()
|
|
||||||
if seq_wait not in ("beat", "downbeat"):
|
|
||||||
seq_wait = "beat"
|
|
||||||
st["sequence_switch_wait"] = seq_wait
|
|
||||||
st["audio_run"] = read_audio_run_state()
|
|
||||||
return {"status": st}
|
|
||||||
|
|
||||||
# Static file route
|
|
||||||
@app.route("/static/<path:path>")
|
|
||||||
def static_handler(request, path):
|
|
||||||
"""Serve static files."""
|
|
||||||
if '..' in path:
|
|
||||||
# Directory traversal is not allowed
|
|
||||||
return 'Not found', 404
|
|
||||||
return send_file('static/' + path)
|
|
||||||
|
|
||||||
@app.route('/ws')
|
|
||||||
@with_websocket
|
|
||||||
async def ws(request, ws):
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
data = await ws.receive()
|
|
||||||
if not data:
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
if isinstance(data, (bytes, bytearray)):
|
|
||||||
await bridge.send(bytes(data))
|
|
||||||
continue
|
|
||||||
parsed = json.loads(data)
|
|
||||||
addr = parsed.pop("to", None)
|
|
||||||
await bridge.send(parsed, addr=addr)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
await ws.send(json.dumps({"error": "Send failed"}))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
Device()
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
server_tasks: list[asyncio.Task] = []
|
|
||||||
|
|
||||||
def _graceful_shutdown(*_args):
|
|
||||||
print("[server] shutting down...")
|
|
||||||
try:
|
|
||||||
audio_detector.stop()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
from util import sequence_playback as seq_pb
|
|
||||||
|
|
||||||
seq_pb.stop()
|
|
||||||
for attr in ("_pending_beat_task", "_sim_beat_task"):
|
|
||||||
t = getattr(seq_pb, attr, None)
|
|
||||||
if t is not None and not t.done():
|
|
||||||
t.cancel()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if getattr(app, "server", None) is not None:
|
|
||||||
try:
|
|
||||||
app.shutdown()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
for t in server_tasks:
|
|
||||||
if not t.done():
|
|
||||||
t.cancel()
|
|
||||||
|
|
||||||
shutdown_handlers_registered = False
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
||||||
loop.add_signal_handler(sig, _graceful_shutdown)
|
|
||||||
shutdown_handlers_registered = True
|
|
||||||
except (NotImplementedError, RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
server_tasks[:] = [
|
|
||||||
asyncio.create_task(
|
|
||||||
app.start_server(host="0.0.0.0", port=port), name="http"
|
|
||||||
),
|
|
||||||
]
|
|
||||||
await asyncio.gather(*server_tasks)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
except OSError as e:
|
|
||||||
if e.errno == errno.EADDRINUSE:
|
|
||||||
print(
|
|
||||||
f"[server] bind failed (address already in use): {e!s}\n"
|
|
||||||
f"[server] HTTP is configured for port {port} (env PORT). "
|
|
||||||
f"Stop the other process or use a free port, e.g. PORT=8080 pipenv run run"
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
audio_detector.stop()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
srv = getattr(app, "server", None)
|
|
||||||
if srv is not None:
|
|
||||||
try:
|
|
||||||
srv.close()
|
|
||||||
await srv.wait_closed()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
app.server = None
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
for t in list(server_tasks):
|
|
||||||
if not t.done():
|
|
||||||
t.cancel()
|
|
||||||
if server_tasks:
|
|
||||||
await asyncio.gather(*server_tasks, return_exceptions=True)
|
|
||||||
pending = [
|
|
||||||
t
|
|
||||||
for t in asyncio.all_tasks(loop)
|
|
||||||
if t is not asyncio.current_task() and not t.done()
|
|
||||||
]
|
|
||||||
for t in pending:
|
|
||||||
t.cancel()
|
|
||||||
if pending:
|
|
||||||
await asyncio.gather(*pending, return_exceptions=True)
|
|
||||||
if shutdown_handlers_registered:
|
|
||||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
||||||
try:
|
|
||||||
loop.remove_signal_handler(sig)
|
|
||||||
except (NotImplementedError, OSError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import os
|
|
||||||
|
|
||||||
port = int(os.environ.get("PORT", 80))
|
|
||||||
try:
|
|
||||||
asyncio.run(main(port=port))
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("[server] interrupted")
|
|
||||||
84
src/microdot_asgi.py
Normal file
84
src/microdot_asgi.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"""ASGI bridge for existing Microdot route handlers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from microdot.microdot import Microdot, NoCaseDict, Request, Response
|
||||||
|
|
||||||
|
|
||||||
|
class MicrodotASGI:
|
||||||
|
"""Dispatch HTTP requests to a :class:`Microdot` application."""
|
||||||
|
|
||||||
|
def __init__(self, microdot_app: Microdot):
|
||||||
|
self.app = microdot_app
|
||||||
|
|
||||||
|
async def __call__(self, scope: dict, receive: Any, send: Any) -> None:
|
||||||
|
if scope.get("type") != "http":
|
||||||
|
return
|
||||||
|
|
||||||
|
body = b""
|
||||||
|
while True:
|
||||||
|
message = await receive()
|
||||||
|
if message["type"] != "http.request":
|
||||||
|
continue
|
||||||
|
body += message.get("body", b"")
|
||||||
|
if not message.get("more_body"):
|
||||||
|
break
|
||||||
|
|
||||||
|
headers = NoCaseDict()
|
||||||
|
for key, value in scope.get("headers", ()):
|
||||||
|
headers[key.decode("latin-1")] = value.decode("latin-1")
|
||||||
|
|
||||||
|
path = scope.get("path", "/") or "/"
|
||||||
|
query = scope.get("query_string", b"").decode("latin-1")
|
||||||
|
url = path + (f"?{query}" if query else "")
|
||||||
|
|
||||||
|
client = scope.get("client") or ("127.0.0.1", 0)
|
||||||
|
req = Request(
|
||||||
|
self.app,
|
||||||
|
client,
|
||||||
|
scope.get("method", "GET"),
|
||||||
|
url,
|
||||||
|
"1.1",
|
||||||
|
headers,
|
||||||
|
body=body,
|
||||||
|
)
|
||||||
|
|
||||||
|
res = await self.app.dispatch_request(req)
|
||||||
|
if res is Response.already_handled:
|
||||||
|
return
|
||||||
|
await _send_microdot_response(res, send)
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_microdot_response(res: Response, send: Any) -> None:
|
||||||
|
res.complete()
|
||||||
|
headers: list[tuple[bytes, bytes]] = []
|
||||||
|
for header, value in res.headers.items():
|
||||||
|
values = value if isinstance(value, list) else [value]
|
||||||
|
for item in values:
|
||||||
|
headers.append(
|
||||||
|
(header.lower().encode("latin-1"), str(item).encode("latin-1"))
|
||||||
|
)
|
||||||
|
|
||||||
|
body = res.body
|
||||||
|
if isinstance(body, str):
|
||||||
|
payload = body.encode()
|
||||||
|
elif isinstance(body, bytes):
|
||||||
|
payload = body
|
||||||
|
else:
|
||||||
|
parts: list[bytes] = []
|
||||||
|
async for chunk in res.body_iter():
|
||||||
|
if isinstance(chunk, str):
|
||||||
|
chunk = chunk.encode()
|
||||||
|
parts.append(chunk)
|
||||||
|
payload = b"".join(parts)
|
||||||
|
|
||||||
|
await send(
|
||||||
|
{
|
||||||
|
"type": "http.response.start",
|
||||||
|
"status": res.status_code,
|
||||||
|
"headers": headers,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await send({"type": "http.response.body", "body": payload})
|
||||||
@@ -1,25 +1,43 @@
|
|||||||
/* Polls server build id; full reload when watchfiles restarts Python (new process = new id). */
|
/* Reload when uvicorn restarts (build-id) or static/template files change (client-rev). */
|
||||||
(function () {
|
(function () {
|
||||||
var prev = null;
|
var prevBuild = null;
|
||||||
function tick() {
|
var prevRev = null;
|
||||||
fetch('/__dev/build-id', { cache: 'no-store', credentials: 'same-origin' })
|
|
||||||
|
function fetchText(url) {
|
||||||
|
return fetch(url, { cache: 'no-store', credentials: 'same-origin' })
|
||||||
.then(function (r) {
|
.then(function (r) {
|
||||||
return r.ok ? r.text() : '';
|
return r.ok ? r.text() : '';
|
||||||
})
|
})
|
||||||
.then(function (id) {
|
.catch(function () {
|
||||||
id = (id || '').trim();
|
return '';
|
||||||
if (!id) return;
|
});
|
||||||
if (prev === null) {
|
|
||||||
prev = id;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (id !== prev) {
|
|
||||||
prev = id;
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(function () {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
Promise.all([
|
||||||
|
fetchText('/__dev/build-id'),
|
||||||
|
fetchText('/__dev/client-rev'),
|
||||||
|
]).then(function (parts) {
|
||||||
|
var buildId = (parts[0] || '').trim();
|
||||||
|
var clientRev = (parts[1] || '').trim();
|
||||||
|
if (!buildId && !clientRev) return;
|
||||||
|
|
||||||
|
if (prevBuild === null && prevRev === null) {
|
||||||
|
prevBuild = buildId;
|
||||||
|
prevRev = clientRev;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var buildChanged = buildId && buildId !== prevBuild;
|
||||||
|
var revChanged = clientRev && clientRev !== prevRev;
|
||||||
|
if (buildChanged || revChanged) {
|
||||||
|
if (buildId) prevBuild = buildId;
|
||||||
|
if (clientRev) prevRev = clientRev;
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setInterval(tick, 750);
|
setInterval(tick, 750);
|
||||||
tick();
|
tick();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ Tests for the LED Controller project live under **`tests/`** (pytest + legacy sc
|
|||||||
| Path | Role |
|
| Path | Role |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `test_endpoints.py` | HTTP endpoint checks (**`LED_CONTROLLER_RUN_DEVICE_ENDPOINT_TESTS=1`**); **`test_zones`** / **`test_zone_edit_workflow`** hit **`/zones`** |
|
| `test_endpoints.py` | HTTP endpoint checks (**`LED_CONTROLLER_RUN_DEVICE_ENDPOINT_TESTS=1`**); **`test_zones`** / **`test_zone_edit_workflow`** hit **`/zones`** |
|
||||||
| `test_endpoints_pytest.py` | Pytest-style endpoint coverage (devices envelope transport mock) |
|
| `api_server.py` | Shared FastAPI `TestClient` fixture (`server`) for in-process API tests |
|
||||||
|
| `test_endpoints_pytest.py` | Pytest API coverage (profiles, zones, devices, bridge, audio, patterns) |
|
||||||
| `test_bridge_ws_client.py` | Bridge WebSocket client reconnect / send behaviour |
|
| `test_bridge_ws_client.py` | Bridge WebSocket client reconnect / send behaviour |
|
||||||
| `test_bridge_envelope.py` | Devices envelope build/split/delivery |
|
| `test_bridge_envelope.py` | Devices envelope build/split/delivery |
|
||||||
| `test_bridge_serial_frame.py` | Pi↔bridge USB serial framing |
|
| `test_bridge_serial_frame.py` | Pi↔bridge USB serial framing |
|
||||||
|
|||||||
167
tests/api_server.py
Normal file
167
tests/api_server.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"""Shared FastAPI test server fixture for API endpoint tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import builtins
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SRC_PATH = PROJECT_ROOT / "src"
|
||||||
|
LIB_PATH = PROJECT_ROOT / "lib"
|
||||||
|
|
||||||
|
for p in (str(PROJECT_ROOT), str(LIB_PATH), str(SRC_PATH)):
|
||||||
|
if p in sys.path:
|
||||||
|
sys.path.remove(p)
|
||||||
|
sys.path.insert(0, p)
|
||||||
|
|
||||||
|
|
||||||
|
class DummyBridge:
|
||||||
|
def __init__(self):
|
||||||
|
self.sent: list[tuple[Any, Optional[str]]] = []
|
||||||
|
|
||||||
|
async def send(self, data: Any, addr: Optional[str] = None):
|
||||||
|
if isinstance(data, dict):
|
||||||
|
from util.bridge_envelope import ( # noqa: E402
|
||||||
|
BROADCAST_MAC,
|
||||||
|
build_devices_envelope,
|
||||||
|
format_mac_key,
|
||||||
|
is_broadcast_mac,
|
||||||
|
normalize_mac_key,
|
||||||
|
)
|
||||||
|
from util.v1_wire import compact_envelope # noqa: E402
|
||||||
|
|
||||||
|
if data.get("v") == "1" and ("devices" in data or "dv" in data):
|
||||||
|
data = compact_envelope(data)
|
||||||
|
elif addr is not None:
|
||||||
|
s = str(addr).strip().lower()
|
||||||
|
if is_broadcast_mac(s):
|
||||||
|
mac_key = BROADCAST_MAC
|
||||||
|
else:
|
||||||
|
h = normalize_mac_key(s)
|
||||||
|
mac_key = format_mac_key(h) if h else None
|
||||||
|
if mac_key:
|
||||||
|
body = {k: v for k, v in data.items() if k != "v"}
|
||||||
|
data = build_devices_envelope({mac_key: body})
|
||||||
|
else:
|
||||||
|
data = json.dumps(data, separators=(",", ":"))
|
||||||
|
else:
|
||||||
|
data = json.dumps(data, separators=(",", ":"))
|
||||||
|
elif isinstance(data, (bytes, bytearray)):
|
||||||
|
data = bytes(data).decode(errors="ignore")
|
||||||
|
self.sent.append((data, addr))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def bridge_sent_envelope(bridge: DummyBridge, index: int) -> Dict[str, Any]:
|
||||||
|
data, _addr = bridge.sent[index]
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return data
|
||||||
|
return json.loads(data)
|
||||||
|
|
||||||
|
|
||||||
|
def device_body_from_envelope(envelope: Dict[str, Any], mac: str) -> Dict[str, Any]:
|
||||||
|
from util.bridge_envelope import format_mac_key, normalize_mac_key # noqa: E402
|
||||||
|
|
||||||
|
devs = envelope.get("dv") or envelope.get("devices") or {}
|
||||||
|
key = format_mac_key(normalize_mac_key(mac))
|
||||||
|
return devs[key]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
def server(monkeypatch, tmp_path_factory):
|
||||||
|
"""In-process FastAPI app with isolated db/settings."""
|
||||||
|
tmp_root = tmp_path_factory.mktemp("endpoint-tests")
|
||||||
|
tmp_db_dir = tmp_root / "db"
|
||||||
|
tmp_settings_file = tmp_root / "settings.json"
|
||||||
|
|
||||||
|
for p in (str(SRC_PATH), str(LIB_PATH), str(PROJECT_ROOT)):
|
||||||
|
if p in sys.path:
|
||||||
|
sys.path.remove(p)
|
||||||
|
sys.path.insert(0, p)
|
||||||
|
|
||||||
|
import settings as settings_mod # noqa: E402
|
||||||
|
|
||||||
|
settings_mod.Settings.SETTINGS_FILE = str(tmp_settings_file)
|
||||||
|
|
||||||
|
import models.model as model_mod # noqa: E402
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_mod, "_db_dir", lambda: str(tmp_db_dir))
|
||||||
|
|
||||||
|
import models.preset as models_preset # noqa: E402
|
||||||
|
import models.profile as models_profile # noqa: E402
|
||||||
|
import models.group as models_group # noqa: E402
|
||||||
|
import models.zone as models_zone # noqa: E402
|
||||||
|
import models.pallet as models_pallet # noqa: E402
|
||||||
|
import models.scene as models_scene # noqa: E402
|
||||||
|
import models.pattern as models_pattern # noqa: E402
|
||||||
|
import models.sequence as models_sequence # noqa: E402
|
||||||
|
import models.device as models_device # noqa: E402
|
||||||
|
|
||||||
|
for cls in (
|
||||||
|
models_preset.Preset,
|
||||||
|
models_profile.Profile,
|
||||||
|
models_group.Group,
|
||||||
|
models_zone.Zone,
|
||||||
|
models_pallet.Palette,
|
||||||
|
models_scene.Scene,
|
||||||
|
models_pattern.Pattern,
|
||||||
|
models_sequence.Sequence,
|
||||||
|
models_device.Device,
|
||||||
|
):
|
||||||
|
if hasattr(cls, "_instance"):
|
||||||
|
delattr(cls, "_instance")
|
||||||
|
|
||||||
|
orig_open = builtins.open
|
||||||
|
|
||||||
|
def patched_open(file, *args, **kwargs):
|
||||||
|
if isinstance(file, str):
|
||||||
|
if file in {"db/pattern.json", "pattern.json", "/db/pattern.json"}:
|
||||||
|
file = str(PROJECT_ROOT / "db" / "pattern.json")
|
||||||
|
return orig_open(file, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(builtins, "open", patched_open)
|
||||||
|
|
||||||
|
old_cwd = os.getcwd()
|
||||||
|
os.chdir(str(SRC_PATH))
|
||||||
|
|
||||||
|
dummy_bridge = DummyBridge()
|
||||||
|
|
||||||
|
try:
|
||||||
|
for mod_name in (
|
||||||
|
"controllers.preset",
|
||||||
|
"controllers.profile",
|
||||||
|
"controllers.group",
|
||||||
|
"controllers.sequence",
|
||||||
|
"controllers.zone",
|
||||||
|
"controllers.palette",
|
||||||
|
"controllers.scene",
|
||||||
|
"controllers.pattern",
|
||||||
|
"controllers.settings",
|
||||||
|
"controllers.device",
|
||||||
|
"controllers.wifi_bridge",
|
||||||
|
"fastapi_app",
|
||||||
|
"app_factory",
|
||||||
|
):
|
||||||
|
sys.modules.pop(mod_name, None)
|
||||||
|
|
||||||
|
from models.transport import set_bridge # noqa: E402
|
||||||
|
from fastapi_app import create_application # noqa: E402
|
||||||
|
|
||||||
|
set_bridge(dummy_bridge)
|
||||||
|
app = create_application(test_mode=True)
|
||||||
|
|
||||||
|
with TestClient(app, raise_server_exceptions=True) as client:
|
||||||
|
yield {
|
||||||
|
"base_url": "",
|
||||||
|
"client": client,
|
||||||
|
"bridge": dummy_bridge,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
os.chdir(old_cwd)
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
pytest_plugins = ["api_server"]
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
SRC_PATH = PROJECT_ROOT / "src"
|
SRC_PATH = PROJECT_ROOT / "src"
|
||||||
LIB_PATH = PROJECT_ROOT / "lib"
|
LIB_PATH = PROJECT_ROOT / "lib"
|
||||||
|
|||||||
@@ -1,85 +1,18 @@
|
|||||||
import asyncio
|
|
||||||
import builtins
|
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from typing import Any, Dict
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import requests
|
|
||||||
|
|
||||||
# Ensure imports resolve to the repo's `src/` + `lib/` code.
|
from api_server import ( # noqa: E402
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
DummyBridge,
|
||||||
SRC_PATH = PROJECT_ROOT / "src"
|
bridge_sent_envelope,
|
||||||
LIB_PATH = PROJECT_ROOT / "lib"
|
device_body_from_envelope,
|
||||||
|
)
|
||||||
for p in (str(SRC_PATH), str(LIB_PATH), str(PROJECT_ROOT)):
|
|
||||||
if p in sys.path:
|
|
||||||
sys.path.remove(p)
|
|
||||||
sys.path.insert(0, p)
|
|
||||||
|
|
||||||
from microdot import Microdot, send_file # noqa: E402
|
|
||||||
from microdot.session import Session # noqa: E402
|
|
||||||
from microdot.websocket import with_websocket # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
class DummyBridge:
|
def _json(resp) -> Dict[str, Any]:
|
||||||
def __init__(self):
|
|
||||||
self.sent: list[tuple[Any, Optional[str]]] = []
|
|
||||||
|
|
||||||
async def send(self, data: Any, addr: Optional[str] = None):
|
|
||||||
if isinstance(data, dict):
|
|
||||||
from util.bridge_envelope import ( # noqa: E402
|
|
||||||
BROADCAST_MAC,
|
|
||||||
build_devices_envelope,
|
|
||||||
format_mac_key,
|
|
||||||
is_broadcast_mac,
|
|
||||||
normalize_mac_key,
|
|
||||||
)
|
|
||||||
from util.v1_wire import compact_envelope # noqa: E402
|
|
||||||
|
|
||||||
if data.get("v") == "1" and ("devices" in data or "dv" in data):
|
|
||||||
data = compact_envelope(data)
|
|
||||||
elif addr is not None:
|
|
||||||
s = str(addr).strip().lower()
|
|
||||||
if is_broadcast_mac(s):
|
|
||||||
mac_key = BROADCAST_MAC
|
|
||||||
else:
|
|
||||||
h = normalize_mac_key(s)
|
|
||||||
mac_key = format_mac_key(h) if h else None
|
|
||||||
if mac_key:
|
|
||||||
body = {k: v for k, v in data.items() if k != "v"}
|
|
||||||
data = build_devices_envelope({mac_key: body})
|
|
||||||
else:
|
|
||||||
data = json.dumps(data, separators=(",", ":"))
|
|
||||||
else:
|
|
||||||
data = json.dumps(data, separators=(",", ":"))
|
|
||||||
elif isinstance(data, (bytes, bytearray)):
|
|
||||||
data = bytes(data).decode(errors="ignore")
|
|
||||||
self.sent.append((data, addr))
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _bridge_sent_envelope(bridge: DummyBridge, index: int) -> Dict[str, Any]:
|
|
||||||
data, _addr = bridge.sent[index]
|
|
||||||
if isinstance(data, dict):
|
|
||||||
return data
|
|
||||||
return json.loads(data)
|
|
||||||
|
|
||||||
|
|
||||||
def _device_body_from_envelope(envelope: Dict[str, Any], mac: str) -> Dict[str, Any]:
|
|
||||||
from util.bridge_envelope import format_mac_key, normalize_mac_key # noqa: E402
|
|
||||||
|
|
||||||
devs = envelope.get("dv") or envelope.get("devices") or {}
|
|
||||||
key = format_mac_key(normalize_mac_key(mac))
|
|
||||||
return devs[key]
|
|
||||||
|
|
||||||
|
|
||||||
def _json(resp: requests.Response) -> Dict[str, Any]:
|
|
||||||
# Many endpoints already set Content-Type; but be tolerant for now.
|
# Many endpoints already set Content-Type; but be tolerant for now.
|
||||||
return resp.json() # pragma: no cover
|
return resp.json() # pragma: no cover
|
||||||
|
|
||||||
@@ -91,7 +24,7 @@ def _find_id_by_field(list_resp_json: Dict[str, Any], field: str, value: str) ->
|
|||||||
raise AssertionError(f"Could not find id for {field}={value!r}")
|
raise AssertionError(f"Could not find id for {field}={value!r}")
|
||||||
|
|
||||||
|
|
||||||
def _create_and_apply_profile(c: requests.Session, base_url: str) -> str:
|
def _create_and_apply_profile(c, base_url: str) -> str:
|
||||||
"""Sequences/scenes/presets need an active profile in session."""
|
"""Sequences/scenes/presets need an active profile in session."""
|
||||||
unique_profile_name = f"pytest-profile-{uuid.uuid4().hex[:8]}"
|
unique_profile_name = f"pytest-profile-{uuid.uuid4().hex[:8]}"
|
||||||
resp = c.post(f"{base_url}/profiles", json={"name": unique_profile_name})
|
resp = c.post(f"{base_url}/profiles", json={"name": unique_profile_name})
|
||||||
@@ -102,243 +35,8 @@ def _create_and_apply_profile(c: requests.Session, base_url: str) -> str:
|
|||||||
return str(profile_id)
|
return str(profile_id)
|
||||||
|
|
||||||
|
|
||||||
def _start_microdot_server(app: Microdot, host: str, port: int):
|
|
||||||
"""
|
|
||||||
Start Microdot server on a background thread.
|
|
||||||
Returns (thread, chosen_port).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def runner():
|
|
||||||
loop = asyncio.new_event_loop()
|
|
||||||
asyncio.set_event_loop(loop)
|
|
||||||
try:
|
|
||||||
loop.run_until_complete(app.start_server(host=host, port=port))
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
loop.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
thread = threading.Thread(target=runner, daemon=True)
|
|
||||||
thread.start()
|
|
||||||
|
|
||||||
# Poll until the socket is bound and app.server is available.
|
|
||||||
chosen_port = None
|
|
||||||
deadline = time.time() + 5.0
|
|
||||||
while time.time() < deadline:
|
|
||||||
server = getattr(app, "server", None)
|
|
||||||
if server and getattr(server, "sockets", None):
|
|
||||||
sockets = server.sockets or []
|
|
||||||
if sockets:
|
|
||||||
chosen_port = sockets[0].getsockname()[1]
|
|
||||||
break
|
|
||||||
time.sleep(0.05)
|
|
||||||
|
|
||||||
if chosen_port is None:
|
|
||||||
raise RuntimeError("Microdot server failed to start in time")
|
|
||||||
|
|
||||||
return thread, chosen_port
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="function")
|
|
||||||
def server(monkeypatch, tmp_path_factory):
|
|
||||||
"""
|
|
||||||
Start the Microdot app in-process and return a test client.
|
|
||||||
"""
|
|
||||||
|
|
||||||
tmp_root = tmp_path_factory.mktemp("endpoint-tests")
|
|
||||||
tmp_db_dir = tmp_root / "db"
|
|
||||||
tmp_settings_file = tmp_root / "settings.json"
|
|
||||||
|
|
||||||
# Be defensive: pytest runners can sometimes alter sys.path ordering.
|
|
||||||
for p in (str(SRC_PATH), str(LIB_PATH), str(PROJECT_ROOT)):
|
|
||||||
if p in sys.path:
|
|
||||||
sys.path.remove(p)
|
|
||||||
sys.path.insert(0, p)
|
|
||||||
|
|
||||||
# Patch Settings so endpoint tests never touch real `settings.json`.
|
|
||||||
import settings as settings_mod # noqa: E402
|
|
||||||
|
|
||||||
settings_mod.Settings.SETTINGS_FILE = str(tmp_settings_file)
|
|
||||||
|
|
||||||
# Patch the Model db directory so endpoint CRUD is isolated.
|
|
||||||
import models.model as model_mod # noqa: E402
|
|
||||||
|
|
||||||
monkeypatch.setattr(model_mod, "_db_dir", lambda: str(tmp_db_dir))
|
|
||||||
|
|
||||||
# Reset model singletons (controllers instantiate model classes at import time).
|
|
||||||
# Import the classes first so we can delete their `_instance` attribute if present.
|
|
||||||
import models.preset as models_preset # noqa: E402
|
|
||||||
import models.profile as models_profile # noqa: E402
|
|
||||||
import models.group as models_group # noqa: E402
|
|
||||||
import models.zone as models_zone # noqa: E402
|
|
||||||
import models.pallet as models_pallet # noqa: E402
|
|
||||||
import models.scene as models_scene # noqa: E402
|
|
||||||
import models.pattern as models_pattern # noqa: E402
|
|
||||||
import models.sequence as models_sequence # noqa: E402
|
|
||||||
import models.device as models_device # noqa: E402
|
|
||||||
|
|
||||||
for cls in (
|
|
||||||
models_preset.Preset,
|
|
||||||
models_profile.Profile,
|
|
||||||
models_group.Group,
|
|
||||||
models_zone.Zone,
|
|
||||||
models_pallet.Palette,
|
|
||||||
models_scene.Scene,
|
|
||||||
models_pattern.Pattern,
|
|
||||||
models_sequence.Sequence,
|
|
||||||
models_device.Device,
|
|
||||||
):
|
|
||||||
if hasattr(cls, "_instance"):
|
|
||||||
delattr(cls, "_instance")
|
|
||||||
|
|
||||||
# Patch open() so pattern definitions work after we `chdir` into src/.
|
|
||||||
orig_open = builtins.open
|
|
||||||
|
|
||||||
def patched_open(file, *args, **kwargs):
|
|
||||||
if isinstance(file, str):
|
|
||||||
# Pattern controller loads definitions from a relative db/ path.
|
|
||||||
if file in {"db/pattern.json", "pattern.json", "/db/pattern.json"}:
|
|
||||||
file = str(PROJECT_ROOT / "db" / "pattern.json")
|
|
||||||
return orig_open(file, *args, **kwargs)
|
|
||||||
|
|
||||||
monkeypatch.setattr(builtins, "open", patched_open)
|
|
||||||
|
|
||||||
old_cwd = os.getcwd()
|
|
||||||
os.chdir(str(SRC_PATH))
|
|
||||||
|
|
||||||
dummy_bridge = DummyBridge()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Ensure controllers are imported fresh after our patching.
|
|
||||||
for mod_name in (
|
|
||||||
"controllers.preset",
|
|
||||||
"controllers.profile",
|
|
||||||
"controllers.group",
|
|
||||||
"controllers.sequence",
|
|
||||||
"controllers.zone",
|
|
||||||
"controllers.palette",
|
|
||||||
"controllers.scene",
|
|
||||||
"controllers.pattern",
|
|
||||||
"controllers.settings",
|
|
||||||
"controllers.device",
|
|
||||||
):
|
|
||||||
sys.modules.pop(mod_name, None)
|
|
||||||
|
|
||||||
# Import controllers after patching db/settings/model singletons.
|
|
||||||
import controllers.preset as preset_ctl # noqa: E402
|
|
||||||
import controllers.profile as profile_ctl # noqa: E402
|
|
||||||
import controllers.group as group_ctl # noqa: E402
|
|
||||||
import controllers.sequence as sequence_ctl # noqa: E402
|
|
||||||
import controllers.zone as zone_ctl # noqa: E402
|
|
||||||
import controllers.palette as palette_ctl # noqa: E402
|
|
||||||
import controllers.scene as scene_ctl # noqa: E402
|
|
||||||
import controllers.pattern as pattern_ctl # noqa: E402
|
|
||||||
import controllers.settings as settings_ctl # noqa: E402
|
|
||||||
import controllers.device as device_ctl # noqa: E402
|
|
||||||
|
|
||||||
# Configure transport bridge used by /presets/send.
|
|
||||||
from models.transport import set_bridge # noqa: E402
|
|
||||||
|
|
||||||
set_bridge(dummy_bridge)
|
|
||||||
|
|
||||||
app = Microdot()
|
|
||||||
|
|
||||||
# Session secret key comes from settings (patched to tmp).
|
|
||||||
settings = settings_mod.Settings()
|
|
||||||
secret_key = settings.get(
|
|
||||||
"session_secret_key",
|
|
||||||
"led-controller-secret-key-change-in-production",
|
|
||||||
)
|
|
||||||
Session(app, secret_key=secret_key)
|
|
||||||
|
|
||||||
# Mount model controllers under their public prefixes.
|
|
||||||
app.mount(preset_ctl.controller, "/presets")
|
|
||||||
app.mount(profile_ctl.controller, "/profiles")
|
|
||||||
app.mount(group_ctl.controller, "/groups")
|
|
||||||
app.mount(sequence_ctl.controller, "/sequences")
|
|
||||||
app.mount(zone_ctl.controller, "/zones")
|
|
||||||
app.mount(palette_ctl.controller, "/palettes")
|
|
||||||
app.mount(scene_ctl.controller, "/scenes")
|
|
||||||
app.mount(pattern_ctl.controller, "/patterns")
|
|
||||||
app.mount(settings_ctl.controller, "/settings")
|
|
||||||
app.mount(device_ctl.controller, "/devices")
|
|
||||||
|
|
||||||
@app.route("/")
|
|
||||||
def index(request):
|
|
||||||
return send_file("templates/index.html")
|
|
||||||
|
|
||||||
@app.route("/settings")
|
|
||||||
def settings_page(request):
|
|
||||||
return send_file("templates/settings.html")
|
|
||||||
|
|
||||||
@app.route("/favicon.ico")
|
|
||||||
def favicon(request):
|
|
||||||
return "", 204
|
|
||||||
|
|
||||||
@app.route("/static/<path:path>")
|
|
||||||
def static_handler(request, path):
|
|
||||||
if ".." in path:
|
|
||||||
return "Not found", 404
|
|
||||||
return send_file("static/" + path)
|
|
||||||
|
|
||||||
@app.route("/ws")
|
|
||||||
@with_websocket
|
|
||||||
async def ws(request, ws):
|
|
||||||
# Minimal websocket handler: forward raw JSON/text payloads to dummy bridge.
|
|
||||||
while True:
|
|
||||||
data = await ws.receive()
|
|
||||||
if not data:
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
parsed = json.loads(data)
|
|
||||||
addr = parsed.pop("to", None)
|
|
||||||
payload = json.dumps(parsed) if parsed else data
|
|
||||||
await dummy_bridge.send(payload, addr=addr)
|
|
||||||
except Exception:
|
|
||||||
await dummy_bridge.send(data)
|
|
||||||
|
|
||||||
thread, chosen_port = _start_microdot_server(app, host="127.0.0.1", port=0)
|
|
||||||
base_url = f"http://127.0.0.1:{chosen_port}"
|
|
||||||
|
|
||||||
client = requests.Session()
|
|
||||||
client.headers.update(
|
|
||||||
{
|
|
||||||
"User-Agent": "pytest/requests",
|
|
||||||
"Accept": "application/json",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
yield {
|
|
||||||
"base_url": base_url,
|
|
||||||
"client": client,
|
|
||||||
"bridge": dummy_bridge,
|
|
||||||
"thread": thread,
|
|
||||||
"app": app,
|
|
||||||
}
|
|
||||||
finally:
|
|
||||||
# Stop server cleanly.
|
|
||||||
try:
|
|
||||||
app = locals().get("app")
|
|
||||||
if app is not None:
|
|
||||||
app.shutdown()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Give it a moment to close sockets.
|
|
||||||
time.sleep(0.1)
|
|
||||||
try:
|
|
||||||
thread = locals().get("thread")
|
|
||||||
if thread is not None:
|
|
||||||
thread.join(timeout=5)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
os.chdir(old_cwd)
|
|
||||||
|
|
||||||
|
|
||||||
def test_main_routes(server):
|
def test_main_routes(server):
|
||||||
c: requests.Session = server["client"]
|
c = server["client"]
|
||||||
base_url: str = server["base_url"]
|
base_url: str = server["base_url"]
|
||||||
|
|
||||||
resp = c.get(f"{base_url}/")
|
resp = c.get(f"{base_url}/")
|
||||||
@@ -355,14 +53,12 @@ def test_main_routes(server):
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert "LED Controller" in resp.text
|
assert "LED Controller" in resp.text
|
||||||
|
|
||||||
resp = c.get(f"{base_url}/ws")
|
with c.websocket_connect("/ws") as ws:
|
||||||
# WebSocket endpoints should reject non-upgraded HTTP requests.
|
ws.send_text('{"v":"1","select":["off"]}')
|
||||||
assert resp.status_code != 200
|
|
||||||
assert resp.status_code in {400, 401, 403, 404, 405, 426}
|
|
||||||
|
|
||||||
|
|
||||||
def test_settings_controller(server):
|
def test_settings_controller(server):
|
||||||
c: requests.Session = server["client"]
|
c = server["client"]
|
||||||
base_url: str = server["base_url"]
|
base_url: str = server["base_url"]
|
||||||
|
|
||||||
resp = c.get(f"{base_url}/settings")
|
resp = c.get(f"{base_url}/settings")
|
||||||
@@ -418,7 +114,7 @@ def test_settings_controller(server):
|
|||||||
|
|
||||||
|
|
||||||
def test_profiles_presets_zones_endpoints(server, monkeypatch):
|
def test_profiles_presets_zones_endpoints(server, monkeypatch):
|
||||||
c: requests.Session = server["client"]
|
c = server["client"]
|
||||||
base_url: str = server["base_url"]
|
base_url: str = server["base_url"]
|
||||||
bridge: DummyBridge = server["bridge"]
|
bridge: DummyBridge = server["bridge"]
|
||||||
|
|
||||||
@@ -594,7 +290,7 @@ def test_profiles_presets_zones_endpoints(server, monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
|
def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
|
||||||
c: requests.Session = server["client"]
|
c = server["client"]
|
||||||
base_url: str = server["base_url"]
|
base_url: str = server["base_url"]
|
||||||
bridge: DummyBridge = server["bridge"]
|
bridge: DummyBridge = server["bridge"]
|
||||||
|
|
||||||
@@ -713,9 +409,9 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json().get("message")
|
assert resp.json().get("message")
|
||||||
assert len(bridge.sent) >= 1
|
assert len(bridge.sent) >= 1
|
||||||
first = _bridge_sent_envelope(bridge, 0)
|
first = bridge_sent_envelope(bridge, 0)
|
||||||
assert first["v"] == "1"
|
assert first["v"] == "1"
|
||||||
first_body = _device_body_from_envelope(first, dev_id)
|
first_body = device_body_from_envelope(first, dev_id)
|
||||||
assert first_body["p"]["__identify"]["p"] == "blink"
|
assert first_body["p"]["__identify"]["p"] == "blink"
|
||||||
assert first_body["p"]["__identify"]["d"] == 50
|
assert first_body["p"]["__identify"]["d"] == 50
|
||||||
assert first_body["s"] == ["__identify"]
|
assert first_body["s"] == ["__identify"]
|
||||||
@@ -723,8 +419,8 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
|
|||||||
while len(bridge.sent) < 2 and time.monotonic() < deadline:
|
while len(bridge.sent) < 2 and time.monotonic() < deadline:
|
||||||
time.sleep(0.02)
|
time.sleep(0.02)
|
||||||
assert len(bridge.sent) >= 2
|
assert len(bridge.sent) >= 2
|
||||||
second = _bridge_sent_envelope(bridge, 1)
|
second = bridge_sent_envelope(bridge, 1)
|
||||||
second_body = _device_body_from_envelope(second, dev_id)
|
second_body = device_body_from_envelope(second, dev_id)
|
||||||
assert second_body["s"] == ["off"]
|
assert second_body["s"] == ["off"]
|
||||||
|
|
||||||
resp = c.post(
|
resp = c.post(
|
||||||
@@ -868,3 +564,118 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
|
|||||||
)
|
)
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_api(server):
|
||||||
|
c = server["client"]
|
||||||
|
base_url = server["base_url"]
|
||||||
|
|
||||||
|
resp = c.get(f"{base_url}/api/audio/status")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert "status" in body
|
||||||
|
assert "audio_run" in body["status"]
|
||||||
|
|
||||||
|
resp = c.get(f"{base_url}/api/audio/devices")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "devices" in resp.json()
|
||||||
|
|
||||||
|
resp = c.put(
|
||||||
|
f"{base_url}/api/audio/device",
|
||||||
|
json={"device_select": "default", "device_override": ""},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json().get("ok") is True
|
||||||
|
|
||||||
|
resp = c.post(f"{base_url}/api/audio/reset")
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
resp = c.post(f"{base_url}/api/audio/stop")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json().get("ok") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_bridge_settings_api(server, monkeypatch):
|
||||||
|
c = server["client"]
|
||||||
|
base_url = server["base_url"]
|
||||||
|
|
||||||
|
import controllers.wifi_bridge as wifi_bridge_ctl # noqa: E402
|
||||||
|
|
||||||
|
monkeypatch.setattr(wifi_bridge_ctl, "nmcli_available", lambda: True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
wifi_bridge_ctl,
|
||||||
|
"list_wifi_interfaces",
|
||||||
|
lambda: [{"device": "wlan0", "type": "wifi", "state": "connected"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _fake_scan(device):
|
||||||
|
_ = device
|
||||||
|
return [{"ssid": "bridge-test", "signal": 80}]
|
||||||
|
|
||||||
|
monkeypatch.setattr(wifi_bridge_ctl, "scan_wifi", _fake_scan)
|
||||||
|
|
||||||
|
resp = c.get(f"{base_url}/settings/wifi/interfaces")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json().get("ok") is True
|
||||||
|
assert resp.json()["interfaces"][0]["device"] == "wlan0"
|
||||||
|
|
||||||
|
resp = c.get(f"{base_url}/settings/wifi/scan", params={"device": "wlan0"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["networks"][0]["ssid"] == "bridge-test"
|
||||||
|
|
||||||
|
resp = c.get(f"{base_url}/settings/wifi/bridges")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
payload = resp.json()
|
||||||
|
assert payload.get("ok") is True
|
||||||
|
assert "bridge_transport" in payload
|
||||||
|
assert "bridges" in payload
|
||||||
|
|
||||||
|
resp = c.put(
|
||||||
|
f"{base_url}/settings/wifi/bridges",
|
||||||
|
json={
|
||||||
|
"bridge_transport": "serial",
|
||||||
|
"bridge_serial_port": "/dev/ttyUSB0",
|
||||||
|
"bridges": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json().get("ok") is True
|
||||||
|
|
||||||
|
resp = c.get(f"{base_url}/settings/wifi/bridges")
|
||||||
|
assert resp.json().get("bridge_transport") == "serial"
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_identify(server, monkeypatch):
|
||||||
|
c = server["client"]
|
||||||
|
base_url = server["base_url"]
|
||||||
|
bridge: DummyBridge = server["bridge"]
|
||||||
|
|
||||||
|
import controllers.device as device_ctl # noqa: E402
|
||||||
|
|
||||||
|
monkeypatch.setattr(device_ctl, "IDENTIFY_OFF_DELAY_S", 0.05)
|
||||||
|
|
||||||
|
_create_and_apply_profile(c, base_url)
|
||||||
|
|
||||||
|
resp = c.post(f"{base_url}/groups", json={"name": "pytest-identify-group"})
|
||||||
|
assert resp.status_code == 201
|
||||||
|
groups_list = c.get(f"{base_url}/groups").json()
|
||||||
|
group_id = _find_id_by_field(groups_list, "name", "pytest-identify-group")
|
||||||
|
|
||||||
|
resp = c.post(
|
||||||
|
f"{base_url}/devices",
|
||||||
|
json={"name": "identify-dev", "address": "aabbccddeeff"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
dev_id = "aabbccddeeff"
|
||||||
|
|
||||||
|
resp = c.put(
|
||||||
|
f"{base_url}/groups/{group_id}",
|
||||||
|
json={"devices": [dev_id]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
bridge.sent.clear()
|
||||||
|
resp = c.post(f"{base_url}/groups/{group_id}/identify")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json().get("sent", 0) >= 1
|
||||||
|
assert len(bridge.sent) >= 1
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user