v0.13.0
This commit is contained in:
Generated
+1
-1
@@ -700,7 +700,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gree-controller"
|
||||
version = "0.12.1"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "gree-controller"
|
||||
version = "0.12.1"
|
||||
version = "0.13.0"
|
||||
edition = "2021"
|
||||
authors = ["GREE Controller contributors"]
|
||||
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
|
||||
|
||||
+41
-44
@@ -1,24 +1,24 @@
|
||||
d56cfff4179a6511aa764a91c6ddebe21fcd35f36b7298e62d012e11f62bfeb9 ./build.rs
|
||||
0696b5112a35943786aa4dbe0547b745d158309795a8e8967946eed07cb0d1e7 ./Cargo.lock
|
||||
000dd8d1a535f357667cf812f817a2abbc72eb79fe062ae2dcf8fef65d6b7491 ./Cargo.toml
|
||||
ceafb0ec649a7a7ea0c469501dab144743306150c15869f3a084bab3ce47f51d ./data/gree-controller.db
|
||||
9596e50efb3ce09afd823d2507c814cfbffe812a622b45c9e0d53526e8cd996a ./docs/API.md
|
||||
2e1e18fd8167dabfe2469c26e85cce62486c7cb6a502c63c6f6b0cd74d5885e0 ./docs/FLOW.md
|
||||
933f560266bd309be0b1eaaa943e86874646e0b9ada5e805bf47a7a62f7063b8 ./docs/openapi.json
|
||||
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env
|
||||
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
|
||||
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
|
||||
d48b8a8fbd32b483e5aa176111ec821450a338438780f73fde11e11ac2ac4271 ./gree-controller.zip
|
||||
6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py
|
||||
e664a2f53234cb1e5d75bb9482c5477484d6d6ad878f26ea0bf84e23c4f35644 ./Cargo.lock
|
||||
cbbebcf672b947987eaaa2122aa080d2c71737fcdd0736aef318fd3099ef5851 ./Cargo.toml
|
||||
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
||||
01243b698f466857db8755e857ac1da49d9ab1e78c7a914c237829c98242bbf7 ./README.md
|
||||
d56cfff4179a6511aa764a91c6ddebe21fcd35f36b7298e62d012e11f62bfeb9 ./build.rs
|
||||
f586babc2a9392035f3821c8b78226aec19589cf0a9f9178097fef9afaf1026f ./docs/API.md
|
||||
2e1e18fd8167dabfe2469c26e85cce62486c7cb6a502c63c6f6b0cd74d5885e0 ./docs/FLOW.md
|
||||
5dc6f579834a991f8c9366e7a13b24214993cba01db89ed637d305a71fdd59c8 ./docs/openapi.json
|
||||
b11e581dd916676e24402112fe6896bc681abf3c94cafc85c4d3ded3f3a272a3 ./home-assistant/README.md
|
||||
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
|
||||
3f6ef15ef58456376ac53fde7cace1ef359d6a6f7a64c5b575ad77ea6e55ccc4 ./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
|
||||
b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./home-assistant/custom_components/gree_controller/const.py
|
||||
ab08fbe40e9bb48ebcbfff98760aaa0c9434b61b705aaaadea0c09c255d99b0e ./home-assistant/custom_components/gree_controller/coordinator.py
|
||||
41a8958a6fe6d10e5679c95ac34832583a2a15ecfd67d09b4e094a83d06f5c47 ./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
|
||||
f62e0122fcd812e42736af85233a4c53fdb34450f38a93d8ad57a18f8a937b91 ./home-assistant/custom_components/gree_controller/manifest.json
|
||||
38d659d0273c0d1428679e44574a2666c605d1b3796d323b626baa40560788cf ./home-assistant/custom_components/gree_controller/number.py
|
||||
39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py
|
||||
cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./home-assistant/custom_components/gree_controller/sensor.py
|
||||
@@ -26,11 +26,9 @@ 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
|
||||
b11e581dd916676e24402112fe6896bc681abf3c94cafc85c4d3ded3f3a272a3 ./home-assistant/README.md
|
||||
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
|
||||
810bf9a9483277cbde14454b6b88dbfe79b873a6f34737031a7e10d23fda9299 ./lang/en.json
|
||||
ad48d11875aa041c0f593aa6e322f8d953aef137d4df4c369c5f82eec5aebd11 ./lang/pl.json
|
||||
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
|
||||
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
||||
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
|
||||
b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json
|
||||
1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json
|
||||
@@ -69,9 +67,9 @@ 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
|
||||
cd2eb8c851ab3cacd00efcb5cdbd404f03279e75126c46f025e132ba1e9d4ce1 ./scripts/api_dev_test.py
|
||||
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
|
||||
52fa6906d2c80d7c8fe0b0d178c16b9b0a53408472a9ecdccbb9d8df1137860f ./scripts/api_dev_test.py
|
||||
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
|
||||
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh
|
||||
1d6e14e26e49aa9d3527f30a23668bf8d9c48b67e6628ef686c3155c012155de ./scripts/dev.sh
|
||||
@@ -79,12 +77,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
|
||||
04084aac0c2af07a9bb2d0d7d4499efaed47944aefff526f32c73a557a491224 ./src/api/assets.rs
|
||||
4b795bcb2e6b73c0dc9687325f6ffb0e6f008c6694b2a9fb8190a45a563451b2 ./src/api.rs
|
||||
5027797b9cefb9f0aa04c792e2ac9a38745c6bf1ee822f50eb70574fa6f997e2 ./src/api/assets.rs
|
||||
05c255919f7b3d81188fc5f1410be6a0037471dfc44e93bffb009f29a1e6a3fc ./src/api/auth.rs
|
||||
9e4fc675306e111ed3db7af9822e2925d88109c31b9e309851665a975c0bd83c ./src/api/automations.rs
|
||||
368cd843dcac970f1aa6800ba132a4bbc3429e8379ed7abf9878dcb0c633af40 ./src/api/configuration.rs
|
||||
@@ -92,20 +90,20 @@ b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/upda
|
||||
0b87a7f0caef2a9c9cdea34d560095a3367e3c9d4322d6bcabc5b8ade8d55d06 ./src/api/devices.rs
|
||||
4b5b784670ae761a6848b9a33fe3fb391bebd39d65e87936907428711e96ca44 ./src/api/events.rs
|
||||
44b74965f3256c2972d6b3cd384a63c68e3b422f414a0902c085bc92e17369bf ./src/api/flows.rs
|
||||
5879d4fb0eda24298a00300bf2237110e13ee0067b513f332bbc7344a859911a ./src/api/groups.rs
|
||||
641f17dc5b107e6f13949ada8d05e8fdf878d0e44666ddc72aea71a57c70df06 ./src/api/history.rs
|
||||
b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs
|
||||
853e80f5adf074b629bca1d7ee1483b6be0aaf397a46a281fc58fb96ebac8cb1 ./src/api/history.rs
|
||||
e8edfbb725d6de650442444b0a929ad2be0bd2c2d7d227d2b3fdd99d05c94f94 ./src/api/house.rs
|
||||
6139e17a08e35d0fcd07ee5625983c602d758ae77452c924f4b4cc05580e9ee8 ./src/api/integrations.rs
|
||||
21636a6beb83e7bda961ef3ee383f6da317ffaa90b89fbcefa1547beb888de2d ./src/api/integrations.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
|
||||
df3297f36f0d0fa071b872967c5c124ed3887805d4f0f1031761cfba25331e97 ./src/api/system.rs
|
||||
badd52159fa5d19c236cf9022b623d15c6fbfc7f0b7fc59e269717eb5ee3d10b ./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
|
||||
@@ -113,35 +111,35 @@ 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
|
||||
4d2c6053586558afbca6b60fbbc9b1cd397f74f8284d7c01f3337eb42bf6d4f7 ./src/engine.rs
|
||||
ec6034df6667b3ac6ec39851443048a56e87fa861f5846950a5e79fa89ee5a30 ./src/engine/automations.rs
|
||||
88f85acf692e12c7061e039dd7c54f101fe0b014c4a3d799150ad92eea9e2894 ./src/engine/commands.rs
|
||||
10ab2d7abc0f149936393129459dc56e85d8fca51d96da71c742cb96e86a6f08 ./src/engine/control_plan.rs
|
||||
8ee70ad21d8ee32914dd90952b04dfa8355ee868252ef4fc682ec40503590a7a ./src/engine/control_plan.rs
|
||||
7b48a33343d519a2ff2d81e9d757eb56a0c1beacfc0b7b8ef61f49c2ad363f9a ./src/engine/deadlines.rs
|
||||
e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/groups.rs
|
||||
f0c8f3384b6174771aeb5640ee1eec87f3d6d9ff79a4fbd6a35554a4eec00cfa ./src/engine/history.rs
|
||||
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
|
||||
f0bcd3a56b600944ccc62fa24ce20838033eeb4354f937cdb534630c1a1f5a78 ./src/engine/runtime.rs
|
||||
c1e8de5392681ba1255e3e2d871a6ee860fb8ca987a0a8f973d9ded3977a68fb ./src/engine/schedules.rs
|
||||
de80d9ff69f1c843fdac23fdc3e3a350fc1082a186261b3a38617af189132356 ./src/engine/targets.rs
|
||||
682c8b3956c657b655746cbd19ee97ee2f4cf953bc9de67d6d70a1b14f3dd746 ./src/engine/temperature.rs
|
||||
833b72907375e75c3f42b3921ad70a8e298f5a93743bf0d0c19059aa272462d1 ./src/engine/temporary_thermostat.rs
|
||||
9da37fb636e2c342c4e86b6e0ecf07a5a6850431b1227cf4b8f02b4d05b02eaa ./src/engine/tests.rs
|
||||
35e8582fcdb13ff6d8850a5274893805a04cabe3b0c6c936b1ad4a9808bf16ee ./src/engine/tests.rs
|
||||
786ae270afb543d03cf6586942394c6e29015d94c71526f4fb54010c220c90d8 ./src/engine/zone_actions.rs
|
||||
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
|
||||
9ec1bc25ebd06cc925e4fddc8b49e189a21ae495c6709c8cb1bba5b4dfa35388 ./src/main.rs
|
||||
23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs
|
||||
920e028d96a14cca9f74a964bb296e68a109cfead314d3164c180201e606d151 ./src/models/automation.rs
|
||||
3c3f344fe42cdde980b13dbec6e92ed0e2f5946cba8ac19b1a668846b48e7c81 ./src/models/control_plan.rs
|
||||
9cd1904a0f822c557f6ad4576cdb5f96d9668d04e79ea9783885c9f39519752e ./src/models/defaults.rs
|
||||
@@ -149,13 +147,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
|
||||
@@ -163,26 +161,26 @@ 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
|
||||
74c1ecdb45e1f4c6275a16a14d157b3a67a18c3f3d80dfdae18aec5bf6aed849 ./src/state.rs
|
||||
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
|
||||
544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html
|
||||
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
|
||||
d0388bf22f34ae28fb63074b0bc15c087a3be298bb702d006d4814ebb786b36e ./web/index.html
|
||||
8fb20fdba11df68feb0b096bb5b4c8f936db974aed5d345a0d01455054f31f95 ./web/js/bootstrap.js
|
||||
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
|
||||
7a0baeaa8703ba8654aa402e320ced127ae12327403f339f8fefdc0cd792af0d ./web/js/bootstrap.js
|
||||
badde8a5214a0322b2c45803c02a1cb723d53b25e57c687369cd804f509d9744 ./web/js/charts.js
|
||||
af92b2140f6248bc636d7c75e37a9460f4128ba59adf1b7842d25244a607555c ./web/js/core.js
|
||||
361d68a9f7dda66a628ebde25d130a26024d809aef521d1b3ad8d8bdf5d39098 ./web/js/dashboard.js
|
||||
13def8de3450b612bc669f441782237e992a7a27aa4784b0dda4d48dcf3482b2 ./web/js/core.js
|
||||
d880ffdac6aa07cd10d79fbacf12f87eb4c69b48a80564bbfdd0248abe8884d5 ./web/js/dashboard.js
|
||||
d6fbd8ef4a67269afadb2f59f19fe9a359f7ec4d7bd1473ecff77df117b9558d ./web/js/entities.js
|
||||
e332b3f811958a938105272c1e8e28d6ca8ba69303001da165b4b0b85727695c ./web/js/events.js
|
||||
1b21b3ae43a6848f9fc13fd4eacaa6a1a50d44c65aa37ca737be60aa4eca8a38 ./web/js/flows.js
|
||||
@@ -190,11 +188,10 @@ 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
|
||||
90dccce563b1cbd6119576d575ea72121a438107c4dd16ea44f8a591f4e6db90 ./web/js/realtime.js
|
||||
c8c82e88c3715b1bfabf155e36266a4c94f5e2fc03eb39dcdd9d08a1985a997c ./web/js/router.js
|
||||
9dd9fbcc5f00584562bae6a840189495f830d1086d9679763dbad6e9c18c4fdb ./web/js/settings.js
|
||||
427376a35832cb3644206c6e89e1654bd807eec78352f80bd0dd43f74b4ec810 ./web/js/settings-ui.js
|
||||
9dd9fbcc5f00584562bae6a840189495f830d1086d9679763dbad6e9c18c4fdb ./web/js/settings.js
|
||||
6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest
|
||||
5c939ab91aa28ba386aefef6ba5f41b0c73fae3936f6e915fbc39e2336859605 ./web/styles.css
|
||||
ae1b03f30b494f474a781a5d32072f1e73eba2b7c768109fc1cc8028cb6662a4 ./web/sw.js
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
Self-hosted controller for GREE-compatible air conditioners with a local Web UI, thermostat zones, schedules, Home Assistant integration, history, notifications and a documented HTTP/WebSocket API.
|
||||
|
||||
**Current release: 0.12.1**
|
||||
**Current release: 0.13.0**
|
||||
|
||||
Release 0.13.0 keeps the existing control-plan HTTP contract while adding a materialized backend snapshot, live `control_plan.updated` WebSocket delivery with resynchronization, HTTP fallback in the Web UI, and a single restricted Home Assistant snapshot request for devices, groups and the control plan.
|
||||
|
||||
> [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples.
|
||||
|
||||
|
||||
+16
-5
@@ -1,6 +1,6 @@
|
||||
# GREE Controller API reference
|
||||
|
||||
HTTP and WebSocket API for GREE Controller **0.12.1**.
|
||||
HTTP and WebSocket API for GREE Controller **0.13.0**.
|
||||
|
||||
[← Main documentation](../README.md)
|
||||
|
||||
@@ -206,6 +206,7 @@ Common statuses:
|
||||
| GET | `/api/access-tokens` | List generated HA tokens without secrets. |
|
||||
| POST | `/api/access-tokens` | Create restricted HA token. |
|
||||
| DELETE | `/api/access-tokens/{id}` | Revoke token. |
|
||||
| GET | `/api/integrations/home-assistant/snapshot` | Restricted devices + groups + control-plan snapshot in one request. |
|
||||
| GET | `/api/integrations/home-assistant/devices` | Restricted device list. |
|
||||
| POST | `/api/integrations/home-assistant/devices/{id}/command` | Restricted direct device command. |
|
||||
| GET | `/api/integrations/home-assistant/control-plan` | Restricted control plan. |
|
||||
@@ -230,7 +231,7 @@ Response:
|
||||
{
|
||||
"status": "ok",
|
||||
"name": "gree-controller",
|
||||
"version": "0.12.1",
|
||||
"version": "0.13.0",
|
||||
"uptime_seconds": 1234,
|
||||
"control_ready": true,
|
||||
"time": "2026-08-30T06:54:00Z"
|
||||
@@ -253,8 +254,10 @@ Returns the initial Web UI snapshot:
|
||||
"access_tokens": [],
|
||||
"house": {"mode": "cool"},
|
||||
"outdoor_temperature": null,
|
||||
"control_plan": {"generated_at": "2026-09-04T08:00:00Z", "zones": [], "rules": []},
|
||||
"control_plan_revision": 42,
|
||||
"system": {
|
||||
"version": "0.12.1",
|
||||
"version": "0.13.0",
|
||||
"uptime_seconds": 1234,
|
||||
"auth_required": false,
|
||||
"control_ready": true,
|
||||
@@ -941,7 +944,8 @@ When InfluxDB is enabled, older history can be read from Influx and merged with
|
||||
|
||||
### `GET /api/control-plan`
|
||||
|
||||
Returns the resolved machine-readable thermostat plan:
|
||||
Returns the latest materialized machine-readable thermostat plan. The response shape is unchanged; the bundled Web UI receives plan updates primarily through WebSocket and uses this endpoint for fallback/resynchronization.
|
||||
|
||||
|
||||
Top-level fields:
|
||||
|
||||
@@ -1330,6 +1334,10 @@ Returns `Device[]`.
|
||||
|
||||
Accepts `DeviceCommand`. A direct command is rejected if the device belongs to a disabled thermostat zone; re-enable the zone for normal HA/controller ownership or use the administrator technical device endpoint deliberately.
|
||||
|
||||
### `GET /api/integrations/home-assistant/snapshot`
|
||||
|
||||
Returns `devices`, `groups`, `control_plan` and `control_plan_revision` in one restricted request. The bundled Home Assistant coordinator uses this endpoint to reduce its normal refresh from three HTTP requests to one. The existing restricted devices/groups/control-plan endpoints remain available.
|
||||
|
||||
### `GET /api/integrations/home-assistant/control-plan`
|
||||
|
||||
Same payload as administrator `GET /api/control-plan`.
|
||||
@@ -1401,12 +1409,13 @@ Every server event uses:
|
||||
}
|
||||
```
|
||||
|
||||
The first frame is always `bootstrap` with the same payload as `GET /api/bootstrap`, unless bootstrap generation itself fails.
|
||||
The first frame is always `bootstrap` with the same payload as `GET /api/bootstrap`, including `control_plan` and `control_plan_revision`, unless bootstrap generation itself fails. If the generic event queue is overrun, the server sends another full `bootstrap` to resynchronize the client instead of silently continuing with missed state.
|
||||
|
||||
Common live events include:
|
||||
|
||||
```text
|
||||
bootstrap
|
||||
control_plan.updated
|
||||
device.created
|
||||
device.updated
|
||||
device.deleted
|
||||
@@ -1439,6 +1448,8 @@ gree.frame
|
||||
log.created
|
||||
```
|
||||
|
||||
`control_plan.updated` uses `data.revision` plus `data.plan`. Revisions increase only when the materialized plan changes semantically; `generated_at` alone does not create a new revision.
|
||||
|
||||
Additional engine/integration events may be introduced without changing the envelope.
|
||||
|
||||
`api.request` data:
|
||||
|
||||
+43
-6
@@ -2,9 +2,9 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "GREE Controller API",
|
||||
"version": "0.12.1",
|
||||
"version": "0.13.0",
|
||||
"summary": "Local HTTP/WebSocket API for GREE HVAC control",
|
||||
"description": "Self-hosted API used by the GREE Controller Web UI and Home Assistant integration.\n\n### Authentication\nNormal administrator endpoints accept `Authorization: Bearer <APP_TOKEN>` or `x-api-token: <APP_TOKEN>` when `GREE_CONTROLLER_APP_TOKEN` is configured. If the app token is empty, those endpoints intentionally run in trusted-LAN mode without authentication.\n\nThe restricted `/api/integrations/home-assistant/*` control surface always requires either a generated access token or the administrator app token. Generated tokens do **not** grant normal administrator API access.\n\nIn Swagger UI use **Authorize** and enter the token in either the bearer field or `x-api-token` field.\n\n### Control ownership\nThermostat zones, groups, house rules, direct device control, schedules and visual Flows share an ownership/arbitration model. Prefer zone/group/house endpoints for thermostat intent; direct device commands are technical/manual control and can deliberately take ownership.\n\n### 0.12.0 breaking API\nRuntime settings are split into functional `/api/settings/*` resources. The former `/api/settings`, `/api/debug`, `/api/events/retention`, `/api/settings/export` and `/api/settings/import` endpoints were removed without compatibility aliases.",
|
||||
"description": "Self-hosted API used by the GREE Controller Web UI and Home Assistant integration.\n\n### Authentication\nNormal administrator endpoints accept `Authorization: Bearer <APP_TOKEN>` or `x-api-token: <APP_TOKEN>` when `GREE_CONTROLLER_APP_TOKEN` is configured. If the app token is empty, those endpoints intentionally run in trusted-LAN mode without authentication.\n\nThe restricted `/api/integrations/home-assistant/*` control surface always requires either a generated access token or the administrator app token. Generated tokens do **not** grant normal administrator API access.\n\nIn Swagger UI use **Authorize** and enter the token in either the bearer field or `x-api-token` field.\n\n### Control ownership\nThermostat zones, groups, house rules, direct device control, schedules and visual Flows share an ownership/arbitration model. Prefer zone/group/house endpoints for thermostat intent; direct device commands are technical/manual control and can deliberately take ownership.\n\n### 0.12.0 breaking API\nRuntime settings are split into functional `/api/settings/*` resources. The former `/api/settings`, `/api/debug`, `/api/events/retention`, `/api/settings/export` and `/api/settings/import` endpoints were removed without compatibility aliases.\n\n### 0.13.0\n`control-plan` keeps its existing HTTP response shape but is materialized in memory and pushed to WebSocket clients as `control_plan.updated`; HTTP remains the fallback/resync path. A restricted Home Assistant snapshot endpoint combines devices, groups and the control plan.",
|
||||
"license": {
|
||||
"name": "MIT"
|
||||
}
|
||||
@@ -122,7 +122,7 @@
|
||||
"System"
|
||||
],
|
||||
"summary": "Get application bootstrap",
|
||||
"description": "Returns the initial Web UI entity snapshot: devices, zones, groups, schedules, automations, Flows, access-token metadata, house mode, outdoor temperature and system diagnostics. Runtime settings are loaded from the functional `/api/settings/*` resources.",
|
||||
"description": "Returns the initial Web UI entity snapshot: devices, zones, groups, schedules, automations, Flows, access-token metadata, house mode, outdoor temperature, the latest materialized control plan with its revision, and system diagnostics. Runtime settings are loaded from the functional `/api/settings/*` resources.",
|
||||
"operationId": "bootstrap",
|
||||
"security": [
|
||||
{
|
||||
@@ -2971,7 +2971,7 @@
|
||||
"History"
|
||||
],
|
||||
"summary": "Get resolved control plan",
|
||||
"description": "Machine-readable desired-vs-actual thermostat plan with ownership, blocking reasons, schedules, next events and per-zone control decisions. This is the preferred endpoint for clients that need to explain why a zone is or is not running.",
|
||||
"description": "Machine-readable desired-vs-actual thermostat plan with ownership, blocking reasons, schedules, next events and per-zone control decisions. The response shape is unchanged; the bundled Web UI receives plan changes through WebSocket and uses this endpoint as a fallback/resynchronization surface.",
|
||||
"operationId": "controlPlan",
|
||||
"security": [
|
||||
{
|
||||
@@ -3511,7 +3511,7 @@
|
||||
"Home Assistant"
|
||||
],
|
||||
"summary": "Get control plan for Home Assistant",
|
||||
"description": "This restricted Home Assistant surface always requires either a generated controller access token or the administrator app token. Generated tokens are intentionally not accepted by normal administrator endpoints. Returns the same machine-readable control plan as the administrator endpoint.",
|
||||
"description": "This restricted Home Assistant surface always requires either a generated controller access token or the administrator app token. Generated tokens are intentionally not accepted by normal administrator endpoints. Returns the same latest materialized machine-readable control plan as the administrator endpoint.",
|
||||
"operationId": "haControlPlan",
|
||||
"security": [
|
||||
{
|
||||
@@ -4799,6 +4799,43 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/integrations/home-assistant/snapshot": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Home Assistant"
|
||||
],
|
||||
"summary": "Get Home Assistant integration snapshot",
|
||||
"description": "Returns devices, derived groups, the latest materialized control plan and its revision in one restricted request. Existing per-resource Home Assistant endpoints remain available for compatibility.",
|
||||
"operationId": "haSnapshot",
|
||||
"security": [
|
||||
{
|
||||
"BearerToken": []
|
||||
},
|
||||
{
|
||||
"ApiTokenHeader": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Home Assistant integration snapshot",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/components/responses/InternalError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -4854,7 +4891,7 @@
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"example": "0.12.1"
|
||||
"example": "0.13.0"
|
||||
},
|
||||
"uptime_seconds": {
|
||||
"type": "integer",
|
||||
|
||||
@@ -60,6 +60,19 @@ class GreeControllerClient:
|
||||
"""Return the public controller health payload."""
|
||||
return await self._request("GET", "/api/health")
|
||||
|
||||
async def snapshot(self) -> dict[str, Any]:
|
||||
"""Return devices, groups and the control plan in one restricted request."""
|
||||
data = await self._request("GET", "/api/integrations/home-assistant/snapshot")
|
||||
if not isinstance(data, dict):
|
||||
raise GreeControllerApiError("Controller returned an invalid snapshot payload")
|
||||
if not isinstance(data.get("devices"), list):
|
||||
raise GreeControllerApiError("Controller returned invalid snapshot devices")
|
||||
if not isinstance(data.get("control_plan"), dict):
|
||||
raise GreeControllerApiError("Controller returned an invalid snapshot control plan")
|
||||
if not isinstance(data.get("groups"), list):
|
||||
raise GreeControllerApiError("Controller returned invalid snapshot groups")
|
||||
return data
|
||||
|
||||
async def devices(self) -> list[dict[str, Any]]:
|
||||
"""Return all controller devices."""
|
||||
data = await self._request("GET", "/api/integrations/home-assistant/devices")
|
||||
|
||||
@@ -40,11 +40,10 @@ class GreeControllerCoordinator(DataUpdateCoordinator[dict[str, dict]]):
|
||||
|
||||
async def _async_update_data(self) -> dict[str, dict]:
|
||||
try:
|
||||
devices, plan, groups = await asyncio.gather(
|
||||
self.client.devices(),
|
||||
self.client.control_plan(),
|
||||
self.client.groups(),
|
||||
)
|
||||
snapshot = await self.client.snapshot()
|
||||
devices = snapshot["devices"]
|
||||
plan = snapshot["control_plan"]
|
||||
groups = snapshot["groups"]
|
||||
except GreeControllerApiError as err:
|
||||
raise UpdateFailed(str(err)) from err
|
||||
self._overlay_pending_zone_controls(plan)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "gree_controller",
|
||||
"name": "GREE Controller",
|
||||
"version": "0.12.1",
|
||||
"version": "0.13.0",
|
||||
"config_flow": true,
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
|
||||
+20
-4
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Developer smoke/integration tests for GREE Controller API 0.12.1.
|
||||
"""Developer smoke/integration tests for GREE Controller API 0.13.0.
|
||||
|
||||
Default mode is read-only and safe to run against a real controller.
|
||||
Use --settings-write to additionally round-trip all split settings resources and
|
||||
@@ -26,7 +26,7 @@ from typing import Any, Callable, Iterable, Optional
|
||||
|
||||
DEFAULT_BASE_URL = os.environ.get("GREE_API_URL", "http://127.0.0.1:8787")
|
||||
DEFAULT_TOKEN = os.environ.get("GREE_API_TOKEN", "")
|
||||
DEFAULT_EXPECTED_VERSION = "0.12.1"
|
||||
DEFAULT_EXPECTED_VERSION = "0.13.0"
|
||||
|
||||
SETTINGS_PATHS: dict[str, set[str]] = {
|
||||
"/api/settings/application": {"simulator_enabled"},
|
||||
@@ -113,6 +113,7 @@ HA_SAFE_GET_PATHS = [
|
||||
"/api/integrations/home-assistant/devices",
|
||||
"/api/integrations/home-assistant/control-plan",
|
||||
"/api/integrations/home-assistant/groups",
|
||||
"/api/integrations/home-assistant/snapshot",
|
||||
]
|
||||
|
||||
DETAIL_COLLECTIONS = [
|
||||
@@ -127,6 +128,7 @@ DETAIL_COLLECTIONS = [
|
||||
EXPECTED_OPENAPI_PATHS = set(SETTINGS_PATHS) | {
|
||||
"/api/configuration/export",
|
||||
"/api/configuration/import",
|
||||
"/api/integrations/home-assistant/snapshot",
|
||||
}
|
||||
|
||||
|
||||
@@ -381,6 +383,20 @@ def test_ha_safe_get(client: ApiClient, path: str) -> str:
|
||||
return "restricted HA auth enforced (no token supplied)"
|
||||
assert_status(resp, 200)
|
||||
data = resp.json()
|
||||
if path == "/api/integrations/home-assistant/snapshot":
|
||||
if not isinstance(data, dict):
|
||||
raise TestFailure(f"snapshot is not an object: {type(data).__name__}")
|
||||
required = {"devices", "control_plan", "control_plan_revision", "groups"}
|
||||
missing = sorted(required - set(data))
|
||||
if missing:
|
||||
raise TestFailure(f"snapshot missing keys: {missing}")
|
||||
if not isinstance(data["devices"], list) or not isinstance(data["groups"], list):
|
||||
raise TestFailure("snapshot devices/groups must be arrays")
|
||||
if not isinstance(data["control_plan"], dict):
|
||||
raise TestFailure("snapshot control_plan must be an object")
|
||||
if not isinstance(data["control_plan_revision"], int):
|
||||
raise TestFailure("snapshot control_plan_revision must be an integer")
|
||||
return f"devices={len(data['devices'])}, groups={len(data['groups'])}, revision={data['control_plan_revision']}"
|
||||
if not isinstance(data, (list, dict)):
|
||||
raise TestFailure(f"unexpected JSON type {type(data).__name__}")
|
||||
if isinstance(data, list):
|
||||
@@ -503,7 +519,7 @@ def run_suite(args: argparse.Namespace) -> int:
|
||||
print(f"Settings write tests: {'ENABLED' if args.settings_write else 'disabled'}\n")
|
||||
|
||||
runner.run("public health", lambda: test_health(client, args.expected_version))
|
||||
runner.run("OpenAPI 0.12.1 contract", lambda: test_openapi(client, args.expected_version))
|
||||
runner.run("OpenAPI 0.13.0 contract", lambda: test_openapi(client, args.expected_version))
|
||||
runner.run("protected API requires auth", lambda: test_protected_auth(client))
|
||||
|
||||
for path in SAFE_GET_PATHS:
|
||||
@@ -571,7 +587,7 @@ def run_suite(args: argparse.Namespace) -> int:
|
||||
|
||||
def parse_args(argv: Optional[Iterable[str]] = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Developer smoke/integration tests for GREE Controller API 0.12.1.",
|
||||
description="Developer smoke/integration tests for GREE Controller API 0.13.0.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""Examples:
|
||||
python3 scripts/api_dev_test.py
|
||||
|
||||
@@ -204,6 +204,10 @@ pub fn router(state: AppState) -> Router {
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), auth));
|
||||
|
||||
let home_assistant_api = Router::new()
|
||||
.route(
|
||||
"/api/integrations/home-assistant/snapshot",
|
||||
get(home_assistant_snapshot),
|
||||
)
|
||||
.route(
|
||||
"/api/integrations/home-assistant/devices",
|
||||
get(list_devices),
|
||||
|
||||
+2
-1
@@ -278,7 +278,7 @@ async fn list_home_assistant_groups(
|
||||
let groups = state.db.list_groups()?;
|
||||
let zones = state.db.list_zones()?;
|
||||
let devices = state.db.list_devices()?;
|
||||
let plan = engine::build_control_plan(&state).await?;
|
||||
let plan = engine::get_control_plan_snapshot(&state).await?;
|
||||
let settings = state.settings.read().await.clone();
|
||||
let mut output = Vec::with_capacity(groups.len());
|
||||
|
||||
@@ -288,6 +288,7 @@ async fn list_home_assistant_groups(
|
||||
.filter(|zone| group.zone_ids.iter().any(|zone_id| zone_id == &zone.id))
|
||||
.collect::<Vec<_>>();
|
||||
let planned_members = plan
|
||||
.plan
|
||||
.zones
|
||||
.iter()
|
||||
.filter(|zone| {
|
||||
|
||||
+2
-3
@@ -485,7 +485,6 @@ async fn history(
|
||||
}
|
||||
|
||||
async fn control_plan(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
|
||||
Ok(Json(serde_json::to_value(
|
||||
engine::build_control_plan(&state).await?,
|
||||
)?))
|
||||
let snapshot = engine::get_control_plan_snapshot(&state).await?;
|
||||
Ok(Json(serde_json::to_value(snapshot.plan.as_ref())?))
|
||||
}
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
async fn home_assistant_snapshot(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let control_plan = engine::get_control_plan_snapshot(&state).await?;
|
||||
let groups = list_home_assistant_groups(State(state.clone())).await?.0;
|
||||
Ok(Json(json!({
|
||||
"devices": state.db.list_devices()?,
|
||||
"control_plan": control_plan.plan.as_ref(),
|
||||
"control_plan_revision": control_plan.revision,
|
||||
"groups": groups,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HaTestRequest {
|
||||
entity_id: Option<String>,
|
||||
|
||||
@@ -20,6 +20,7 @@ async fn build_bootstrap(state: &AppState) -> Result<Value, AppError> {
|
||||
let online_count = devices.iter().filter(|value| value.online).count();
|
||||
let simulator_count = devices.iter().filter(|value| value.simulated).count();
|
||||
let (received_frames_total, received_frames_by_device) = state.gree.received_frame_stats();
|
||||
let control_plan = engine::get_control_plan_snapshot(state).await?;
|
||||
Ok(json!({
|
||||
"devices": devices,
|
||||
"zones": state.db.list_zones()?,
|
||||
@@ -30,6 +31,8 @@ async fn build_bootstrap(state: &AppState) -> Result<Value, AppError> {
|
||||
"access_tokens": state.db.list_api_tokens()?,
|
||||
"house": {"mode": settings.house_mode},
|
||||
"outdoor_temperature": *state.outdoor_temperature.read().await,
|
||||
"control_plan": control_plan.plan.as_ref(),
|
||||
"control_plan_revision": control_plan.revision,
|
||||
"system": {
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"uptime_seconds": state.started.elapsed().as_secs(),
|
||||
|
||||
+72
-12
@@ -14,21 +14,60 @@ async fn websocket(
|
||||
Ok(ws.on_upgrade(move |socket| websocket_loop(state, socket)))
|
||||
}
|
||||
|
||||
async fn websocket_loop(state: AppState, mut socket: WebSocket) {
|
||||
let initial = match build_bootstrap(&state).await {
|
||||
Ok(data) => json!({"event":"bootstrap","timestamp":Utc::now(),"data":data}),
|
||||
Err(err) => {
|
||||
json!({"event":"error","timestamp":Utc::now(),"data":{"message":err.to_string()}})
|
||||
fn control_plan_ws_message(snapshot: &crate::state::ControlPlanSnapshot) -> Value {
|
||||
json!({
|
||||
"event": "control_plan.updated",
|
||||
"timestamp": Utc::now(),
|
||||
"data": {
|
||||
"revision": snapshot.revision,
|
||||
"plan": snapshot.plan.as_ref(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn send_ws_bootstrap(state: &AppState, socket: &mut WebSocket) -> Result<Option<u64>, ()> {
|
||||
let (message, revision) = match build_bootstrap(state).await {
|
||||
Ok(data) => {
|
||||
let revision = data.get("control_plan_revision").and_then(Value::as_u64);
|
||||
(
|
||||
json!({"event":"bootstrap","timestamp":Utc::now(),"data":data}),
|
||||
revision,
|
||||
)
|
||||
}
|
||||
Err(err) => (
|
||||
json!({"event":"error","timestamp":Utc::now(),"data":{"message":err.to_string()}}),
|
||||
None,
|
||||
),
|
||||
};
|
||||
if socket
|
||||
.send(Message::Text(initial.to_string()))
|
||||
socket
|
||||
.send(Message::Text(message.to_string()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
.map_err(|_| ())?;
|
||||
Ok(revision)
|
||||
}
|
||||
|
||||
async fn websocket_loop(state: AppState, mut socket: WebSocket) {
|
||||
// Subscribe before building the bootstrap so state changes during bootstrap generation are
|
||||
// queued and can be applied immediately after the first frame.
|
||||
let mut receiver = state.events.subscribe();
|
||||
let mut control_plan = state.subscribe_control_plan();
|
||||
|
||||
let bootstrap_revision = match send_ws_bootstrap(&state, &mut socket).await {
|
||||
Ok(revision) => revision,
|
||||
Err(()) => return,
|
||||
};
|
||||
let mut last_control_plan_revision = bootstrap_revision;
|
||||
|
||||
// If the watch value is exactly the plan embedded in bootstrap, mark it seen to avoid a
|
||||
// duplicate control_plan.updated frame. A newer revision remains pending and is sent below.
|
||||
let current_revision = control_plan
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.map(|snapshot| snapshot.revision);
|
||||
if current_revision == bootstrap_revision {
|
||||
control_plan.borrow_and_update();
|
||||
}
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
event = receiver.recv() => {
|
||||
@@ -38,10 +77,31 @@ async fn websocket_loop(state: AppState, mut socket: WebSocket) {
|
||||
if socket.send(Message::Text(text)).await.is_err() { break; }
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
|
||||
tracing::warn!(skipped, "websocket client lagged; sending full bootstrap resync");
|
||||
// Drop the retained stale backlog before taking the replacement snapshot.
|
||||
// Events created while bootstrap is built are queued on this fresh receiver.
|
||||
receiver = state.events.subscribe();
|
||||
match send_ws_bootstrap(&state, &mut socket).await {
|
||||
Ok(revision) => last_control_plan_revision = revision,
|
||||
Err(()) => break,
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
changed = control_plan.changed() => {
|
||||
if changed.is_err() { break; }
|
||||
let snapshot = control_plan.borrow_and_update().clone();
|
||||
if let Some(snapshot) = snapshot {
|
||||
if last_control_plan_revision == Some(snapshot.revision) {
|
||||
continue;
|
||||
}
|
||||
let text = control_plan_ws_message(snapshot.as_ref()).to_string();
|
||||
if socket.send(Message::Text(text)).await.is_err() { break; }
|
||||
last_control_plan_revision = Some(snapshot.revision);
|
||||
}
|
||||
}
|
||||
message = socket.next() => {
|
||||
match message {
|
||||
Some(Ok(Message::Ping(value))) => { if socket.send(Message::Pong(value)).await.is_err() { break; } }
|
||||
|
||||
+3
-3
@@ -6,14 +6,14 @@ use crate::{
|
||||
DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, RuntimeSettings,
|
||||
Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan, ZoneReading,
|
||||
},
|
||||
state::{AppState, PendingControllerCommand},
|
||||
state::{AppState, ControlPlanSnapshot, PendingControllerCommand},
|
||||
};
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Datelike, Local, NaiveTime, Timelike, Utc, Weekday};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::atomic::Ordering,
|
||||
collections::{HashMap, HashSet},
|
||||
sync::{atomic::Ordering, Arc},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::time::sleep;
|
||||
|
||||
@@ -1,3 +1,100 @@
|
||||
const CONTROL_PLAN_DEBOUNCE: Duration = Duration::from_millis(120);
|
||||
const CONTROL_PLAN_PERIODIC_REFRESH: Duration = Duration::from_secs(10);
|
||||
|
||||
pub async fn get_control_plan_snapshot(
|
||||
state: &AppState,
|
||||
) -> Result<Arc<ControlPlanSnapshot>, AppError> {
|
||||
refresh_control_plan_cache(state, false).await
|
||||
}
|
||||
|
||||
async fn refresh_control_plan_cache(
|
||||
state: &AppState,
|
||||
force: bool,
|
||||
) -> Result<Arc<ControlPlanSnapshot>, AppError> {
|
||||
let _guard = state.control_plan_build_lock.lock().await;
|
||||
if !force {
|
||||
if let Some(snapshot) = state.control_plan_snapshot() {
|
||||
if !state.control_plan_dirty.load(Ordering::Acquire) {
|
||||
return Ok(snapshot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear before calculating. Any relevant mutation that happens during the build sets the
|
||||
// flag again and schedules another pass, so an update cannot be lost.
|
||||
state.control_plan_dirty.store(false, Ordering::Release);
|
||||
let plan = match build_control_plan(state).await {
|
||||
Ok(plan) => plan,
|
||||
Err(err) => {
|
||||
state.control_plan_dirty.store(true, Ordering::Release);
|
||||
state.control_plan_wakeup.notify_one();
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(previous) = state.control_plan_snapshot() {
|
||||
if control_plans_semantically_equal(previous.plan.as_ref(), &plan)? {
|
||||
// Keep generated_at fresh for HTTP/bootstrap consumers without creating a new
|
||||
// semantic revision. WebSocket clients suppress same-revision watch updates.
|
||||
let refreshed = Arc::new(ControlPlanSnapshot {
|
||||
revision: previous.revision,
|
||||
plan: Arc::new(plan),
|
||||
});
|
||||
state.control_plan.send_replace(Some(refreshed.clone()));
|
||||
return Ok(refreshed);
|
||||
}
|
||||
}
|
||||
|
||||
let revision = state
|
||||
.control_plan_revision
|
||||
.fetch_add(1, Ordering::AcqRel)
|
||||
.saturating_add(1);
|
||||
let snapshot = Arc::new(ControlPlanSnapshot {
|
||||
revision,
|
||||
plan: Arc::new(plan),
|
||||
});
|
||||
state.control_plan.send_replace(Some(snapshot.clone()));
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
fn control_plans_semantically_equal(
|
||||
left: &ControlPlan,
|
||||
right: &ControlPlan,
|
||||
) -> Result<bool, AppError> {
|
||||
fn semantic_value(plan: &ControlPlan) -> Result<Value, AppError> {
|
||||
let mut value = serde_json::to_value(plan)?;
|
||||
if let Some(object) = value.as_object_mut() {
|
||||
object.remove("generated_at");
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
Ok(semantic_value(left)? == semantic_value(right)?)
|
||||
}
|
||||
|
||||
async fn control_plan_cache_loop(state: AppState) {
|
||||
let mut periodic = tokio::time::interval(CONTROL_PLAN_PERIODIC_REFRESH);
|
||||
periodic.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
// Tokio intervals tick immediately once; consume that tick because startup is refreshed below.
|
||||
periodic.tick().await;
|
||||
|
||||
if let Err(err) = refresh_control_plan_cache(&state, false).await {
|
||||
tracing::warn!(error=?err, "cannot build initial control plan snapshot");
|
||||
}
|
||||
|
||||
loop {
|
||||
let force = tokio::select! {
|
||||
_ = state.control_plan_wakeup.notified() => {
|
||||
sleep(CONTROL_PLAN_DEBOUNCE).await;
|
||||
false
|
||||
}
|
||||
_ = periodic.tick() => true,
|
||||
};
|
||||
if let Err(err) = refresh_control_plan_cache(&state, force).await {
|
||||
tracing::warn!(error=?err, "cannot refresh control plan snapshot");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppError> {
|
||||
let settings = state.settings.read().await.clone();
|
||||
let schedules = state.db.list_schedules()?;
|
||||
@@ -333,11 +430,22 @@ fn next_schedule_events(
|
||||
if mode == "off" {
|
||||
return Vec::new();
|
||||
}
|
||||
let boundary_minutes = schedule_boundary_minutes(&zone.id, schedules);
|
||||
if boundary_minutes.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut events = Vec::new();
|
||||
let mut current = active_schedule_for_zone(zone, schedules, now).map(|item| item.id.as_str());
|
||||
let base = minute_floor(now);
|
||||
for minute in 1..=(8 * 24 * 60) {
|
||||
let candidate = base + chrono::Duration::minutes(minute);
|
||||
let minute_of_day = candidate.hour() * 60 + candidate.minute();
|
||||
let previous_local = base + chrono::Duration::minutes(minute - 1);
|
||||
let clock_discontinuity = candidate.naive_local() - previous_local.naive_local()
|
||||
!= chrono::Duration::minutes(1);
|
||||
if !boundary_minutes.contains(&minute_of_day) && !clock_discontinuity {
|
||||
continue;
|
||||
}
|
||||
let next = active_schedule_for_zone(zone, schedules, candidate);
|
||||
let next_id = next.map(|item| item.id.as_str());
|
||||
if next_id == current {
|
||||
|
||||
@@ -24,6 +24,11 @@ pub fn start(state: AppState) {
|
||||
if let Err(err) = reset_temporary_condition_observations_after_restart(&state) {
|
||||
tracing::warn!(error=?err, "cannot reset temporary thermostat observation continuity after restart");
|
||||
}
|
||||
|
||||
let control_plan_state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
control_plan_cache_loop(control_plan_state).await;
|
||||
});
|
||||
let poll_state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
|
||||
+28
-1
@@ -17,6 +17,21 @@ fn minute_floor(now: DateTime<Local>) -> DateTime<Local> {
|
||||
.unwrap_or(now)
|
||||
}
|
||||
|
||||
fn schedule_boundary_minutes(zone_id: &str, schedules: &[Schedule]) -> HashSet<u32> {
|
||||
let mut result = HashSet::new();
|
||||
for item in schedules
|
||||
.iter()
|
||||
.filter(|item| item.enabled && item.zone_id == zone_id)
|
||||
{
|
||||
for raw in [&item.start_time, &item.end_time] {
|
||||
if let Ok(time) = NaiveTime::parse_from_str(raw, "%H:%M") {
|
||||
result.insert(time.hour() * 60 + time.minute());
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn next_schedule_boundary_utc(
|
||||
zone_id: &str,
|
||||
schedules: &[Schedule],
|
||||
@@ -27,10 +42,22 @@ pub fn next_schedule_boundary_utc(
|
||||
.filter(|item| item.enabled && item.zone_id == zone_id && schedule_active(item, now))
|
||||
.max_by_key(|item| item.updated_at)
|
||||
.map(|item| item.id.as_str());
|
||||
let boundary_minutes = schedule_boundary_minutes(zone_id, schedules);
|
||||
if boundary_minutes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let base = minute_floor(now);
|
||||
// Eight days cover a complete weekly schedule plus the next transition.
|
||||
// State normally changes on a configured start/end minute. Also evaluate local-clock
|
||||
// discontinuities so DST jumps/repeats preserve the original absolute-minute behavior.
|
||||
for minute in 1..=(8 * 24 * 60) {
|
||||
let candidate = base + chrono::Duration::minutes(minute);
|
||||
let minute_of_day = candidate.hour() * 60 + candidate.minute();
|
||||
let previous_local = base + chrono::Duration::minutes(minute - 1);
|
||||
let clock_discontinuity = candidate.naive_local() - previous_local.naive_local()
|
||||
!= chrono::Duration::minutes(1);
|
||||
if !boundary_minutes.contains(&minute_of_day) && !clock_discontinuity {
|
||||
continue;
|
||||
}
|
||||
let next = schedules
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
|
||||
@@ -11,6 +11,31 @@ mod tests {
|
||||
assert_eq!(effective_sensor_stale_after_seconds(120_000, 600), 86_400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_plan_semantic_equality_ignores_only_generation_timestamp() {
|
||||
let generated_at = Utc.with_ymd_and_hms(2026, 9, 4, 8, 0, 0).unwrap();
|
||||
let left = ControlPlan {
|
||||
generated_at: generated_at.clone(),
|
||||
house_mode: "cool".into(),
|
||||
house_preset: Some("comfort".into()),
|
||||
house_power: true,
|
||||
outdoor_temperature: Some(24.0),
|
||||
control_strategy: "thermostat".into(),
|
||||
night_mode_active: false,
|
||||
night_mode_start: "22:00".into(),
|
||||
night_mode_end: "06:00".into(),
|
||||
night_mode_max_fan_speed: 2,
|
||||
next_events: Vec::new(),
|
||||
zones: Vec::new(),
|
||||
rules: Vec::new(),
|
||||
};
|
||||
let mut right = left.clone();
|
||||
right.generated_at = generated_at + chrono::Duration::seconds(30);
|
||||
assert!(control_plans_semantically_equal(&left, &right).unwrap());
|
||||
right.house_mode = "heat".into();
|
||||
assert!(!control_plans_semantically_equal(&left, &right).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overnight_schedule_works() {
|
||||
let now = Utc.with_ymd_and_hms(2025, 1, 7, 1, 0, 0).unwrap().with_timezone(&Local); // Tuesday
|
||||
|
||||
+11
-2
@@ -18,13 +18,16 @@ use models::Device;
|
||||
use protocol::GreeClient;
|
||||
use state::AppState;
|
||||
use std::{
|
||||
sync::{atomic::AtomicBool, Arc},
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU64},
|
||||
Arc,
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::{
|
||||
net::TcpListener,
|
||||
signal,
|
||||
sync::{broadcast, Notify, RwLock},
|
||||
sync::{broadcast, watch, Notify, RwLock},
|
||||
};
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
@@ -56,6 +59,7 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
|
||||
let (events, _) = broadcast::channel(512);
|
||||
let (control_plan, _) = watch::channel(None);
|
||||
let debug_gree_frames = Arc::new(AtomicBool::new(runtime_settings.debug.gree_frames));
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
@@ -78,6 +82,11 @@ async fn main() -> Result<()> {
|
||||
debug_gree_frames,
|
||||
initial_device_sync_complete: Arc::new(AtomicBool::new(false)),
|
||||
zone_control_wakeup: Arc::new(Notify::new()),
|
||||
control_plan,
|
||||
control_plan_wakeup: Arc::new(Notify::new()),
|
||||
control_plan_dirty: Arc::new(AtomicBool::new(true)),
|
||||
control_plan_revision: Arc::new(AtomicU64::new(0)),
|
||||
control_plan_build_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
device_operation_locks: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
|
||||
zone_operation_locks: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
|
||||
group_operation_locks: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
|
||||
|
||||
+83
-4
@@ -1,17 +1,20 @@
|
||||
use crate::{
|
||||
config::Config,
|
||||
db::Db,
|
||||
models::{ApiEvent, DeviceCommand, RuntimeSettings},
|
||||
models::{ApiEvent, ControlPlan, DeviceCommand, RuntimeSettings},
|
||||
protocol::GreeClient,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{atomic::AtomicBool, Arc},
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
Arc,
|
||||
},
|
||||
time::Instant,
|
||||
};
|
||||
use tokio::sync::{broadcast, Mutex, Notify, OwnedMutexGuard, RwLock};
|
||||
use tokio::sync::{broadcast, watch, Mutex, Notify, OwnedMutexGuard, RwLock};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PendingControllerCommand {
|
||||
@@ -24,6 +27,12 @@ pub(crate) struct PendingControllerCommand {
|
||||
pub expires_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ControlPlanSnapshot {
|
||||
pub revision: u64,
|
||||
pub plan: Arc<ControlPlan>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: Db,
|
||||
@@ -39,6 +48,14 @@ pub struct AppState {
|
||||
pub initial_device_sync_complete: Arc<AtomicBool>,
|
||||
/// Explicit thermostat changes wake the regulator instead of waiting for the next fixed interval.
|
||||
pub zone_control_wakeup: Arc<Notify>,
|
||||
/// Latest materialized control plan. WebSocket clients subscribe to this state stream,
|
||||
/// while HTTP uses the same snapshot as a compatibility/fallback surface.
|
||||
pub control_plan: watch::Sender<Option<Arc<ControlPlanSnapshot>>>,
|
||||
/// Coalesces changes that can affect the derived control plan.
|
||||
pub control_plan_wakeup: Arc<Notify>,
|
||||
pub control_plan_dirty: Arc<AtomicBool>,
|
||||
pub(crate) control_plan_revision: Arc<AtomicU64>,
|
||||
pub(crate) control_plan_build_lock: Arc<Mutex<()>>,
|
||||
pub(crate) device_operation_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
|
||||
pub(crate) zone_operation_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
|
||||
pub(crate) group_operation_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
|
||||
@@ -112,15 +129,36 @@ impl AppState {
|
||||
self.zone_control_wakeup.notify_one();
|
||||
}
|
||||
|
||||
pub fn control_plan_snapshot(&self) -> Option<Arc<ControlPlanSnapshot>> {
|
||||
self.control_plan.borrow().clone()
|
||||
}
|
||||
|
||||
pub fn subscribe_control_plan(&self) -> watch::Receiver<Option<Arc<ControlPlanSnapshot>>> {
|
||||
self.control_plan.subscribe()
|
||||
}
|
||||
|
||||
pub fn invalidate_control_plan(&self) {
|
||||
self.control_plan_dirty.store(true, Ordering::Release);
|
||||
self.control_plan_wakeup.notify_one();
|
||||
}
|
||||
|
||||
pub fn broadcast(&self, event: impl Into<String>, data: Value) {
|
||||
let event = event.into();
|
||||
let invalidates_control_plan = control_plan_event_affects_plan(&event);
|
||||
let _ = self.events.send(ApiEvent {
|
||||
event: event.into(),
|
||||
event,
|
||||
timestamp: Utc::now(),
|
||||
data,
|
||||
});
|
||||
if invalidates_control_plan {
|
||||
self.invalidate_control_plan();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn log(&self, level: &str, kind: &str, message: &str, metadata: Value) {
|
||||
if control_plan_event_affects_plan(kind) {
|
||||
self.invalidate_control_plan();
|
||||
}
|
||||
if let Err(err) = self.db.log_event(level, kind, message, &metadata) {
|
||||
tracing::warn!(error=?err, "cannot persist event log");
|
||||
}
|
||||
@@ -144,3 +182,44 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn control_plan_event_affects_plan(event: &str) -> bool {
|
||||
event.starts_with("device.")
|
||||
|| event == "devices.discovered"
|
||||
|| event.starts_with("zone.")
|
||||
|| event.starts_with("group.")
|
||||
|| event.starts_with("schedule.")
|
||||
|| event.starts_with("automation.")
|
||||
|| event.starts_with("flow.")
|
||||
|| event.starts_with("settings.")
|
||||
|| matches!(
|
||||
event,
|
||||
"house.mode_changed" | "outdoor.updated" | "configuration.imported"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::control_plan_event_affects_plan;
|
||||
|
||||
#[test]
|
||||
fn control_plan_invalidation_tracks_state_events_but_not_diagnostics() {
|
||||
for event in [
|
||||
"device.updated",
|
||||
"zone.updated",
|
||||
"group.updated",
|
||||
"schedule.updated",
|
||||
"automation.updated",
|
||||
"flow.updated",
|
||||
"settings.night.updated",
|
||||
"house.mode_changed",
|
||||
"outdoor.updated",
|
||||
"configuration.imported",
|
||||
] {
|
||||
assert!(control_plan_event_affects_plan(event), "{event}");
|
||||
}
|
||||
for event in ["log.created", "api.request", "gree.frame"] {
|
||||
assert!(!control_plan_event_affects_plan(event), "{event}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+3
-1
@@ -16,8 +16,10 @@ async function loadBootstrap(showMessage = false) {
|
||||
app.system = data.system || {};
|
||||
app.systemSnapshotAt = Date.now();
|
||||
app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : null;
|
||||
app.controlPlan = data.control_plan || app.controlPlan;
|
||||
app.controlPlanRevision = Number.isFinite(Number(data.control_plan_revision)) ? Number(data.control_plan_revision) : app.controlPlanRevision;
|
||||
renderAll();
|
||||
loadControlPlan();
|
||||
if (!data.control_plan) scheduleControlPlanLoad(0);
|
||||
if (app.settings?.debug?.overlay_enabled) loadDebugBacklog();
|
||||
if (showMessage) toast(tr('common.updated'));
|
||||
if ($('#tokenDialog').open) $('#tokenDialog').close();
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ const app = {
|
||||
historyTab: 'overview', historyData: { zones: [], devices: [], sensors: [] }, historyCounts: {},
|
||||
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false,
|
||||
customChartSeries: [], savedCharts: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, groupControlSeq: {}, zoneControlQueue: {}, groupControlQueue: {}, deviceControlQueue: {}, houseControlQueue: null, climateControlQueue: null, groupCustomDrafts: {}, zoneTemperatureTimers: {}, deviceTemperatureDrafts: {},
|
||||
controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {}, flowSharedInputs: [], flowSharedInputValueCache: {}, flowSharedInputValueRequests: {}, flowSharedInputValueTimer: null,
|
||||
controlPlan: null, controlPlanRevision: null, controlPlanPushReady: false, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {}, flowSharedInputs: [], flowSharedInputValueCache: {}, flowSharedInputValueRequests: {}, flowSharedInputValueTimer: null,
|
||||
simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false, dashboardTab: 'main', settingsTab: 'app', systemSnapshotAt: Date.now(),
|
||||
pingMonitor: { targetId: '', all: false, running: false, timer: null, inFlight: false, samples: {} },
|
||||
flowDraft: null, flowSelectedNodeId: null, flowSelectedNodeIds: [], flowConnectFrom: null, flowDirty: false, flowZoom: 1,
|
||||
|
||||
+38
-3
@@ -133,9 +133,38 @@ function renderControlPlan() {
|
||||
host.innerHTML = content;
|
||||
if (section) section.hidden = !content;
|
||||
}
|
||||
function applyControlPlan(plan, revision = null) {
|
||||
if (!plan || typeof plan !== 'object') return false;
|
||||
const hasRevision = revision !== null && revision !== undefined && revision !== '';
|
||||
const hasCurrentRevision = app.controlPlanRevision !== null && app.controlPlanRevision !== undefined && app.controlPlanRevision !== '';
|
||||
const nextRevision = hasRevision ? Number(revision) : NaN;
|
||||
const currentRevision = hasCurrentRevision ? Number(app.controlPlanRevision) : NaN;
|
||||
if (Number.isFinite(nextRevision) && Number.isFinite(currentRevision) && nextRevision < currentRevision) return false;
|
||||
app.controlPlan = plan;
|
||||
if (Number.isFinite(nextRevision)) app.controlPlanRevision = nextRevision;
|
||||
renderControlPlan();
|
||||
renderSimulationPage();
|
||||
return true;
|
||||
}
|
||||
|
||||
function controlPlanWebSocketReady() {
|
||||
return !!app.ws && app.ws.readyState === WebSocket.OPEN && app.controlPlanPushReady;
|
||||
}
|
||||
|
||||
async function loadControlPlan() {
|
||||
try { app.controlPlan = await api('/api/control-plan'); renderControlPlan(); renderSimulationPage(); }
|
||||
try {
|
||||
const plan = await api('/api/control-plan');
|
||||
// A fallback request may have started while disconnected and finish after WS resync.
|
||||
// Never let that older unversioned HTTP response overwrite a revisioned pushed plan.
|
||||
if (!controlPlanWebSocketReady()) applyControlPlan(plan);
|
||||
}
|
||||
catch (error) { console.warn('Unable to load control plan:', error); }
|
||||
finally {
|
||||
if (!controlPlanWebSocketReady()) {
|
||||
clearTimeout(app.controlPlanTimer);
|
||||
app.controlPlanTimer = setTimeout(loadControlPlan, 10000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function simulationTime(value, options = {}) {
|
||||
@@ -409,8 +438,14 @@ function renderSimulationPage() {
|
||||
rulesHost.innerHTML = activeRules.length ? activeRules.map(rule => `<article class="simulation-rule-card"><div class="simulation-rule-head"><div><span class="eyebrow">${esc(tr('common.automation'))}</span><h3>${esc(rule.name)}</h3></div><span class="badge active">${esc(automationTriggerLabel(rule))}</span></div><p>${esc(tr('simulation.ruleOnDevice', { device: rule.action_device_name || tr('common.noDevice') }))}</p><div class="simulation-rule-action">${esc(simulationRuleAction(rule))}</div><div class="simulation-rule-meta"><small>${esc(tr('automations.last'))}: ${esc(dateTime(rule.last_fired_at))}</small><small>${esc(tr('simulation.nextReady'))}: ${esc(dateTime(rule.next_ready_at))}</small></div></article>`).join('') : `<div class="empty">${esc(tr('simulation.noRules'))}</div>`;
|
||||
}
|
||||
|
||||
function scheduleControlPlanLoad() {
|
||||
function scheduleControlPlanLoad(delay = 180) {
|
||||
if (controlPlanWebSocketReady()) return;
|
||||
clearTimeout(app.controlPlanTimer);
|
||||
app.controlPlanTimer = setTimeout(loadControlPlan, 180);
|
||||
app.controlPlanTimer = setTimeout(loadControlPlan, delay);
|
||||
}
|
||||
|
||||
function stopControlPlanFallback() {
|
||||
clearTimeout(app.controlPlanTimer);
|
||||
app.controlPlanTimer = null;
|
||||
}
|
||||
|
||||
|
||||
+13
-3
@@ -15,10 +15,20 @@ async function handleWebSocketMessage(event) {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.event === 'bootstrap') {
|
||||
const data = message.data; app.devices = data.devices || []; app.zones = data.zones || []; app.groups = data.groups || []; app.schedules = data.schedules || []; app.automations = data.automations || [];
|
||||
app.flows = data.flows || []; await loadSettingsSections(data.house?.mode || app.settings?.house_mode || 'cool'); app.system = data.system || app.system; app.systemSnapshotAt = Date.now(); app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : app.outdoorTemperature; renderAll(); scheduleControlPlanLoad(); if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return;
|
||||
app.flows = data.flows || []; await loadSettingsSections(data.house?.mode || app.settings?.house_mode || 'cool'); app.system = data.system || app.system; app.systemSnapshotAt = Date.now(); app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : app.outdoorTemperature;
|
||||
if (data.control_plan) {
|
||||
app.controlPlan = data.control_plan;
|
||||
app.controlPlanRevision = Number.isFinite(Number(data.control_plan_revision)) ? Number(data.control_plan_revision) : app.controlPlanRevision;
|
||||
app.controlPlanPushReady = true;
|
||||
stopControlPlanFallback();
|
||||
} else {
|
||||
app.controlPlanPushReady = false;
|
||||
}
|
||||
renderAll(); if (!data.control_plan) scheduleControlPlanLoad(0); if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return;
|
||||
}
|
||||
const data = message.data || {};
|
||||
if (['device.updated', 'device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); }
|
||||
if (message.event === 'control_plan.updated') { app.controlPlanPushReady = true; stopControlPlanFallback(); applyControlPlan(data.plan, data.revision); }
|
||||
else if (['device.updated', 'device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'device.deleted') { app.devices = app.devices.filter(v => v.id !== data.id); renderAll(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'devices.discovered') { (data.devices || []).forEach(updateDevice); renderAll(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'zone.updated') { const i = app.zones.findIndex(v => v.id === data.id); if (i >= 0) app.zones[i] = data; else app.zones.push(data); renderSummary(); renderHouseClimate(); renderZones(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); }
|
||||
@@ -63,7 +73,7 @@ function connectWebSocket() {
|
||||
const query = app.token ? `?token=${encodeURIComponent(app.token)}` : '';
|
||||
const ws = new WebSocket(`${protocol}//${location.host}${withBase('/ws')}${query}`); app.ws = ws;
|
||||
ws.onopen = () => updateConnectionIndicator('connected');
|
||||
ws.onclose = () => { updateConnectionIndicator('disconnected'); app.wsTimer = setTimeout(connectWebSocket, 3000); };
|
||||
ws.onclose = () => { app.controlPlanPushReady = false; updateConnectionIndicator('disconnected'); scheduleControlPlanLoad(0); app.wsTimer = setTimeout(connectWebSocket, 3000); };
|
||||
ws.onerror = () => updateConnectionIndicator('connectionError');
|
||||
let messageQueue = Promise.resolve();
|
||||
ws.onmessage = event => {
|
||||
|
||||
Reference in New Issue
Block a user