v0.12.1-cleanups
This commit is contained in:
+24
-27
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
})();
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user