v0.12.1-cleanups

This commit is contained in:
Mateusz Gruszczyński
2026-09-04 09:42:43 +02:00
parent 7fd5921ba9
commit 642f43ed9f
12 changed files with 63 additions and 8712 deletions
+24 -27
View File
@@ -1,16 +1,14 @@
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
d56cfff4179a6511aa764a91c6ddebe21fcd35f36b7298e62d012e11f62bfeb9 ./build.rs
0696b5112a35943786aa4dbe0547b745d158309795a8e8967946eed07cb0d1e7 ./Cargo.lock
000dd8d1a535f357667cf812f817a2abbc72eb79fe062ae2dcf8fef65d6b7491 ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
c353091e7695cd19f77bdcad80b41438710a7315c5f2696016dc482000023a21 ./README.md
d56cfff4179a6511aa764a91c6ddebe21fcd35f36b7298e62d012e11f62bfeb9 ./build.rs
ceafb0ec649a7a7ea0c469501dab144743306150c15869f3a084bab3ce47f51d ./data/gree-controller.db
9596e50efb3ce09afd823d2507c814cfbffe812a622b45c9e0d53526e8cd996a ./docs/API.md
1b522e1b61643733accf0a45b8b862cfb96e1468343c9819600e263fe67a2b96 ./docs/CODE_AUDIT_0.12.0.md
2e1e18fd8167dabfe2469c26e85cce62486c7cb6a502c63c6f6b0cd74d5885e0 ./docs/FLOW.md
933f560266bd309be0b1eaaa943e86874646e0b9ada5e805bf47a7a62f7063b8 ./docs/openapi.json
a0893b2a56eb1523f1a72871842e9be2139a5fafba1f51ae942fc407a6e4ca34 ./home-assistant/README.md
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
d48b8a8fbd32b483e5aa176111ec821450a338438780f73fde11e11ac2ac4271 ./gree-controller.zip
6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py
71c8058f85fd9ce9f063b7a84dcf2956f23cdbbd02bd4bd99d2fbb9d177c72ad ./home-assistant/custom_components/gree_controller/climate.py
5e4aef2143e81bedb5a15dd4be5c71b64a3ab448ec6d33a20851edd098e5f529 ./home-assistant/custom_components/gree_controller/config_flow.py
@@ -18,6 +16,7 @@ b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./home-assista
ab08fbe40e9bb48ebcbfff98760aaa0c9434b61b705aaaadea0c09c255d99b0e ./home-assistant/custom_components/gree_controller/coordinator.py
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.png
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png
fec5ff50552f331bc0f994d9980059f1b1c309a57419e20f785c04d93dcd14f9 ./home-assistant/custom_components/gree_controller/manifest.json
38d659d0273c0d1428679e44574a2666c605d1b3796d323b626baa40560788cf ./home-assistant/custom_components/gree_controller/number.py
@@ -27,14 +26,12 @@ cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./home-assista
6bddb7b4620021ecd2099a86a77ef5c7f2c2dcd3d07d5db4e7b4c4ce6d3e8c03 ./home-assistant/custom_components/gree_controller/translations/en.json
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json
c5fc5c87273d82d2834a0a5a365c821a416413ed509613441e24e4dc3507c4d2 ./home-assistant/generated/gree_controller_entities.example.json
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
b11e581dd916676e24402112fe6896bc681abf3c94cafc85c4d3ded3f3a272a3 ./home-assistant/README.md
810bf9a9483277cbde14454b6b88dbfe79b873a6f34737031a7e10d23fda9299 ./lang/en.json
ad48d11875aa041c0f593aa6e322f8d953aef137d4df4c369c5f82eec5aebd11 ./lang/pl.json
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
daf25cf3faf39865033e57cc7f79a34b0e0dbca174a37d349611f14d77143e15 ./mock/index.html
efe0fb67500857bfcebb2dcfba77fdc551e5c2e32d2ffff4b092c19528dc679e ./mock/mock.js
6abcb2d3607aa76d763f4ccaa17523f6bc69faaf19e89f0b306559750ff2f066 ./mock/styles.css
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./mock/theme-init.js
b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json
1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json
c5276fecc6a33c5de912e5a73b0a829d4e18c18d57b9c0ee49179ef96f133fc7 ./presets/dual_threshold_control.json
@@ -72,8 +69,8 @@ fffa18bd989a0dcc1c5458975522517d40af4af40cc1ca30640e79d3db711053 ./presets/unoc
ae21459a261712bcb8d57594528b1648432e8d03a8a38502234829c0dbfef774 ./presets/weekend_comfort.json
fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/window_available_guard.json
804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json
c353091e7695cd19f77bdcad80b41438710a7315c5f2696016dc482000023a21 ./README.md
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
cd2eb8c851ab3cacd00efcb5cdbd404f03279e75126c46f025e132ba1e9d4ce1 ./scripts/api_dev_test.py
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh
@@ -82,13 +79,12 @@ cd2eb8c851ab3cacd00efcb5cdbd404f03279e75126c46f025e132ba1e9d4ce1 ./scripts/api_
e4849261fd9ed1f01df96c0637c439c0c4eff8fa317b2918026167bba343af79 ./scripts/install-lxc.sh
bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./scripts/install.sh
e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./scripts/regenerate-sha.sh
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
7e8f558b284362f55f50bd7223a89754a2a4960e746a27035cc61180f4a2da6f ./scripts/smoke.sh
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
0790b7b388467d9e75b621188cca473e22d73e01bcfa74e92fa23f92b0695855 ./scripts/verify_flow_logic.py
0d1471ef9a384919fff623086027a9edd58a844524588a6860d7b2016667a319 ./src/api.rs
d090e676e55fd9de1cb46accfec841b93e3dae81006f6e18faa3466639b2e51e ./src/api/assets.rs
04084aac0c2af07a9bb2d0d7d4499efaed47944aefff526f32c73a557a491224 ./src/api/assets.rs
05c255919f7b3d81188fc5f1410be6a0037471dfc44e93bffb009f29a1e6a3fc ./src/api/auth.rs
9e4fc675306e111ed3db7af9822e2925d88109c31b9e309851665a975c0bd83c ./src/api/automations.rs
368cd843dcac970f1aa6800ba132a4bbc3429e8379ed7abf9878dcb0c633af40 ./src/api/configuration.rs
@@ -100,16 +96,16 @@ d090e676e55fd9de1cb46accfec841b93e3dae81006f6e18faa3466639b2e51e ./src/api/asse
641f17dc5b107e6f13949ada8d05e8fdf878d0e44666ddc72aea71a57c70df06 ./src/api/history.rs
e8edfbb725d6de650442444b0a929ad2be0bd2c2d7d227d2b3fdd99d05c94f94 ./src/api/house.rs
6139e17a08e35d0fcd07ee5625983c602d758ae77452c924f4b4cc05580e9ee8 ./src/api/integrations.rs
ef3b245ffafd73e0084da9b291e9c7d98c014206a42a914e33ed6ca73aad070c ./src/api/middleware.rs
6bfbe5135d4f93bd8d19a707877b53ed2029fd8490b4b1954a7520888fb1d664 ./src/api/middleware.rs
45e34a447ff384d99a3c42c489ca9fa75849c74d4d246caa91c63d927de17396 ./src/api/openapi.rs
744ce18aa9e0227c6cdfb6980063d0d738d74c5865d45c20fd20026d4742bb3e ./src/api/public_settings.rs
e86df48db04868bd65bb7815fb9205fec8b29b5212cfb84af74990e0eb380120 ./src/api.rs
5b29f2e5aa2c422c5829ef89c7b59cf753883aa7f60661c95ab0f95cf676fa3f ./src/api/schedules.rs
9d157e1901081c077e32430d5d4d7f328ffb4933996579f584ce2959d5d10f47 ./src/api/settings.rs
20dddb2a07f4a647226783b66f6f3032c6a1ba2910134408d79cf362f909299c ./src/api/system.rs
63f02760d0e2cfd59788b5b1cdf4317c685aab1b5a0a5665d52b50f793ce5c74 ./src/api/websocket.rs
c742634b5c8001b8e31ce313a1114abb1f44f18186a86860de4b6b1c01a4832f ./src/api/zones.rs
f06014454d5062b1be17ac50d875a42c02fc778b59702ec455611265ab7de934 ./src/config.rs
e0340b0e5945d5a38a1054e549c819566d5c1ef6034ab0e4d44c68a3b9a249cf ./src/db.rs
6c278fc5728124ab2f53ac7789a733942a0462f397144a231cc878c8cfcf7426 ./src/db/climate.rs
be4082b142fe16ba40c5978b287b7ef808e67c13edbd8f2d6637ccad974b8847 ./src/db/configuration.rs
bc10f1a66ddcaacbb08b096a02998482da547024dd0ba3144d00f993b4351cb0 ./src/db/core_devices.rs
@@ -117,10 +113,10 @@ bc10f1a66ddcaacbb08b096a02998482da547024dd0ba3144d00f993b4351cb0 ./src/db/core_
eb07d94b58bcc8ea17b5b086cc88c165a35666f84c3d9a1d74f72dc30b979829 ./src/db/events_tokens.rs
bb5048ee60053f39ce3c1203dc1f24c7047310538d2357f105a052c016d21ec4 ./src/db/flows.rs
5f4b567c6bc866d0674b3b0c66f4aeccf561774623f575b872ff25bf015b91c2 ./src/db/ha_history.rs
e0340b0e5945d5a38a1054e549c819566d5c1ef6034ab0e4d44c68a3b9a249cf ./src/db.rs
c0a7fa3eac198737d7261ad00c542a614e6d7291f6581af8e7f7aef9dec1a049 ./src/db/schedules_automations.rs
1a209dc6d8df864778bb03c980f309a2ec86fc62df274880fbb95eff8470174e ./src/db/tests.rs
f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs
35f7ed5fb42199afbb331dd6cfb992815a77a3fea346edc176cd4a2b0fcab58f ./src/engine.rs
ec6034df6667b3ac6ec39851443048a56e87fa861f5846950a5e79fa89ee5a30 ./src/engine/automations.rs
88f85acf692e12c7061e039dd7c54f101fe0b014c4a3d799150ad92eea9e2894 ./src/engine/commands.rs
10ab2d7abc0f149936393129459dc56e85d8fca51d96da71c742cb96e86a6f08 ./src/engine/control_plan.rs
@@ -130,6 +126,7 @@ f0c8f3384b6174771aeb5640ee1eec87f3d6d9ff79a4fbd6a35554a4eec00cfa ./src/engine/h
41b7045877771b7c352328998cb45ce2433e7f9631fad3eb1a882ab4897dc31f ./src/engine/local_thermostat.rs
6280a7f5059d9737987bf2d72b19a26b3a6709ce578af1108c4215f3f47d7763 ./src/engine/ownership.rs
9c37adf514c96840fd6a5c715c579350fa86d6dcc0655a2aa97d1fd7c7e04299 ./src/engine/polling.rs
35f7ed5fb42199afbb331dd6cfb992815a77a3fea346edc176cd4a2b0fcab58f ./src/engine.rs
b0e33539dc7ef52dfa4cad500a96b26bf124b2bc195c9f7965fe03eb71de6621 ./src/engine/runtime.rs
7c4ad4de24833504c2d2c692f5a154977f0d6ddba2bd82f16235806a85a8d64e ./src/engine/schedules.rs
de80d9ff69f1c843fdac23fdc3e3a350fc1082a186261b3a38617af189132356 ./src/engine/targets.rs
@@ -140,12 +137,11 @@ de80d9ff69f1c843fdac23fdc3e3a350fc1082a186261b3a38617af189132356 ./src/engine/t
56d9af59eb700ac6363d26db0ffbea777270cdbbe8e5b7eb982bff9f02539eee ./src/engine/zone_control.rs
2276aca42bca712a55de9b6c5dbe64639675c84befa85a446a7d34613596ed7e ./src/error.rs
2731f629f22a9d4fdb831ea3b283f43900b190f7e6a08fdab5b8fbb2c288547b ./src/home_assistant.rs
342dcb54caf22d2282a3f3f2297f54bd413b5242c17756276ea74994e88e28fb ./src/influxdb.rs
da4068295f37c23222bb13bebac022d88ba7729604a49ad25438c55e45458a49 ./src/influxdb/codec.rs
1769e2a96e336bf39b28ae9de02a5d942350afa2bb9702df7603fabe9d7fcba7 ./src/influxdb/query.rs
342dcb54caf22d2282a3f3f2297f54bd413b5242c17756276ea74994e88e28fb ./src/influxdb.rs
29dc8ed02dfa1f6419bf4aedfb99c805b25c8a2e772efef20681c750570928cf ./src/influxdb/write.rs
df4b6550fea08e070488ca9abafb8c3794d13b21715a7160d255839e662768e2 ./src/main.rs
23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs
920e028d96a14cca9f74a964bb296e68a109cfead314d3164c180201e606d151 ./src/models/automation.rs
3c3f344fe42cdde980b13dbec6e92ed0e2f5946cba8ac19b1a668846b48e7c81 ./src/models/control_plan.rs
9cd1904a0f822c557f6ad4576cdb5f96d9668d04e79ea9783885c9f39519752e ./src/models/defaults.rs
@@ -153,13 +149,13 @@ f935d35b4a409f12774209f3fa2ffe60f579a8bc1722badbb27d4766efd6368e ./src/models/d
aeab5b5acf35d38a54fd1d82e964fe56635ade69c0149ad423df317095d01555 ./src/models/flow.rs
cf63af00d98410a1452f205ce00b8a6c5f0f3525b994cba3e971d631db288396 ./src/models/history.rs
ea216f24f0f385903324f15a1164a0fc508adbdcc3c30d16383ea2e835c53e6e ./src/models/integrations.rs
23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs
8294749dd3af524925309c51ad641ffd32ff1fd9c164cfa71660aa88c2d13d6e ./src/models/runtime.rs
887eb80a58998e90023e952c1fde67435eb843493387ae64536613b74c084534 ./src/models/settings_api.rs
c4de6101de85d9d7e6814b2504b87d9216561e3d9e274054f081f505fb152140 ./src/models/temporary_thermostat.rs
7d798228a8d4d4e60a112167c001535f4b8a5fa32ec7a33554c73125c42256c2 ./src/models/zone.rs
2ccac88080ef3ca8546facad5167cbaadc080bdee3f5d9100c8fabb0fc3ae49d ./src/notifications.rs
cca742bb2b500eb9269f69385fcf88053cd08aabf6a98b7669b8a7a2fd67df13 ./src/protocol/crypto.rs
28969da06a454ddd1ebea5fa606e8446a83dfd21e21352767c189013c508cb90 ./src/protocol/gree.rs
7907c33e6396c7e0d61705b735b21c84d6ed784012fb61022b872c6745540f1d ./src/protocol/gree/binding.rs
d4df52f0202eb32290e68dc945c4b0b44f3be8aa90bd57fd116eb20285960616 ./src/protocol/gree/commands.rs
4e419ba3b86ca9d154e8c04445cf005965175c54041093b2daad3666d4d852bd ./src/protocol/gree/core.rs
@@ -167,14 +163,15 @@ d4df52f0202eb32290e68dc945c4b0b44f3be8aa90bd57fd116eb20285960616 ./src/protocol
0ab094f609edb99715402d8eeaa7e38cbfd2d271036aec787dcbb3be3e005720 ./src/protocol/gree/merge.rs
a96061fbbcadac6c2df2e9708a48dcf819b951eb147f32c7d0d285ac5ae2491b ./src/protocol/gree/network.rs
de37931303fc92a9399e6edc17dfabd6870a38cd2ddb27257427c76768a66ec2 ./src/protocol/gree/polling.rs
28969da06a454ddd1ebea5fa606e8446a83dfd21e21352767c189013c508cb90 ./src/protocol/gree.rs
c868422c02ba457df21ae92acbd00698c243855ddd1d4e95ac2166796eba9a44 ./src/protocol/gree/tests.rs
3c93e5b254a2a40a07854532cb5fc07e493ad65d9ba2cf28093cff4a4a4e451c ./src/protocol/gree/transport.rs
923bb4b2d342d6c1870474e43f33b0aba16dc88590c561d765b874412e83df1f ./src/protocol/mod.rs
830621fa75265da7288b8135704c0ce27e0687f87d55abf3cdbe40a6e2f3d2b1 ./src/queries.rs
7fac65a9dcbcecee09816f5447c7792dc94f736b1efb66ac85629db75f6280be ./src/queries/device_history.rs
3fb50f57d30ac23ef122c45dae4bd96c7cb7184df02d99aef00d9890b66479e2 ./src/queries/entities.rs
2c5fba462b72158b04dbaaa53c8536263f8d72a1338a6c4ff06cb28a36f6ae27 ./src/queries/ha_history.rs
c91e4b8b250b30cbe76f313b70c16244e9003bce92e02c82283a1831f91974c5 ./src/queries/maintenance.rs
830621fa75265da7288b8135704c0ce27e0687f87d55abf3cdbe40a6e2f3d2b1 ./src/queries.rs
3a49bad74a2f047893e726b5e00a6e833c228ad268bdf50cd60f786ba164ea54 ./src/queries/schema.rs
f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs
df2f13c98b9d7f84916dcbf9e01a479a6f392f888413fe22bae586526747f704 ./src/state.rs
@@ -182,7 +179,6 @@ b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree
544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
d0388bf22f34ae28fb63074b0bc15c087a3be298bb702d006d4814ebb786b36e ./web/index.html
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
8fb20fdba11df68feb0b096bb5b4c8f936db974aed5d345a0d01455054f31f95 ./web/js/bootstrap.js
badde8a5214a0322b2c45803c02a1cb723d53b25e57c687369cd804f509d9744 ./web/js/charts.js
af92b2140f6248bc636d7c75e37a9460f4128ba59adf1b7842d25244a607555c ./web/js/core.js
@@ -194,11 +190,12 @@ f06557c6d338259059b990f8f79c91d68ac8ec1f245c302468f08929b5506901 ./web/js/forms
dbf36223863ba882c03eccea4516ba8db7acf0c2e72c6c64490cb994f1e20f96 ./web/js/history.js
7eaa04992ea828dc89f5eaebf674cbbfd160f53eb8d0d6ef0d4e47bd8c28c435 ./web/js/main.js
733fcc595ed9c03c9639acc6caba46f0588f29e0b95bf72891e34443e7aa9db8 ./web/js/navigation.js
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
8505ff605de9bcb11f79a0d01a6c5c15f0482d3f65a5e94cc510308a36f558cc ./web/js/realtime.js
c8c82e88c3715b1bfabf155e36266a4c94f5e2fc03eb39dcdd9d08a1985a997c ./web/js/router.js
427376a35832cb3644206c6e89e1654bd807eec78352f80bd0dd43f74b4ec810 ./web/js/settings-ui.js
9dd9fbcc5f00584562bae6a840189495f830d1086d9679763dbad6e9c18c4fdb ./web/js/settings.js
427376a35832cb3644206c6e89e1654bd807eec78352f80bd0dd43f74b4ec810 ./web/js/settings-ui.js
6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest
9976b7dd54d0075c3bc153f520740d25b7870042c32020b96ac8547669a1f148 ./web/styles.css
5c939ab91aa28ba386aefef6ba5f41b0c73fae3936f6e915fbc39e2336859605 ./web/styles.css
ae1b03f30b494f474a781a5d32072f1e73eba2b7c768109fc1cc8028cb6662a4 ./web/sw.js
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
-75
View File
@@ -1,75 +0,0 @@
# Code audit — 0.12.0
Scope: settings/configuration API, frontend consumers, configuration import/export, and the largest control handlers touched by the API/runtime ownership changes.
## Breaking API redesign
0.12.0 intentionally removes the previous monolithic/compatibility routes. There are no aliases.
| Removed | Replacement |
| --- | --- |
| `GET/PUT /api/settings` | functional `/api/settings/*` resources |
| `GET/PUT /api/debug` | `GET/PUT /api/settings/debug` |
| `PUT /api/events/retention` | `GET/PUT /api/settings/history` |
| `GET /api/settings/export` | `GET /api/configuration/export` |
| `POST /api/settings/import` | `POST /api/configuration/import` |
Functional settings resources:
- `/api/settings/application`
- `/api/settings/gree`
- `/api/settings/history`
- `/api/settings/influxdb`
- `/api/settings/notifications`
- `/api/settings/night`
- `/api/settings/home-assistant`
- `/api/settings/debug`
The bootstrap payload no longer exposes runtime settings. The Web UI fetches each settings resource explicitly.
## Correctness and security findings addressed
- Settings writes no longer require a full `RuntimeSettings` document, so changing one section cannot overwrite unrelated settings.
- Integration secrets are never returned by settings GET endpoints. Secret-bearing PUT fields are optional: omitted/null preserves the stored value; an explicit empty string clears it.
- Configuration backup/restore was separated from runtime settings into `/api/configuration/*`.
- Configuration format was raised to version `3`; imports accept only version `3`.
- Format-v3 imports reject unsupported control strategies; `0.12.0` accepts only `setpoint`.
- Import validates and normalizes the same GREE/history/InfluxDB/notification/night/Home Assistant constraints used by functional settings endpoints.
- Shared Flow inputs in an imported backup are validated against devices/zones/groups from that backup, not against the configuration currently stored in the database.
- Obsolete `house_power_enabled` and `master_power_enabled` compatibility state was removed from runtime/settings/group responses.
- Settings WebSocket broadcasts are section-specific (`settings.<section>.updated`) instead of the former whole-settings event.
## Refactoring performed
Approximate function lengths are shown to make the audit measurable; line count is only a hotspot signal, not a quality metric by itself.
| Area | Before | After |
| --- | ---: | ---: |
| `engine::control_group` | 292 | 137 |
| `api::apply_zone_control_patch` | 415 | 214 |
| `engine::control_zones` | 666 | 413 |
| monolithic `api::update_settings` | 83 | removed |
| monolithic configuration validator | 155 | removed/split |
| monolithic settings import handler | 98 | removed/split |
New smaller responsibilities include:
- GREE settings normalization and compressor-runtime cleanup;
- InfluxDB and notification update/secret merge helpers;
- Home Assistant alias/entity/input validation;
- configuration header, IDs, flows, schedules and automation validation;
- configuration runtime sanitation, resource locking, detached-device shutdown and reconciliation;
- group patch validation, climate changes, power changes and ownership updates;
- Temporary Quick Thermostat start, target and finish resolution;
- control-cycle outdoor sensor resolution, parallel room sensor reads, temperature-source selection and pre-control state handling.
## Remaining hotspots
`control_zones` remains the largest function because it is the ordered thermostat-cycle orchestrator and contains compressor/setpoint command planning. It was reduced substantially without changing lock ordering or splitting the core state machine across opaque abstractions. Flow evaluation/compiler functions remain domain-heavy but were not changed as part of this API release because they are independent of the settings redesign and already have dedicated logical verification.
## Frontend/documentation
- Web UI settings loading/saving uses only the new functional endpoints.
- Configuration import/export uses `/api/configuration/*`.
- Realtime settings handling uses section-specific events and serializes WebSocket message processing so reconnect bootstrap cannot race later updates.
- `docs/API.md`, `docs/openapi.json`, README and smoke/static Flow checks were updated for 0.12.0.
+6 -6
View File
@@ -10,7 +10,7 @@ It creates HA entities for physical units, thermostat zones, whole-house control
The climate proxy supports power/turn on/off, HVAC modes, target temperature, fan mode, vertical swing and horizontal swing.
Version 0.5.0 additionally exposes the controller's automation plan:
Automation plan:
- `sensor` for the whole-house plan (house mode, upcoming events and active rules),
- one `sensor` per thermostat zone with current demand, active preset/schedule and upcoming transitions,
@@ -118,13 +118,13 @@ Bedroom -> GREE Bedroom + sensor.bedroom_temperature
```
The recommended `combined` strategy keeps the GREE sensor as the primary input and uses the room sensor as a configurable supporting measurement (40% weight by default). A zone may also select the room sensor as its preferred source. If HA or that entity becomes unavailable, the controller falls back to the corresponding GREE unit, so local control and schedules continue to run.
## Zone climate and optional unit features (0.5.4)
## Zone climate and optional unit features
Each controller zone is exposed as a full Home Assistant `climate` entity with current/target temperature and HVAC modes: Off, Auto (follow the controller house mode), Cool and Heat. For a **zone thermostat**, Auto does not mean the GREE unit's native automatic heat/cool algorithm: it means **inherit the whole-house Heating/Cooling selection from GREE Controller**. Direct physical-device climate entities still use the native GREE Auto mode. The target temperature remains published while a zone is Off, so Home Assistant can display the configured setpoint instead of `unknown`. The existing zone target `number` and enabled `switch` remain available for compatibility.
From version 0.7.9, each zone climate also supports preset modes `auto`, `comfort`, `sleep` and `away`, and the same choices are exposed as a separate **Work profile** `select` on the zone device. `auto` removes the temporary per-zone profile override and returns the zone to its schedule. The zone enable switch reports the configured zone state independently from group control. Turning a group OFF powers its member units down and releases group ownership. Members are not blocked merely because the group remains OFF: an individual thermostat can be turned back on independently. Turning the group ON clears that scoped OFF state and immediately resumes group thermostat arbitration. From version 0.7.10, climate entity ON/OFF controls local thermostat power rather than technically enabling/disabling the zone. From 0.7.12, every local OFF creates a fresh backend-owned 15-minute hand-back deadline exposed as `local_thermostat_resume_at`. A direct/pilot takeover suspends expiry of that local timer; if the unit is returned to the previous OFF state, the backend re-arms a fresh 15-minute countdown from that moment. The separate zone Enabled switch remains the technical availability switch.
Each zone climate also supports preset modes `auto`, `comfort`, `sleep` and `away`, and the same choices are exposed as a separate **Work profile** `select` on the zone device. `auto` removes the temporary per-zone profile override and returns the zone to its schedule. The zone enable switch reports the configured zone state independently from group control. Turning a group OFF powers its member units down and releases group ownership. Members are not blocked merely because the group remains OFF: an individual thermostat can be turned back on independently. Turning the group ON clears that scoped OFF state and immediately resumes group thermostat arbitration. From version 0.7.10, climate entity ON/OFF controls local thermostat power rather than technically enabling/disabling the zone. From 0.7.12, every local OFF creates a fresh backend-owned 15-minute hand-back deadline exposed as `local_thermostat_resume_at`. A direct/pilot takeover suspends expiry of that local timer; if the unit is returned to the previous OFF state, the backend re-arms a fresh 15-minute countdown from that moment. The separate zone Enabled switch remains the technical availability switch.
From version 0.6.4, zone climate entities use `climate.<zone_name>_thermostat`, for example `climate.igor_thermostat`. On integration reload, existing zone climate registry entries are migrated to this scheme using the zone's current name. If the target entity ID is already occupied, the old ID is retained and Home Assistant logs a warning.
Zone climate entities use `climate.<zone_name>_thermostat`, for example `climate.igor_thermostat`. On integration reload, existing zone climate registry entries are migrated to this scheme using the zone's current name. If the target entity ID is already occupied, the old ID is retained and Home Assistant logs a warning.
When the controller detects optional GREE properties, the integration also creates switches for supported features such as panel light, Quiet, Turbo, X-FAN, Air, Health and native Sleep. Reload the integration (or restart Home Assistant) after upgrading so newly added entity types are created.
@@ -140,7 +140,7 @@ The integration also exposes three controller-level entities on the **GREE Contr
These controls use the Home Assistant integration token and the dedicated `/api/integrations/home-assistant/house/*` endpoints.
## Climate groups (0.7.3)
## Climate groups
Every group configured in **GREE Controller -> Groups** is published as a separate Home Assistant device. A group does not receive a fake common target temperature because its member zones can legitimately use different profile temperatures. Instead, the group device exposes the controls that exactly match the controller model:
@@ -153,6 +153,6 @@ If member zones have been changed individually and no longer share one mode or p
After upgrading the custom integration, restart Home Assistant or reload **Settings -> Devices & services -> GREE Controller**. Also reload the integration after adding/removing/renaming groups so new group devices/entities are created.
## Command state stability (0.7.6)
## Command state stability
Direct physical-unit commands use a short pending-state guard in the Home Assistant coordinator. Some GREE firmware acknowledges a command before its status endpoint stops returning the previous value; the guard prevents that transient stale read from rendering as an `ON -> OFF -> ON` (or reverse) bounce. From version 0.7.9 the same guard also covers per-zone enable, HVAC mode, profile and target-temperature commands. The standalone controller also retries post-command verification for a bounded settling window. Failed commands drop the guard immediately and refresh factual state.
-1308
View File
File diff suppressed because it is too large Load Diff
-36
View File
@@ -1,36 +0,0 @@
document.addEventListener('DOMContentLoaded',()=>{
const q=new URLSearchParams(location.search);
document.documentElement.dataset.theme='dark';
const view=q.get('view')||'dashboard';
document.querySelectorAll('.view').forEach(v=>v.classList.toggle('active',v.dataset.view===view));
if(view==='dashboard'){
document.querySelector('#summaryText').textContent='3 urządzenia online · 2 aktywne termostaty';
document.querySelector('#metrics').innerHTML='<span>Online <b>3/3</b></span><span>Aktywne <b>2</b></span>';
document.querySelector('#heroTemperature').innerHTML='21.4<small>°C</small>';
document.querySelector('#houseClimate').innerHTML='<div class="house-climate-head"><div><h3>Tryb domu</h3><p>Wspólne ustawienia dla stref.</p></div><span class="outside-pill">Na zewnątrz 12.8°C</span></div><div class="house-mode-row"><button class="active">Grzanie</button><button>Chłodzenie</button></div><div class="house-preset-row"><button>Auto</button><button class="active">Komfort</button><button>Sen</button><button>Poza domem</button></div>';
document.querySelector('#controlPlan').innerHTML='<article class="list-card"><div class="list-card-head"><div><h3>Salon</h3><p>Utrzymuj 21.5°C do 22:30</p></div><span class="badge">Aktywne</span></div></article><article class="list-card"><div class="list-card-head"><div><h3>Sypialnia</h3><p>Tryb nocny od 22:30</p></div><span class="badge">za 2h</span></div></article>';
}
if(view==='simulation'){
document.querySelector('#simulationTarget').innerHTML='<option>Cały dom</option><option>Salon</option><option>Sypialnia</option>';
document.querySelector('#simulationSummary').innerHTML='<article class="panel simulation-summary-card"><small>Tryb domu</small><strong>Grzanie</strong><span>Strategia: temperatura zadana</span></article><article class="panel simulation-summary-card"><small>Tryb nocny</small><strong>Wyłączony</strong><span>22:00 → 06:00</span></article><article class="panel simulation-summary-card"><small>Na zewnątrz</small><strong>12.8°C</strong><span>Aktualizacja 08:30</span></article><article class="panel simulation-summary-card"><small>Aktywne strefy</small><strong>2</strong><span>1 strefa zgłasza zapotrzebowanie</span></article>';
const board=document.querySelector('#simulationFlowBoard'); board.style.width='1325px'; board.style.height='390px';
board.innerHTML='<div class="flow-lane-label" style="top:128px"><strong>Salon</strong><span>GREE Salon</span></div><article class="flow-node input" style="left:45px;top:155px;width:190px;min-height:106px"><div class="flow-node-top"><span>Temperatura</span><b class="flow-node-badge active">●</b></div><h3>Salon</h3><strong>20.8°C</strong><p>Home Assistant</p></article><article class="flow-node logic" style="left:305px;top:155px;width:190px;min-height:106px"><div class="flow-node-top"><span>Termostat</span></div><h3>Salon</h3><strong>21.5°C</strong><p>Komfort</p></article><article class="flow-node logic decision demand" style="left:565px;top:155px;width:190px;min-height:106px"><div class="flow-node-top"><span>Decyzja</span><b class="flow-node-badge active">▶</b></div><h3>Grzanie</h3><strong>Potrzeba ciepła</strong><p>Poniżej celu</p></article><article class="flow-node action" style="left:825px;top:155px;width:190px;min-height:106px"><div class="flow-node-top"><span>Polecenie</span></div><h3>GREE Salon</h3><strong>22.0°C · Auto</strong><p>Quiet: wył.</p></article>';
document.querySelector('#simulationTimeline').innerHTML='<article class="simulation-timeline-item"><time>08:45</time><div><strong>Salon</strong><p>Komfort · 21.5°C · GREE Salon</p></div></article><article class="simulation-timeline-item"><time>22:00</time><div><strong>Cały dom</strong><p>Tryb nocny</p></div></article><article class="simulation-timeline-item"><time>06:00 jutro</time><div><strong>Sypialnia</strong><p>Komfort · 20.5°C · GREE Sypialnia</p></div></article>';
document.querySelector('#simulationRules').innerHTML='<article class="simulation-rule-card"><div class="simulation-rule-head"><div><span class="eyebrow">Automatyzacja</span><h3>Otwarte okno</h3></div><span class="badge active">Stan</span></div><p>Urządzenie: GREE Salon</p><div class="simulation-rule-action">Wyłącz termostat, gdy okno pozostaje otwarte.</div><div class="simulation-rule-meta"><small>Ostatnio: 07:12</small><small>Gotowa: teraz</small></div></article><article class="simulation-rule-card"><div class="simulation-rule-head"><div><span class="eyebrow">Automatyzacja</span><h3>Tani prąd</h3></div><span class="badge active">Próg</span></div><p>Urządzenie: GREE Sypialnia</p><div class="simulation-rule-action">Podnieś temperaturę docelową o 0.5°C.</div><div class="simulation-rule-meta"><small>Ostatnio: wczoraj</small><small>Gotowa: teraz</small></div></article>';
}
if(view==='flows'){
document.querySelector('#flowListCount').textContent='3 flow';
document.querySelector('#flowList').innerHTML=`<article class="list-card flow-card"><div class="list-card-head"><div><h3>Okno w salonie</h3><p>Wyłącza grzanie, gdy okno jest otwarte.</p></div><button class="zone-enable-toggle active"><span>✓</span>Włączone</button></div><div class="card-stats"><div class="card-stat"><small>Bloki</small><strong>5</strong></div><div class="card-stat"><small>Harmonogramy</small><strong>0</strong></div><div class="card-stat"><small>Automatyzacje</small><strong>1</strong></div></div><div class="card-footer"><small>1 automatyzacja</small><div class="card-menu"><button>Otwórz edytor</button><button class="danger">Usuń</button></div></div></article><article class="list-card flow-card is-disabled"><div class="list-card-head"><div><h3>Tani prąd</h3><p>Podbija temperaturę przy niskiej cenie energii.</p></div><button class="zone-enable-toggle"><span>○</span>Wyłączone</button></div><div class="card-stats"><div class="card-stat"><small>Bloki</small><strong>7</strong></div><div class="card-stat"><small>Harmonogramy</small><strong>1</strong></div><div class="card-stat"><small>Automatyzacje</small><strong>2</strong></div></div><div class="card-footer"><small>1 harmonogram · 2 automatyzacje</small><div class="card-menu"><button>Otwórz edytor</button><button class="danger">Usuń</button></div></div></article>`;
}
if(q.get('editor')==='1'){
document.querySelector('#flowEditor').hidden=false; document.body.classList.add('flow-editor-open');
document.querySelector('#flowName').hidden=true; document.querySelector('#flowNameView').hidden=false; document.querySelector('#flowNameText').textContent='Okno w salonie';
document.querySelector('#flowCompileStatus').textContent='0 harm. · 1 autom.'; document.querySelector('#flowSaveStatus').textContent='Zapisano';
document.querySelector('#flowInterpretation').textContent='Jeśli okno w salonie jest otwarte przez 30 s, wyłącz termostat Salon.';
document.querySelector('#flowRuntimeSummary').textContent='Ostatnie wykonanie: 07:12 · wynik: prawda';
document.querySelector('#flowEmptyHint').style.display='none';
document.querySelector('#flowNodes').innerHTML=`<div class="flow-node flow-node-sensor selected" style="left:60px;top:80px"><div class="flow-node-head">Wspólne wejście <button>×</button></div><div class="flow-node-body">Okno w salonie<br><div class="flow-node-current"><span>Teraz</span><strong>otwarte</strong></div></div><button class="flow-port flow-port-out"></button></div><div class="flow-node flow-node-timeop" style="left:300px;top:160px"><button class="flow-port flow-port-in"></button><div class="flow-node-head">Utrzymuje się przez… <button>×</button></div><div class="flow-node-body">30 sekund</div><button class="flow-port flow-port-out"></button></div><div class="flow-node flow-node-action" style="left:550px;top:95px"><button class="flow-port flow-port-in"></button><div class="flow-node-head">Termostat <button>×</button></div><div class="flow-node-body">Salon · wyłącz</div></div>`;
document.querySelector('#flowInspector').classList.add('has-selection');
document.querySelector('#flowInspector').innerHTML='<div class="flow-inspector-head"><div><span class="eyebrow">Ustawienia bloku</span><h3>Wspólne wejście</h3></div><div class="flow-inspector-head-actions"><button class="flow-inspector-close">×</button></div></div><label><span>Źródło</span><select><option>Okno w salonie</option></select></label><p class="field-note">Czujnik używany tylko we Flow. Nie jest zapisywany do metryk.</p>';
}
});
-6746
View File
File diff suppressed because it is too large Load Diff
-9
View File
@@ -1,9 +0,0 @@
'use strict';
(() => {
const row = document.cookie.split('; ').find(item => item.startsWith('gree_controller_theme='));
const requested = row ? decodeURIComponent(row.split('=').slice(1).join('=')) : 'system';
const resolved = requested === 'light' || requested === 'dark'
? requested
: (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
document.documentElement.dataset.theme = resolved;
})();
-473
View File
@@ -1,473 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import math
import re
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CONDITIONS = {
'weekday','time_range','date_range','cron_trigger','stable_for','delay','state_duration','on_change','rate_limit','rolling_stat','oscillates',
'outdoor_temperature','device_temperature','zone_temperature','ha_state','ha_numeric','ha_attribute',
'ha_available','house_mode','device_state','zone_state','group_state','night_mode','constant','shared_input'
}
LOGIC = {'logic_and','logic_or','logic_not'}
ACTIONS = {'zone_thermostat','device_action','group_action','ha_service_action'}
ALL = CONDITIONS | LOGIC | ACTIONS
passed = 0
def check(value, message):
global passed
if not value:
raise AssertionError(message)
passed += 1
def cron_field_valid(field: str, lo: int, hi: int, allow_seven=False):
upper = 7 if allow_seven else hi
if not field.strip(): return False
for part in field.split(','):
part = part.strip()
if part == '*': continue
if part.startswith('*/'):
try: step = int(part[2:])
except ValueError: return False
if not (0 < step <= hi - lo + 1): return False
continue
if '-' in part:
try: a,b = map(int, part.split('-',1))
except ValueError: return False
if not (lo <= a <= b <= upper): return False
continue
try: v = int(part)
except ValueError: return False
if not (lo <= v <= upper): return False
return True
def cron_valid(expr: str):
f=expr.split()
return len(f)==5 and cron_field_valid(f[0],0,59) and cron_field_valid(f[1],0,23) and cron_field_valid(f[2],1,31) and cron_field_valid(f[3],1,12) and cron_field_valid(f[4],0,6,True)
def cron_part_match(part, value, lo, hi):
if part == '*': return True
if part.startswith('*/'):
step=int(part[2:]); return lo <= value <= hi and (value-lo)%step==0
if '-' in part:
a,b=map(int,part.split('-',1)); return a <= value <= b and a >= lo and b <= hi
v=int(part); return v==value and lo <= v <= hi
def cron_field_match(field,value,lo,hi):
return any(cron_part_match(p.strip(),value,lo,hi) for p in field.split(','))
def cron_matches(expr: str, dt: datetime):
if not cron_valid(expr): return False
f=expr.split(); # Python Monday=0; backend Sunday=0
weekday=(dt.weekday()+1)%7
return (cron_field_match(f[0],dt.minute,0,59) and cron_field_match(f[1],dt.hour,0,23)
and cron_field_match(f[2],dt.day,1,31) and cron_field_match(f[3],dt.month,1,12)
and (cron_field_match(f[4],weekday,0,7) or (weekday==0 and cron_field_match(f[4],7,0,7))))
@dataclass
class Runtime:
since: datetime|None=None
samples: list[tuple[datetime,float]]=field(default_factory=list)
last_value: object|None=None
def timed_gate(rt: Runtime, input_value: bool, seconds: int, now: datetime):
if not input_value:
rt.since=None; return False
if rt.since is None: rt.since=now
return (now-rt.since).total_seconds() >= seconds
def state_duration(rt: Runtime, input_value: bool, minimum: int, maximum: int|None, now: datetime):
if not input_value:
rt.since=None; return False,0
if rt.since is None: rt.since=now
elapsed=max(0,int((now-rt.since).total_seconds()))
return elapsed >= minimum and (maximum is None or elapsed <= maximum), elapsed
def changed(rt: Runtime, current):
result=rt.last_value is not None and rt.last_value != current
rt.last_value=current
return result
def rate_limit_status(rt: Runtime, maximum: int, period: int, now: datetime):
cutoff=now-timedelta(seconds=period)
rt.samples=[x for x in rt.samples if x[0] >= cutoff]
return len(rt.samples) < maximum, len(rt.samples)
def rate_limit_record(rt: Runtime, period: int, now: datetime):
cutoff=now-timedelta(seconds=period)
rt.samples=[x for x in rt.samples if x[0] >= cutoff]
rt.samples.append((now,1.0))
def rolling(rt: Runtime, sample: float, window: int, statistic: str, now: datetime):
cutoff=now-timedelta(seconds=window)
rt.samples=[x for x in rt.samples if x[0] >= cutoff]
rt.samples.append((now,sample))
vals=[v for _,v in rt.samples]
if statistic == 'median':
vals=sorted(vals); mid=len(vals)//2
return (vals[mid-1]+vals[mid])/2 if len(vals)%2==0 else vals[mid]
return sum(vals)/len(vals)
def oscillation_metrics(values):
if len(values)<3: return None
span=max(values)-min(values)
prev=0; changes=0
for a,b in zip(values,values[1:]):
d=b-a; sign=1 if d>1e-6 else -1 if d<-1e-6 else 0
if not sign: continue
if prev and sign != prev: changes += 1
prev=sign
return span,changes
def oscillates(rt: Runtime, sample: float, window: int, min_span: float, min_changes: int, now: datetime):
cutoff=now-timedelta(seconds=window)
rt.samples=[x for x in rt.samples if x[0] >= cutoff]
rt.samples.append((now,sample))
metrics=oscillation_metrics([v for _,v in rt.samples])
return bool(metrics and metrics[0] >= min_span and metrics[1] >= min_changes)
NUMERIC_OPS = {'lt','lte','gt','gte','eq','neq'}
TEXT_OPS = {'eq','neq'}
DEVICE_STATE_FIELDS = {'enabled','online','power','mode','fan_speed','swing_vertical','swing_horizontal','quiet','turbo','light','air','xfan','health','sleep'}
ZONE_STATE_FIELDS = {'enabled','mode','active_preset','demand','control_owner','device_manual_override','local_thermostat_power'}
THERMOSTAT_PRESETS = {'auto','comfort','sleep','away','custom'}
THERMOSTAT_MODES = {'auto','heat','cool'}
GROUP_PRESETS = {'auto','comfort','sleep','away','custom'}
GROUP_MODES = {'auto','house','cool','heat'}
ROLLING_SOURCES = {'outdoor_temperature','device_temperature','zone_temperature','ha_numeric'}
def nonempty(value):
return isinstance(value,str) and bool(value.strip())
def finite_number(value):
return isinstance(value,(int,float)) and not isinstance(value,bool) and math.isfinite(float(value))
def optional_text(value):
return value.strip() if isinstance(value,str) and value.strip() else None
def validate_numeric_comparison(config, name):
check(config.get('operator','lt') in NUMERIC_OPS, f'{name}: unsupported numeric operator')
check(finite_number(config.get('value')), f'{name}: numeric comparison needs finite value')
def validate_text_comparison_config(config, name):
check(config.get('operator','eq') in TEXT_OPS, f'{name}: unsupported text operator')
check('value' in config, f'{name}: state comparison needs value')
def validate_source_config(config, name):
source=config.get('source')
check(source in ROLLING_SOURCES, f'{name}: unsupported statistic source')
if source == 'device_temperature': check(nonempty(config.get('device_id')), f'{name}: device source needs device_id')
if source == 'zone_temperature': check(nonempty(config.get('zone_id')), f'{name}: zone source needs zone_id')
if source == 'ha_numeric': check(nonempty(config.get('entity_id')), f'{name}: HA source needs entity_id')
def validate_node_config(node, name):
kind=node['kind']; c=node.get('config',{})
check(isinstance(c,dict), f'{name}/{node.get("id")}: config is not an object')
tag=f'{name}/{node.get("id")}:{kind}'
if kind == 'weekday':
days=c.get('days'); check(isinstance(days,list) and bool(days), f'{tag}: weekdays missing')
check(all(isinstance(d,int) and not isinstance(d,bool) and 1 <= d <= 7 for d in days), f'{tag}: invalid weekday')
elif kind == 'time_range':
for key in ('start','end'):
try: datetime.strptime(c.get(key,''),'%H:%M')
except (TypeError,ValueError): check(False, f'{tag}: invalid {key} time')
else: check(True, f'{tag}: valid {key} time')
elif kind == 'date_range':
try:
start=datetime.strptime(c.get('start',''),'%Y-%m-%d').date(); end=datetime.strptime(c.get('end',''),'%Y-%m-%d').date()
except (TypeError,ValueError):
check(False, f'{tag}: invalid date range')
else:
check(start <= end, f'{tag}: reversed date range')
elif kind == 'cron_trigger':
check(nonempty(c.get('expression')) and cron_valid(c['expression']), f'{tag}: invalid cron')
elif kind in {'stable_for','delay'}:
seconds=c.get('seconds'); check(isinstance(seconds,int) and not isinstance(seconds,bool) and 1 <= seconds <= 604800, f'{tag}: invalid duration')
elif kind == 'state_duration':
minimum=c.get('min_seconds',0); maximum=c.get('max_seconds')
check(isinstance(minimum,int) and not isinstance(minimum,bool) and 0 <= minimum <= 604800, f'{tag}: invalid min duration')
check(maximum is None or (isinstance(maximum,int) and not isinstance(maximum,bool) and minimum <= maximum <= 604800), f'{tag}: invalid max duration')
check(minimum > 0 or maximum is not None, f'{tag}: empty duration range')
elif kind == 'on_change':
check(c.get('mode','result') in {'result','value'}, f'{tag}: invalid change mode')
elif kind == 'rate_limit':
count=c.get('max_count'); period=c.get('period_seconds')
check(isinstance(count,int) and not isinstance(count,bool) and 1 <= count <= 1000, f'{tag}: invalid max count')
check(isinstance(period,int) and not isinstance(period,bool) and 1 <= period <= 2678400, f'{tag}: invalid rate period')
elif kind == 'rolling_stat':
validate_source_config(c,tag)
window=c.get('window_seconds'); check(isinstance(window,int) and not isinstance(window,bool) and 10 <= window <= 604800, f'{tag}: invalid window')
check(c.get('statistic') in {'mean','median'}, f'{tag}: invalid statistic')
validate_numeric_comparison(c,tag)
elif kind == 'oscillates':
validate_source_config(c,tag)
window=c.get('window_seconds'); check(isinstance(window,int) and not isinstance(window,bool) and 10 <= window <= 604800, f'{tag}: invalid window')
check(finite_number(c.get('min_span')) and float(c['min_span']) > 0, f'{tag}: min_span must be positive')
changes=c.get('min_direction_changes'); check(isinstance(changes,int) and not isinstance(changes,bool) and 1 <= changes <= 1000, f'{tag}: invalid direction-change count')
elif kind == 'outdoor_temperature':
validate_numeric_comparison(c,tag)
elif kind == 'device_temperature':
check(nonempty(c.get('device_id')), f'{tag}: device_id missing'); validate_numeric_comparison(c,tag)
elif kind == 'zone_temperature':
check(nonempty(c.get('zone_id')), f'{tag}: zone_id missing'); validate_numeric_comparison(c,tag)
elif kind == 'ha_state':
check(nonempty(c.get('entity_id')), f'{tag}: entity_id missing'); validate_text_comparison_config(c,tag)
elif kind == 'ha_numeric':
check(nonempty(c.get('entity_id')), f'{tag}: entity_id missing'); validate_numeric_comparison(c,tag)
elif kind == 'ha_attribute':
check(nonempty(c.get('entity_id')), f'{tag}: entity_id missing')
check(nonempty(c.get('attribute')), f'{tag}: attribute missing')
check(c.get('operator','eq') in NUMERIC_OPS, f'{tag}: invalid attribute operator')
check('value' in c, f'{tag}: attribute value missing')
elif kind == 'ha_available':
check(nonempty(c.get('entity_id')), f'{tag}: entity_id missing')
elif kind == 'house_mode':
check(c.get('value') in {'cool','heat','off'}, f'{tag}: invalid house mode'); validate_text_comparison_config(c,tag)
elif kind == 'device_state':
check(nonempty(c.get('device_id')), f'{tag}: device_id missing')
check(c.get('field') in DEVICE_STATE_FIELDS, f'{tag}: invalid device field'); validate_text_comparison_config(c,tag)
elif kind == 'zone_state':
check(nonempty(c.get('zone_id')), f'{tag}: zone_id missing')
check(c.get('field') in ZONE_STATE_FIELDS, f'{tag}: invalid zone field'); validate_text_comparison_config(c,tag)
elif kind == 'group_state':
check(nonempty(c.get('group_id')), f'{tag}: group_id missing')
check(c.get('field') == 'power_enabled', f'{tag}: invalid group field'); validate_text_comparison_config(c,tag)
elif kind == 'constant':
check(isinstance(c.get('value'),bool), f'{tag}: constant needs boolean')
elif kind == 'shared_input':
check(nonempty(c.get('input_id')), f'{tag}: input_id missing')
elif kind == 'zone_thermostat':
check(nonempty(c.get('zone_id')), f'{tag}: zone_id missing')
preset=optional_text(c.get('preset')) or 'comfort'; check(preset in THERMOSTAT_PRESETS, f'{tag}: invalid preset')
mode=optional_text(c.get('mode')); check(mode is None or mode in THERMOSTAT_MODES, f'{tag}: invalid mode')
if preset == 'custom': check(finite_number(c.get('setpoint')) and 8 <= float(c['setpoint']) <= 30, f'{tag}: invalid custom target')
if 'power' in c and c['power'] is not None: check(isinstance(c['power'],bool), f'{tag}: power is not boolean')
if 'cooldown_seconds' in c: check(isinstance(c['cooldown_seconds'],int) and c['cooldown_seconds'] >= 0, f'{tag}: invalid cooldown')
elif kind == 'device_action':
check(nonempty(c.get('device_id')), f'{tag}: device_id missing')
fields=('power','mode','target_temperature','fan_speed','swing_vertical','swing_horizontal','quiet','turbo','light','air','xfan','health','sleep')
check(any(c.get(field) is not None for field in fields), f'{tag}: empty device action')
elif kind == 'group_action':
check(nonempty(c.get('group_id')), f'{tag}: group_id missing')
mode=optional_text(c.get('mode')); preset=optional_text(c.get('preset'))
check(mode is None or mode in GROUP_MODES, f'{tag}: invalid group mode')
check(preset is None or preset in GROUP_PRESETS, f'{tag}: invalid group preset')
check(c.get('power') is not None or mode is not None or preset is not None, f'{tag}: empty group action')
if preset == 'custom': check(finite_number(c.get('setpoint')) and 8 <= float(c['setpoint']) <= 30, f'{tag}: invalid custom target')
elif kind == 'ha_service_action':
check(nonempty(c.get('domain')), f'{tag}: domain missing'); check(nonempty(c.get('service')), f'{tag}: service missing')
# Empty entity_id is intentionally treated as omitted by backend flow_string().
check(isinstance(c.get('data',{}),dict), f'{tag}: HA service data must be object')
elif kind in LOGIC or kind == 'night_mode':
pass
def flow_action_truth_table(flow, action_id):
by={n['id']:n for n in flow.get('nodes',[])}
incoming={}
for edge in flow.get('edges',[]): incoming.setdefault(edge['to'],[]).append(edge['from'])
ancestors=set(); stack=list(incoming.get(action_id,[]))
while stack:
node_id=stack.pop()
if node_id in ancestors: continue
ancestors.add(node_id); stack.extend(incoming.get(node_id,[]))
primitive=[node_id for node_id in ancestors if by[node_id]['kind'] not in LOGIC and by[node_id]['kind'] not in {'stable_for','delay','state_duration','on_change','rate_limit'}]
check(len(primitive) <= 16, f'action {action_id}: truth table unexpectedly large')
def eval_node(node_id, assigned, memo):
if node_id in memo: return memo[node_id]
node=by[node_id]; kind=node['kind']; ins=incoming.get(node_id,[])
if kind == 'logic_and': value=bool(ins) and all(eval_node(x,assigned,memo) for x in ins)
elif kind == 'logic_or': value=bool(ins) and any(eval_node(x,assigned,memo) for x in ins)
elif kind == 'logic_not': value=len(ins)==1 and not eval_node(ins[0],assigned,memo)
elif kind in {'stable_for','delay','state_duration','on_change','rate_limit'}: value=len(ins)==1 and eval_node(ins[0],assigned,memo)
else:
predecessors=all(eval_node(x,assigned,memo) for x in ins)
value=predecessors and assigned.get(node_id,False)
memo[node_id]=value; return value
outcomes=[]
for mask in range(1 << len(primitive)):
assigned={node_id: bool(mask & (1 << index)) for index,node_id in enumerate(primitive)}
memo={}
outcomes.append(bool(incoming.get(action_id)) and all(eval_node(x,assigned,memo) for x in incoming.get(action_id,[])))
return outcomes
def validate_graph(flow, name):
nodes=flow.get('nodes',[]); edges=flow.get('edges',[])
check(bool(nodes), f'{name}: no nodes')
ids=[n.get('id') for n in nodes]
check(all(ids) and len(ids)==len(set(ids)), f'{name}: duplicate/empty node ids')
by={n['id']:n for n in nodes}
check(all(n.get('kind') in ALL for n in nodes), f'{name}: unsupported node kind')
check(any(n['kind'] in ACTIONS for n in nodes), f'{name}: no action')
edge_ids=[e.get('id') for e in edges]
check(all(edge_ids) and len(edge_ids)==len(set(edge_ids)), f'{name}: duplicate/empty edge ids')
pairs=[]
incoming={}
outgoing={}
for e in edges:
a,b=e.get('from'),e.get('to')
check(a in by and b in by and a != b, f'{name}: invalid edge endpoint')
check(by[a]['kind'] not in ACTIONS, f'{name}: action has outgoing edge')
pairs.append((a,b)); incoming.setdefault(b,[]).append(a); outgoing.setdefault(a,[]).append(b)
check(len(pairs)==len(set(pairs)), f'{name}: duplicate connections')
# DAG
temp=set(); done=set()
def visit(x):
if x in done: return
check(x not in temp, f'{name}: cycle')
temp.add(x)
for y in outgoing.get(x,[]): visit(y)
temp.remove(x); done.add(x)
for x in ids: visit(x)
for n in nodes:
validate_node_config(n, name)
ins=incoming.get(n['id'],[])
if n['kind']=='logic_not': check(len(ins)==1, f'{name}: NOT arity')
if n['kind'] in {'logic_and','logic_or'}: check(len(ins)>=1, f'{name}: logic arity')
if n['kind'] in {'stable_for','delay','state_duration','on_change','rate_limit'}: check(len(ins)==1, f'{name}: stateful gate arity')
if n['kind'] in ACTIONS: check(len(ins)>=1, f'{name}: action without condition')
if n['kind'] == 'rate_limit':
targets=[by[target] for target in outgoing.get(n['id'],[]) if target in by]
check(bool(targets) and all(target['kind'] in ACTIONS for target in targets), f'{name}: rate limit must be directly before action')
if n['kind'] == 'on_change' and n.get('config',{}).get('mode','result') == 'value':
sources=[by[source] for source in incoming.get(n['id'],[]) if source in by]
blocked=LOGIC | {'stable_for','delay','state_duration','on_change','rate_limit','rolling_stat','oscillates'}
check(len(sources)==1 and sources[0]['kind'] not in blocked, f'{name}: value-change mode needs direct source input')
for action in (n for n in nodes if n['kind'] in ACTIONS):
outcomes=flow_action_truth_table(flow, action['id'])
check(any(outcomes), f'{name}/{action["id"]}: action condition graph can never become true')
return len(nodes),len(edges)
def main():
# Deterministic backend semantics.
check(cron_valid('*/5 * * * *'), 'cron */5 invalid')
check(cron_valid('0,15,30,45 6-18 * * 1-5'), 'cron list/range invalid')
for bad in ('* * * *','*/0 * * * *','61 * * * *','* 24 * * *','* * 0 * *','* * * 13 *','* * * * 8','*/100 * * * *'):
check(not cron_valid(bad), f'cron accepted invalid: {bad}')
monday=datetime(2026,9,7,10,15)
check(cron_matches('15 10 * * 1', monday), 'cron exact weekday mismatch')
check(cron_matches('15 10 * * 1-7', monday), 'cron weekday range containing 7 mismatch')
check(not cron_matches('16 10 * * 1', monday), 'cron false positive')
sunday=datetime(2026,9,6,8,0)
check(cron_matches('0 8 * * 0', sunday) and cron_matches('0 8 * * 7', sunday) and cron_matches('0 8 * * 1-7', sunday), 'cron Sunday 0/7/range mismatch')
base=datetime(2026,9,2,12,0,0)
rt=Runtime(); check(not timed_gate(rt,True,30,base),'stable fired immediately'); check(not timed_gate(rt,True,30,base+timedelta(seconds=29)),'stable fired early'); check(timed_gate(rt,True,30,base+timedelta(seconds=30)),'stable did not fire'); check(not timed_gate(rt,False,30,base+timedelta(seconds=31)),'stable did not reset'); check(not timed_gate(rt,True,30,base+timedelta(seconds=32)),'stable restart should wait')
rt=Runtime(); check(not timed_gate(rt,True,3,base),'delay immediate'); check(timed_gate(rt,True,3,base+timedelta(seconds=3)),'delay did not pass after wait')
rt=Runtime(); check(state_duration(rt,True,10,30,base)==(False,0),'state duration immediate'); check(state_duration(rt,True,10,30,base+timedelta(seconds=10))==(True,10),'state duration minimum'); check(state_duration(rt,True,10,30,base+timedelta(seconds=30))==(True,30),'state duration maximum boundary'); check(state_duration(rt,True,10,30,base+timedelta(seconds=31))==(False,31),'state duration exceeded maximum'); check(state_duration(rt,False,10,30,base+timedelta(seconds=32))==(False,0) and rt.since is None,'state duration reset')
rt=Runtime(); check(not changed(rt,'off'),'change fired on first observation'); check(not changed(rt,'off'),'change fired without change'); check(changed(rt,'on'),'change not detected'); check(not changed(rt,'on'),'change repeated without new edge')
rt=Runtime(); rate_limit_record(rt,60,base); rate_limit_record(rt,60,base+timedelta(seconds=10)); check(rate_limit_status(rt,2,60,base+timedelta(seconds=20))==(False,2),'rate limit did not block'); check(rate_limit_status(rt,2,60,base+timedelta(seconds=61))==(True,1),'rate limit did not prune rolling window')
synthetic={
'nodes':[
{'id':'source','kind':'constant','config':{'value':True}},
{'id':'duration','kind':'state_duration','config':{'min_seconds':10,'max_seconds':60}},
{'id':'change','kind':'on_change','config':{'mode':'result'}},
{'id':'limit','kind':'rate_limit','config':{'max_count':2,'period_seconds':3600}},
{'id':'action','kind':'ha_service_action','config':{'domain':'switch','service':'turn_on','entity_id':'switch.test','data':{}}},
],
'edges':[
{'id':'e1','from':'source','to':'duration'}, {'id':'e2','from':'duration','to':'change'},
{'id':'e3','from':'change','to':'limit'}, {'id':'e4','from':'limit','to':'action'},
]
}
check(validate_graph(synthetic,'synthetic-new-stateful-flow')==(5,4),'new stateful flow graph validation failed')
rt=Runtime(); check(math.isclose(rolling(rt,10,60,'mean',base),10),'mean one'); check(math.isclose(rolling(rt,20,60,'mean',base+timedelta(seconds=10)),15),'mean two'); check(math.isclose(rolling(rt,30,60,'median',base+timedelta(seconds=20)),20),'median odd'); check(math.isclose(rolling(rt,40,60,'median',base+timedelta(seconds=30)),25),'median even'); check(math.isclose(rolling(rt,100,60,'mean',base+timedelta(seconds=100)),100),'window prune')
rt=Runtime();
for i,v in enumerate((20,22,19,23,20)):
result=oscillates(rt,v,300,3,2,base+timedelta(seconds=i*10))
check(result,'oscillation not detected')
rt=Runtime();
for i,v in enumerate((20,21,22,23,24)):
result=oscillates(rt,v,300,3,2,base+timedelta(seconds=i*10))
check(not result,'monotonic trend detected as oscillation')
# Static backend/frontend wiring checks.
engine=(ROOT/'src/engine/automations.rs').read_text()
api=(ROOT/'src/api/flows.rs').read_text()
js=(ROOT/'web/js/flows.js').read_text()
html=(ROOT/'web/index.html').read_text()
for kind in ('cron_trigger','stable_for','delay','state_duration','on_change','rate_limit','rolling_stat','oscillates'):
check(f'"{kind}"' in api and f'"{kind}"' in engine, f'{kind}: backend wiring missing')
check(kind in js and f'data-flow-add="{kind}"' in html, f'{kind}: UI wiring missing')
meta_block=js.split('const FLOW_NODE_META = Object.freeze({',1)[1].split('});',1)[0]
meta_kinds=set(re.findall(r'^\s*([a-z_]+):\s*\{', meta_block, re.M))
palette_kinds=set(re.findall(r'data-flow-add="([a-z_]+)"', html))
check(meta_kinds == ALL, f'UI metadata/backend kind mismatch: missing={ALL-meta_kinds}, extra={meta_kinds-ALL}')
check(palette_kinds == ALL, f'palette/backend kind mismatch: missing={ALL-palette_kinds}, extra={palette_kinds-ALL}')
check('"ha_service_action" => Ok(None)' in api, 'HA action missing dry-run support')
check('home_assistant::call_service' in engine and 'action_ha_domain' in engine, 'HA action runtime missing')
check('format!("ha:{entity_id}")' in engine, 'HA target conflict claim missing')
check('Some(&mut runtime)' in api, 'dry-run does not use stateful runtime')
check('conditions.iter().any(|condition| condition.kind == "cron_trigger")' in engine and 'last.minute() == now.minute()' in engine, 'CRON same-minute duplicate guard missing')
check('previous.config == current.config' in api and 'runtime.retain' in api, 'runtime reset-on-edit protection missing')
check('flow_record_rate_limited_execution' in engine and 'Ok(true)' in engine, 'rate limit is not recorded after successful execution')
check('if !item.enabled { continue; }' in engine and 'if !ready || !should_fire { continue; }' in engine, 'Flow runtime may be skipped during action cooldown')
check('rate-limit block must be placed directly before an action' in api, 'rate-limit placement guard missing')
check('on-change value mode needs one direct source/condition input' in api, 'on-change value-source guard missing')
models=(ROOT/'src/models/flow.rs').read_text()
core=(ROOT/'web/js/core.js').read_text()
configuration_api=(ROOT/'src/api/configuration.rs').read_text()
check('pub draft: bool' in models and '#[serde(default)]' in models, 'Flow draft persistence field missing')
check('fn validate_flow_draft_graph' in api, 'draft structural validator missing')
check(api.count('if input.draft { validate_flow_draft_graph(&input)?; } else { validate_flow_graph(&input)?; }') >= 3, 'draft create/update/import validation split missing')
check('enabled: if draft { false } else { input.enabled }' in api, 'draft does not force Flow disabled')
check(api.count('(prepare_draft_flow(flow), vec![], vec![])') >= 3, 'draft save/import may compile executable outputs')
check('flow.compiled_schedule_ids.clear()' in api and 'flow.compiled_automation_ids.clear()' in api, 'draft output clearing missing')
check('"draft": flow.draft' in api, 'draft state missing from Flow export')
check('import contains an executable Flow draft' in configuration_api, 'configuration import does not enforce draft safety invariant')
check('error.status = response.status' in core, 'frontend API errors do not expose HTTP validation status')
check("error.status !== 400" in js and "draft:true" in js and "enabled:false" in js, 'save-as-draft retry flow missing')
check("if (flow.draft) return toast(tr('flow.draftCannotEnable')" in js, 'draft quick-enable guard missing')
check("draft: app.flowDraft.draft === true" in js, 'draft portability state missing from source payload')
runtime_leaf=engine.split('async fn flow_leaf_observation',1)[1].split('pub async fn evaluate_flow_conditions_trace',1)[0]
for kind in CONDITIONS - {'stable_for','delay','state_duration','on_change','rate_limit','rolling_stat','oscillates'}:
check(f'\"{kind}\" =>' in runtime_leaf or (kind == 'shared_input' and 'condition.kind == \"shared_input\"' in runtime_leaf), f'{kind}: runtime leaf implementation missing')
evaluator=engine.split('pub async fn evaluate_flow_conditions_trace',1)[1].split('async fn flow_conditions_match',1)[0]
for kind in ('stable_for','delay','state_duration','on_change','rate_limit','rolling_stat','oscillates','logic_and','logic_or','logic_not'):
check(f'\"{kind}\"' in evaluator, f'{kind}: evaluator implementation missing')
for category in ('trigger','time','timeop','sensor','logic','action','haaction'):
check(f'flow-palette-{category}' in html, f'palette category {category} missing')
css=(ROOT/'web/styles.css').read_text()
for marker in ('.flow-palette-group button', 'min-height:30px', 'padding:6px 8px', '.flow-palette-timeop', '.flow-palette-haaction', '.flow-editor-title input:hover', '.flow-editor-title input:focus', '.flow-draft-badge'):
check(marker in css, f'compact/color Flow CSS missing: {marker}')
# Translation pack correctness: all new keys must be inside translations, not root.
for lang in ('pl','en'):
pack=json.loads((ROOT/f'lang/{lang}.json').read_text())
check(not any(k.startswith('flow.') for k in pack), f'{lang}: flow translations leaked to root')
for key in ('flow.triggers','flow.timeOps','flow.haActions','flow.node.cronTrigger','flow.node.stableFor','flow.node.stateDuration','flow.node.onChange','flow.node.rateLimit','flow.node.delay','flow.node.rollingStat','flow.node.oscillates','flow.node.haServiceAction','flow.draft','flow.draftStatus','flow.draftNoExecution','flow.draftDisabledHint','flow.draftCannotEnable','flow.saveAsDraftConfirm','flow.savedAsDraft'):
check(bool(pack.get('translations',{}).get(key)), f'{lang}: missing {key}')
title_keys=set(re.findall(r"titleKey: '([^']+)'", meta_block))
missing_titles=sorted(key for key in title_keys if not pack.get('translations',{}).get(key))
check(not missing_titles, f'{lang}: missing Flow node title translations: {missing_titles}')
referenced_flow_keys=set(re.findall(r'''(?:data-i18n(?:-[a-z]+)?=|tr\()\s*[\"'](flow\.[A-Za-z0-9_.]+)[\"']''', js + '\n' + html))
missing_referenced=sorted(key for key in referenced_flow_keys if key not in pack.get('translations',{}))
check(not missing_referenced, f'{lang}: missing referenced Flow translations: {missing_referenced}')
# Regression pass over every bundled Flow preset.
preset_files=sorted((ROOT/'presets').glob('*.json'))
check(len(preset_files) >= 30, 'unexpectedly small preset library')
nodes=edges=0
for f in preset_files:
data=json.loads(f.read_text())
n,e=validate_graph(data['flow'],f.name); nodes+=n; edges+=e
print(f'PASS: {passed} logical assertions; {len(preset_files)} presets; {nodes} nodes; {edges} edges')
if __name__=='__main__': main()
-3
View File
@@ -253,9 +253,6 @@ pub fn router(state: AppState) -> Router {
.route(APP_JS_ASSET_PATH, get(app_js))
.route(THEME_INIT_ASSET_PATH, get(theme_init_js))
.route(STYLES_CSS_ASSET_PATH, get(styles_css))
.route("/app.js", get(app_js_legacy))
.route("/theme-init.js", get(theme_init_js_legacy))
.route("/styles.css", get(styles_css_legacy))
.route("/manifest.webmanifest", get(manifest))
.route("/sw.js", get(service_worker))
.route("/favicon.svg", get(favicon))
+3 -13
View File
@@ -24,7 +24,7 @@ async fn not_found(State(state): State<AppState>, headers: HeaderMap) -> Respons
);
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("private, no-store, no-cache, must-revalidate"),
HeaderValue::from_static("private, no-store, no-cache"),
);
response
}
@@ -49,7 +49,7 @@ async fn index(State(state): State<AppState>, headers: HeaderMap) -> Response {
);
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("private, no-store, no-cache, must-revalidate"),
HeaderValue::from_static("private, no-store, no-cache"),
);
response
}
@@ -98,13 +98,6 @@ async fn theme_init_js() -> Response {
"public, max-age=31536000, immutable",
)
}
async fn theme_init_js_legacy() -> Response {
static_response(
THEME_INIT_JS,
"application/javascript; charset=utf-8",
"no-cache",
)
}
async fn styles_css() -> Response {
static_response(
STYLES_CSS,
@@ -112,9 +105,6 @@ async fn styles_css() -> Response {
"public, max-age=31536000, immutable",
)
}
async fn styles_css_legacy() -> Response {
static_response(STYLES_CSS, "text/css; charset=utf-8", "no-cache")
}
async fn manifest() -> Response {
static_response(
MANIFEST,
@@ -160,7 +150,7 @@ async fn preset_index() -> Response {
static_response(
PRESET_MANIFEST_JSON,
"application/json; charset=utf-8",
"no-cache",
"no-cache, no-store",
)
}
async fn preset_file(Path(file): Path<String>) -> Response {
+22 -3
View File
@@ -1,6 +1,10 @@
async fn security_headers(request: Request, next: Next) -> Response {
let is_api = request.uri().path().contains("/api/");
let path = request.uri().path();
let is_api = path == "/api" || path.starts_with("/api/");
let is_api_docs = path == "/api-docs" || path.starts_with("/api-docs/");
let mut response = next.run(request).await;
let is_html = response
.headers()
.get(header::CONTENT_TYPE)
@@ -13,6 +17,7 @@ async fn security_headers(request: Request, next: Next) -> Response {
});
let headers = response.headers_mut();
headers.insert(
header::HeaderName::from_static("x-content-type-options"),
HeaderValue::from_static("nosniff"),
@@ -27,7 +32,12 @@ async fn security_headers(request: Request, next: Next) -> Response {
header::HeaderName::from_static("x-frame-options"),
HeaderValue::from_static("SAMEORIGIN"),
);
headers.insert(header::HeaderName::from_static("content-security-policy"), HeaderValue::from_static("default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none'"));
headers.insert(
header::HeaderName::from_static("content-security-policy"),
HeaderValue::from_static(
"default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none'"
),
);
headers.insert(
header::HeaderName::from_static("permissions-policy"),
HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
@@ -35,7 +45,16 @@ async fn security_headers(request: Request, next: Next) -> Response {
}
if is_api {
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static("no-cache, no-store"),
);
} else if is_api_docs {
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static("no-cache, must-revalidate"),
);
}
response
}
+7 -12
View File
@@ -2593,7 +2593,7 @@ legend {
background: color-mix(in srgb, var(--surface-muted) 52%, var(--surface));
}
.simulation-panel-head > div:first-child {
.simulation-panel-head>div:first-child {
min-width: 0;
}
@@ -2685,7 +2685,7 @@ legend {
background: var(--surface-muted);
}
.simulation-rule-card > p {
.simulation-rule-card>p {
margin: 0;
color: var(--muted);
font-size: 12px;
@@ -8947,7 +8947,6 @@ body.flow-editor-open {
vector-effect: non-scaling-stroke;
}
/* Mobile UX refinements: dashboard headers, control plan, landscape navigation and Flow toolbar. */
@media (max-width: 700px) {
.dashboard-thermostat-section .dashboard-section-head.compact-head {
flex-direction: row;
@@ -8956,7 +8955,7 @@ body.flow-editor-open {
gap: 10px;
}
.dashboard-thermostat-section .dashboard-section-head.compact-head > div:first-child {
.dashboard-thermostat-section .dashboard-section-head.compact-head>div:first-child {
min-width: 0;
text-align: left;
}
@@ -9000,7 +8999,6 @@ body.flow-editor-open {
min-width: 0;
}
/* Phones in landscape can cross desktop width breakpoints. Keep navigation compact when height is phone-like. */
@media (orientation: landscape) and (max-height: 640px) and (max-width: 1400px) {
body:not(.flow-editor-open) main {
width: 100%;
@@ -9087,7 +9085,7 @@ body.flow-editor-open {
gap: 4px;
}
.flow-zoom-controls > span {
.flow-zoom-controls>span {
display: none;
}
@@ -9121,7 +9119,6 @@ body.flow-editor-open {
}
}
/* Mobile UX correction pass: explicit left alignment, compact Flow fit control, natural chart fullscreen. */
@media (max-width: 700px) {
#controlPlanSection .dashboard-section-head.compact-head {
display: block;
@@ -9130,7 +9127,7 @@ body.flow-editor-open {
text-align: left;
}
#controlPlanSection .dashboard-section-head.compact-head > div:first-child {
#controlPlanSection .dashboard-section-head.compact-head>div:first-child {
width: 100%;
min-width: 0;
text-align: left;
@@ -9165,7 +9162,7 @@ body.flow-editor-open {
gap: 4px;
}
.flow-zoom-controls > span {
.flow-zoom-controls>span {
display: none;
}
@@ -9214,7 +9211,7 @@ body.flow-editor-open {
gap: 4px;
}
.flow-zoom-controls > span {
.flow-zoom-controls>span {
display: none;
}
@@ -9240,7 +9237,6 @@ body.flow-editor-open {
}
}
/* In phone landscape there is enough width: keep the full Fit label instead of clipping it. */
@media (orientation: landscape) and (max-height: 700px) and (min-width: 500px) {
.flow-fit-button {
display: inline-flex;
@@ -9259,4 +9255,3 @@ body.flow-editor-open {
content: none;
}
}