From bb5caa769f41d750e7d1c8f69fef7aa72f8e1dd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 21 Oct 2026 08:08:59 +0200 Subject: [PATCH] v0.16.4 --- Cargo.lock | 2 +- Cargo.toml | 2 +- FILE_MANIFEST.sha256 | 65 ++++----- README.md | 4 +- SOURCE_MANIFEST.sha256 | 63 ++++----- docs/API.md | 8 +- docs/FLOW.md | 6 + docs/openapi.json | 18 ++- .../gree_controller/manifest.json | 2 +- .../repository/gree-controller/CHANGELOG.md | 11 ++ .../repository/gree-controller/config.yaml | 2 +- scripts/test_flow_disable_ui.js | 52 ++++++++ scripts/verify_simple_heating.py | 2 +- src/api/automations.rs | 11 +- src/api/flows.rs | 22 ++- src/api/schedules.rs | 11 +- src/api/zones.rs | 2 + src/db/flows.rs | 20 +++ src/db/schedules_automations.rs | 46 ++++++- src/db/tests.rs | 99 ++++++++++++++ src/engine/automations.rs | 44 +++++- src/engine/control_plan.rs | 7 +- src/engine/tests.rs | 71 ++++++++++ src/engine/zone_actions.rs | 3 + src/engine/zone_control.rs | 125 +++++++++++++++--- src/models/zone.rs | 6 + web/js-dynamic/dashboard.js | 14 +- web/js-dynamic/realtime.js | 4 +- 28 files changed, 592 insertions(+), 130 deletions(-) create mode 100644 scripts/test_flow_disable_ui.js diff --git a/Cargo.lock b/Cargo.lock index b3cafd0..8aead0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -719,7 +719,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.16.3" +version = "0.16.4" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index 793966a..521fa3b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.16.3" +version = "0.16.4" edition = "2021" authors = ["GREE Controller contributors"] description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support" diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index 3c0b3f3..802b50e 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,17 +1,17 @@ de0793949cf01d27d903653226ecc9b5f72523d8711f20bcab022cf42c4c4ed0 ./.env.example 2fd3bf1fb7ef548c7abc9b26d1cc4ef470784b8637af65446b845b27a45b547b ./.gitignore -2f663bd03e41e40d584183074dca077c8963afc8308bd66e7d33ea6aed6ec1e5 ./Cargo.lock -8f4f035398326b1beec96d2c0d4b8fcf4b7f1542e39fe719743cc95ecca740e1 ./Cargo.toml +147eed97b308e0c27d6a5ee738a0ee710fa6ba2c6bbf7503df0fe484e0c7f842 ./Cargo.lock +c604af0060aef4e79d2e11fd1e1222a2444cebc835bc50629cff3d2373c5a05a ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -eaea820d2578f56442792e6fd6403eb1253686e50dbfcd8e75179c98574e1859 ./README.md -2cc1518c6484c340037700ef258e27cdf71a7aa5233aefcb51cfa7c199c3f3a7 ./SOURCE_MANIFEST.sha256 +75a6652611273ae458cb3509401cdb196d1084db1e49496ecdce448f490e6249 ./README.md +eedafc7faaa9eb95b564a249a4fef82d5f4cc32697eb0e370b96264f3c47b69c ./SOURCE_MANIFEST.sha256 29e9821e5ed216ea3565dd6d8bc0b174ed3cf9fa72c4ccebbfa99e98db3ad8f9 ./build.rs -e23bf048051f522364d213b0bd0ddfaff82d311a84542e20568dcf3f6aec7ffc ./docs/API.md -c64564481d1a7bb4657f12b2fe0cbddf86050a4c6a2fc600ad8f02051387aa27 ./docs/FLOW.md -bfb0bba73a02e71aba78c0f5de1fd41d51d70812f6e08eaf2eaceb1295908c9d ./docs/openapi.json -a5ad73f7933031dfaa99aa92e27f10223d53fb84b14ee0a378eb5f10eb80e8c8 ./flows/simple-heating/igor-simple-heat-v5.flow.json -9743f4328a18bc9d89d10a9011d13ca201a4f352db2119335a66a2147a9a486d ./flows/simple-heating/jan-pieczara-simple-heat-v5.flow.json -2d804831a72577e80dc1ec3cf4f86dc8bcf00b1d8c80929896f11da0230b2b07 ./flows/simple-heating/sypialnia-simple-heat-v5.flow.json +bb9840a0dc2b89d057d1461ae581ee61ebf856e3b6d38bc7d1bdd111eef7c550 ./docs/API.md +4f9826f1bf3b42ed2d62d8a8e9221b50175b3a9fdda7e9d219351f7a33039257 ./docs/FLOW.md +52c8dc48aed987fd28b2b543af1e5a782efb2a44104ccb0fd6b1740255cf384e ./docs/openapi.json +d8a1b3e34cf8a79ac3296bf600ad47826d639cdef4c1c2c099f8b6b21a391ebd ./flows/simple-heating/igor-simple-heat-v5.flow.json +bd9a07b8ca4b090ad0089e9037492cc25c47f3456b97ec08152947314ea904a6 ./flows/simple-heating/jan-pieczara-simple-heat-v5.flow.json +c4e6e3e8df5eb7f9730a79fb90edd155f0510f7bb4862a04b9f35ba649c22376 ./flows/simple-heating/sypialnia-simple-heat-v5.flow.json 77b8becf7a2c16a384ea85721d878b76e2d520549cb4455d9aa806e57e5a2ecd ./ha-addon/.env.example 0ca6e58170c2b8925194431ebbdb55f0c8b377fec5cd422cd2bd257e2d9328e3 ./ha-addon/Dockerfile 0951696a2c2e66d4d969f8e7f8641184e3012f261583b31074bc38a7a1c0cc1a ./ha-addon/README.md @@ -25,7 +25,7 @@ b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./ha-addon/hom 6509286c7b7de638dcc5d40ce53181e65d1eddc5dc36a13306dc6d87220fedbb ./ha-addon/home-assistant/custom_components/gree_controller/coordinator.py 5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./ha-addon/home-assistant/custom_components/gree_controller/entity_map.py c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./ha-addon/home-assistant/custom_components/gree_controller/icon.png -ebe14ca743c50cc0cc02f94621b17119984704f702df04cd94e1e5b86635a014 ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json +e8d079ebe63cd2916bd219cd45f583940e3d7ba5769af8deb1038415084a1a35 ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json c52a484b671ce738ecc00228a1db19a5d703f69ab27530c2896a5c2ae3b4a96f ./ha-addon/home-assistant/custom_components/gree_controller/number.py 39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./ha-addon/home-assistant/custom_components/gree_controller/select.py cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./ha-addon/home-assistant/custom_components/gree_controller/sensor.py @@ -33,10 +33,10 @@ cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./ha-addon/hom a745cc458c235158d5d70b6a33d8000fd8b40ff93bfe2f2796af0f4b3a9eef9b ./ha-addon/home-assistant/custom_components/gree_controller/translations/en.json 50b7d6fe6329ad4ea6bee9612ab141c2f73f8382fcc2af0bb076a8e8d4dd4465 ./ha-addon/home-assistant/custom_components/gree_controller/translations/pl.json c5fc5c87273d82d2834a0a5a365c821a416413ed509613441e24e4dc3507c4d2 ./ha-addon/home-assistant/generated/gree_controller_entities.example.json -1b32f617ed09f37d58fd8532107cfcbf2fb92c4434bd173b0fc73464664ce927 ./ha-addon/repository/gree-controller/CHANGELOG.md +904b21f54c7af0f59d91016d8513baaea5e927a349e18207f4290c51d7f2be8a ./ha-addon/repository/gree-controller/CHANGELOG.md 50b8ba050c6b4919e4d057f7b38c1e23d3913b5b7c87552626452e9695d37251 ./ha-addon/repository/gree-controller/DOCS.md -0e92e538aa03b442f8fc2c21562cc7aa19d8ae346f78cecca1eec131288edade ./ha-addon/repository/gree-controller/README.md -e48d172951db6df8afe97b0d463902e744c22bd3e677041bf5e6ee5624646f25 ./ha-addon/repository/gree-controller/config.yaml +e3834bf712d4cd2acc715d8c85db96dd6e2fb3fbb758dac537f6034c337b3745 ./ha-addon/repository/gree-controller/README.md +efe06cb35044b19474ae11e5ef42e3201787ff1d164087cdbdb845a5ad6a16d8 ./ha-addon/repository/gree-controller/config.yaml 3aae6cdae4c3aaab7786a9575e7093b7e2c5b69288167075f4b7475611691a6b ./ha-addon/repository/gree-controller/topologia-ha-vlan-gree-pl.png 0d0b42a7639b128946eec379175436fa1f150294f39d2dfd169077bfa87fd14d ./ha-addon/repository/gree-controller/topology-ha-vlan-gree-en.png 2272740b185e9520e948f74b8f45cf98b8b5fd28c93bdee760da626f91fe5c2a ./ha-addon/repository/gree-controller/translations/en.yaml @@ -101,19 +101,20 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh 36396ecfcfd30f50016b17a83cd76cbccd8debaacef45a0d6b4e61946fef098d ./scripts/smoke.sh b1e7a7a7a1d6e21012808818ff1478f5116a3dc4c7da3650b57a719ca9df7873 ./scripts/test_flow_block_help_ui.js +e3825cfd9e6e149fd227c521d886dc116170f2553d4d7639d61874267f257b6e ./scripts/test_flow_disable_ui.js 2942b34d82d06c950fbf167442fd1a25185de193055e02f2ce8dff62c2039add ./scripts/test_simple_heating_ui.js b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh -14f07768257945ed52c218f3bf03c25cb55053e7a6683c8ff17f395e16d28f2e ./scripts/verify_simple_heating.py +c260a6d291ad913f6aca60f23a0acf6928d68cfb0545b2a698d58c5a95fcf934 ./scripts/verify_simple_heating.py c32caf3ac7b521324d1d27233c271374ccf4efc3d243544ece727372c792e14a ./src/api.rs 0db0d81da4b8161004ddcbed2096812c285ecec9d944771670e6c8edf0775175 ./src/api/assets.rs 1139dacbd3f6ff94bf721eb5a60d469016fb935ddcf484d71bfeb249b72c833a ./src/api/auth.rs -34378c387d34d67483030d1016b874a74115c1ef5f268f854180c25146fdb08b ./src/api/automations.rs +69ce9a3e0cc3b805e605beb811ee8fd80c37cc38a552896500bf522dccdbb23e ./src/api/automations.rs fffad7874465739cd6b508e9d7791387b864fa88420ce0f7e2fc933d129a2751 ./src/api/configuration.rs 19bc141286a336ad67e99079496eb0ae5126f986ab28be62b5f97758e5f6969c ./src/api/debug_tokens.rs ca21f6d5a407e2e3532899d53ba4e8682c2fe961a46d8cd870ddc459721513ef ./src/api/device_groups.rs 8f92909dc575ea6238f4a003f8bd4056c2752fa3247b9ab226e47f875c80b916 ./src/api/devices.rs 4b5b784670ae761a6848b9a33fe3fb391bebd39d65e87936907428711e96ca44 ./src/api/events.rs -8c5d46ccaa45950f4dbaae63e68ad6efe393ff0904e46420f5e7950f043fb691 ./src/api/flows.rs +c7a01af66fe69de6dda6ca0d3bf4489ab3f7c1181853bd394340cdc8dc65c7fc ./src/api/flows.rs 82c02fa135132ea3ebdb4d88dd1210dc536d22f987151d7f82595ee3d3d03440 ./src/api/gree_cloud.rs b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs b6d586bc42b166ac05648f3da7d9aeb88efde2e9eaab5642685eb4d9cf15f95a ./src/api/history.rs @@ -122,11 +123,11 @@ b6d586bc42b166ac05648f3da7d9aeb88efde2e9eaab5642685eb4d9cf15f95a ./src/api/hist 7658111f7d125db55a6e23f88157339b110b2cd1ed13ceabd8f860b42dba5101 ./src/api/middleware.rs 9d996d326f2ec192c79fb108a8d2c06efaa1c9634d9e9e54fa65a65524f92a75 ./src/api/openapi.rs b777e7fbeccef699e11ffd16bb18633843c99afecacce2221280e54b09f6d2da ./src/api/public_settings.rs -5b29f2e5aa2c422c5829ef89c7b59cf753883aa7f60661c95ab0f95cf676fa3f ./src/api/schedules.rs +1d51fe0def463fd0d1a9a97f66f362f3f8bd24daafb14993f3105b1a8ffed5b6 ./src/api/schedules.rs fd08162d0f9287700196028ad0ca2aec7c242238056b0a228ca5bdcb319ece4e ./src/api/settings.rs 7c974a98c72092886247f67c3a1c62736898d4b279ceb9c4c306690a3c6e4294 ./src/api/system.rs 3b0124a76dbc654584bcf186d43f89d20b6e7fd980ce0ffbf5a9e8eced5ec7f6 ./src/api/websocket.rs -7c65692070e51af1ebd66d98b087b7f7a241ed31f8ff2f41baf77e46e027688c ./src/api/zones.rs +eaa4070afbc19686d5e9bdb2d24fb2c7fc4bf1f90dc1c4f12fef17017d4cb975 ./src/api/zones.rs 32fa38d4315682cd3ef3ecdc91e571c2729b5e68b6f2bff965e43de3fdd2aeed ./src/config.rs de0ce7807aebe4c0e325fbef01855e7fc1cfd117529f53f04a416fda3997673a ./src/db.rs 87f1614a80addde69b619421187526d7f86f893c0ac18998854f11675d6e7cca ./src/db/climate.rs @@ -135,17 +136,17 @@ a8fe3636ac79df191e2f2c9d8c1eec1e5181e048ef8c9036cfa18f84aef1a462 ./src/db/confi 4c20289c08b0cb9c3c4324d9dbc6a47c7dd953aff6fd1746f04b6024043d3bc5 ./src/db/device_history.rs 7bc79d9d33726fbdd33b485c8c2e839cedbb8fe29fee872611da978ed149d6a0 ./src/db/energy_history.rs 96aac6510ddda8c18fd7e29802a937b96e8a74badf504980846c80cd7933a4c6 ./src/db/events_tokens.rs -bba6e3fb9f4800ae06d0ca67772b06a8c389d308243306719442d0c1f2623420 ./src/db/flows.rs +a951422c51073ad8131edb9c0d264ce04c7cd71e62da7412619d67b774472ecf ./src/db/flows.rs 5f4b567c6bc866d0674b3b0c66f4aeccf561774623f575b872ff25bf015b91c2 ./src/db/ha_history.rs 4fc007c08304e96818ad1d78997a5b2458e31aa9c48ad68b5be2a007533924bb ./src/db/network_history.rs -d6b9b70a04c774173c97f7426487d5e2932d7713e12ae3fb7f64cf2b4e0b3c10 ./src/db/schedules_automations.rs -28ee42d08147b0f154af818065d7d014d74ee8ddd59fdef98115f5c5368056cc ./src/db/tests.rs +253b0c77897a848f93452ddf809c794373ec4942203838a51976805f50e2aa1b ./src/db/schedules_automations.rs +51ecf9c0a2554bfd599ee9881ff17a88f184d3cfa2cd57f3166bbb1dd544be2d ./src/db/tests.rs f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs 7b29709d2a97e201a6f7baab9c3a72ea3792488499c47e426ebb93369fcbb3fc ./src/engine.rs -4412770793e4905d490a56e0f46cc678a5e28044fc0d136912ef1b81da3e3e3f ./src/engine/automations.rs +e6d6bf39e254bd2779d3bcb31d966aa3f6688ebfa91b4088b70fd796cad66596 ./src/engine/automations.rs 1dccb8779f87dfdbd4ccda3f02ec51273e7be2e34e85166861668685528eca79 ./src/engine/commands.rs 357ef92c3cc42b243af4da31ac52bb360d7444ae423f58d7a13bdee79bf0a748 ./src/engine/connectivity.rs -dbf73f1459185836487653d05e73ffd8314fb0244d23878fc84232fd10935b8a ./src/engine/control_plan.rs +62a15ea757c53796149e670863dae1b4e1a16ceb6ef88ed84ebd3089c0b9880d ./src/engine/control_plan.rs 7b48a33343d519a2ff2d81e9d757eb56a0c1beacfc0b7b8ef61f49c2ad363f9a ./src/engine/deadlines.rs 519f67ab805f61afea107215f5df50562fa1669a48334cc015570952e25e773a ./src/engine/energy.rs e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/groups.rs @@ -159,9 +160,9 @@ c73691c3637465ab0f79aecf7f5428d0317799a8ab5329ed564cf57963bbe86e ./src/engine/r 2694363e607b7f4407ed07c24019c457f0e0d093cb336ee2dc5cf77487c528f6 ./src/engine/targets.rs d62ac5e97a36bbdb31bc23871ea7de73d8ac64a1e8203153d9faff0b1a075bc9 ./src/engine/temperature.rs 833b72907375e75c3f42b3921ad70a8e298f5a93743bf0d0c19059aa272462d1 ./src/engine/temporary_thermostat.rs -6f81f1b93f653b8450b9e086ed4728eca90b3d5d7639b7f4fb4fa2f42c514a28 ./src/engine/tests.rs -c89ff36c6a67bf4fbf68c43e18aa0a556de2dc69263fd79db376363afc741a4f ./src/engine/zone_actions.rs -2cbfc34ad0868591e60e3110d8ae346dff07a58c894e87598e4b7b5749fb4613 ./src/engine/zone_control.rs +4160811f47eca6e75970fe6c6b95198f86a4b97b7050531c19a19efb3d76d97d ./src/engine/tests.rs +07c3c462884344f018654267de3d454d895343b4699e34635e4d1ec88546df0a ./src/engine/zone_actions.rs +e246b6d6e3126941b989735a27e5fbe88dcaf351420aaaa060bb3761620d8244 ./src/engine/zone_control.rs bfac95cec87f0df6ce562153cd3cfe6731359d703e6c592d2c2f7ba01ea62d99 ./src/error.rs d681f2aa53c39f37411834c300e9200299f94ee4ea021f44f70a3ea1774bd698 ./src/home_assistant.rs bcd14e4d1af3ca580f3b95bcd86164c40f762511e1b8e2501cb8a598a6d8adf4 ./src/influxdb.rs @@ -180,7 +181,7 @@ aeab5b5acf35d38a54fd1d82e964fe56635ade69c0149ad423df317095d01555 ./src/models/f 17cac849056b677a3872e600a0d99218fb383ad5ec91cde596562df8e2f47508 ./src/models/runtime.rs 41b85cff6fd234c8d6ea007b2c3c22df5fee5107dcd57c64dcc94a356ff70c12 ./src/models/settings_api.rs c4de6101de85d9d7e6814b2504b87d9216561e3d9e274054f081f505fb152140 ./src/models/temporary_thermostat.rs -5229bdb5ce1018601b081654d16cee9ac28afdfda6c67effe1b14d44b14b3257 ./src/models/zone.rs +79cb16a9f12e8a20fcf0acc8649d771b8a76a05503c0372443d29003536c1bcb ./src/models/zone.rs 4c1f676eaf0b6da45b4d4b090ae3ac862fb97c1f9e53284a1640767ae9591d8f ./src/notifications.rs 7746bca683809b82529ed152272ae5c0cd3971758a02e076def58f770e7adf10 ./src/protocol/crypto.rs 578b9f39110abbeb98323044b6ae1a941733c82021afeb4d7ff78ad4256b5aa5 ./src/protocol/gree.rs @@ -206,15 +207,15 @@ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/ d8a602ed8906d77593bd140315ce1bd56716038e789f53ff31762e9b3cb7220d ./src/state.rs b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service 544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html -282ef3c84f053cfce36fa85828365cac2cb6dfa4375993345ff3bee05c8a884f ./web/css/styles.css +b030e61b30435495f492e15fa9daf228968cc1de2982e8f40a626452dbf5b81a ./web/css/styles.css 183d122ba366303d9952ac0e68aff4bced4e0d9a468fbe51316c13f15227402b ./web/custom-chart.html e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg -e76b1e0bbdad0297d5ae2e79e40e658e0263f038ab70db81d330a342e01a0a8b ./web/index.html +bbe640e99447f632383b2eb4b52da580ab95d60cd45cda47f6fae1c2380e5b8a ./web/index.html 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js-dynamic/README.md e7318cd92b76e8a0109dc01adc2431474be17d8bb1488cdf6ffb39dc5450909d ./web/js-dynamic/bootstrap.js 5d57253a013fd1cb79e4b8701d4be6e394e3f2acbce73dfcd726a86e5878f1a1 ./web/js-dynamic/charts.js 07629b331bd498e8bea769d577321bbd12d9b076264242706d4ef16165fb22fa ./web/js-dynamic/core.js -008a85cddf6a5357eff35764eabddf1d7baaecf949b481d195124de50431e0d2 ./web/js-dynamic/dashboard.js +ef20fb1e706105d0afdc2fa505d92ea52e43d93c9a687f2a09d381227a464564 ./web/js-dynamic/dashboard.js 3a6d2edd64fbdc2a8d468b05816366a9e05df284adea752c42a91e2a3a7bd786 ./web/js-dynamic/entities.js bc1e7c4cccda0447fe805c61e655b773375bf503141195b82d4e95cdf093ec80 ./web/js-dynamic/events.js 3e2a5f1aab56e918624e6e7af064eecb9e94e554fd4d185715f8ecf3747a6895 ./web/js-dynamic/flows.js @@ -222,7 +223,7 @@ bc1e7c4cccda0447fe805c61e655b773375bf503141195b82d4e95cdf093ec80 ./web/js-dynam 92d013bb5d64f64aa8c55380704540a5f72732bde7ccd16554965e401daf50ea ./web/js-dynamic/history.js 511c4e7cc4c48fbbdab64558a9d1c27e6759c9b6dca255d11f301b9708e955c7 ./web/js-dynamic/main.js d9596cef1b86c0d3f73ccfc8d3a73c2b5293d81293fdbdcefd77346cbd3fe983 ./web/js-dynamic/navigation.js -3ff9c027beb8fb033330bf3cfb3496e06fb38c42041f1ffe522c6c7af18e9e30 ./web/js-dynamic/realtime.js +54412ea35574b1e852df86d33fac089ca925253277316b6917eacd66c9927c0e ./web/js-dynamic/realtime.js 1b667f0a8bce824b27f56a8d2f44ff8feef9a011289591dc629ee288db70d137 ./web/js-dynamic/router.js c2710fd9c1f2b31878c1e25ceb02d74ae30456c1bbb400e69fcf7619ecc72bc2 ./web/js-dynamic/select-ui.js bc42b4a0dd0d20239ce22ea1dff09b3f94e8c0e1395e01251a4585a051968a8f ./web/js-dynamic/settings-ui.js diff --git a/README.md b/README.md index 2cb512d..b9157b4 100644 --- a/README.md +++ b/README.md @@ -2,9 +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. -**Version: 0.16.3** +**Version: 0.16.4** -Version 0.16.3 adds built-in, localized explanations for the two Flow blocks (constant Yes and Simple day/night heating), including live examples calculated from each room's configured temperature and tooltips for every setting. Heating/control behavior from 0.16.2 is unchanged. +Version 0.16.4 fixes disabled Flows that could resume execution after an asynchronous condition evaluation, hides disabled Flow outputs from control plans and schedules, resets timing gates on re-enable, and releases stale Flow thermostat intent. > [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples. > Built-in API: `/api/*` and `/ws`; interactive Swagger docs: `/api-docs`; OpenAPI JSON: `/api-docs/openapi.json`. diff --git a/SOURCE_MANIFEST.sha256 b/SOURCE_MANIFEST.sha256 index 0e1fe32..5af6dd6 100644 --- a/SOURCE_MANIFEST.sha256 +++ b/SOURCE_MANIFEST.sha256 @@ -1,16 +1,16 @@ de0793949cf01d27d903653226ecc9b5f72523d8711f20bcab022cf42c4c4ed0 ./.env.example 2fd3bf1fb7ef548c7abc9b26d1cc4ef470784b8637af65446b845b27a45b547b ./.gitignore -2f663bd03e41e40d584183074dca077c8963afc8308bd66e7d33ea6aed6ec1e5 ./Cargo.lock -8f4f035398326b1beec96d2c0d4b8fcf4b7f1542e39fe719743cc95ecca740e1 ./Cargo.toml +147eed97b308e0c27d6a5ee738a0ee710fa6ba2c6bbf7503df0fe484e0c7f842 ./Cargo.lock +c604af0060aef4e79d2e11fd1e1222a2444cebc835bc50629cff3d2373c5a05a ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -eaea820d2578f56442792e6fd6403eb1253686e50dbfcd8e75179c98574e1859 ./README.md +75a6652611273ae458cb3509401cdb196d1084db1e49496ecdce448f490e6249 ./README.md 29e9821e5ed216ea3565dd6d8bc0b174ed3cf9fa72c4ccebbfa99e98db3ad8f9 ./build.rs -e23bf048051f522364d213b0bd0ddfaff82d311a84542e20568dcf3f6aec7ffc ./docs/API.md -c64564481d1a7bb4657f12b2fe0cbddf86050a4c6a2fc600ad8f02051387aa27 ./docs/FLOW.md -bfb0bba73a02e71aba78c0f5de1fd41d51d70812f6e08eaf2eaceb1295908c9d ./docs/openapi.json -a5ad73f7933031dfaa99aa92e27f10223d53fb84b14ee0a378eb5f10eb80e8c8 ./flows/simple-heating/igor-simple-heat-v5.flow.json -9743f4328a18bc9d89d10a9011d13ca201a4f352db2119335a66a2147a9a486d ./flows/simple-heating/jan-pieczara-simple-heat-v5.flow.json -2d804831a72577e80dc1ec3cf4f86dc8bcf00b1d8c80929896f11da0230b2b07 ./flows/simple-heating/sypialnia-simple-heat-v5.flow.json +bb9840a0dc2b89d057d1461ae581ee61ebf856e3b6d38bc7d1bdd111eef7c550 ./docs/API.md +4f9826f1bf3b42ed2d62d8a8e9221b50175b3a9fdda7e9d219351f7a33039257 ./docs/FLOW.md +52c8dc48aed987fd28b2b543af1e5a782efb2a44104ccb0fd6b1740255cf384e ./docs/openapi.json +d8a1b3e34cf8a79ac3296bf600ad47826d639cdef4c1c2c099f8b6b21a391ebd ./flows/simple-heating/igor-simple-heat-v5.flow.json +bd9a07b8ca4b090ad0089e9037492cc25c47f3456b97ec08152947314ea904a6 ./flows/simple-heating/jan-pieczara-simple-heat-v5.flow.json +c4e6e3e8df5eb7f9730a79fb90edd155f0510f7bb4862a04b9f35ba649c22376 ./flows/simple-heating/sypialnia-simple-heat-v5.flow.json 77b8becf7a2c16a384ea85721d878b76e2d520549cb4455d9aa806e57e5a2ecd ./ha-addon/.env.example 0ca6e58170c2b8925194431ebbdb55f0c8b377fec5cd422cd2bd257e2d9328e3 ./ha-addon/Dockerfile 0951696a2c2e66d4d969f8e7f8641184e3012f261583b31074bc38a7a1c0cc1a ./ha-addon/README.md @@ -24,7 +24,7 @@ b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./ha-addon/hom 6509286c7b7de638dcc5d40ce53181e65d1eddc5dc36a13306dc6d87220fedbb ./ha-addon/home-assistant/custom_components/gree_controller/coordinator.py 5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./ha-addon/home-assistant/custom_components/gree_controller/entity_map.py c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./ha-addon/home-assistant/custom_components/gree_controller/icon.png -ebe14ca743c50cc0cc02f94621b17119984704f702df04cd94e1e5b86635a014 ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json +e8d079ebe63cd2916bd219cd45f583940e3d7ba5769af8deb1038415084a1a35 ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json c52a484b671ce738ecc00228a1db19a5d703f69ab27530c2896a5c2ae3b4a96f ./ha-addon/home-assistant/custom_components/gree_controller/number.py 39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./ha-addon/home-assistant/custom_components/gree_controller/select.py cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./ha-addon/home-assistant/custom_components/gree_controller/sensor.py @@ -32,10 +32,10 @@ cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./ha-addon/hom a745cc458c235158d5d70b6a33d8000fd8b40ff93bfe2f2796af0f4b3a9eef9b ./ha-addon/home-assistant/custom_components/gree_controller/translations/en.json 50b7d6fe6329ad4ea6bee9612ab141c2f73f8382fcc2af0bb076a8e8d4dd4465 ./ha-addon/home-assistant/custom_components/gree_controller/translations/pl.json c5fc5c87273d82d2834a0a5a365c821a416413ed509613441e24e4dc3507c4d2 ./ha-addon/home-assistant/generated/gree_controller_entities.example.json -1b32f617ed09f37d58fd8532107cfcbf2fb92c4434bd173b0fc73464664ce927 ./ha-addon/repository/gree-controller/CHANGELOG.md +904b21f54c7af0f59d91016d8513baaea5e927a349e18207f4290c51d7f2be8a ./ha-addon/repository/gree-controller/CHANGELOG.md 50b8ba050c6b4919e4d057f7b38c1e23d3913b5b7c87552626452e9695d37251 ./ha-addon/repository/gree-controller/DOCS.md -0e92e538aa03b442f8fc2c21562cc7aa19d8ae346f78cecca1eec131288edade ./ha-addon/repository/gree-controller/README.md -e48d172951db6df8afe97b0d463902e744c22bd3e677041bf5e6ee5624646f25 ./ha-addon/repository/gree-controller/config.yaml +e3834bf712d4cd2acc715d8c85db96dd6e2fb3fbb758dac537f6034c337b3745 ./ha-addon/repository/gree-controller/README.md +efe06cb35044b19474ae11e5ef42e3201787ff1d164087cdbdb845a5ad6a16d8 ./ha-addon/repository/gree-controller/config.yaml 3aae6cdae4c3aaab7786a9575e7093b7e2c5b69288167075f4b7475611691a6b ./ha-addon/repository/gree-controller/topologia-ha-vlan-gree-pl.png 0d0b42a7639b128946eec379175436fa1f150294f39d2dfd169077bfa87fd14d ./ha-addon/repository/gree-controller/topology-ha-vlan-gree-en.png 2272740b185e9520e948f74b8f45cf98b8b5fd28c93bdee760da626f91fe5c2a ./ha-addon/repository/gree-controller/translations/en.yaml @@ -100,19 +100,20 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh 36396ecfcfd30f50016b17a83cd76cbccd8debaacef45a0d6b4e61946fef098d ./scripts/smoke.sh b1e7a7a7a1d6e21012808818ff1478f5116a3dc4c7da3650b57a719ca9df7873 ./scripts/test_flow_block_help_ui.js +e3825cfd9e6e149fd227c521d886dc116170f2553d4d7639d61874267f257b6e ./scripts/test_flow_disable_ui.js 2942b34d82d06c950fbf167442fd1a25185de193055e02f2ce8dff62c2039add ./scripts/test_simple_heating_ui.js b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh -14f07768257945ed52c218f3bf03c25cb55053e7a6683c8ff17f395e16d28f2e ./scripts/verify_simple_heating.py +c260a6d291ad913f6aca60f23a0acf6928d68cfb0545b2a698d58c5a95fcf934 ./scripts/verify_simple_heating.py c32caf3ac7b521324d1d27233c271374ccf4efc3d243544ece727372c792e14a ./src/api.rs 0db0d81da4b8161004ddcbed2096812c285ecec9d944771670e6c8edf0775175 ./src/api/assets.rs 1139dacbd3f6ff94bf721eb5a60d469016fb935ddcf484d71bfeb249b72c833a ./src/api/auth.rs -34378c387d34d67483030d1016b874a74115c1ef5f268f854180c25146fdb08b ./src/api/automations.rs +69ce9a3e0cc3b805e605beb811ee8fd80c37cc38a552896500bf522dccdbb23e ./src/api/automations.rs fffad7874465739cd6b508e9d7791387b864fa88420ce0f7e2fc933d129a2751 ./src/api/configuration.rs 19bc141286a336ad67e99079496eb0ae5126f986ab28be62b5f97758e5f6969c ./src/api/debug_tokens.rs ca21f6d5a407e2e3532899d53ba4e8682c2fe961a46d8cd870ddc459721513ef ./src/api/device_groups.rs 8f92909dc575ea6238f4a003f8bd4056c2752fa3247b9ab226e47f875c80b916 ./src/api/devices.rs 4b5b784670ae761a6848b9a33fe3fb391bebd39d65e87936907428711e96ca44 ./src/api/events.rs -8c5d46ccaa45950f4dbaae63e68ad6efe393ff0904e46420f5e7950f043fb691 ./src/api/flows.rs +c7a01af66fe69de6dda6ca0d3bf4489ab3f7c1181853bd394340cdc8dc65c7fc ./src/api/flows.rs 82c02fa135132ea3ebdb4d88dd1210dc536d22f987151d7f82595ee3d3d03440 ./src/api/gree_cloud.rs b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs b6d586bc42b166ac05648f3da7d9aeb88efde2e9eaab5642685eb4d9cf15f95a ./src/api/history.rs @@ -121,11 +122,11 @@ b6d586bc42b166ac05648f3da7d9aeb88efde2e9eaab5642685eb4d9cf15f95a ./src/api/hist 7658111f7d125db55a6e23f88157339b110b2cd1ed13ceabd8f860b42dba5101 ./src/api/middleware.rs 9d996d326f2ec192c79fb108a8d2c06efaa1c9634d9e9e54fa65a65524f92a75 ./src/api/openapi.rs b777e7fbeccef699e11ffd16bb18633843c99afecacce2221280e54b09f6d2da ./src/api/public_settings.rs -5b29f2e5aa2c422c5829ef89c7b59cf753883aa7f60661c95ab0f95cf676fa3f ./src/api/schedules.rs +1d51fe0def463fd0d1a9a97f66f362f3f8bd24daafb14993f3105b1a8ffed5b6 ./src/api/schedules.rs fd08162d0f9287700196028ad0ca2aec7c242238056b0a228ca5bdcb319ece4e ./src/api/settings.rs 7c974a98c72092886247f67c3a1c62736898d4b279ceb9c4c306690a3c6e4294 ./src/api/system.rs 3b0124a76dbc654584bcf186d43f89d20b6e7fd980ce0ffbf5a9e8eced5ec7f6 ./src/api/websocket.rs -7c65692070e51af1ebd66d98b087b7f7a241ed31f8ff2f41baf77e46e027688c ./src/api/zones.rs +eaa4070afbc19686d5e9bdb2d24fb2c7fc4bf1f90dc1c4f12fef17017d4cb975 ./src/api/zones.rs 32fa38d4315682cd3ef3ecdc91e571c2729b5e68b6f2bff965e43de3fdd2aeed ./src/config.rs de0ce7807aebe4c0e325fbef01855e7fc1cfd117529f53f04a416fda3997673a ./src/db.rs 87f1614a80addde69b619421187526d7f86f893c0ac18998854f11675d6e7cca ./src/db/climate.rs @@ -134,17 +135,17 @@ a8fe3636ac79df191e2f2c9d8c1eec1e5181e048ef8c9036cfa18f84aef1a462 ./src/db/confi 4c20289c08b0cb9c3c4324d9dbc6a47c7dd953aff6fd1746f04b6024043d3bc5 ./src/db/device_history.rs 7bc79d9d33726fbdd33b485c8c2e839cedbb8fe29fee872611da978ed149d6a0 ./src/db/energy_history.rs 96aac6510ddda8c18fd7e29802a937b96e8a74badf504980846c80cd7933a4c6 ./src/db/events_tokens.rs -bba6e3fb9f4800ae06d0ca67772b06a8c389d308243306719442d0c1f2623420 ./src/db/flows.rs +a951422c51073ad8131edb9c0d264ce04c7cd71e62da7412619d67b774472ecf ./src/db/flows.rs 5f4b567c6bc866d0674b3b0c66f4aeccf561774623f575b872ff25bf015b91c2 ./src/db/ha_history.rs 4fc007c08304e96818ad1d78997a5b2458e31aa9c48ad68b5be2a007533924bb ./src/db/network_history.rs -d6b9b70a04c774173c97f7426487d5e2932d7713e12ae3fb7f64cf2b4e0b3c10 ./src/db/schedules_automations.rs -28ee42d08147b0f154af818065d7d014d74ee8ddd59fdef98115f5c5368056cc ./src/db/tests.rs +253b0c77897a848f93452ddf809c794373ec4942203838a51976805f50e2aa1b ./src/db/schedules_automations.rs +51ecf9c0a2554bfd599ee9881ff17a88f184d3cfa2cd57f3166bbb1dd544be2d ./src/db/tests.rs f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs 7b29709d2a97e201a6f7baab9c3a72ea3792488499c47e426ebb93369fcbb3fc ./src/engine.rs -4412770793e4905d490a56e0f46cc678a5e28044fc0d136912ef1b81da3e3e3f ./src/engine/automations.rs +e6d6bf39e254bd2779d3bcb31d966aa3f6688ebfa91b4088b70fd796cad66596 ./src/engine/automations.rs 1dccb8779f87dfdbd4ccda3f02ec51273e7be2e34e85166861668685528eca79 ./src/engine/commands.rs 357ef92c3cc42b243af4da31ac52bb360d7444ae423f58d7a13bdee79bf0a748 ./src/engine/connectivity.rs -dbf73f1459185836487653d05e73ffd8314fb0244d23878fc84232fd10935b8a ./src/engine/control_plan.rs +62a15ea757c53796149e670863dae1b4e1a16ceb6ef88ed84ebd3089c0b9880d ./src/engine/control_plan.rs 7b48a33343d519a2ff2d81e9d757eb56a0c1beacfc0b7b8ef61f49c2ad363f9a ./src/engine/deadlines.rs 519f67ab805f61afea107215f5df50562fa1669a48334cc015570952e25e773a ./src/engine/energy.rs e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/groups.rs @@ -158,9 +159,9 @@ c73691c3637465ab0f79aecf7f5428d0317799a8ab5329ed564cf57963bbe86e ./src/engine/r 2694363e607b7f4407ed07c24019c457f0e0d093cb336ee2dc5cf77487c528f6 ./src/engine/targets.rs d62ac5e97a36bbdb31bc23871ea7de73d8ac64a1e8203153d9faff0b1a075bc9 ./src/engine/temperature.rs 833b72907375e75c3f42b3921ad70a8e298f5a93743bf0d0c19059aa272462d1 ./src/engine/temporary_thermostat.rs -6f81f1b93f653b8450b9e086ed4728eca90b3d5d7639b7f4fb4fa2f42c514a28 ./src/engine/tests.rs -c89ff36c6a67bf4fbf68c43e18aa0a556de2dc69263fd79db376363afc741a4f ./src/engine/zone_actions.rs -2cbfc34ad0868591e60e3110d8ae346dff07a58c894e87598e4b7b5749fb4613 ./src/engine/zone_control.rs +4160811f47eca6e75970fe6c6b95198f86a4b97b7050531c19a19efb3d76d97d ./src/engine/tests.rs +07c3c462884344f018654267de3d454d895343b4699e34635e4d1ec88546df0a ./src/engine/zone_actions.rs +e246b6d6e3126941b989735a27e5fbe88dcaf351420aaaa060bb3761620d8244 ./src/engine/zone_control.rs bfac95cec87f0df6ce562153cd3cfe6731359d703e6c592d2c2f7ba01ea62d99 ./src/error.rs d681f2aa53c39f37411834c300e9200299f94ee4ea021f44f70a3ea1774bd698 ./src/home_assistant.rs bcd14e4d1af3ca580f3b95bcd86164c40f762511e1b8e2501cb8a598a6d8adf4 ./src/influxdb.rs @@ -179,7 +180,7 @@ aeab5b5acf35d38a54fd1d82e964fe56635ade69c0149ad423df317095d01555 ./src/models/f 17cac849056b677a3872e600a0d99218fb383ad5ec91cde596562df8e2f47508 ./src/models/runtime.rs 41b85cff6fd234c8d6ea007b2c3c22df5fee5107dcd57c64dcc94a356ff70c12 ./src/models/settings_api.rs c4de6101de85d9d7e6814b2504b87d9216561e3d9e274054f081f505fb152140 ./src/models/temporary_thermostat.rs -5229bdb5ce1018601b081654d16cee9ac28afdfda6c67effe1b14d44b14b3257 ./src/models/zone.rs +79cb16a9f12e8a20fcf0acc8649d771b8a76a05503c0372443d29003536c1bcb ./src/models/zone.rs 4c1f676eaf0b6da45b4d4b090ae3ac862fb97c1f9e53284a1640767ae9591d8f ./src/notifications.rs 7746bca683809b82529ed152272ae5c0cd3971758a02e076def58f770e7adf10 ./src/protocol/crypto.rs 578b9f39110abbeb98323044b6ae1a941733c82021afeb4d7ff78ad4256b5aa5 ./src/protocol/gree.rs @@ -205,15 +206,15 @@ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/ d8a602ed8906d77593bd140315ce1bd56716038e789f53ff31762e9b3cb7220d ./src/state.rs b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service 544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html -282ef3c84f053cfce36fa85828365cac2cb6dfa4375993345ff3bee05c8a884f ./web/css/styles.css +b030e61b30435495f492e15fa9daf228968cc1de2982e8f40a626452dbf5b81a ./web/css/styles.css 183d122ba366303d9952ac0e68aff4bced4e0d9a468fbe51316c13f15227402b ./web/custom-chart.html e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg -e76b1e0bbdad0297d5ae2e79e40e658e0263f038ab70db81d330a342e01a0a8b ./web/index.html +bbe640e99447f632383b2eb4b52da580ab95d60cd45cda47f6fae1c2380e5b8a ./web/index.html 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js-dynamic/README.md e7318cd92b76e8a0109dc01adc2431474be17d8bb1488cdf6ffb39dc5450909d ./web/js-dynamic/bootstrap.js 5d57253a013fd1cb79e4b8701d4be6e394e3f2acbce73dfcd726a86e5878f1a1 ./web/js-dynamic/charts.js 07629b331bd498e8bea769d577321bbd12d9b076264242706d4ef16165fb22fa ./web/js-dynamic/core.js -008a85cddf6a5357eff35764eabddf1d7baaecf949b481d195124de50431e0d2 ./web/js-dynamic/dashboard.js +ef20fb1e706105d0afdc2fa505d92ea52e43d93c9a687f2a09d381227a464564 ./web/js-dynamic/dashboard.js 3a6d2edd64fbdc2a8d468b05816366a9e05df284adea752c42a91e2a3a7bd786 ./web/js-dynamic/entities.js bc1e7c4cccda0447fe805c61e655b773375bf503141195b82d4e95cdf093ec80 ./web/js-dynamic/events.js 3e2a5f1aab56e918624e6e7af064eecb9e94e554fd4d185715f8ecf3747a6895 ./web/js-dynamic/flows.js @@ -221,7 +222,7 @@ bc1e7c4cccda0447fe805c61e655b773375bf503141195b82d4e95cdf093ec80 ./web/js-dynam 92d013bb5d64f64aa8c55380704540a5f72732bde7ccd16554965e401daf50ea ./web/js-dynamic/history.js 511c4e7cc4c48fbbdab64558a9d1c27e6759c9b6dca255d11f301b9708e955c7 ./web/js-dynamic/main.js d9596cef1b86c0d3f73ccfc8d3a73c2b5293d81293fdbdcefd77346cbd3fe983 ./web/js-dynamic/navigation.js -3ff9c027beb8fb033330bf3cfb3496e06fb38c42041f1ffe522c6c7af18e9e30 ./web/js-dynamic/realtime.js +54412ea35574b1e852df86d33fac089ca925253277316b6917eacd66c9927c0e ./web/js-dynamic/realtime.js 1b667f0a8bce824b27f56a8d2f44ff8feef9a011289591dc629ee288db70d137 ./web/js-dynamic/router.js c2710fd9c1f2b31878c1e25ceb02d74ae30456c1bbb400e69fcf7619ecc72bc2 ./web/js-dynamic/select-ui.js bc42b4a0dd0d20239ce22ea1dff09b3f94e8c0e1395e01251a4585a051968a8f ./web/js-dynamic/settings-ui.js diff --git a/docs/API.md b/docs/API.md index 662e2c0..1097d4d 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,8 +1,8 @@ # GREE Controller API reference -HTTP and WebSocket API for GREE Controller **0.16.3**. +HTTP and WebSocket API for GREE Controller **0.16.4**. -Version 0.16.3: the Flow editor adds localized descriptions and contextual examples for the two heating blocks; the API and heating behavior remain compatible with 0.16.2. +Version 0.16.4: disabled parent Flows suppress their generated automations and schedules throughout execution, API status and control plans. Newly enabled Flows restart their stateful timing gates. The zone response includes optional `flow_control_id` and `flow_control_node_id` as internal ownership diagnostics. Version 0.16.2: Flow adds the `zone_heat_cycle` action with `zone_id`, `day_target`, `night_target`, `day_min`, `boost_after_minutes` and `day_off_after_minutes`. An always-true Flow condition is sufficient. The Zone runtime stores the active `simple_heat` profile and timers; source `automation.flow` means automatic control, not manual. Nighttime modulation keeps the unit ON and uses 19/21/23 °C around a 20.6 °C target. Daytime power is OFF above the minimum until required, then OFF after 30 minutes at target. Existing `zone_thermostat` coasting options remain supported. @@ -248,7 +248,7 @@ Response: { "status": "ok", "name": "gree-controller", - "version": "0.16.3", + "version": "0.16.4", "uptime_seconds": 1234, "control_ready": true, "time": "2026-08-30T06:54:00Z" @@ -311,7 +311,7 @@ Returns the initial Web UI snapshot: "control_plan": {"generated_at": "2026-09-04T08:00:00Z", "zones": [], "rules": []}, "control_plan_revision": 42, "system": { - "version": "0.16.3", + "version": "0.16.4", "uptime_seconds": 1234, "auth_required": false, "control_ready": true, diff --git a/docs/FLOW.md b/docs/FLOW.md index 75f72a3..af98e73 100644 --- a/docs/FLOW.md +++ b/docs/FLOW.md @@ -22,6 +22,12 @@ Flow is an authoring/orchestration layer. It does not replace the thermostat, sc - Thermostat hysteresis, compressor protection, schedule hand-back, manual/local ownership and Temporary Quick Thermostat keep their existing priority. - GREE fan/quiet/sleep and dry/fan HVAC modes are suppressed on thermostat-assigned devices when they would fight the regulator. Device-only features such as light/turbo/louver/air/xfan/health remain available through the normal command path. +## Wyłączanie Flow / Disabling Flow (0.16.4) + +`Flow.enabled=false` and `draft=true` are authoritative: generated automations and schedules do not execute, create deadlines or contribute to the control plan even if a legacy/imported child record still says `enabled=true`. Runtime-only Flow checkpoints cannot overwrite `enabled`, resurrect deleted rules or persist across a Flow recompile. Actions recheck both their own enabled state and the parent Flow under the automation execution lock. + +Disabling, deleting or replacing a thermostat Flow releases zone-level thermostat intent attributed to that Flow (including stale manual setpoints and pending compressor actions) immediately during the Flow edit, with a regulator-cycle fallback for old/imported data. Manual/local takeover is never cleared. Single-shot device/group/HA commands that have already reached an external device/service are not retroactively undone; disable blocks **future executions**. Stateful timing counters, change-event latches, rolling samples and cooldowns start fresh when the Flow is enabled again; disabled time is not counted. + ## Concurrency invariants Structural Flow writes use the global order: diff --git a/docs/openapi.json b/docs/openapi.json index b44f50b..ac9c462 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "GREE Controller API", - "version": "0.16.3", + "version": "0.16.4", "summary": "Local HTTP/WebSocket API for GREE Controller", "description": "Local API used by the GREE Controller Web UI and Home Assistant integration.", "license": { @@ -6079,7 +6079,7 @@ }, "version": { "type": "string", - "example": "0.16.3" + "example": "0.16.4" }, "uptime_seconds": { "type": "integer", @@ -7380,6 +7380,20 @@ "heat_coasting": { "type": "boolean", "description": "True when the controller requested low setpoint while keeping unit powered ON." + }, + "flow_control_id": { + "type": [ + "string", + "null" + ], + "description": "Last Flow owning the zone; null if none or legacy record." + }, + "flow_control_node_id": { + "type": [ + "string", + "null" + ], + "description": "Last Flow action block owning the zone; null if none or legacy record." } }, "required": [ diff --git a/ha-addon/home-assistant/custom_components/gree_controller/manifest.json b/ha-addon/home-assistant/custom_components/gree_controller/manifest.json index a5097d9..2dd93a0 100644 --- a/ha-addon/home-assistant/custom_components/gree_controller/manifest.json +++ b/ha-addon/home-assistant/custom_components/gree_controller/manifest.json @@ -1,7 +1,7 @@ { "domain": "gree_controller", "name": "GREE Controller", - "version": "0.16.3", + "version": "0.16.4", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/ha-addon/repository/gree-controller/CHANGELOG.md b/ha-addon/repository/gree-controller/CHANGELOG.md index 0e8c50a..aeff2e6 100644 --- a/ha-addon/repository/gree-controller/CHANGELOG.md +++ b/ha-addon/repository/gree-controller/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 0.16.4 + +- Fixes a race in stateful Flow checkpoint saving: no stale condition evaluation can re-enable, overwrite or resurrect a disabled/deleted/recompiled automation. +- Requires both the generated automation/schedule and its parent Flow to be enabled before participating in the engine, control plan, next-event timeline or API enabled status, including mismatched imported/legacy data. +- Adds a second enable/revision check under the execution lock immediately before sending an action. No newly initiated Flow actions after disabling it. +- Releases orphaned thermostat intent and pending compressor tasks when a Flow is disabled, deleted, or its action is removed; preserves real manual ownership and avoids retroactively undoing already sent external commands. +- Resets Flow timer, change-detection, rolling-window and cooldown state after an OFF → ON transition, so disabled intervals never advance timing conditions. +- Filters the dashboard's recent-Flow and simulation cards against the parent state, including the moment between a WebSocket Flow update and a rebuilt control plan. +- Adds database and engine regression tests for disabled parents, stale writes, orphaned rules, zone handback and manual takeover. +- Restores the three ready-to-import simple heating Flow JSON examples missing from the prior source package and updates the static verification script for the new ownership checker. + ## 0.16.3 - Adds expandable, per-room examples for nighttime 19/21/23°C modulation, daytime OFF/boost/30-minute stability, zone temperature-source fallback and Flow vs manual ownership. diff --git a/ha-addon/repository/gree-controller/config.yaml b/ha-addon/repository/gree-controller/config.yaml index 2e79fa5..c81b1bb 100644 --- a/ha-addon/repository/gree-controller/config.yaml +++ b/ha-addon/repository/gree-controller/config.yaml @@ -1,5 +1,5 @@ name: "GREE Controller" -version: "0.16.3" +version: "0.16.4" slug: "gree_controller" description: "Local GREE HVAC controller with Web UI, API, and Home Assistant integration" url: "https://git.linuxiarz.pl/gru/gree-controller-ha-addon/" diff --git a/scripts/test_flow_disable_ui.js b/scripts/test_flow_disable_ui.js new file mode 100644 index 0000000..3f4fd8f --- /dev/null +++ b/scripts/test_flow_disable_ui.js @@ -0,0 +1,52 @@ +// Run with: node scripts/test_flow_disable_ui.js +// Regression checks for Flow OFF hiding generated rules even with stale plan data. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const path = require('node:path'); +const root = path.resolve(__dirname, '..'); +const host = { innerHTML: '' }; +const section = { hidden: false }; +const app = { + flows: [{ id: 'flow1', enabled: false, draft: false, name: 'Disabled heating' }], + automations: [], devices: [], groups: [], zones: [], + controlPlan: { zones: [], next_events: [], rules: [], emergency_stop_active: false }, +}; +const context = vm.createContext({ + app, + $: selector => selector === '#controlPlan' ? host : selector === '#controlPlanSection' ? section : null, + tr: key => key, + esc: x => String(x), + fmtTemp: n => String(n), + houseModeLabel: x => x, + zoneRuntimeStatusLabel: () => '', + zonePresetLabel: x => x, + groupState: () => ({}), + modeLabel: x => x, + locale: () => 'pl-PL', +}); +vm.runInContext(fs.readFileSync(path.join(root, 'web/js-dynamic/dashboard.js'), 'utf8'), context); +function rule(flowId, enabled = true, id = 'FLOW_RULE_MARKER') { + return { id, name: id, enabled, flow_id: flowId, trigger_kind: 'flow', action: {} }; +} +const generated = rule('flow1'); +const orphan = rule('missing-flow', true, 'ORPHAN_RULE_MARKER'); +const local = rule(null, true, 'LOCAL_RULE_MARKER'); +app.controlPlan.rules = [generated, orphan, local]; +function visible(r) { return context.planRuleEffectivelyEnabled(r); } +assert.equal(visible(generated), false, 'disabled parent must block generated rule'); +assert.equal(visible(orphan), false, 'missing parent must block generated rule'); +assert.equal(visible(local), true, 'unrelated local automation remains available'); +context.renderControlPlan(); +assert.doesNotMatch(host.innerHTML, /FLOW_RULE_MARKER|ORPHAN_RULE_MARKER/); +assert.match(host.innerHTML, /LOCAL_RULE_MARKER/); +app.flows[0].enabled = true; +assert.equal(visible(generated), true); +context.renderControlPlan(); +assert.match(host.innerHTML, /FLOW_RULE_MARKER/); +app.flows[0].draft = true; +assert.equal(visible(generated), false, 'draft Flow must never execute'); +app.flows[0].draft = false; +app.flows[0].enabled = false; +assert.equal(visible(rule('flow1', false)), false, 'disabled compiled rule must remain disabled'); +console.log('OK: 9 disabled Flow UI/regression checks'); diff --git a/scripts/verify_simple_heating.py b/scripts/verify_simple_heating.py index 2a394c8..80d03d7 100644 --- a/scripts/verify_simple_heating.py +++ b/scripts/verify_simple_heating.py @@ -78,7 +78,7 @@ def main() -> None: for value in ("zone_heat_cycle", "boost_after_minutes", "day_off_after_minutes"): assert value in rust and value in ui, value assert "simple_heat_step(" in engine and "action_zone_simple_heat" in auto - assert "zone.simple_heat" in engine and "enabled_heat_actions" in engine + assert "zone.simple_heat" in engine and "active_flow_zone_action_ids" in engine assert "automation.flow" in status and "zones.flowDayOff" in status assert 'data-flow-add="zone_heat_cycle"' in html assert "day_target_20_starts_at_21_then_boosts_to_22" in (ROOT / "src" / "engine" / "simple_heat.rs").read_text() diff --git a/src/api/automations.rs b/src/api/automations.rs index 97f0c50..68d334f 100644 --- a/src/api/automations.rs +++ b/src/api/automations.rs @@ -243,11 +243,12 @@ async fn get_automation( State(state): State, Path(id): Path, ) -> Result, AppError> { - state - .db - .get_automation(&id)? - .map(Json) - .ok_or_else(|| AppError::NotFound(format!("automation {id}"))) + let mut item = state.db.get_automation(&id)? + .ok_or_else(|| AppError::NotFound(format!("automation {id}")))?; + if !state.db.flow_output_enabled(item.flow_id.as_deref())? { + item.enabled = false; + } + Ok(Json(item)) } async fn create_automation( State(state): State, diff --git a/src/api/flows.rs b/src/api/flows.rs index 4525667..72400f2 100644 --- a/src/api/flows.rs +++ b/src/api/flows.rs @@ -1365,6 +1365,11 @@ fn compile_flow( let zones = state.db.list_zones()?; let groups = state.db.list_groups()?; let devices = state.db.list_devices()?; + // Disabling a Flow is a hard timing boundary. Neither disabled time nor + // the previous run's latches/observations may advance a newly enabled Flow. + let resumed = state.db.get_flow(&flow.id)? + .is_some_and(|previous| !previous.enabled && flow.enabled); + let reset_flow_runtime = !flow.enabled || resumed; let previous_schedules: std::collections::HashMap = state .db .list_schedules()? @@ -1504,13 +1509,13 @@ fn compile_flow( .get(&id) .map(|item| item.created_at.clone()) .unwrap_or_else(|| now.clone()); - let last_fired_at = previous_automations - .get(&id) - .and_then(|item| item.last_fired_at.clone()); - let last_trigger_trace = previous_automations - .get(&id) - .map(|item| item.last_trigger_trace.clone()) - .unwrap_or_default(); + let last_fired_at = if resumed { None } else { + previous_automations.get(&id).and_then(|item| item.last_fired_at.clone()) + }; + let last_trigger_trace = if reset_flow_runtime { Vec::new() } else { + previous_automations.get(&id) + .map(|item| item.last_trigger_trace.clone()).unwrap_or_default() + }; let flow_runtime = previous_automations .get(&id) .map(|item| { @@ -1547,6 +1552,7 @@ fn compile_flow( runtime }) .unwrap_or_default(); + let flow_runtime = if reset_flow_runtime { Default::default() } else { flow_runtime }; let mut item = Automation { id, name: generated_flow_name(&flow.id, &action_node.id), @@ -1975,6 +1981,7 @@ async fn update_flow( state .db .replace_flow_outputs(&flow, &schedules, &automations)?; + engine::release_inactive_flow_intents(&state).await?; let mut zone_ids = old_zone_ids; zone_ids.extend(schedules.iter().map(|s| s.zone_id.clone())); for zone_id in zone_ids { @@ -2008,6 +2015,7 @@ async fn delete_flow( if !state.db.delete_flow(&id)? { return Err(AppError::NotFound(format!("flow {id}"))); } + engine::release_inactive_flow_intents(&state).await?; for zone_id in zone_ids { refresh_zone_override_boundary(&state, &zone_id).await?; } diff --git a/src/api/schedules.rs b/src/api/schedules.rs index 2c6199e..49324c4 100644 --- a/src/api/schedules.rs +++ b/src/api/schedules.rs @@ -158,11 +158,12 @@ async fn get_schedule( State(state): State, Path(id): Path, ) -> Result, AppError> { - state - .db - .get_schedule(&id)? - .map(Json) - .ok_or_else(|| AppError::NotFound(format!("schedule {id}"))) + let mut item = state.db.get_schedule(&id)? + .ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?; + if !state.db.flow_output_enabled(item.flow_id.as_deref())? { + item.enabled = false; + } + Ok(Json(item)) } async fn create_schedule( State(state): State, diff --git a/src/api/zones.rs b/src/api/zones.rs index 2a7ffc3..ab59998 100644 --- a/src/api/zones.rs +++ b/src/api/zones.rs @@ -246,6 +246,8 @@ impl ZoneInput { revision: 1, control_owner: "automation".into(), control_source: "automation".into(), + flow_control_id: None, + flow_control_node_id: None, control_since: Some(Utc::now()), control_resume_at: None, control_reason: "zone created".into(), diff --git a/src/db/flows.rs b/src/db/flows.rs index 1122227..5c55e9b 100644 --- a/src/db/flows.rs +++ b/src/db/flows.rs @@ -1,4 +1,24 @@ impl Db { + fn enabled_flow_ids(&self) -> Result> { + Ok(self.list_flows()?.into_iter() + .filter(|flow| flow.enabled && !flow.draft) + .map(|flow| flow.id) + .collect()) + } + + fn flow_output_enabled_locked(conn: &Connection, flow_id: Option<&str>) -> Result { + let Some(flow_id) = flow_id else { return Ok(true); }; + let raw: Option = conn.query_row(queries::GET_FLOW, [flow_id], |row| row.get(0)).optional()?; + let Some(raw) = raw else { return Ok(false); }; + let flow: Flow = Self::from_json(raw)?; + Ok(flow.enabled && !flow.draft) + } + + pub fn flow_output_enabled(&self, flow_id: Option<&str>) -> Result { + let conn = self.lock()?; + Self::flow_output_enabled_locked(&conn, flow_id) + } + pub fn list_flows(&self) -> Result> { self.list_payloads(queries::LIST_FLOWS) } diff --git a/src/db/schedules_automations.rs b/src/db/schedules_automations.rs index 0838ec3..9d1f9e6 100644 --- a/src/db/schedules_automations.rs +++ b/src/db/schedules_automations.rs @@ -15,7 +15,16 @@ impl Db { } pub fn list_schedules(&self) -> Result> { - self.list_payloads(queries::LIST_SCHEDULES) + let mut schedules: Vec = self.list_payloads(queries::LIST_SCHEDULES)?; + // The parent Flow is the execution authority. Legacy backups and stale engine + // snapshots may contain generated schedules whose own enabled bit is still true. + let enabled_flows = self.enabled_flow_ids()?; + for item in &mut schedules { + if item.flow_id.as_deref().is_some_and(|id| !enabled_flows.contains(id)) { + item.enabled = false; + } + } + Ok(schedules) } pub fn get_schedule(&self, id: &str) -> Result> { @@ -57,13 +66,46 @@ impl Db { } pub fn list_automations(&self) -> Result> { - self.list_payloads(queries::LIST_AUTOMATIONS) + let mut automations: Vec = self.list_payloads(queries::LIST_AUTOMATIONS)?; + let enabled_flows = self.enabled_flow_ids()?; + for item in &mut automations { + if item.flow_id.as_deref().is_some_and(|id| !enabled_flows.contains(id)) { + item.enabled = false; + } + } + Ok(automations) } pub fn get_automation(&self, id: &str) -> Result> { self.get_payload(queries::GET_AUTOMATION, id) } + /// Never upsert a stale Flow evaluation. Only the runtime field is mutable here: + /// configuration, enabled flag, execution time and even deletion must be preserved. + /// Reading the current record, checking its owner and writing it share the DB mutex. + pub fn save_flow_runtime_if_current(&self, sampled: &Automation) -> Result { + let conn = self.lock()?; + let raw: Option = conn.query_row( + queries::GET_AUTOMATION, + [sampled.id.as_str()], + |row| row.get(0), + ).optional()?; + let Some(raw) = raw else { return Ok(false); }; + let mut current: Automation = Self::from_json(raw)?; + if !current.enabled || current.updated_at != sampled.updated_at + || current.flow_id != sampled.flow_id || current.trigger_kind != "flow" + || !Self::flow_output_enabled_locked(&conn, current.flow_id.as_deref())? + { + return Ok(false); + } + current.flow_runtime = sampled.flow_runtime.clone(); + conn.execute( + "UPDATE automations SET payload=?2 WHERE id=?1", + params![current.id, Self::to_json(¤t)?], + )?; + Ok(true) + } + pub fn delete_automation(&self, id: &str) -> Result { self.delete_by_id("automations", id) } diff --git a/src/db/tests.rs b/src/db/tests.rs index acf466c..60d2113 100644 --- a/src/db/tests.rs +++ b/src/db/tests.rs @@ -3,6 +3,105 @@ mod tests { use super::*; use crate::models::{ApiTokenInfo, ConnectionType, Device, HaReading, Reading}; + #[test] + fn disabled_flow_must_not_schedule_execute_or_restore_stale_automation() { + let dir = tempfile::tempdir().unwrap(); + let db = Db::open(&dir.path().join("flow_disabled.db")).unwrap(); + let now = Utc::now(); + let mut flow = Flow { + id: "flow-1".into(), + name: "Heating".into(), + enabled: true, + draft: false, + description: String::new(), + nodes: Vec::new(), + edges: Vec::new(), + summary: String::new(), + compiled_schedule_ids: vec!["schedule-1".into()], + compiled_automation_ids: vec!["auto-1".into()], + revision: 1, + created_at: now, + updated_at: now, + }; + let mut auto: Automation = serde_json::from_value(serde_json::json!({ + "id": "auto-1", "name": "Generated", "enabled": true, + "trigger_kind": "flow", "flow_id": flow.id, "flow_node_id": "action-1", + "created_at": now, "updated_at": now + })).unwrap(); + let schedule = Schedule { + id: "schedule-1".into(), zone_id: "zone-1".into(), + name: "Generated schedule".into(), enabled: true, + weekdays: vec![1, 2, 3, 4, 5, 6, 7], + start_time: "08:00".into(), end_time: "20:00".into(), + preset: "comfort".into(), setpoint: 21.0, + created_at: now, updated_at: now, + flow_id: Some(flow.id.clone()), flow_node_id: Some("action-2".into()), + }; + db.replace_flow_outputs(&flow, &[schedule.clone()], &[auto.clone()]).unwrap(); + assert!(db.list_automations().unwrap()[0].enabled); + assert!(db.list_schedules().unwrap()[0].enabled); + + // Simulate the asynchronous evaluator taking a snapshot before Flow OFF. + let mut sampled = db.get_automation("auto-1").unwrap().unwrap(); + sampled.flow_runtime.insert("timer-1".into(), Default::default()); + assert!(db.save_flow_runtime_if_current(&sampled).unwrap()); + assert_eq!(db.get_automation("auto-1").unwrap().unwrap().flow_runtime.len(), 1); + + flow.enabled = false; + flow.revision = 2; + flow.updated_at = now + Duration::seconds(2); + auto.enabled = false; + auto.updated_at = flow.updated_at; + let mut disabled_schedule = schedule.clone(); + disabled_schedule.enabled = false; + db.replace_flow_outputs(&flow, &[disabled_schedule], &[auto.clone()]).unwrap(); + assert!(!db.save_flow_runtime_if_current(&sampled).unwrap()); + assert!(!db.get_automation("auto-1").unwrap().unwrap().enabled); + assert!(!db.list_automations().unwrap()[0].enabled); + assert!(!db.list_schedules().unwrap()[0].enabled); + + // Imported/legacy/corrupt configurations can have an enabled child and + // disabled parent. Parent authority must still win for ALL consumers. + db.save_automation(&sampled).unwrap(); + db.save_schedule(&schedule).unwrap(); + assert!(db.get_automation("auto-1").unwrap().unwrap().enabled); + assert!(!db.list_automations().unwrap()[0].enabled); + assert!(!db.list_schedules().unwrap()[0].enabled); + assert!(!db.flow_output_enabled(Some("flow-1")).unwrap()); + assert!(!db.save_flow_runtime_if_current(&sampled).unwrap()); + + // A delete must not be undone by a previously in-flight evaluation. + db.delete_flow("flow-1").unwrap(); + assert!(!db.save_flow_runtime_if_current(&sampled).unwrap()); + assert!(db.get_automation("auto-1").unwrap().is_none()); + assert!(!db.flow_output_enabled(Some("flow-1")).unwrap()); + db.save_automation(&sampled).unwrap(); + assert!(!db.list_automations().unwrap()[0].enabled); // Orphan fails closed + } + + #[test] + fn disabled_rule_can_never_be_reenabled_by_runtime_checkpoint() { + let dir = tempfile::tempdir().unwrap(); + let db = Db::open(&dir.path().join("race.db")).unwrap(); + let now = Utc::now(); + let mut rule: Automation = serde_json::from_value(serde_json::json!({ + "id":"test-rule", "name":"No parent", "enabled":true, "trigger_kind":"flow", + "created_at":now, "updated_at":now + })).unwrap(); + db.save_automation(&rule).unwrap(); + let stale = rule.clone(); + rule.enabled = false; + rule.updated_at = now + Duration::seconds(1); + db.save_automation(&rule).unwrap(); + assert!(!db.save_flow_runtime_if_current(&stale).unwrap()); + assert!(!db.get_automation("test-rule").unwrap().unwrap().enabled); + // Also validate optimistic concurrency: a new definition takes precedence. + rule.enabled = true; + db.save_automation(&rule).unwrap(); + assert!(!db.save_flow_runtime_if_current(&stale).unwrap()); + assert!(db.get_automation("test-rule").unwrap().unwrap().enabled); + } + #[test] fn history_compaction_keeps_one_sample_per_old_bucket() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/engine/automations.rs b/src/engine/automations.rs index 0ec6ab4..347715d 100644 --- a/src/engine/automations.rs +++ b/src/engine/automations.rs @@ -26,6 +26,8 @@ fn flow_off_physical_mode(action: &DeviceCommand) -> Option { fn flow_zone_action_already_satisfied( zone: &Zone, + flow_id: Option<&str>, + flow_node_id: Option<&str>, device_power: bool, device_swing_vertical: u8, device_swing_horizontal: u8, @@ -35,9 +37,18 @@ fn flow_zone_action_already_satisfied( standby_offset_c: Option, simple_heat: Option<&SimpleHeatSettings>, ) -> bool { + // Matching settings from another (or formerly deleted) Flow are not ownership of + // this action. It needs to execute and record its own provenance. + if zone.control_source != "automation.flow" + // Legacy zone rows without attribution must run once to claim their + // current Flow; matching settings alone do not prove ownership. + || (flow_id.is_some() && zone.flow_control_id.as_deref() != flow_id) + || (flow_node_id.is_some() && zone.flow_control_node_id.as_deref() != flow_node_id) + { + return false; + } if let Some(profile) = simple_heat { - return zone.simple_heat.as_ref() == Some(profile) - && zone.control_source == "automation.flow"; + return zone.simple_heat.as_ref() == Some(profile); } if heat_anticipation_c.is_some_and(|value| (zone.heat_anticipation_c - value).abs() > 0.001) || standby_offset_c.is_some_and(|value| (zone.standby_offset_c - value).abs() > 0.001) @@ -115,7 +126,13 @@ async fn run_automations(state: &AppState) -> Result<()> { "flow" => { let before_runtime = item.flow_runtime.clone(); let result = flow_conditions_match(state, &devices, &mut item).await; - if item.flow_runtime != before_runtime { state.db.save_automation(&item)?; } + if item.flow_runtime != before_runtime + && !state.db.save_flow_runtime_if_current(&item)? + { + // A Flow was disabled, deleted or recompiled during asynchronous + // condition evaluation. Never execute or resurrect its old snapshot. + continue; + } match result { Ok((value, trace)) => { trigger_trace = trace; @@ -146,7 +163,11 @@ async fn run_automations(state: &AppState) -> Result<()> { continue; } let Some(latest_item) = state.db.get_automation(&item.id)? else { continue; }; - if latest_item.updated_at != item.updated_at { continue; } + if !latest_item.enabled || latest_item.updated_at != item.updated_at + || !state.db.flow_output_enabled(latest_item.flow_id.as_deref())? + { + continue; + } item = latest_item; let mut effective_action = item.action.clone(); let mut effective_ha_data = item.action_ha_data.clone(); @@ -200,6 +221,8 @@ async fn run_automations(state: &AppState) -> Result<()> { if let Some(device) = state.db.get_device(&zone.device_id)? { if flow_zone_action_already_satisfied( zone, + item.flow_id.as_deref(), + item.flow_node_id.as_deref(), device.power, device.swing_vertical, device.swing_horizontal, @@ -212,7 +235,9 @@ async fn run_automations(state: &AppState) -> Result<()> { let before_runtime = item.flow_runtime.clone(); flow_acknowledge_change_gates(&mut item); if item.flow_runtime != before_runtime { - state.db.save_automation(&item)?; + // The action lock prevents Flow edits here. A runtime-only + // update still guarantees we cannot overwrite enablement. + if !state.db.save_flow_runtime_if_current(&item)? { continue; } } continue; } @@ -296,6 +321,7 @@ async fn run_automations(state: &AppState) -> Result<()> { state, zone_id, item.action_zone_preset.as_deref(), &effective_action, item.action_zone_heat_anticipation_c, item.action_zone_standby_offset_c, item.action_zone_simple_heat.as_ref(), + item.flow_id.as_deref(), item.flow_node_id.as_deref(), ).await } else if let Some(group_id) = item.action_group_id.as_deref() { let group_mode = effective_action.mode.as_deref().map(|mode| if mode == "auto" { "house".to_string() } else { mode.to_string() }); @@ -306,7 +332,10 @@ async fn run_automations(state: &AppState) -> Result<()> { setpoint: effective_action.target_temperature, }, "automation.group").await.map(|value| !value.get("suppressed").and_then(Value::as_bool).unwrap_or(false)) } else { - match apply_automatic_device_action(state, &item.action_device_id, effective_action.clone()).await { + match apply_automatic_device_action( + state, &item.action_device_id, effective_action.clone(), + item.flow_id.as_deref().zip(item.flow_node_id.as_deref()), + ).await { Ok(Some(_)) => Ok(true), Ok(None) => { state.log("info", "automation.blocked_by_fresh_ownership", &format!("Automation {} was suppressed after ownership changed", item.name), json!({ @@ -1128,6 +1157,7 @@ async fn apply_flow_zone_action( state: &AppState, zone_id: &str, preset: Option<&str>, action: &DeviceCommand, heat_anticipation_c: Option, standby_offset_c: Option, simple_heat: Option<&SimpleHeatSettings>, + flow_id: Option<&str>, flow_node_id: Option<&str>, ) -> Result { let _schedule_guard = state.lock_schedule_operation().await; // Match the canonical thermostat ordering used by Web/HA zone control. This prevents @@ -1185,6 +1215,8 @@ async fn apply_flow_zone_action( zone.updated_at = Utc::now(); zone.control_owner = "automation".into(); zone.control_source = "automation.flow".into(); + zone.flow_control_id = flow_id.map(str::to_string); + zone.flow_control_node_id = flow_node_id.map(str::to_string); zone.control_reason = "Visual Flow automation".into(); let schedules = state.db.list_schedules()?; let runtime_settings = state.settings.read().await; diff --git a/src/engine/control_plan.rs b/src/engine/control_plan.rs index 20908a0..b549d9b 100644 --- a/src/engine/control_plan.rs +++ b/src/engine/control_plan.rs @@ -311,9 +311,10 @@ pub async fn build_control_plan(state: &AppState) -> Result = [ + ("flow-a".into(), "action-a".into(), zone.id.clone()), + ].into_iter().collect(); + assert!(!release_inactive_flow_zone_intent(&mut zone, &active)); + assert_eq!(zone.manual_setpoint, Some(22.0)); + assert!(release_inactive_flow_zone_intent(&mut zone, &HashSet::new())); + assert_eq!(zone.control_source, "automation"); + assert!(zone.manual_preset.is_none()); + assert!(zone.manual_setpoint.is_none()); + assert!(zone.compressor_pending_action.is_none()); + assert!(zone.flow_control_id.is_none()); + } + + #[test] + fn old_flow_state_and_manual_takeover_are_handled_safely() { + let mut zone = test_zone("device"); + zone.control_source = "automation.flow".into(); + zone.manual_preset = Some("comfort".into()); + // A pre-upgrade zone has no flow_control_id: when no compiled Flow + // targets it, its previous automatic intent must also be released. + assert!(release_inactive_flow_zone_intent(&mut zone, &HashSet::new())); + assert!(zone.manual_preset.is_none()); + + zone.control_source = "web_thermostat".into(); + zone.manual_setpoint = Some(24.0); + assert!(!release_inactive_flow_zone_intent(&mut zone, &HashSet::new())); + assert_eq!(zone.manual_setpoint, Some(24.0)); + + zone.control_source = "automation.flow".into(); + zone.flow_control_id = Some("flow-old".into()); + zone.flow_control_node_id = Some("old-action".into()); + zone.manual_preset = Some("sleep".into()); + let new_action: HashSet<(String, String, String)> = [ + ("flow-old".into(), "new-action".into(), zone.id.clone()), + ].into_iter().collect(); + assert!(release_inactive_flow_zone_intent(&mut zone, &new_action)); + assert!(zone.manual_preset.is_none()); + } + #[test] fn heat_coasting_modulates_setpoint_without_power_cycle() { assert!(!heat_coasting_next_state(false, "heat", 19.8, 20.8, 0.9)); @@ -241,6 +289,9 @@ mod tests { #[test] fn flow_zone_action_noop_detects_persisted_on_intent_and_real_off_state() { let mut zone = test_zone("device"); + zone.control_source = "automation.flow".into(); + zone.flow_control_id = Some("flow-test".into()); + zone.flow_control_node_id = Some("action-test".into()); zone.inherit_house_mode = false; zone.mode = "heat".into(); zone.manual_preset = Some("custom".into()); @@ -254,6 +305,7 @@ mod tests { }; assert!(flow_zone_action_already_satisfied( &zone, + Some("flow-test"), Some("action-test"), false, 0, 0, @@ -265,6 +317,7 @@ mod tests { )); assert!(!flow_zone_action_already_satisfied( &zone, + Some("flow-test"), Some("action-test"), false, 0, 0, @@ -274,10 +327,19 @@ mod tests { Some(5.0), None )); + // A matching pre-upgrade setpoint without a Flow owner is not enough + // to suppress the first real action; the action must claim ownership. + zone.flow_control_id = None; + assert!(!flow_zone_action_already_satisfied( + &zone, Some("flow-test"), Some("action-test"), false, 0, 0, + Some("custom"), &on, None, None, None, + )); + zone.flow_control_id = Some("flow-test".into()); zone.heat_anticipation_c = 1.1; zone.standby_offset_c = 5.0; assert!(flow_zone_action_already_satisfied( &zone, + Some("flow-test"), Some("action-test"), false, 0, 0, @@ -301,6 +363,7 @@ mod tests { }; assert!(flow_zone_action_already_satisfied( &zone, + Some("flow-test"), Some("action-test"), false, 0, 0, @@ -312,6 +375,7 @@ mod tests { )); assert!(!flow_zone_action_already_satisfied( &zone, + Some("flow-test"), Some("action-test"), true, 0, 0, @@ -326,6 +390,9 @@ mod tests { #[test] fn flow_zone_action_noop_keeps_auxiliary_swing_changes_executable() { let mut zone = test_zone("device"); + zone.control_source = "automation.flow".into(); + zone.flow_control_id = Some("flow-test".into()); + zone.flow_control_node_id = Some("action-test".into()); zone.inherit_house_mode = false; zone.mode = "heat".into(); zone.manual_preset = Some("custom".into()); @@ -339,6 +406,7 @@ mod tests { }; assert!(!flow_zone_action_already_satisfied( &zone, + Some("flow-test"), Some("action-test"), true, 0, 0, @@ -350,6 +418,7 @@ mod tests { )); assert!(flow_zone_action_already_satisfied( &zone, + Some("flow-test"), Some("action-test"), true, 1, 0, @@ -717,6 +786,8 @@ mod tests { revision: 1, control_owner: "automation".into(), control_source: "automation".into(), + flow_control_id: None, + flow_control_node_id: None, control_since: Some(Utc::now()), control_resume_at: None, control_reason: "test".into(), diff --git a/src/engine/zone_actions.rs b/src/engine/zone_actions.rs index 0da0a2f..4594fab 100644 --- a/src/engine/zone_actions.rs +++ b/src/engine/zone_actions.rs @@ -180,6 +180,7 @@ async fn apply_automatic_device_action( state: &AppState, device_id: &str, command: DeviceCommand, + flow_source: Option<(&str, &str)>, ) -> Result, AppError> { // Direct automation target/setpoint is translated into zone state and may use the next // schedule boundary. Serialize that derivation with schedule edits before locking the zone. @@ -249,6 +250,8 @@ async fn apply_automatic_device_action( zone.revision = zone.revision.saturating_add(1); zone.updated_at = Utc::now(); zone.control_source = "automation.device".into(); + zone.flow_control_id = flow_source.map(|(id, _)| id.to_string()); + zone.flow_control_node_id = flow_source.map(|(_, node)| node.to_string()); state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); state.wake_zone_control(); diff --git a/src/engine/zone_control.rs b/src/engine/zone_control.rs index 06fc622..0f09fe8 100644 --- a/src/engine/zone_control.rs +++ b/src/engine/zone_control.rs @@ -533,6 +533,102 @@ async fn handle_zone_pre_control_state( Ok(false) } +/// Flow actions are durable zone intent, not just one-shot physical commands. An +/// inactive/deleted Flow must release any remaining thermostat demand and compressor +/// task even if a previous version persisted an enabled compiled automation. +fn release_inactive_flow_zone_intent( + zone: &mut Zone, + active_actions: &HashSet<(String, String, String)>, +) -> bool { + let old_profile = zone.simple_heat.as_ref(); + let stale_simple_heat = old_profile.is_some_and(|profile| { + !active_actions.contains(&( + profile.flow_id.clone(), profile.flow_node_id.clone(), zone.id.clone(), + )) + }); + let actual_owner = zone.flow_control_id.as_deref() + .zip(zone.flow_control_node_id.as_deref()) + .or_else(|| old_profile.map(|profile| (profile.flow_id.as_str(), profile.flow_node_id.as_str()))); + let active_owner = match actual_owner { + Some((flow_id, node_id)) => active_actions.contains(&( + flow_id.to_string(), node_id.to_string(), zone.id.clone(), + )), + None => active_actions.iter().any(|(_, _, zone_id)| zone_id == &zone.id), + }; + // Never clear genuine manual/local/temporary control because a disabled Flow + // happened to be the previous owner of the same thermostat. + let flow_owned = zone.control_source == "automation.flow" + || (zone.control_source == "automation.device" && zone.flow_control_id.is_some()); + let stale_control = flow_owned + && !zone.device_manual_override && zone.local_thermostat_power.is_none() + && zone.temporary_quick_thermostat.is_none() + && !active_owner; + if stale_simple_heat { + zone.simple_heat = None; + zone.simple_heat_runtime = SimpleHeatRuntime::default(); + } + if stale_control { + zone.manual_preset = None; + zone.manual_setpoint = None; + zone.manual_override_until = None; + zone.flow_control_id = None; + zone.flow_control_node_id = None; + zone.control_source = "automation".into(); + zone.control_reason = "Disabled/deleted Flow released thermostat control".into(); + zone.control_since = Some(Utc::now()); + zone.demand = false; + zone.demand_since = None; + zone.heat_coasting = false; + clear_compressor_pending(zone, true); + } + stale_control || stale_simple_heat +} + +fn active_flow_zone_action_ids(state: &AppState) -> Result> { + let zone_by_device: HashMap = state.db.list_zones()? + .into_iter().map(|zone| (zone.device_id, zone.id)).collect(); + let mut owners = HashSet::new(); + for rule in state.db.list_automations()?.into_iter().filter(|item| item.enabled) { + let (Some(flow_id), Some(node_id)) = (rule.flow_id, rule.flow_node_id) else { continue; }; + // Both explicit Flow thermostat actions and Flow commands addressed to the + // underlying GREE device can own durable thermostat state. + let zone_id = rule.action_zone_id.or_else(|| { + if rule.action_group_id.is_some() || rule.action_ha_domain.is_some() { + None + } else { + zone_by_device.get(&rule.action_device_id).cloned() + } + }); + if let Some(zone_id) = zone_id { + owners.insert((flow_id, node_id, zone_id)); + } + } + Ok(owners) +} + +/// Call only with the automation, schedule and thermostat-cycle locks held, in +/// that order (as in the Flow update/delete handlers). +pub(crate) async fn release_inactive_flow_intents(state: &AppState) -> Result<()> { + let active_actions = active_flow_zone_action_ids(state)?; + for item in state.db.list_zones()? { + if item.control_source != "automation.flow" + && !(item.control_source == "automation.device" && item.flow_control_id.is_some()) + && item.simple_heat.is_none() + { + continue; + } + let _zone_guard = state.lock_zone_operation(&item.id).await; + let Some(mut zone) = state.db.get_zone(&item.id)? else { continue; }; + if !release_inactive_flow_zone_intent(&mut zone, &active_actions) { continue; } + zone.revision = zone.revision.saturating_add(1); + zone.updated_at = Utc::now(); + state.db.save_zone(&zone)?; + state.broadcast("zone.updated", serde_json::to_value(&zone)?); + state.log("info", "flow.zone_released", "Inactive Flow released thermostat intent", json!({"zone_id": zone.id})); + } + Ok(()) +} + async fn control_zones(state: &AppState) -> Result<()> { let _cycle_guard = state.lock_zone_control_cycle().await; let settings = state.settings.read().await.clone(); @@ -541,17 +637,8 @@ async fn control_zones(state: &AppState) -> Result<()> { } let schedules = state.db.list_schedules()?; let mut zone_snapshot = state.db.list_zones()?; - // Only an enabled, compiled Flow action is allowed to retain simple heating - // ownership. Disabling, deleting or recompiling a Flow immediately removes - // stale heating profiles in the next control cycle. - let enabled_heat_actions: HashSet<(String, String, String)> = state.db.list_automations()? - .into_iter() - .filter(|item| item.enabled) - .filter_map(|item| { - let profile = item.action_zone_simple_heat?; - Some((profile.flow_id, profile.flow_node_id, item.action_zone_id?)) - }) - .collect(); + // Flow ownership is valid only while its exact compiled action is enabled. + let active_flow_actions = active_flow_zone_action_ids(state)?; // Local/temporary thermostat ownership has its own deadlines. Expire and activate sessions independently. expire_local_thermostat_overrides(state, &mut zone_snapshot, &schedules, &settings.house_mode) .await?; @@ -587,15 +674,13 @@ async fn control_zones(state: &AppState) -> Result<()> { continue; }; let cycle_started_at = zone.updated_at.clone(); - if zone.simple_heat.as_ref().is_some_and(|profile| { - !enabled_heat_actions.contains(&(profile.flow_id.clone(), profile.flow_node_id.clone(), zone.id.clone())) - }) { - zone.simple_heat = None; - zone.simple_heat_runtime = SimpleHeatRuntime::default(); - if zone.control_source == "automation.flow" { - zone.control_source = "automation".into(); - zone.control_reason = "Simple heating Flow disabled; thermostat idle".into(); - } + if release_inactive_flow_zone_intent(&mut zone, &active_flow_actions) { + // Persist even if this device is currently unavailable and the control + // cycle would otherwise return early without saving the zone. + zone.revision = zone.revision.saturating_add(1); + zone.updated_at = Utc::now(); + state.db.save_zone(&zone)?; + state.broadcast("zone.updated", serde_json::to_value(&zone)?); } let outdoor_temperature = zone_outdoor_sensor_results .remove(&zone.id) diff --git a/src/models/zone.rs b/src/models/zone.rs index b2b5b0b..b30c483 100644 --- a/src/models/zone.rs +++ b/src/models/zone.rs @@ -166,6 +166,12 @@ pub struct Zone { pub control_owner: String, #[serde(default)] pub control_source: String, + /// The last Flow action owning this zone. Missing on pre-0.16.4 databases. + /// Used to release durable thermostat intent when this exact action is disabled. + #[serde(default)] + pub flow_control_id: Option, + #[serde(default)] + pub flow_control_node_id: Option, #[serde(default)] pub control_since: Option>, #[serde(default)] diff --git a/web/js-dynamic/dashboard.js b/web/js-dynamic/dashboard.js index c5cf24d..a563444 100644 --- a/web/js-dynamic/dashboard.js +++ b/web/js-dynamic/dashboard.js @@ -169,6 +169,12 @@ function planFlowTraceActual(rule, item, node) { return JSON.stringify(actual); } +function planRuleEffectivelyEnabled(rule) { + if (!rule.enabled) return false; + // A stale pushed plan must never advertise a disabled/missing parent Flow. + return !rule.flow_id || (app.flows || []).some(flow => flow.id === rule.flow_id && flow.enabled && !flow.draft); +} + function planFlowTriggerTrace(rule, expanded = false) { const trace = Array.isArray(rule.last_trigger_trace) ? rule.last_trigger_trace : []; if (rule.trigger_kind !== 'flow' || !trace.length) return ''; @@ -195,7 +201,7 @@ function planReasonCard(plan) { }).join(''); const recentRules = (plan.rules || []) - .filter(rule => rule.enabled && rule.last_fired_at) + .filter(rule => planRuleEffectivelyEnabled(rule) && rule.last_fired_at) .sort((a, b) => new Date(b.last_fired_at) - new Date(a.last_fired_at)) .slice(0, 4) .map((rule, index) => { @@ -284,7 +290,7 @@ function renderControlPlan() { return `
${esc(scope)}

${esc(zone.zone_name)}

${esc(planState)}
${fmtTemp(zone.current_temperature)}→${esc(target)}°C

${esc(houseModeLabel(zone.mode || 'off'))} · ${esc(zonePresetLabel(zone.preset))}${zone.current_schedule_name ? ` · ${esc(zone.current_schedule_name)}` : ''}

    ${events.map(planEventMarkup).join('')}
`; }).join(''); - const rules = (plan.rules || []).filter(rule => rule.enabled); + const rules = (plan.rules || []).filter(planRuleEffectivelyEnabled); const ruleCard = rules.length ? `
${esc(tr('plan.rules'))}

${esc(tr(plan.emergency_stop_active ? 'plan.ruleCountPaused' : 'plan.ruleCount', { count: rules.length }))}

    ${rules.slice(0, 3).map(rule => { const automation = (app.automations || []).find(item => item.id === rule.id); const flow = automation?.flow_id ? (app.flows || []).find(item => item.id === automation.flow_id) : null; @@ -537,7 +543,7 @@ function renderSimulationPage() { { label: tr('simulation.houseMode'), value: houseModeLabel(plan.house_mode || 'off'), note: tr('simulation.strategy', { strategy: (plan.control_strategy || 'setpoint') === 'setpoint' ? tr('plan.strategySetpoint') : (plan.control_strategy || 'setpoint') }) }, { label: tr('settings.nightMode'), value: nightOn ? tr('common.active') : (app.settings?.night_mode?.enabled ? tr('simulation.scheduled') : tr('common.disabled')), note: `${plan.night_mode_start || '22:00'} → ${plan.night_mode_end || '06:00'} · ${tr('simulation.fanMax')} ${fanLabel(plan.night_mode_max_fan_speed || 1)}` }, { label: tr('simulation.outdoor'), value: plan.outdoor_temperature == null ? '—' : fmtTemp(plan.outdoor_temperature), note: tr('simulation.generatedAt', { time: dateTime(plan.generated_at) }) }, - { label: tr('simulation.activeZones'), value: String(enabledZones.length), note: `${tr('simulation.requestingZones', { count: demandingZones.length })} · ${tr('simulation.rulesLabel')}: ${(plan.rules || []).filter(rule => rule.enabled && simulationRuleVisible(rule, zones)).length}` }, + { label: tr('simulation.activeZones'), value: String(enabledZones.length), note: `${tr('simulation.requestingZones', { count: demandingZones.length })} · ${tr('simulation.rulesLabel')}: ${(plan.rules || []).filter(rule => planRuleEffectivelyEnabled(rule) && simulationRuleVisible(rule, zones)).length}` }, ].map(card => `
    ${esc(card.label)}${esc(card.value)}${esc(card.note)}
    `).join(''); const nodeW = 190, nodeH = 106, rowH = 190, topY = 155; @@ -607,7 +613,7 @@ function renderSimulationPage() { return `
    ${esc(item.scope)}

    ${esc(item.event.label)}${esc(target)}${item.device ? ` · ${esc(item.device)}` : ''}

    `; }).join('') : `
    ${esc(tr('plan.noEvents'))}
    `; - const activeRules = (plan.rules || []).filter(rule => rule.enabled && simulationRuleVisible(rule, zones)); + const activeRules = (plan.rules || []).filter(rule => planRuleEffectivelyEnabled(rule) && simulationRuleVisible(rule, zones)); rulesHost.innerHTML = activeRules.length ? activeRules.map(rule => `
    ${esc(tr('common.automation'))}

    ${esc(rule.name)}

    ${esc(automationTriggerLabel(rule))}

    ${esc(tr('simulation.ruleOnDevice', { device: rule.action_device_name || tr('common.noDevice') }))}

    ${esc(simulationRuleAction(rule))}
    ${esc(tr('automations.last'))}: ${esc(dateTime(rule.last_fired_at))}${esc(tr('simulation.nextReady'))}: ${esc(dateTime(rule.next_ready_at))}
    `).join('') : `
    ${esc(tr('simulation.noRules'))}
    `; } diff --git a/web/js-dynamic/realtime.js b/web/js-dynamic/realtime.js index 0157e60..88c274b 100644 --- a/web/js-dynamic/realtime.js +++ b/web/js-dynamic/realtime.js @@ -96,8 +96,8 @@ async function handleWebSocketMessage(event) { else if (message.event === 'schedule.template_applied') { try { app.schedules = await api('/api/schedules'); renderHouseClimate(); renderSchedules(); fillSelects(); } catch (_) { } scheduleControlPlanLoad(); } else if (['automation.updated', 'automation.created'].includes(message.event)) { const i = app.automations.findIndex(v => v.id === data.id); if (i >= 0) app.automations[i] = data; else app.automations.push(data); renderHouseClimate(); renderAutomations(); fillSelects(); scheduleControlPlanLoad(); } else if (message.event === 'automation.deleted') { app.automations = app.automations.filter(v => v.id !== data.id); renderHouseClimate(); renderAutomations(); fillSelects(); scheduleControlPlanLoad(); } - else if (['flow.updated', 'flow.created'].includes(message.event)) { const i = app.flows.findIndex(v => v.id === data.id); if (i >= 0) app.flows[i] = data; else app.flows.push(data); renderHouseClimate(); renderFlows(); scheduleControlPlanLoad(); } - else if (message.event === 'flow.deleted') { app.flows = app.flows.filter(v => v.id !== data.id); renderHouseClimate(); renderFlows(); scheduleControlPlanLoad(); } + else if (['flow.updated', 'flow.created'].includes(message.event)) { const i = app.flows.findIndex(v => v.id === data.id); if (i >= 0) app.flows[i] = data; else app.flows.push(data); renderHouseClimate(); renderFlows(); renderControlPlan(); renderSimulationPage(); scheduleControlPlanLoad(); } + else if (message.event === 'flow.deleted') { app.flows = app.flows.filter(v => v.id !== data.id); renderHouseClimate(); renderFlows(); renderControlPlan(); renderSimulationPage(); scheduleControlPlanLoad(); } else if (message.event === 'settings.application.updated') { applySettingsSection('application', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); renderSimulationModeBanner(); renderSystemInfo(); } else if (message.event === 'settings.gree.updated') { applySettingsSection('gree', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); scheduleControlPlanLoad(); } else if (message.event === 'settings.gree_cloud.updated') { applySettingsSection('greeCloud', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); }