This commit is contained in:
Mateusz Gruszczyński
2026-09-04 11:00:15 +02:00
parent 8152e63d63
commit 236540e0f0
27 changed files with 557 additions and 102 deletions
Generated
+1 -1
View File
@@ -700,7 +700,7 @@ dependencies = [
[[package]] [[package]]
name = "gree-controller" name = "gree-controller"
version = "0.12.1" version = "0.13.0"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "gree-controller" name = "gree-controller"
version = "0.12.1" version = "0.13.0"
edition = "2021" edition = "2021"
authors = ["GREE Controller contributors"] authors = ["GREE Controller contributors"]
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support" description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
+41 -44
View File
@@ -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 d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
d48b8a8fbd32b483e5aa176111ec821450a338438780f73fde11e11ac2ac4271 ./gree-controller.zip e664a2f53234cb1e5d75bb9482c5477484d6d6ad878f26ea0bf84e23c4f35644 ./Cargo.lock
6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py 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 71c8058f85fd9ce9f063b7a84dcf2956f23cdbbd02bd4bd99d2fbb9d177c72ad ./home-assistant/custom_components/gree_controller/climate.py
5e4aef2143e81bedb5a15dd4be5c71b64a3ab448ec6d33a20851edd098e5f529 ./home-assistant/custom_components/gree_controller/config_flow.py 5e4aef2143e81bedb5a15dd4be5c71b64a3ab448ec6d33a20851edd098e5f529 ./home-assistant/custom_components/gree_controller/config_flow.py
b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./home-assistant/custom_components/gree_controller/const.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 5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.png 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 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 38d659d0273c0d1428679e44574a2666c605d1b3796d323b626baa40560788cf ./home-assistant/custom_components/gree_controller/number.py
39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py 39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py
cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./home-assistant/custom_components/gree_controller/sensor.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 6bddb7b4620021ecd2099a86a77ef5c7f2c2dcd3d07d5db4e7b4c4ce6d3e8c03 ./home-assistant/custom_components/gree_controller/translations/en.json
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json 13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json
c5fc5c87273d82d2834a0a5a365c821a416413ed509613441e24e4dc3507c4d2 ./home-assistant/generated/gree_controller_entities.example.json c5fc5c87273d82d2834a0a5a365c821a416413ed509613441e24e4dc3507c4d2 ./home-assistant/generated/gree_controller_entities.example.json
b11e581dd916676e24402112fe6896bc681abf3c94cafc85c4d3ded3f3a272a3 ./home-assistant/README.md 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
810bf9a9483277cbde14454b6b88dbfe79b873a6f34737031a7e10d23fda9299 ./lang/en.json 810bf9a9483277cbde14454b6b88dbfe79b873a6f34737031a7e10d23fda9299 ./lang/en.json
ad48d11875aa041c0f593aa6e322f8d953aef137d4df4c369c5f82eec5aebd11 ./lang/pl.json ad48d11875aa041c0f593aa6e322f8d953aef137d4df4c369c5f82eec5aebd11 ./lang/pl.json
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json
1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json 1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json
@@ -69,9 +67,9 @@ fffa18bd989a0dcc1c5458975522517d40af4af40cc1ca30640e79d3db711053 ./presets/unoc
ae21459a261712bcb8d57594528b1648432e8d03a8a38502234829c0dbfef774 ./presets/weekend_comfort.json ae21459a261712bcb8d57594528b1648432e8d03a8a38502234829c0dbfef774 ./presets/weekend_comfort.json
fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/window_available_guard.json fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/window_available_guard.json
804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json 804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json
c353091e7695cd19f77bdcad80b41438710a7315c5f2696016dc482000023a21 ./README.md
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh
cd2eb8c851ab3cacd00efcb5cdbd404f03279e75126c46f025e132ba1e9d4ce1 ./scripts/api_dev_test.py bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
52fa6906d2c80d7c8fe0b0d178c16b9b0a53408472a9ecdccbb9d8df1137860f ./scripts/api_dev_test.py
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh 6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh
1d6e14e26e49aa9d3527f30a23668bf8d9c48b67e6628ef686c3155c012155de ./scripts/dev.sh 1d6e14e26e49aa9d3527f30a23668bf8d9c48b67e6628ef686c3155c012155de ./scripts/dev.sh
@@ -79,12 +77,12 @@ cd2eb8c851ab3cacd00efcb5cdbd404f03279e75126c46f025e132ba1e9d4ce1 ./scripts/api_
e4849261fd9ed1f01df96c0637c439c0c4eff8fa317b2918026167bba343af79 ./scripts/install-lxc.sh e4849261fd9ed1f01df96c0637c439c0c4eff8fa317b2918026167bba343af79 ./scripts/install-lxc.sh
bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./scripts/install.sh bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./scripts/install.sh
e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./scripts/regenerate-sha.sh 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./scripts/regenerate-sha.sh
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
7e8f558b284362f55f50bd7223a89754a2a4960e746a27035cc61180f4a2da6f ./scripts/smoke.sh 7e8f558b284362f55f50bd7223a89754a2a4960e746a27035cc61180f4a2da6f ./scripts/smoke.sh
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
04084aac0c2af07a9bb2d0d7d4499efaed47944aefff526f32c73a557a491224 ./src/api/assets.rs 4b795bcb2e6b73c0dc9687325f6ffb0e6f008c6694b2a9fb8190a45a563451b2 ./src/api.rs
5027797b9cefb9f0aa04c792e2ac9a38745c6bf1ee822f50eb70574fa6f997e2 ./src/api/assets.rs
05c255919f7b3d81188fc5f1410be6a0037471dfc44e93bffb009f29a1e6a3fc ./src/api/auth.rs 05c255919f7b3d81188fc5f1410be6a0037471dfc44e93bffb009f29a1e6a3fc ./src/api/auth.rs
9e4fc675306e111ed3db7af9822e2925d88109c31b9e309851665a975c0bd83c ./src/api/automations.rs 9e4fc675306e111ed3db7af9822e2925d88109c31b9e309851665a975c0bd83c ./src/api/automations.rs
368cd843dcac970f1aa6800ba132a4bbc3429e8379ed7abf9878dcb0c633af40 ./src/api/configuration.rs 368cd843dcac970f1aa6800ba132a4bbc3429e8379ed7abf9878dcb0c633af40 ./src/api/configuration.rs
@@ -92,20 +90,20 @@ b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/upda
0b87a7f0caef2a9c9cdea34d560095a3367e3c9d4322d6bcabc5b8ade8d55d06 ./src/api/devices.rs 0b87a7f0caef2a9c9cdea34d560095a3367e3c9d4322d6bcabc5b8ade8d55d06 ./src/api/devices.rs
4b5b784670ae761a6848b9a33fe3fb391bebd39d65e87936907428711e96ca44 ./src/api/events.rs 4b5b784670ae761a6848b9a33fe3fb391bebd39d65e87936907428711e96ca44 ./src/api/events.rs
44b74965f3256c2972d6b3cd384a63c68e3b422f414a0902c085bc92e17369bf ./src/api/flows.rs 44b74965f3256c2972d6b3cd384a63c68e3b422f414a0902c085bc92e17369bf ./src/api/flows.rs
5879d4fb0eda24298a00300bf2237110e13ee0067b513f332bbc7344a859911a ./src/api/groups.rs b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs
641f17dc5b107e6f13949ada8d05e8fdf878d0e44666ddc72aea71a57c70df06 ./src/api/history.rs 853e80f5adf074b629bca1d7ee1483b6be0aaf397a46a281fc58fb96ebac8cb1 ./src/api/history.rs
e8edfbb725d6de650442444b0a929ad2be0bd2c2d7d227d2b3fdd99d05c94f94 ./src/api/house.rs e8edfbb725d6de650442444b0a929ad2be0bd2c2d7d227d2b3fdd99d05c94f94 ./src/api/house.rs
6139e17a08e35d0fcd07ee5625983c602d758ae77452c924f4b4cc05580e9ee8 ./src/api/integrations.rs 21636a6beb83e7bda961ef3ee383f6da317ffaa90b89fbcefa1547beb888de2d ./src/api/integrations.rs
6bfbe5135d4f93bd8d19a707877b53ed2029fd8490b4b1954a7520888fb1d664 ./src/api/middleware.rs 6bfbe5135d4f93bd8d19a707877b53ed2029fd8490b4b1954a7520888fb1d664 ./src/api/middleware.rs
45e34a447ff384d99a3c42c489ca9fa75849c74d4d246caa91c63d927de17396 ./src/api/openapi.rs 45e34a447ff384d99a3c42c489ca9fa75849c74d4d246caa91c63d927de17396 ./src/api/openapi.rs
744ce18aa9e0227c6cdfb6980063d0d738d74c5865d45c20fd20026d4742bb3e ./src/api/public_settings.rs 744ce18aa9e0227c6cdfb6980063d0d738d74c5865d45c20fd20026d4742bb3e ./src/api/public_settings.rs
e86df48db04868bd65bb7815fb9205fec8b29b5212cfb84af74990e0eb380120 ./src/api.rs
5b29f2e5aa2c422c5829ef89c7b59cf753883aa7f60661c95ab0f95cf676fa3f ./src/api/schedules.rs 5b29f2e5aa2c422c5829ef89c7b59cf753883aa7f60661c95ab0f95cf676fa3f ./src/api/schedules.rs
9d157e1901081c077e32430d5d4d7f328ffb4933996579f584ce2959d5d10f47 ./src/api/settings.rs 9d157e1901081c077e32430d5d4d7f328ffb4933996579f584ce2959d5d10f47 ./src/api/settings.rs
20dddb2a07f4a647226783b66f6f3032c6a1ba2910134408d79cf362f909299c ./src/api/system.rs df3297f36f0d0fa071b872967c5c124ed3887805d4f0f1031761cfba25331e97 ./src/api/system.rs
63f02760d0e2cfd59788b5b1cdf4317c685aab1b5a0a5665d52b50f793ce5c74 ./src/api/websocket.rs badd52159fa5d19c236cf9022b623d15c6fbfc7f0b7fc59e269717eb5ee3d10b ./src/api/websocket.rs
c742634b5c8001b8e31ce313a1114abb1f44f18186a86860de4b6b1c01a4832f ./src/api/zones.rs c742634b5c8001b8e31ce313a1114abb1f44f18186a86860de4b6b1c01a4832f ./src/api/zones.rs
f06014454d5062b1be17ac50d875a42c02fc778b59702ec455611265ab7de934 ./src/config.rs f06014454d5062b1be17ac50d875a42c02fc778b59702ec455611265ab7de934 ./src/config.rs
e0340b0e5945d5a38a1054e549c819566d5c1ef6034ab0e4d44c68a3b9a249cf ./src/db.rs
6c278fc5728124ab2f53ac7789a733942a0462f397144a231cc878c8cfcf7426 ./src/db/climate.rs 6c278fc5728124ab2f53ac7789a733942a0462f397144a231cc878c8cfcf7426 ./src/db/climate.rs
be4082b142fe16ba40c5978b287b7ef808e67c13edbd8f2d6637ccad974b8847 ./src/db/configuration.rs be4082b142fe16ba40c5978b287b7ef808e67c13edbd8f2d6637ccad974b8847 ./src/db/configuration.rs
bc10f1a66ddcaacbb08b096a02998482da547024dd0ba3144d00f993b4351cb0 ./src/db/core_devices.rs bc10f1a66ddcaacbb08b096a02998482da547024dd0ba3144d00f993b4351cb0 ./src/db/core_devices.rs
@@ -113,35 +111,35 @@ bc10f1a66ddcaacbb08b096a02998482da547024dd0ba3144d00f993b4351cb0 ./src/db/core_
eb07d94b58bcc8ea17b5b086cc88c165a35666f84c3d9a1d74f72dc30b979829 ./src/db/events_tokens.rs eb07d94b58bcc8ea17b5b086cc88c165a35666f84c3d9a1d74f72dc30b979829 ./src/db/events_tokens.rs
bb5048ee60053f39ce3c1203dc1f24c7047310538d2357f105a052c016d21ec4 ./src/db/flows.rs bb5048ee60053f39ce3c1203dc1f24c7047310538d2357f105a052c016d21ec4 ./src/db/flows.rs
5f4b567c6bc866d0674b3b0c66f4aeccf561774623f575b872ff25bf015b91c2 ./src/db/ha_history.rs 5f4b567c6bc866d0674b3b0c66f4aeccf561774623f575b872ff25bf015b91c2 ./src/db/ha_history.rs
e0340b0e5945d5a38a1054e549c819566d5c1ef6034ab0e4d44c68a3b9a249cf ./src/db.rs
c0a7fa3eac198737d7261ad00c542a614e6d7291f6581af8e7f7aef9dec1a049 ./src/db/schedules_automations.rs c0a7fa3eac198737d7261ad00c542a614e6d7291f6581af8e7f7aef9dec1a049 ./src/db/schedules_automations.rs
1a209dc6d8df864778bb03c980f309a2ec86fc62df274880fbb95eff8470174e ./src/db/tests.rs 1a209dc6d8df864778bb03c980f309a2ec86fc62df274880fbb95eff8470174e ./src/db/tests.rs
f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs
4d2c6053586558afbca6b60fbbc9b1cd397f74f8284d7c01f3337eb42bf6d4f7 ./src/engine.rs
ec6034df6667b3ac6ec39851443048a56e87fa861f5846950a5e79fa89ee5a30 ./src/engine/automations.rs ec6034df6667b3ac6ec39851443048a56e87fa861f5846950a5e79fa89ee5a30 ./src/engine/automations.rs
88f85acf692e12c7061e039dd7c54f101fe0b014c4a3d799150ad92eea9e2894 ./src/engine/commands.rs 88f85acf692e12c7061e039dd7c54f101fe0b014c4a3d799150ad92eea9e2894 ./src/engine/commands.rs
10ab2d7abc0f149936393129459dc56e85d8fca51d96da71c742cb96e86a6f08 ./src/engine/control_plan.rs 8ee70ad21d8ee32914dd90952b04dfa8355ee868252ef4fc682ec40503590a7a ./src/engine/control_plan.rs
7b48a33343d519a2ff2d81e9d757eb56a0c1beacfc0b7b8ef61f49c2ad363f9a ./src/engine/deadlines.rs 7b48a33343d519a2ff2d81e9d757eb56a0c1beacfc0b7b8ef61f49c2ad363f9a ./src/engine/deadlines.rs
e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/groups.rs e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/groups.rs
f0c8f3384b6174771aeb5640ee1eec87f3d6d9ff79a4fbd6a35554a4eec00cfa ./src/engine/history.rs f0c8f3384b6174771aeb5640ee1eec87f3d6d9ff79a4fbd6a35554a4eec00cfa ./src/engine/history.rs
41b7045877771b7c352328998cb45ce2433e7f9631fad3eb1a882ab4897dc31f ./src/engine/local_thermostat.rs 41b7045877771b7c352328998cb45ce2433e7f9631fad3eb1a882ab4897dc31f ./src/engine/local_thermostat.rs
6280a7f5059d9737987bf2d72b19a26b3a6709ce578af1108c4215f3f47d7763 ./src/engine/ownership.rs 6280a7f5059d9737987bf2d72b19a26b3a6709ce578af1108c4215f3f47d7763 ./src/engine/ownership.rs
9c37adf514c96840fd6a5c715c579350fa86d6dcc0655a2aa97d1fd7c7e04299 ./src/engine/polling.rs 9c37adf514c96840fd6a5c715c579350fa86d6dcc0655a2aa97d1fd7c7e04299 ./src/engine/polling.rs
35f7ed5fb42199afbb331dd6cfb992815a77a3fea346edc176cd4a2b0fcab58f ./src/engine.rs f0bcd3a56b600944ccc62fa24ce20838033eeb4354f937cdb534630c1a1f5a78 ./src/engine/runtime.rs
b0e33539dc7ef52dfa4cad500a96b26bf124b2bc195c9f7965fe03eb71de6621 ./src/engine/runtime.rs c1e8de5392681ba1255e3e2d871a6ee860fb8ca987a0a8f973d9ded3977a68fb ./src/engine/schedules.rs
7c4ad4de24833504c2d2c692f5a154977f0d6ddba2bd82f16235806a85a8d64e ./src/engine/schedules.rs
de80d9ff69f1c843fdac23fdc3e3a350fc1082a186261b3a38617af189132356 ./src/engine/targets.rs de80d9ff69f1c843fdac23fdc3e3a350fc1082a186261b3a38617af189132356 ./src/engine/targets.rs
682c8b3956c657b655746cbd19ee97ee2f4cf953bc9de67d6d70a1b14f3dd746 ./src/engine/temperature.rs 682c8b3956c657b655746cbd19ee97ee2f4cf953bc9de67d6d70a1b14f3dd746 ./src/engine/temperature.rs
833b72907375e75c3f42b3921ad70a8e298f5a93743bf0d0c19059aa272462d1 ./src/engine/temporary_thermostat.rs 833b72907375e75c3f42b3921ad70a8e298f5a93743bf0d0c19059aa272462d1 ./src/engine/temporary_thermostat.rs
9da37fb636e2c342c4e86b6e0ecf07a5a6850431b1227cf4b8f02b4d05b02eaa ./src/engine/tests.rs 35e8582fcdb13ff6d8850a5274893805a04cabe3b0c6c936b1ad4a9808bf16ee ./src/engine/tests.rs
786ae270afb543d03cf6586942394c6e29015d94c71526f4fb54010c220c90d8 ./src/engine/zone_actions.rs 786ae270afb543d03cf6586942394c6e29015d94c71526f4fb54010c220c90d8 ./src/engine/zone_actions.rs
56d9af59eb700ac6363d26db0ffbea777270cdbbe8e5b7eb982bff9f02539eee ./src/engine/zone_control.rs 56d9af59eb700ac6363d26db0ffbea777270cdbbe8e5b7eb982bff9f02539eee ./src/engine/zone_control.rs
2276aca42bca712a55de9b6c5dbe64639675c84befa85a446a7d34613596ed7e ./src/error.rs 2276aca42bca712a55de9b6c5dbe64639675c84befa85a446a7d34613596ed7e ./src/error.rs
2731f629f22a9d4fdb831ea3b283f43900b190f7e6a08fdab5b8fbb2c288547b ./src/home_assistant.rs 2731f629f22a9d4fdb831ea3b283f43900b190f7e6a08fdab5b8fbb2c288547b ./src/home_assistant.rs
342dcb54caf22d2282a3f3f2297f54bd413b5242c17756276ea74994e88e28fb ./src/influxdb.rs
da4068295f37c23222bb13bebac022d88ba7729604a49ad25438c55e45458a49 ./src/influxdb/codec.rs da4068295f37c23222bb13bebac022d88ba7729604a49ad25438c55e45458a49 ./src/influxdb/codec.rs
1769e2a96e336bf39b28ae9de02a5d942350afa2bb9702df7603fabe9d7fcba7 ./src/influxdb/query.rs 1769e2a96e336bf39b28ae9de02a5d942350afa2bb9702df7603fabe9d7fcba7 ./src/influxdb/query.rs
342dcb54caf22d2282a3f3f2297f54bd413b5242c17756276ea74994e88e28fb ./src/influxdb.rs
29dc8ed02dfa1f6419bf4aedfb99c805b25c8a2e772efef20681c750570928cf ./src/influxdb/write.rs 29dc8ed02dfa1f6419bf4aedfb99c805b25c8a2e772efef20681c750570928cf ./src/influxdb/write.rs
df4b6550fea08e070488ca9abafb8c3794d13b21715a7160d255839e662768e2 ./src/main.rs 9ec1bc25ebd06cc925e4fddc8b49e189a21ae495c6709c8cb1bba5b4dfa35388 ./src/main.rs
23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs
920e028d96a14cca9f74a964bb296e68a109cfead314d3164c180201e606d151 ./src/models/automation.rs 920e028d96a14cca9f74a964bb296e68a109cfead314d3164c180201e606d151 ./src/models/automation.rs
3c3f344fe42cdde980b13dbec6e92ed0e2f5946cba8ac19b1a668846b48e7c81 ./src/models/control_plan.rs 3c3f344fe42cdde980b13dbec6e92ed0e2f5946cba8ac19b1a668846b48e7c81 ./src/models/control_plan.rs
9cd1904a0f822c557f6ad4576cdb5f96d9668d04e79ea9783885c9f39519752e ./src/models/defaults.rs 9cd1904a0f822c557f6ad4576cdb5f96d9668d04e79ea9783885c9f39519752e ./src/models/defaults.rs
@@ -149,13 +147,13 @@ f935d35b4a409f12774209f3fa2ffe60f579a8bc1722badbb27d4766efd6368e ./src/models/d
aeab5b5acf35d38a54fd1d82e964fe56635ade69c0149ad423df317095d01555 ./src/models/flow.rs aeab5b5acf35d38a54fd1d82e964fe56635ade69c0149ad423df317095d01555 ./src/models/flow.rs
cf63af00d98410a1452f205ce00b8a6c5f0f3525b994cba3e971d631db288396 ./src/models/history.rs cf63af00d98410a1452f205ce00b8a6c5f0f3525b994cba3e971d631db288396 ./src/models/history.rs
ea216f24f0f385903324f15a1164a0fc508adbdcc3c30d16383ea2e835c53e6e ./src/models/integrations.rs ea216f24f0f385903324f15a1164a0fc508adbdcc3c30d16383ea2e835c53e6e ./src/models/integrations.rs
23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs
8294749dd3af524925309c51ad641ffd32ff1fd9c164cfa71660aa88c2d13d6e ./src/models/runtime.rs 8294749dd3af524925309c51ad641ffd32ff1fd9c164cfa71660aa88c2d13d6e ./src/models/runtime.rs
887eb80a58998e90023e952c1fde67435eb843493387ae64536613b74c084534 ./src/models/settings_api.rs 887eb80a58998e90023e952c1fde67435eb843493387ae64536613b74c084534 ./src/models/settings_api.rs
c4de6101de85d9d7e6814b2504b87d9216561e3d9e274054f081f505fb152140 ./src/models/temporary_thermostat.rs c4de6101de85d9d7e6814b2504b87d9216561e3d9e274054f081f505fb152140 ./src/models/temporary_thermostat.rs
7d798228a8d4d4e60a112167c001535f4b8a5fa32ec7a33554c73125c42256c2 ./src/models/zone.rs 7d798228a8d4d4e60a112167c001535f4b8a5fa32ec7a33554c73125c42256c2 ./src/models/zone.rs
2ccac88080ef3ca8546facad5167cbaadc080bdee3f5d9100c8fabb0fc3ae49d ./src/notifications.rs 2ccac88080ef3ca8546facad5167cbaadc080bdee3f5d9100c8fabb0fc3ae49d ./src/notifications.rs
cca742bb2b500eb9269f69385fcf88053cd08aabf6a98b7669b8a7a2fd67df13 ./src/protocol/crypto.rs cca742bb2b500eb9269f69385fcf88053cd08aabf6a98b7669b8a7a2fd67df13 ./src/protocol/crypto.rs
28969da06a454ddd1ebea5fa606e8446a83dfd21e21352767c189013c508cb90 ./src/protocol/gree.rs
7907c33e6396c7e0d61705b735b21c84d6ed784012fb61022b872c6745540f1d ./src/protocol/gree/binding.rs 7907c33e6396c7e0d61705b735b21c84d6ed784012fb61022b872c6745540f1d ./src/protocol/gree/binding.rs
d4df52f0202eb32290e68dc945c4b0b44f3be8aa90bd57fd116eb20285960616 ./src/protocol/gree/commands.rs d4df52f0202eb32290e68dc945c4b0b44f3be8aa90bd57fd116eb20285960616 ./src/protocol/gree/commands.rs
4e419ba3b86ca9d154e8c04445cf005965175c54041093b2daad3666d4d852bd ./src/protocol/gree/core.rs 4e419ba3b86ca9d154e8c04445cf005965175c54041093b2daad3666d4d852bd ./src/protocol/gree/core.rs
@@ -163,26 +161,26 @@ d4df52f0202eb32290e68dc945c4b0b44f3be8aa90bd57fd116eb20285960616 ./src/protocol
0ab094f609edb99715402d8eeaa7e38cbfd2d271036aec787dcbb3be3e005720 ./src/protocol/gree/merge.rs 0ab094f609edb99715402d8eeaa7e38cbfd2d271036aec787dcbb3be3e005720 ./src/protocol/gree/merge.rs
a96061fbbcadac6c2df2e9708a48dcf819b951eb147f32c7d0d285ac5ae2491b ./src/protocol/gree/network.rs a96061fbbcadac6c2df2e9708a48dcf819b951eb147f32c7d0d285ac5ae2491b ./src/protocol/gree/network.rs
de37931303fc92a9399e6edc17dfabd6870a38cd2ddb27257427c76768a66ec2 ./src/protocol/gree/polling.rs de37931303fc92a9399e6edc17dfabd6870a38cd2ddb27257427c76768a66ec2 ./src/protocol/gree/polling.rs
28969da06a454ddd1ebea5fa606e8446a83dfd21e21352767c189013c508cb90 ./src/protocol/gree.rs
c868422c02ba457df21ae92acbd00698c243855ddd1d4e95ac2166796eba9a44 ./src/protocol/gree/tests.rs c868422c02ba457df21ae92acbd00698c243855ddd1d4e95ac2166796eba9a44 ./src/protocol/gree/tests.rs
3c93e5b254a2a40a07854532cb5fc07e493ad65d9ba2cf28093cff4a4a4e451c ./src/protocol/gree/transport.rs 3c93e5b254a2a40a07854532cb5fc07e493ad65d9ba2cf28093cff4a4a4e451c ./src/protocol/gree/transport.rs
923bb4b2d342d6c1870474e43f33b0aba16dc88590c561d765b874412e83df1f ./src/protocol/mod.rs 923bb4b2d342d6c1870474e43f33b0aba16dc88590c561d765b874412e83df1f ./src/protocol/mod.rs
830621fa75265da7288b8135704c0ce27e0687f87d55abf3cdbe40a6e2f3d2b1 ./src/queries.rs
7fac65a9dcbcecee09816f5447c7792dc94f736b1efb66ac85629db75f6280be ./src/queries/device_history.rs 7fac65a9dcbcecee09816f5447c7792dc94f736b1efb66ac85629db75f6280be ./src/queries/device_history.rs
3fb50f57d30ac23ef122c45dae4bd96c7cb7184df02d99aef00d9890b66479e2 ./src/queries/entities.rs 3fb50f57d30ac23ef122c45dae4bd96c7cb7184df02d99aef00d9890b66479e2 ./src/queries/entities.rs
2c5fba462b72158b04dbaaa53c8536263f8d72a1338a6c4ff06cb28a36f6ae27 ./src/queries/ha_history.rs 2c5fba462b72158b04dbaaa53c8536263f8d72a1338a6c4ff06cb28a36f6ae27 ./src/queries/ha_history.rs
c91e4b8b250b30cbe76f313b70c16244e9003bce92e02c82283a1831f91974c5 ./src/queries/maintenance.rs c91e4b8b250b30cbe76f313b70c16244e9003bce92e02c82283a1831f91974c5 ./src/queries/maintenance.rs
830621fa75265da7288b8135704c0ce27e0687f87d55abf3cdbe40a6e2f3d2b1 ./src/queries.rs
3a49bad74a2f047893e726b5e00a6e833c228ad268bdf50cd60f786ba164ea54 ./src/queries/schema.rs 3a49bad74a2f047893e726b5e00a6e833c228ad268bdf50cd60f786ba164ea54 ./src/queries/schema.rs
f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs
df2f13c98b9d7f84916dcbf9e01a479a6f392f888413fe22bae586526747f704 ./src/state.rs 74c1ecdb45e1f4c6275a16a14d157b3a67a18c3f3d80dfdae18aec5bf6aed849 ./src/state.rs
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html 544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
d0388bf22f34ae28fb63074b0bc15c087a3be298bb702d006d4814ebb786b36e ./web/index.html d0388bf22f34ae28fb63074b0bc15c087a3be298bb702d006d4814ebb786b36e ./web/index.html
8fb20fdba11df68feb0b096bb5b4c8f936db974aed5d345a0d01455054f31f95 ./web/js/bootstrap.js 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
7a0baeaa8703ba8654aa402e320ced127ae12327403f339f8fefdc0cd792af0d ./web/js/bootstrap.js
badde8a5214a0322b2c45803c02a1cb723d53b25e57c687369cd804f509d9744 ./web/js/charts.js badde8a5214a0322b2c45803c02a1cb723d53b25e57c687369cd804f509d9744 ./web/js/charts.js
af92b2140f6248bc636d7c75e37a9460f4128ba59adf1b7842d25244a607555c ./web/js/core.js 13def8de3450b612bc669f441782237e992a7a27aa4784b0dda4d48dcf3482b2 ./web/js/core.js
361d68a9f7dda66a628ebde25d130a26024d809aef521d1b3ad8d8bdf5d39098 ./web/js/dashboard.js d880ffdac6aa07cd10d79fbacf12f87eb4c69b48a80564bbfdd0248abe8884d5 ./web/js/dashboard.js
d6fbd8ef4a67269afadb2f59f19fe9a359f7ec4d7bd1473ecff77df117b9558d ./web/js/entities.js d6fbd8ef4a67269afadb2f59f19fe9a359f7ec4d7bd1473ecff77df117b9558d ./web/js/entities.js
e332b3f811958a938105272c1e8e28d6ca8ba69303001da165b4b0b85727695c ./web/js/events.js e332b3f811958a938105272c1e8e28d6ca8ba69303001da165b4b0b85727695c ./web/js/events.js
1b21b3ae43a6848f9fc13fd4eacaa6a1a50d44c65aa37ca737be60aa4eca8a38 ./web/js/flows.js 1b21b3ae43a6848f9fc13fd4eacaa6a1a50d44c65aa37ca737be60aa4eca8a38 ./web/js/flows.js
@@ -190,11 +188,10 @@ f06557c6d338259059b990f8f79c91d68ac8ec1f245c302468f08929b5506901 ./web/js/forms
dbf36223863ba882c03eccea4516ba8db7acf0c2e72c6c64490cb994f1e20f96 ./web/js/history.js dbf36223863ba882c03eccea4516ba8db7acf0c2e72c6c64490cb994f1e20f96 ./web/js/history.js
7eaa04992ea828dc89f5eaebf674cbbfd160f53eb8d0d6ef0d4e47bd8c28c435 ./web/js/main.js 7eaa04992ea828dc89f5eaebf674cbbfd160f53eb8d0d6ef0d4e47bd8c28c435 ./web/js/main.js
733fcc595ed9c03c9639acc6caba46f0588f29e0b95bf72891e34443e7aa9db8 ./web/js/navigation.js 733fcc595ed9c03c9639acc6caba46f0588f29e0b95bf72891e34443e7aa9db8 ./web/js/navigation.js
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md 90dccce563b1cbd6119576d575ea72121a438107c4dd16ea44f8a591f4e6db90 ./web/js/realtime.js
8505ff605de9bcb11f79a0d01a6c5c15f0482d3f65a5e94cc510308a36f558cc ./web/js/realtime.js
c8c82e88c3715b1bfabf155e36266a4c94f5e2fc03eb39dcdd9d08a1985a997c ./web/js/router.js c8c82e88c3715b1bfabf155e36266a4c94f5e2fc03eb39dcdd9d08a1985a997c ./web/js/router.js
9dd9fbcc5f00584562bae6a840189495f830d1086d9679763dbad6e9c18c4fdb ./web/js/settings.js
427376a35832cb3644206c6e89e1654bd807eec78352f80bd0dd43f74b4ec810 ./web/js/settings-ui.js 427376a35832cb3644206c6e89e1654bd807eec78352f80bd0dd43f74b4ec810 ./web/js/settings-ui.js
9dd9fbcc5f00584562bae6a840189495f830d1086d9679763dbad6e9c18c4fdb ./web/js/settings.js
6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest 6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest
5c939ab91aa28ba386aefef6ba5f41b0c73fae3936f6e915fbc39e2336859605 ./web/styles.css 5c939ab91aa28ba386aefef6ba5f41b0c73fae3936f6e915fbc39e2336859605 ./web/styles.css
ae1b03f30b494f474a781a5d32072f1e73eba2b7c768109fc1cc8028cb6662a4 ./web/sw.js ae1b03f30b494f474a781a5d32072f1e73eba2b7c768109fc1cc8028cb6662a4 ./web/sw.js
+3 -1
View File
@@ -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. 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. > [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples.
+16 -5
View File
@@ -1,6 +1,6 @@
# GREE Controller API reference # 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) [← Main documentation](../README.md)
@@ -206,6 +206,7 @@ Common statuses:
| GET | `/api/access-tokens` | List generated HA tokens without secrets. | | GET | `/api/access-tokens` | List generated HA tokens without secrets. |
| POST | `/api/access-tokens` | Create restricted HA token. | | POST | `/api/access-tokens` | Create restricted HA token. |
| DELETE | `/api/access-tokens/{id}` | Revoke 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. | | GET | `/api/integrations/home-assistant/devices` | Restricted device list. |
| POST | `/api/integrations/home-assistant/devices/{id}/command` | Restricted direct device command. | | POST | `/api/integrations/home-assistant/devices/{id}/command` | Restricted direct device command. |
| GET | `/api/integrations/home-assistant/control-plan` | Restricted control plan. | | GET | `/api/integrations/home-assistant/control-plan` | Restricted control plan. |
@@ -230,7 +231,7 @@ Response:
{ {
"status": "ok", "status": "ok",
"name": "gree-controller", "name": "gree-controller",
"version": "0.12.1", "version": "0.13.0",
"uptime_seconds": 1234, "uptime_seconds": 1234,
"control_ready": true, "control_ready": true,
"time": "2026-08-30T06:54:00Z" "time": "2026-08-30T06:54:00Z"
@@ -253,8 +254,10 @@ Returns the initial Web UI snapshot:
"access_tokens": [], "access_tokens": [],
"house": {"mode": "cool"}, "house": {"mode": "cool"},
"outdoor_temperature": null, "outdoor_temperature": null,
"control_plan": {"generated_at": "2026-09-04T08:00:00Z", "zones": [], "rules": []},
"control_plan_revision": 42,
"system": { "system": {
"version": "0.12.1", "version": "0.13.0",
"uptime_seconds": 1234, "uptime_seconds": 1234,
"auth_required": false, "auth_required": false,
"control_ready": true, "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` ### `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: 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. 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` ### `GET /api/integrations/home-assistant/control-plan`
Same payload as administrator `GET /api/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: Common live events include:
```text ```text
bootstrap bootstrap
control_plan.updated
device.created device.created
device.updated device.updated
device.deleted device.deleted
@@ -1439,6 +1448,8 @@ gree.frame
log.created 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. Additional engine/integration events may be introduced without changing the envelope.
`api.request` data: `api.request` data:
+43 -6
View File
@@ -2,9 +2,9 @@
"openapi": "3.1.0", "openapi": "3.1.0",
"info": { "info": {
"title": "GREE Controller API", "title": "GREE Controller API",
"version": "0.12.1", "version": "0.13.0",
"summary": "Local HTTP/WebSocket API for GREE HVAC control", "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": { "license": {
"name": "MIT" "name": "MIT"
} }
@@ -122,7 +122,7 @@
"System" "System"
], ],
"summary": "Get application bootstrap", "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", "operationId": "bootstrap",
"security": [ "security": [
{ {
@@ -2971,7 +2971,7 @@
"History" "History"
], ],
"summary": "Get resolved control plan", "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", "operationId": "controlPlan",
"security": [ "security": [
{ {
@@ -3511,7 +3511,7 @@
"Home Assistant" "Home Assistant"
], ],
"summary": "Get control plan for 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", "operationId": "haControlPlan",
"security": [ "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": { "components": {
@@ -4854,7 +4891,7 @@
}, },
"version": { "version": {
"type": "string", "type": "string",
"example": "0.12.1" "example": "0.13.0"
}, },
"uptime_seconds": { "uptime_seconds": {
"type": "integer", "type": "integer",
@@ -60,6 +60,19 @@ class GreeControllerClient:
"""Return the public controller health payload.""" """Return the public controller health payload."""
return await self._request("GET", "/api/health") 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]]: async def devices(self) -> list[dict[str, Any]]:
"""Return all controller devices.""" """Return all controller devices."""
data = await self._request("GET", "/api/integrations/home-assistant/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]: async def _async_update_data(self) -> dict[str, dict]:
try: try:
devices, plan, groups = await asyncio.gather( snapshot = await self.client.snapshot()
self.client.devices(), devices = snapshot["devices"]
self.client.control_plan(), plan = snapshot["control_plan"]
self.client.groups(), groups = snapshot["groups"]
)
except GreeControllerApiError as err: except GreeControllerApiError as err:
raise UpdateFailed(str(err)) from err raise UpdateFailed(str(err)) from err
self._overlay_pending_zone_controls(plan) self._overlay_pending_zone_controls(plan)
@@ -1,7 +1,7 @@
{ {
"domain": "gree_controller", "domain": "gree_controller",
"name": "GREE Controller", "name": "GREE Controller",
"version": "0.12.1", "version": "0.13.0",
"config_flow": true, "config_flow": true,
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
+20 -4
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/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. 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 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_BASE_URL = os.environ.get("GREE_API_URL", "http://127.0.0.1:8787")
DEFAULT_TOKEN = os.environ.get("GREE_API_TOKEN", "") 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]] = { SETTINGS_PATHS: dict[str, set[str]] = {
"/api/settings/application": {"simulator_enabled"}, "/api/settings/application": {"simulator_enabled"},
@@ -113,6 +113,7 @@ HA_SAFE_GET_PATHS = [
"/api/integrations/home-assistant/devices", "/api/integrations/home-assistant/devices",
"/api/integrations/home-assistant/control-plan", "/api/integrations/home-assistant/control-plan",
"/api/integrations/home-assistant/groups", "/api/integrations/home-assistant/groups",
"/api/integrations/home-assistant/snapshot",
] ]
DETAIL_COLLECTIONS = [ DETAIL_COLLECTIONS = [
@@ -127,6 +128,7 @@ DETAIL_COLLECTIONS = [
EXPECTED_OPENAPI_PATHS = set(SETTINGS_PATHS) | { EXPECTED_OPENAPI_PATHS = set(SETTINGS_PATHS) | {
"/api/configuration/export", "/api/configuration/export",
"/api/configuration/import", "/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)" return "restricted HA auth enforced (no token supplied)"
assert_status(resp, 200) assert_status(resp, 200)
data = resp.json() 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)): if not isinstance(data, (list, dict)):
raise TestFailure(f"unexpected JSON type {type(data).__name__}") raise TestFailure(f"unexpected JSON type {type(data).__name__}")
if isinstance(data, list): 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") 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("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)) runner.run("protected API requires auth", lambda: test_protected_auth(client))
for path in SAFE_GET_PATHS: 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: def parse_args(argv: Optional[Iterable[str]] = None) -> argparse.Namespace:
parser = argparse.ArgumentParser( 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, formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""Examples: epilog="""Examples:
python3 scripts/api_dev_test.py python3 scripts/api_dev_test.py
+4
View File
@@ -204,6 +204,10 @@ pub fn router(state: AppState) -> Router {
.route_layer(middleware::from_fn_with_state(state.clone(), auth)); .route_layer(middleware::from_fn_with_state(state.clone(), auth));
let home_assistant_api = Router::new() let home_assistant_api = Router::new()
.route(
"/api/integrations/home-assistant/snapshot",
get(home_assistant_snapshot),
)
.route( .route(
"/api/integrations/home-assistant/devices", "/api/integrations/home-assistant/devices",
get(list_devices), get(list_devices),
+2 -1
View File
@@ -278,7 +278,7 @@ async fn list_home_assistant_groups(
let groups = state.db.list_groups()?; let groups = state.db.list_groups()?;
let zones = state.db.list_zones()?; let zones = state.db.list_zones()?;
let devices = state.db.list_devices()?; 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 settings = state.settings.read().await.clone();
let mut output = Vec::with_capacity(groups.len()); 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)) .filter(|zone| group.zone_ids.iter().any(|zone_id| zone_id == &zone.id))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let planned_members = plan let planned_members = plan
.plan
.zones .zones
.iter() .iter()
.filter(|zone| { .filter(|zone| {
+2 -3
View File
@@ -485,7 +485,6 @@ async fn history(
} }
async fn control_plan(State(state): State<AppState>) -> Result<Json<Value>, AppError> { async fn control_plan(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
Ok(Json(serde_json::to_value( let snapshot = engine::get_control_plan_snapshot(&state).await?;
engine::build_control_plan(&state).await?, Ok(Json(serde_json::to_value(snapshot.plan.as_ref())?))
)?))
} }
+13
View File
@@ -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)] #[derive(Debug, Deserialize)]
struct HaTestRequest { struct HaTestRequest {
entity_id: Option<String>, entity_id: Option<String>,
+3
View File
@@ -20,6 +20,7 @@ async fn build_bootstrap(state: &AppState) -> Result<Value, AppError> {
let online_count = devices.iter().filter(|value| value.online).count(); let online_count = devices.iter().filter(|value| value.online).count();
let simulator_count = devices.iter().filter(|value| value.simulated).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 (received_frames_total, received_frames_by_device) = state.gree.received_frame_stats();
let control_plan = engine::get_control_plan_snapshot(state).await?;
Ok(json!({ Ok(json!({
"devices": devices, "devices": devices,
"zones": state.db.list_zones()?, "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()?, "access_tokens": state.db.list_api_tokens()?,
"house": {"mode": settings.house_mode}, "house": {"mode": settings.house_mode},
"outdoor_temperature": *state.outdoor_temperature.read().await, "outdoor_temperature": *state.outdoor_temperature.read().await,
"control_plan": control_plan.plan.as_ref(),
"control_plan_revision": control_plan.revision,
"system": { "system": {
"version": env!("CARGO_PKG_VERSION"), "version": env!("CARGO_PKG_VERSION"),
"uptime_seconds": state.started.elapsed().as_secs(), "uptime_seconds": state.started.elapsed().as_secs(),
+72 -12
View File
@@ -14,21 +14,60 @@ async fn websocket(
Ok(ws.on_upgrade(move |socket| websocket_loop(state, socket))) Ok(ws.on_upgrade(move |socket| websocket_loop(state, socket)))
} }
async fn websocket_loop(state: AppState, mut socket: WebSocket) { fn control_plan_ws_message(snapshot: &crate::state::ControlPlanSnapshot) -> Value {
let initial = match build_bootstrap(&state).await { json!({
Ok(data) => json!({"event":"bootstrap","timestamp":Utc::now(),"data":data}), "event": "control_plan.updated",
Err(err) => { "timestamp": Utc::now(),
json!({"event":"error","timestamp":Utc::now(),"data":{"message":err.to_string()}}) "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 socket
.send(Message::Text(initial.to_string())) .send(Message::Text(message.to_string()))
.await .await
.is_err() .map_err(|_| ())?;
{ Ok(revision)
return; }
}
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 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 { loop {
tokio::select! { tokio::select! {
event = receiver.recv() => { 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; } 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, 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() => { message = socket.next() => {
match message { match message {
Some(Ok(Message::Ping(value))) => { if socket.send(Message::Pong(value)).await.is_err() { break; } } Some(Ok(Message::Ping(value))) => { if socket.send(Message::Pong(value)).await.is_err() { break; } }
+3 -3
View File
@@ -6,14 +6,14 @@ use crate::{
DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, RuntimeSettings, DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, RuntimeSettings,
Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan, ZoneReading, Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan, ZoneReading,
}, },
state::{AppState, PendingControllerCommand}, state::{AppState, ControlPlanSnapshot, PendingControllerCommand},
}; };
use anyhow::Result; use anyhow::Result;
use chrono::{DateTime, Datelike, Local, NaiveTime, Timelike, Utc, Weekday}; use chrono::{DateTime, Datelike, Local, NaiveTime, Timelike, Utc, Weekday};
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::{ use std::{
collections::HashMap, collections::{HashMap, HashSet},
sync::atomic::Ordering, sync::{atomic::Ordering, Arc},
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use tokio::time::sleep; use tokio::time::sleep;
+108
View File
@@ -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> { pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppError> {
let settings = state.settings.read().await.clone(); let settings = state.settings.read().await.clone();
let schedules = state.db.list_schedules()?; let schedules = state.db.list_schedules()?;
@@ -333,11 +430,22 @@ fn next_schedule_events(
if mode == "off" { if mode == "off" {
return Vec::new(); 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 events = Vec::new();
let mut current = active_schedule_for_zone(zone, schedules, now).map(|item| item.id.as_str()); let mut current = active_schedule_for_zone(zone, schedules, now).map(|item| item.id.as_str());
let base = minute_floor(now); let base = minute_floor(now);
for minute in 1..=(8 * 24 * 60) { for minute in 1..=(8 * 24 * 60) {
let candidate = base + chrono::Duration::minutes(minute); 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 = active_schedule_for_zone(zone, schedules, candidate);
let next_id = next.map(|item| item.id.as_str()); let next_id = next.map(|item| item.id.as_str());
if next_id == current { if next_id == current {
+5
View File
@@ -24,6 +24,11 @@ pub fn start(state: AppState) {
if let Err(err) = reset_temporary_condition_observations_after_restart(&state) { if let Err(err) = reset_temporary_condition_observations_after_restart(&state) {
tracing::warn!(error=?err, "cannot reset temporary thermostat observation continuity after restart"); 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(); let poll_state = state.clone();
tokio::spawn(async move { tokio::spawn(async move {
sleep(Duration::from_millis(500)).await; sleep(Duration::from_millis(500)).await;
+28 -1
View File
@@ -17,6 +17,21 @@ fn minute_floor(now: DateTime<Local>) -> DateTime<Local> {
.unwrap_or(now) .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( pub fn next_schedule_boundary_utc(
zone_id: &str, zone_id: &str,
schedules: &[Schedule], 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)) .filter(|item| item.enabled && item.zone_id == zone_id && schedule_active(item, now))
.max_by_key(|item| item.updated_at) .max_by_key(|item| item.updated_at)
.map(|item| item.id.as_str()); .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); 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) { for minute in 1..=(8 * 24 * 60) {
let candidate = base + chrono::Duration::minutes(minute); 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 let next = schedules
.iter() .iter()
.filter(|item| { .filter(|item| {
+25
View File
@@ -11,6 +11,31 @@ mod tests {
assert_eq!(effective_sensor_stale_after_seconds(120_000, 600), 86_400); 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] #[test]
fn overnight_schedule_works() { fn overnight_schedule_works() {
let now = Utc.with_ymd_and_hms(2025, 1, 7, 1, 0, 0).unwrap().with_timezone(&Local); // Tuesday let now = Utc.with_ymd_and_hms(2025, 1, 7, 1, 0, 0).unwrap().with_timezone(&Local); // Tuesday
+11 -2
View File
@@ -18,13 +18,16 @@ use models::Device;
use protocol::GreeClient; use protocol::GreeClient;
use state::AppState; use state::AppState;
use std::{ use std::{
sync::{atomic::AtomicBool, Arc}, sync::{
atomic::{AtomicBool, AtomicU64},
Arc,
},
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use tokio::{ use tokio::{
net::TcpListener, net::TcpListener,
signal, signal,
sync::{broadcast, Notify, RwLock}, sync::{broadcast, watch, Notify, RwLock},
}; };
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
@@ -56,6 +59,7 @@ async fn main() -> Result<()> {
} }
let (events, _) = broadcast::channel(512); 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 debug_gree_frames = Arc::new(AtomicBool::new(runtime_settings.debug.gree_frames));
let http = reqwest::Client::builder() let http = reqwest::Client::builder()
.timeout(Duration::from_secs(10)) .timeout(Duration::from_secs(10))
@@ -78,6 +82,11 @@ async fn main() -> Result<()> {
debug_gree_frames, debug_gree_frames,
initial_device_sync_complete: Arc::new(AtomicBool::new(false)), initial_device_sync_complete: Arc::new(AtomicBool::new(false)),
zone_control_wakeup: Arc::new(Notify::new()), 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())), 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())), 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())), group_operation_locks: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
+83 -4
View File
@@ -1,17 +1,20 @@
use crate::{ use crate::{
config::Config, config::Config,
db::Db, db::Db,
models::{ApiEvent, DeviceCommand, RuntimeSettings}, models::{ApiEvent, ControlPlan, DeviceCommand, RuntimeSettings},
protocol::GreeClient, protocol::GreeClient,
}; };
use chrono::Utc; use chrono::Utc;
use serde_json::Value; use serde_json::Value;
use std::{ use std::{
collections::HashMap, collections::HashMap,
sync::{atomic::AtomicBool, Arc}, sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
Arc,
},
time::Instant, time::Instant,
}; };
use tokio::sync::{broadcast, Mutex, Notify, OwnedMutexGuard, RwLock}; use tokio::sync::{broadcast, watch, Mutex, Notify, OwnedMutexGuard, RwLock};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct PendingControllerCommand { pub(crate) struct PendingControllerCommand {
@@ -24,6 +27,12 @@ pub(crate) struct PendingControllerCommand {
pub expires_at: Instant, pub expires_at: Instant,
} }
#[derive(Debug, Clone)]
pub struct ControlPlanSnapshot {
pub revision: u64,
pub plan: Arc<ControlPlan>,
}
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub db: Db, pub db: Db,
@@ -39,6 +48,14 @@ pub struct AppState {
pub initial_device_sync_complete: Arc<AtomicBool>, pub initial_device_sync_complete: Arc<AtomicBool>,
/// Explicit thermostat changes wake the regulator instead of waiting for the next fixed interval. /// Explicit thermostat changes wake the regulator instead of waiting for the next fixed interval.
pub zone_control_wakeup: Arc<Notify>, 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) device_operation_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
pub(crate) zone_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<()>>>>>, pub(crate) group_operation_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
@@ -112,15 +129,36 @@ impl AppState {
self.zone_control_wakeup.notify_one(); 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) { 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 { let _ = self.events.send(ApiEvent {
event: event.into(), event,
timestamp: Utc::now(), timestamp: Utc::now(),
data, data,
}); });
if invalidates_control_plan {
self.invalidate_control_plan();
}
} }
pub fn log(&self, level: &str, kind: &str, message: &str, metadata: Value) { 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) { if let Err(err) = self.db.log_event(level, kind, message, &metadata) {
tracing::warn!(error=?err, "cannot persist event log"); 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}");
}
}
}
+3 -1
View File
@@ -16,8 +16,10 @@ async function loadBootstrap(showMessage = false) {
app.system = data.system || {}; app.system = data.system || {};
app.systemSnapshotAt = Date.now(); app.systemSnapshotAt = Date.now();
app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : null; 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(); renderAll();
loadControlPlan(); if (!data.control_plan) scheduleControlPlanLoad(0);
if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); if (app.settings?.debug?.overlay_enabled) loadDebugBacklog();
if (showMessage) toast(tr('common.updated')); if (showMessage) toast(tr('common.updated'));
if ($('#tokenDialog').open) $('#tokenDialog').close(); if ($('#tokenDialog').open) $('#tokenDialog').close();
+1 -1
View File
@@ -32,7 +32,7 @@ const app = {
historyTab: 'overview', historyData: { zones: [], devices: [], sensors: [] }, historyCounts: {}, historyTab: 'overview', historyData: { zones: [], devices: [], sensors: [] }, historyCounts: {},
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false, historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false,
customChartSeries: [], savedCharts: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, groupControlSeq: {}, zoneControlQueue: {}, groupControlQueue: {}, deviceControlQueue: {}, houseControlQueue: null, climateControlQueue: null, groupCustomDrafts: {}, zoneTemperatureTimers: {}, deviceTemperatureDrafts: {}, 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(), simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false, dashboardTab: 'main', settingsTab: 'app', systemSnapshotAt: Date.now(),
pingMonitor: { targetId: '', all: false, running: false, timer: null, inFlight: false, samples: {} }, pingMonitor: { targetId: '', all: false, running: false, timer: null, inFlight: false, samples: {} },
flowDraft: null, flowSelectedNodeId: null, flowSelectedNodeIds: [], flowConnectFrom: null, flowDirty: false, flowZoom: 1, flowDraft: null, flowSelectedNodeId: null, flowSelectedNodeIds: [], flowConnectFrom: null, flowDirty: false, flowZoom: 1,
+38 -3
View File
@@ -133,9 +133,38 @@ function renderControlPlan() {
host.innerHTML = content; host.innerHTML = content;
if (section) section.hidden = !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() { 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); } 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 = {}) { 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>`; 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); clearTimeout(app.controlPlanTimer);
app.controlPlanTimer = setTimeout(loadControlPlan, 180); app.controlPlanTimer = setTimeout(loadControlPlan, delay);
}
function stopControlPlanFallback() {
clearTimeout(app.controlPlanTimer);
app.controlPlanTimer = null;
} }
+13 -3
View File
@@ -15,10 +15,20 @@ async function handleWebSocketMessage(event) {
const message = JSON.parse(event.data); const message = JSON.parse(event.data);
if (message.event === 'bootstrap') { 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 || []; 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 || {}; 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 === '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 === '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(); } 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 query = app.token ? `?token=${encodeURIComponent(app.token)}` : '';
const ws = new WebSocket(`${protocol}//${location.host}${withBase('/ws')}${query}`); app.ws = ws; const ws = new WebSocket(`${protocol}//${location.host}${withBase('/ws')}${query}`); app.ws = ws;
ws.onopen = () => updateConnectionIndicator('connected'); 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'); ws.onerror = () => updateConnectionIndicator('connectionError');
let messageQueue = Promise.resolve(); let messageQueue = Promise.resolve();
ws.onmessage = event => { ws.onmessage = event => {