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:
2026-06-08 10:33:38 +12:00
parent cfdd6de291
commit 2382ef16a1
14 changed files with 1309 additions and 814 deletions

View File

@@ -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
View File

@@ -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",

View File

@@ -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

View File

@@ -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.*'

View File

@@ -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
View 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
View 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()

View File

@@ -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
View 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})

View File

@@ -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();
})(); })();

View File

@@ -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
View 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)

View File

@@ -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"

View File

@@ -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