diff --git a/Pipfile b/Pipfile index 438abba..d1280a6 100644 --- a/Pipfile +++ b/Pipfile @@ -14,9 +14,12 @@ requests = "*" selenium = "*" adafruit-ampy = "*" microdot = "*" +fastapi = "*" websockets = "*" +httpx = "*" numpy = "*" sounddevice = "*" +uvicorn = {extras = ["standard"], version = "*"} [dev-packages] pytest = "*" @@ -27,8 +30,8 @@ python_version = "3.11" [scripts] web = "python tests/web.py" watch = "python -m watchfiles \"python tests/web.py\" src tests" -run = "sh -c 'cd src && python main.py'" -dev = "python -m watchfiles \"sh -c 'cd src && LED_CONTROLLER_LIVE_RELOAD=1 python main.py'\" src" +run = "sh -c 'cd src && uvicorn fastapi_app:app --host 0.0.0.0 --port \"${PORT:-80}\"'" +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-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'" diff --git a/Pipfile.lock b/Pipfile.lock index b1b8b6a..8a2ee6b 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "2387dc49cb5166bfd75072d96e74e8fdac3b60afccba34d8bdca3d9da1552b80" + "sha256": "b6783f8de9b1f98387fccab1d9fed190f2acbf66f0f188e43fe91f28a6950ad1" }, "pipfile-spec": 6, "requires": { @@ -24,6 +24,22 @@ "index": "pypi", "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": { "hashes": [ "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", @@ -455,11 +471,20 @@ }, "esptool": { "hashes": [ - "sha256:9c355b7d6331cc92979cc710ae5c41f59830d1ea29ec24c467c6005a092c06d6" + "sha256:0a077cb3ee8e60e223882c06ab7dae9b3686816c2547904d7472a42e6284e7de" ], "index": "pypi", "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": { "hashes": [ @@ -469,13 +494,85 @@ "markers": "python_version >= '3.8'", "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": { "hashes": [ - "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", - "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d" + "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", + "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848" ], "markers": "python_version >= '3.9'", - "version": "==3.16" + "version": "==3.18" }, "intelhex": { "hashes": [ @@ -607,11 +704,11 @@ }, "platformdirs": { "hashes": [ - "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", - "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917" + "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", + "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a" ], "markers": "python_version >= '3.10'", - "version": "==4.9.6" + "version": "==4.10.0" }, "pycparser": { "hashes": [ @@ -621,6 +718,140 @@ "markers": "implementation_name != 'PyPy'", "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": { "hashes": [ "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", @@ -775,11 +1006,11 @@ }, "rich-click": { "hashes": [ - "sha256:022997c1e30731995bdbc8ec2f82819340d42543237f033a003c7b1f843fc5dc", - "sha256:2f99120fca78f536e07b114d3b60333bc4bb2a0969053b1250869bcdc1b5351b" + "sha256:12873865396e6927835d4eabb1cc3996edcd65b7ac9b2391a29eca4f335a2f93", + "sha256:4008f921da88b5d91646c134ec881c1500e5a6b3f093e90e8f29400e09608371" ], "markers": "python_version >= '3.8'", - "version": "==1.9.7" + "version": "==1.9.8" }, "selenium": { "hashes": [ @@ -818,6 +1049,14 @@ "markers": "python_version >= '3.7'", "version": "==0.5.5" }, + "starlette": { + "hashes": [ + "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", + "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6" + ], + "markers": "python_version >= '3.10'", + "version": "==1.2.1" + }, "tibs": { "hashes": [ "sha256:01ea5258bdf942d21560dc07d532082cd04f07cfef65fedd58ae84f7d0d2562a", @@ -879,6 +1118,14 @@ "markers": "python_version >= '3.9'", "version": "==4.15.0" }, + "typing-inspection": { + "hashes": [ + "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", + "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464" + ], + "markers": "python_version >= '3.9'", + "version": "==0.4.2" + }, "urllib3": { "extras": [ "socks" @@ -890,6 +1137,71 @@ "markers": "python_version >= '3.10'", "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": { "hashes": [ "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", diff --git a/README.md b/README.md index 3048d08..b19bf3a 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ LED controller web app for managing profiles, **zones**, presets, and colour pal ## Run - 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) -- Dev watcher (auto-restart on `src/` changes): `pipenv run dev` +- Start app: `pipenv run run` (FastAPI + uvicorn; override listen port with **`PORT`**) +- 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) ## UI modes diff --git a/scripts/dev-run.sh b/scripts/dev-run.sh index 8d183bc..4b1eab4 100644 --- a/scripts/dev-run.sh +++ b/scripts/dev-run.sh @@ -13,4 +13,8 @@ if [ -n "${pids}" ]; then fi 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.*' diff --git a/scripts/start.sh b/scripts/start.sh index 370fd51..89369c0 100755 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -35,4 +35,4 @@ if [ -z "$PYTHON" ]; then fi cd "$ROOT/src" -exec "$PYTHON" -u main.py +exec "$PYTHON" -u -m uvicorn fastapi_app:app --host 0.0.0.0 --port "$PORT" diff --git a/src/app_factory.py b/src/app_factory.py new file mode 100644 index 0000000..ac2e304 --- /dev/null +++ b/src/app_factory.py @@ -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 = '' + if "" in html: + html = html.replace("", tag + "\n", 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/") + 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 diff --git a/src/fastapi_app.py b/src/fastapi_app.py new file mode 100644 index 0000000..620fab6 --- /dev/null +++ b/src/fastapi_app.py @@ -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() diff --git a/src/main.py b/src/main.py deleted file mode 100644 index ef95007..0000000 --- a/src/main.py +++ /dev/null @@ -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 = '' - if "" in html: - html = html.replace("", tag + "\n", 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/") - 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") diff --git a/src/microdot_asgi.py b/src/microdot_asgi.py new file mode 100644 index 0000000..3512939 --- /dev/null +++ b/src/microdot_asgi.py @@ -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}) diff --git a/src/static/dev-live-reload.js b/src/static/dev-live-reload.js index 64f3ec6..69a194a 100644 --- a/src/static/dev-live-reload.js +++ b/src/static/dev-live-reload.js @@ -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 () { - var prev = null; - function tick() { - fetch('/__dev/build-id', { cache: 'no-store', credentials: 'same-origin' }) + var prevBuild = null; + var prevRev = null; + + function fetchText(url) { + return fetch(url, { cache: 'no-store', credentials: 'same-origin' }) .then(function (r) { return r.ok ? r.text() : ''; }) - .then(function (id) { - id = (id || '').trim(); - if (!id) return; - if (prev === null) { - prev = id; - return; - } - if (id !== prev) { - prev = id; - window.location.reload(); - } - }) - .catch(function () {}); + .catch(function () { + return ''; + }); } + + 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); tick(); })(); diff --git a/tests/README.md b/tests/README.md index b13af3a..76fe80e 100644 --- a/tests/README.md +++ b/tests/README.md @@ -7,7 +7,8 @@ Tests for the LED Controller project live under **`tests/`** (pytest + legacy sc | 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_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_envelope.py` | Devices envelope build/split/delivery | | `test_bridge_serial_frame.py` | Pi↔bridge USB serial framing | diff --git a/tests/api_server.py b/tests/api_server.py new file mode 100644 index 0000000..f342142 --- /dev/null +++ b/tests/api_server.py @@ -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) diff --git a/tests/conftest.py b/tests/conftest.py index 7e71963..4da0c8e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,8 @@ from pathlib import Path import sys +pytest_plugins = ["api_server"] + PROJECT_ROOT = Path(__file__).resolve().parents[1] SRC_PATH = PROJECT_ROOT / "src" LIB_PATH = PROJECT_ROOT / "lib" diff --git a/tests/test_endpoints_pytest.py b/tests/test_endpoints_pytest.py index ea72fbf..162d0ca 100644 --- a/tests/test_endpoints_pytest.py +++ b/tests/test_endpoints_pytest.py @@ -1,85 +1,18 @@ -import asyncio -import builtins import json -import os -import sys -import threading import time import uuid -from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Dict import pytest -import requests -# Ensure imports resolve to the repo's `src/` + `lib/` code. -PROJECT_ROOT = Path(__file__).resolve().parents[1] -SRC_PATH = PROJECT_ROOT / "src" -LIB_PATH = PROJECT_ROOT / "lib" - -for p in (str(SRC_PATH), str(LIB_PATH), str(PROJECT_ROOT)): - if p in sys.path: - sys.path.remove(p) - sys.path.insert(0, p) - -from microdot import Microdot, send_file # noqa: E402 -from microdot.session import Session # noqa: E402 -from microdot.websocket import with_websocket # noqa: E402 +from api_server import ( # noqa: E402 + DummyBridge, + bridge_sent_envelope, + device_body_from_envelope, +) -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] - - -def _json(resp: requests.Response) -> Dict[str, Any]: +def _json(resp) -> Dict[str, Any]: # Many endpoints already set Content-Type; but be tolerant for now. 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}") -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.""" unique_profile_name = f"pytest-profile-{uuid.uuid4().hex[:8]}" 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) -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/") - 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): - c: requests.Session = server["client"] + c = server["client"] base_url: str = server["base_url"] resp = c.get(f"{base_url}/") @@ -355,14 +53,12 @@ def test_main_routes(server): assert resp.status_code == 200 assert "LED Controller" in resp.text - resp = c.get(f"{base_url}/ws") - # WebSocket endpoints should reject non-upgraded HTTP requests. - assert resp.status_code != 200 - assert resp.status_code in {400, 401, 403, 404, 405, 426} + with c.websocket_connect("/ws") as ws: + ws.send_text('{"v":"1","select":["off"]}') def test_settings_controller(server): - c: requests.Session = server["client"] + c = server["client"] base_url: str = server["base_url"] resp = c.get(f"{base_url}/settings") @@ -418,7 +114,7 @@ def test_settings_controller(server): def test_profiles_presets_zones_endpoints(server, monkeypatch): - c: requests.Session = server["client"] + c = server["client"] base_url: str = server["base_url"] 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): - c: requests.Session = server["client"] + c = server["client"] base_url: str = server["base_url"] bridge: DummyBridge = server["bridge"] @@ -713,9 +409,9 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server): assert resp.status_code == 200 assert resp.json().get("message") assert len(bridge.sent) >= 1 - first = _bridge_sent_envelope(bridge, 0) + first = bridge_sent_envelope(bridge, 0) 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"]["d"] == 50 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: time.sleep(0.02) assert len(bridge.sent) >= 2 - second = _bridge_sent_envelope(bridge, 1) - second_body = _device_body_from_envelope(second, dev_id) + second = bridge_sent_envelope(bridge, 1) + second_body = device_body_from_envelope(second, dev_id) assert second_body["s"] == ["off"] resp = c.post( @@ -868,3 +564,118 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server): ) 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 +