From 3479ed750d70ff32bad0625c23470f8917a2b945 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 1 Sep 2026 10:20:19 +0200 Subject: [PATCH] v0.8.19 --- Cargo.lock | 2 +- Cargo.toml | 2 +- FILE_MANIFEST.sha256 | 66 ++++++++--------- README.md | 4 +- docs/API.md | 16 +++-- .../gree_controller/manifest.json | 2 +- lang/en.json | 11 ++- lang/pl.json | 11 ++- src/api/automations.rs | 14 ++++ src/api/debug_tokens.rs | 2 + src/api/devices.rs | 19 ++++- src/api/events.rs | 2 + src/api/groups.rs | 40 ++++++++++- src/api/house.rs | 71 ++++++++++++++++--- src/api/schedules.rs | 23 ++++-- src/api/settings.rs | 62 +++++++++++++--- src/api/zones.rs | 38 +++++++--- src/engine/automations.rs | 19 ++++- src/engine/groups.rs | 63 +++++++++++++--- src/engine/ownership.rs | 10 ++- src/engine/polling.rs | 33 +++++++++ src/engine/temporary_thermostat.rs | 20 +++++- src/engine/tests.rs | 15 ++++ src/engine/zone_actions.rs | 11 ++- src/main.rs | 5 ++ src/models/temporary_thermostat.rs | 3 + src/models/zone.rs | 5 +- src/state.rs | 29 ++++++++ web/index.html | 2 +- web/js/core.js | 2 +- web/js/entities.js | 30 +++++++- web/js/events.js | 22 +++++- web/js/navigation.js | 25 ++++++- web/styles.css | 9 ++- 34 files changed, 575 insertions(+), 113 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4379953..b432aaf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.8.17" +version = "0.8.19" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index 81956ca..7f427ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.8.17" +version = "0.8.19" 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 96feb5c..3ad05d5 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,11 +1,11 @@ d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -d3ee62701b27064418df4f62f43ef4a00528275e8338c2f48b4ea2010e11c10d ./Cargo.lock -6ab182c92c822b5f1685538d1d7f9e22e17ad1a748932fdda6e04444b4eefaf8 ./Cargo.toml +6efa62dde5c1b169e60a30bcfaa43ec20c7ada84546f58d33d61e3aba7e56ed1 ./Cargo.lock +245b9ca11d543ab2bf475e46d24f076f9ac80f4f9f0726f0dcb8356e3099af61 ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -4a40bef9464c7da8e46c3a7396b417bafee37fea8e0d89406d025eb4e8e6c47e ./README.md +030f04d570b57bb7d427007125230564fc85ed9037a22908f231a51c9375e7f3 ./README.md ec0f495ee6a618bdec10b260ee7838f7a872d8c5b0aa91c546226c1ad818f601 ./build.rs -bbac52c61e28b554027902f97044ab5039acf3f58aeca1d5fd204013ffcc0cbf ./docs/API.md +4cc13a859ec285cabcf339a9c817c0aef6da32ce2b79e1c7af43d793e514ece7 ./docs/API.md 95e3c8c99ba439a86a2fb05cfd8ef00c75cb959a024a349a082da935b726f500 ./home-assistant/README.md f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py 6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py @@ -16,7 +16,7 @@ ab08fbe40e9bb48ebcbfff98760aaa0c9434b61b705aaaadea0c09c255d99b0e ./home-assista 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/logo.png -548759fff3e2f96352db7b3f96caced9f5d3a57fa4165ab27f22640ad53c1fab ./home-assistant/custom_components/gree_controller/manifest.json +85200f0f91e27e74549f8a7ca9892a6481b328381f15fe51659a5f1efb0c96f5 ./home-assistant/custom_components/gree_controller/manifest.json 38d659d0273c0d1428679e44574a2666c605d1b3796d323b626baa40560788cf ./home-assistant/custom_components/gree_controller/number.py 39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py 1cae12876eb81085910907cf1fcf340a18dcba4f8c967ef222c9496325057849 ./home-assistant/custom_components/gree_controller/sensor.py @@ -25,8 +25,8 @@ c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assista 13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json 4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md -4b1b820432bd96ba16c0c3a9f363cf6d3a6bdd12b58bdbf7b45a0611ad56324b ./lang/en.json -a1eb1b3cab4d9f23c6121259157060ba3375e29c6507bc94095f033170242244 ./lang/pl.json +78bc797f28939a0e0372fe497db44d53a26d31d3a0cae593d2a0319ab1205085 ./lang/en.json +c24adedfe0f66f8518ed5d175de853582000900b8a732e9dac84ebce4ff0c616 ./lang/pl.json d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh @@ -42,21 +42,21 @@ b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/upda ffa4d44d5f9f7a3e0e80e34c61ee510ab0f6b4b80a8d6c8137aae2ea74fbab02 ./src/api.rs 50b653f5ae1c9d24eb2e158fb4af713f5ccabe259c83053fa3cd2a310c7780da ./src/api/assets.rs 6c34b1294d76b0eba4c56bfd0bf69bdd502de0e40838104f6ddf2db4e922683d ./src/api/auth.rs -bc6dbb4b8fb6972ee9065f3caf36881e4316e70f2b7a7d04e7848839fd0801f3 ./src/api/automations.rs -05fdfca0e5d0eca7ace3267c4d95409ef12ead087bb9aad73d5a09b5cc7c3de1 ./src/api/debug_tokens.rs -ffa0a35a0c6badbbbaad88dfaedd46049a0bc4ce2f7e8b930dbdbffe5df4cffb ./src/api/devices.rs -1f13d0fffc9d0d0ff0c008645da57291d924a855de4b8fcb98756026d098e60d ./src/api/events.rs -28e6ae79a22ef87037dc6fb43d515878151abe9200a318903b3140fe06f9c2ca ./src/api/groups.rs +387bd4e2d9f73ad19acadf388a898d45c8039c4f6cf364681ce3402f0a684f38 ./src/api/automations.rs +2317b7cd0c2437c128ac4982d64412c8184ecb5e774825761d61aab6e45fae4a ./src/api/debug_tokens.rs +d048cc3bff5b1817a4155308c9bdc92644bbbf2bf6d75f3d531c51a26035276a ./src/api/devices.rs +417940b467c6f00bbf01cc47eb4479880dee9b2f284a80e3662a6d433dc11b92 ./src/api/events.rs +242ba0d2267ce009e90d3e593110a116c7ccfb755b676bab44067110f3db92e2 ./src/api/groups.rs a13d4e217fe3ddaa73873ba6e0d1bc93750cf61764d867ff7372274ed21d9599 ./src/api/history.rs -f312f8a91d7ce1e60b189e684632edbd68e5496723f49d07aef885699a9f60f3 ./src/api/house.rs +53cb0c85729961d3665840b278889444bc8e5008d3e671922ab41a30b05636dd ./src/api/house.rs 0d7d598f7ed24315ed0547a60a72a06f20ebcf926489e1c454986f42d22cc305 ./src/api/integrations.rs bb2a746ecdcc2da5fe54e18b455c7bd19453486dd5c008e71951a2c81d0e7d64 ./src/api/middleware.rs 2abb8d155b832bd7bde575b12ef37fbbf09c246255d9abd2adae0d56016ce170 ./src/api/public_settings.rs -eed70af84e9ff9f0ec1b2005836e9ab9aa6a4e73c5c876ec161e5d5c96965b26 ./src/api/schedules.rs -a4d0d6c6ffb7944d336a2cf749a27ee5ed966fd94ab29464ce641873783ce756 ./src/api/settings.rs +e47da65c53ac271ea1f3ed21eb939f6ff1d6e9d87271de194c6b05fa63bd26d8 ./src/api/schedules.rs +0f1c14e611db08e33b88e467fd7957b0b0479cd2ee25ea424696cb2039238b88 ./src/api/settings.rs 74c156717b37e83d329f7c52ac622e02f690621544c4eb07c4502194db55e6e9 ./src/api/system.rs 681cccb8d09f4ad9c2e6467a4dbe2f9d991125775a5d2bfc9043daf940fecd29 ./src/api/websocket.rs -29b6503aa80dd4ad4e444c4f25ca763adfb60c2de099afbb4fc1131e9d080cfd ./src/api/zones.rs +d1d6b49ab6051d6b16aa4648606781843824d41af1b727bf8b12b86b44ea4706 ./src/api/zones.rs 9040e8cb6647c76a875148b7591abcccfe4e2d4708ef462a1e5d25dd4ccac911 ./src/config.rs 369455b5ffb125f1a80b767f62fd8c84a953adb69548dbd8adb653a6fbaaa45b ./src/db.rs ae0f7635ba0523c1be980a28314d45445be1354444bcf74a6397da1d6d7c79df ./src/db/climate.rs @@ -69,22 +69,22 @@ f3bd91d0bdb7699f319b9341d928d3cc3b25f759653fcd2095a5c5f695d08fac ./src/db/event 35cbb521a8ec456756496b8aee71f9dfebad528a2cf2653ba8b48f6035be0c01 ./src/db/tests.rs a384ea4042d1d11692b5f8693df1f54850dcc4e80726780b44b2b096bf667b78 ./src/db/zone_history.rs 952ea5906d39ef917ad71edc345f76b29c07c63079644fd5699b12aa254cb35f ./src/engine.rs -2d9476fb5d3071b0f49c5844df93768f17f882b0bf05182763ab79628f50e63d ./src/engine/automations.rs +808a65705b31a0887873601c8066958d3b6dc6e876e5b85949ba9cea5a49ddff ./src/engine/automations.rs 832fe59c59a0ab1a0d949b656b194eab7aae578fa1eb30c6d55753d97a508c3b ./src/engine/commands.rs 3967cc60a108c71bd7e5d6a99c7caf32ed8f6cbbb63282a5a5d3357e7c07f933 ./src/engine/control_plan.rs 47acda24f31ec6af5ac94b00f52e232f0fdd443106abb9eeca7cc28d26b994bd ./src/engine/deadlines.rs -445c1f7503c6f01adefa10b5b4414431aa630b739678de7fe6161f3d938b4de2 ./src/engine/groups.rs +fabb590ab74c37d6d75b8bcae30b16900dcb6cd97df30937308f765ec5d36528 ./src/engine/groups.rs 18a7669318949e533f206d25cf7b63771d7f0f2c0b375a72ff78a2d921baaa61 ./src/engine/history.rs dad8930c3d811f02cfeced3aa4b9a404fe273ba1b99539bb174b9430575f3e22 ./src/engine/local_thermostat.rs -92b8c4a386fb883f93eb774f3e94545cdf6e48cc674ad7ba40a4c3821c454980 ./src/engine/ownership.rs -88b88dc1e7778bd65d04d58172cfc01b473687264742fd50896cb5eb5f2080f1 ./src/engine/polling.rs +2e91d677fa8bef74cd5a56c46b72b3d101b07629724d124b0bbfcc4e7bafaa30 ./src/engine/ownership.rs +9c37adf514c96840fd6a5c715c579350fa86d6dcc0655a2aa97d1fd7c7e04299 ./src/engine/polling.rs 3d48608a065aa52a1fd8b9c59356a74d8216ecee3d47f8ff8ef7d3d47170c617 ./src/engine/runtime.rs ffa72b93502eece187a96adb4ec94aec6048d4a6dc7af66e7c7af0931318787f ./src/engine/schedules.rs e0d5347f73066404f9df9d211e059fd10cf054466ae35bdad6533a862b5a816a ./src/engine/targets.rs a9188b588b2617ce1547a45d46141f7e5f1bb0027a48675dd86b274f807d0875 ./src/engine/temperature.rs -b453a8841494fd82d8fa4133dcd47dc59715a6a327375b5e13ffbaef431be700 ./src/engine/temporary_thermostat.rs -36038bf3f6d5c5ef3a146e99caacfbf20f7636872a8bdfe08866e3037db82f11 ./src/engine/tests.rs -c7e07dcf4419a4a64566620ba2006c5532a6d4576c90cb92855bca4c2deddcf6 ./src/engine/zone_actions.rs +fcd40789045b7cbf168880ff4a7fee12fb4afdc6fd376c8cca9c0cf63390e5a0 ./src/engine/temporary_thermostat.rs +5efbf22c557679fbf88c8c1ade75bc8a65070c92ee69bd23ce1e72f6c40a0b6a ./src/engine/tests.rs +8333115324fea27f52908a00766a56d31138fa2d63574c9ca20d9ea27977d059 ./src/engine/zone_actions.rs 9f95022773670a9c369bac90cf1514a13b289cbd7b497d8faf91d0fe6b33c353 ./src/engine/zone_control.rs 4b271b6fc365b1078c01d6178eb563841b2ecaed5d8639196f58e1312d2236fe ./src/error.rs c6ff66da9ad08506f839ec56a869ead3abc824b5311e0ea9244517d11f7f4207 ./src/home_assistant.rs @@ -92,7 +92,7 @@ c6ff66da9ad08506f839ec56a869ead3abc824b5311e0ea9244517d11f7f4207 ./src/home_ass feb50ecd60b9a9fe9de1f7d8e1e26caab5435d59347468f6266a318f431bc3d3 ./src/influxdb/codec.rs 553b44ec0321e62480059bc2451fd34ec7aabe9fc87fc717daba0c4b55679d53 ./src/influxdb/query.rs 5e4d94f8a010df05bae9acab72c518080d2dba94d054df95b87e492305357222 ./src/influxdb/write.rs -c67212da6a2bd5c2933ece31586ca4048b484fda3105f23a6e06e881b079d129 ./src/main.rs +4d45200b03d43d1869760b7cf3281776d268bbaa7860167ea2a533d2f4f12d3d ./src/main.rs e935e1fcf0d379b627df5d5055dbdad3dd55786fde7191376918b05afdde1727 ./src/models.rs fca124ec30b5c9286f0f44cc18e8953bb2809f5009d4d521deaebfdeb00c1bd1 ./src/models/automation.rs 9a100f778d1f71697dca7ae4db9991a1b7b2da4f26e006ce0612f98c5406acec ./src/models/control_plan.rs @@ -101,8 +101,8 @@ fca124ec30b5c9286f0f44cc18e8953bb2809f5009d4d521deaebfdeb00c1bd1 ./src/models/a cfbaa853a5185c4be572588577b313a2d618837f7c07a3860d0e8d2cca04ee60 ./src/models/history.rs 668c9cf157679f8c9fe72846f743629c79e263b14827110834b4addbdf9d13ec ./src/models/integrations.rs c19f243d709acb59db7bc4a91c02c538146fc5a7c014d90c53e8d8c62c4d6008 ./src/models/runtime.rs -0e0d47d88bb6b7b446fe9a89a69cf46dfe041f8f279eda2f5d8f91c296e19312 ./src/models/temporary_thermostat.rs -799113b1209cc40c99a463a49fc28aca4d49d10d3e8dc6861bb579f1e3f637d0 ./src/models/zone.rs +e77f8cdcb5fc0e51cdbf2527a69585a2f6f9089e159d1e45ec4a48735ab0e304 ./src/models/temporary_thermostat.rs +c78d7f10437c92d9fa9d6227e1de336dd22684c14fd20ee3b50dec28e23b56a7 ./src/models/zone.rs 9c735d7c475f21f2f388c21be784376f7184f0bf18af1886544ef51585a3b290 ./src/notifications.rs 7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs f1f765331469f4219a551414833c19b6de413114c22fdd0468e1f417659553c9 ./src/protocol/gree.rs @@ -123,27 +123,27 @@ cd5d0befe9f51a7067201a6eaa8d8ed72182d359e42cbdc67c80aa12d722654b ./src/queries/ 2b46a8cdc5efb5f930dd8ff7b9f6c0e720cbca096a51d9ae047711dd280fa3b8 ./src/queries/maintenance.rs f688db6f440ee7b0129ccc22838de0ee0e0cf2b6376173b05f853ca2555c2c1e ./src/queries/schema.rs f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs -2d69811db832c90ce06035ee29665205a04e4c514a21ecae9623991a9b14d825 ./src/state.rs +5a6d9134310c801a6378598b2057ac1369f8e7c0f42b3eedd8352bb3e5c222ec ./src/state.rs b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service de66cab9b650cb448814bfeda6d216e168dd3e6f07a1a438e4a9de9acbe6b798 ./web/404.html e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg -4df1b4c17f178d8edff61394da9195961f850db4d926d30ae44b04c3120e7a12 ./web/index.html +68fbf1630c68f28e94008e609d33ba2d5b4a922a021c7386714e49e913f289d2 ./web/index.html 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md b3d5cb473bf9771de78e14c7de730cb86c73f8cdbb6344da67ba7d6abac879ea ./web/js/bootstrap.js 64a43e2142a69ebddf4160c0d0d3b573fa379c650431090fd15c9ba3b62bc4e7 ./web/js/charts.js -0f743533728e3be60a30cb7955afc8dcefc13b68f967538e3a6ffbe76cfa601c ./web/js/core.js +c14eb49105d83d02f4ca77ee68044ee18304e3a074fb69c3124cf00b45d75c69 ./web/js/core.js 6e421388c7f6deb2aa42f1205dfdc52839f9f178fbd672129972528b8517a870 ./web/js/dashboard.js -04247ee5a716376dcb959f6f9d2005b0b058da7bacca2cd7721bccfe0123b07e ./web/js/entities.js -668b52056d6e083b6adaa72e483cf70eca680144d1f821ca6485b601e340b4aa ./web/js/events.js +e8f1d1966acad8864af001e92e02044c629692f42ab26df700d962b010f4090b ./web/js/entities.js +44957aa415047f7cce6f55cf4f1f8ea1439159a27512e8426792d4192074e89a ./web/js/events.js 2fe6c804f86b0613a50a56c54f7a0cbee77273759edf3e6fec36ac77d35edbd7 ./web/js/forms.js be07a341ba4b1f514a5e13553494194b22aa87108cb04de40635a0158921780b ./web/js/history.js 6d4a9f17ff241eaf44e8d2260163d9226fca3c8d6e1a287d31a2c96b976532f9 ./web/js/main.js -963282960efbaf4f52954f828304ed1f452a8b661c500a6fe7c230c0ff3b210a ./web/js/navigation.js +900f1b083bfcef7782a35d1e708d1cd9fd975d89c03d828290400a85b1006826 ./web/js/navigation.js d375868ded6ffcd6b18d4c661bb2051e7cc5b136f95d4d68355cdd6e0caaf278 ./web/js/realtime.js 45a666dbc1cddcc0ece507130cde7719f6232c710b4288d6a16e1232791f2a3d ./web/js/router.js 28ff1324f4495b7bcd431eb94f51012c1b1e558f8acb931ac35b71b48374c200 ./web/js/settings-ui.js 9d44596929f67400f5f09d82330af4452914f2b4729eadcd4f695672d2cd849c ./web/js/settings.js fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest -98fe010f13bec1f408bcd1a0015e4d866ae2a966812d6a5e76ec47fbf8b0bdfd ./web/styles.css +a27f36816edbe82780cac97683ed574cbd4e5e8f782df3eadf930dfbdbd25fd5 ./web/styles.css 03ac3a5918539904ae829ee24999d8d59efb41bdf6c0e30f76bab4cee98c3130 ./web/sw.js d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js diff --git a/README.md b/README.md index 054d025..a5e4a50 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 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.8.17** +**Current release: 0.8.19** > [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples. @@ -28,7 +28,7 @@ The interface is intentionally split by responsibility: | Dashboard | Whole-house summary and Quick Thermostat controls. | | Devices | Discovery, binding and direct/technical GREE device control. | | Zones | Thermostat configuration, profiles, sensors and control ownership. | -| Groups | Climate group membership and group power/mode/preset control. | +| Groups | Climate group membership and group power/mode/preset/custom-temperature control. | | Schedules | Weekly thermostat schedules and ready-made templates. | | Automations | Time/temperature triggered device or group actions. | | History | Overview, zones, devices and HA sensors with precise chart tooltips and zoom. | diff --git a/docs/API.md b/docs/API.md index 6c51455..09b3e5c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,6 +1,6 @@ # GREE Controller API reference -HTTP and WebSocket API for GREE Controller **0.8.17**. +HTTP and WebSocket API for GREE Controller **0.8.19**. [← Main documentation](../README.md) @@ -137,7 +137,7 @@ Common statuses: | GET | `/api/groups/{id}` | Read a group. | | PUT | `/api/groups/{id}` | Replace group definition. | | DELETE | `/api/groups/{id}` | Delete a group. | -| POST | `/api/groups/{id}/control` | Group power/mode/preset control. | +| POST | `/api/groups/{id}/control` | Group power/mode/preset/custom-temperature control. | | POST | `/api/house/control` | Set global thermostat mode. | | POST | `/api/house/power` | Set whole-house master power. | | POST | `/api/house/preset` | Set/clear whole-house preset override. | @@ -212,7 +212,7 @@ Response: { "status": "ok", "name": "gree-controller", - "version": "0.8.17", + "version": "0.8.19", "uptime_seconds": 1234, "control_ready": true, "time": "2026-08-30T06:54:00Z" @@ -236,7 +236,7 @@ Returns the initial Web UI snapshot: "settings": {}, "outdoor_temperature": null, "system": { - "version": "0.8.17", + "version": "0.8.19", "uptime_seconds": 1234, "auth_required": false, "control_ready": true, @@ -655,7 +655,8 @@ Read, replace or delete a group. `DELETE` returns `204`. { "power": true, "mode": "house", - "preset": "comfort" + "preset": "custom", + "setpoint": 22.3 } ``` @@ -663,9 +664,10 @@ All fields optional: - `power`: group gate, - `mode`: `house`, `cool`, `heat`, -- `preset`: `auto`, `comfort`, `sleep`, `away`. +- `preset`: `auto`, `comfort`, `sleep`, `away`, `custom`, +- `setpoint`: custom group target in the `8–30°C` range; requires `preset: "custom"`. -Returns a group control/result object including updated members/state. +A custom setpoint creates the same temporary manual override for every member zone and normally expires at that zone's next schedule boundary. Returns a group control/result object including updated members/state. --- diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index 11d0b17..86e1a1d 100644 --- a/home-assistant/custom_components/gree_controller/manifest.json +++ b/home-assistant/custom_components/gree_controller/manifest.json @@ -1,7 +1,7 @@ { "domain": "gree_controller", "name": "GREE Controller", - "version": "0.8.17", + "version": "0.8.19", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/lang/en.json b/lang/en.json index e775f53..1d4cbb7 100644 --- a/lang/en.json +++ b/lang/en.json @@ -559,10 +559,10 @@ "nav.groups": "Groups", "groups.eyebrow": "Group control", "groups.dashboardTitle": "Selected air conditioners", - "groups.dashboardHint": "Control each group independently with its own heating/cooling mode and Auto/Comfort/Sleep/Away profile.", + "groups.dashboardHint": "Control each group independently: choose a mode, profile, or set one custom temperature for the whole group.", "groups.manage": "Manage groups", "groups.new": "New group", - "groups.description": "Combine thermostat zones, for example upstairs and downstairs, and control only that set. One group can use Sleep + Cooling while another uses Comfort + Heating.", + "groups.description": "Combine thermostat zones, for example upstairs and downstairs, and control only that set. A group can use a profile or one custom temperature for all member zones.", "groups.group": "Group", "groups.namePlaceholder": "Upstairs", "groups.members": "Group members", @@ -759,6 +759,11 @@ "debug.emptyRequests": "No HTTP requests captured yet.", "debug.emptyGree": "No GREE frames captured yet.", "simulation.bannerTitle": "SIMULATION MODE", - "simulation.bannerText": "Simulation mode is enabled. Treat current states and results as test data." + "simulation.bannerText": "Simulation mode is enabled. Treat current states and results as test data.", + "zones.separateHysteresisHint": "Enable to use independent hysteresis values for heating and cooling.", + "groups.customTemperature": "Custom temperature", + "groups.customTemperatureHint": "Enter the target temperature for every zone in this group.", + "groups.applyCustomTemperatureHint": "Set this temperature as the manual target for the whole group.", + "groups.customTemperatureRange": "Group temperature must be between 8 and 30°C." } } diff --git a/lang/pl.json b/lang/pl.json index 0f7cdb9..a9adee8 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -559,10 +559,10 @@ "nav.groups": "Grupy", "groups.eyebrow": "Sterowanie grupami", "groups.dashboardTitle": "Wybrane klimatyzatory", - "groups.dashboardHint": "Steruj każdą grupą niezależnie: własny tryb grzania/chłodzenia oraz profil Auto/Komfort/Sen/Poza domem.", + "groups.dashboardHint": "Steruj każdą grupą niezależnie: wybierz tryb, profil albo ustaw własną temperaturę dla całej grupy.", "groups.manage": "Zarządzaj grupami", "groups.new": "Nowa grupa", - "groups.description": "Połącz strefy termostatów, np. góra i dół, i steruj tylko wybranym zestawem. Jedna grupa może mieć Sen + Chłodzenie, a druga Komfort + Grzanie.", + "groups.description": "Połącz strefy termostatów, np. góra i dół, i steruj tylko wybranym zestawem. Grupa może używać profilu lub jednej własnej temperatury dla wszystkich stref.", "groups.group": "Grupa", "groups.namePlaceholder": "Góra", "groups.members": "Członkowie grupy", @@ -759,6 +759,11 @@ "debug.emptyRequests": "Brak przechwyconych requestów HTTP.", "debug.emptyGree": "Brak przechwyconych ramek GREE.", "simulation.bannerTitle": "TRYB SYMULACJI", - "simulation.bannerText": "Tryb symulacji jest włączony. Traktuj bieżące stany i wyniki jako testowe." + "simulation.bannerText": "Tryb symulacji jest włączony. Traktuj bieżące stany i wyniki jako testowe.", + "zones.separateHysteresisHint": "Włącz, aby używać niezależnej histerezy dla grzania i chłodzenia.", + "groups.customTemperature": "Własna temperatura", + "groups.customTemperatureHint": "Wpisz temperaturę docelową dla wszystkich stref w tej grupie.", + "groups.applyCustomTemperatureHint": "Ustaw tę temperaturę jako ręczny cel dla całej grupy.", + "groups.customTemperatureRange": "Temperatura grupy musi mieścić się w zakresie 8–30°C." } } diff --git a/src/api/automations.rs b/src/api/automations.rs index d1c8403..237adb7 100644 --- a/src/api/automations.rs +++ b/src/api/automations.rs @@ -112,7 +112,13 @@ async fn get_automation(State(state): State, Path(id): Path) - state.db.get_automation(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("automation {id}"))) } async fn create_automation(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), AppError> { + let _configuration_guard = state.lock_configuration_operation().await; + let _automation_guard = state.lock_automation_operation().await; input.validate()?; + let action_group_id = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()).map(str::to_string); + let _group_guard = if let Some(group_id) = action_group_id.as_deref() { + Some(state.lock_group_operation(group_id).await) + } else { None }; validate_automation_references(&state, &input)?; let item = input.into_automation(Uuid::new_v4().to_string(), Utc::now(), None); state.db.save_automation(&item)?; @@ -120,8 +126,14 @@ async fn create_automation(State(state): State, Json(input): Json, Path(id): Path, Json(input): Json) -> Result, AppError> { + let _configuration_guard = state.lock_configuration_operation().await; + let _automation_guard = state.lock_automation_operation().await; input.validate()?; let existing = state.db.get_automation(&id)?.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?; + let action_group_id = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()).map(str::to_string); + let _group_guard = if let Some(group_id) = action_group_id.as_deref() { + Some(state.lock_group_operation(group_id).await) + } else { None }; validate_automation_references(&state, &input)?; let item = input.into_automation(id, existing.created_at, existing.last_fired_at); state.db.save_automation(&item)?; @@ -129,6 +141,8 @@ async fn update_automation(State(state): State, Path(id): Path Ok(Json(item)) } async fn delete_automation(State(state): State, Path(id): Path) -> Result { + let _configuration_guard = state.lock_configuration_operation().await; + let _automation_guard = state.lock_automation_operation().await; if !state.db.delete_automation(&id)? { return Err(AppError::NotFound(format!("automation {id}"))); } state.broadcast("automation.deleted", json!({"id": id})); Ok(StatusCode::NO_CONTENT) diff --git a/src/api/debug_tokens.rs b/src/api/debug_tokens.rs index 7178b75..1de0a21 100644 --- a/src/api/debug_tokens.rs +++ b/src/api/debug_tokens.rs @@ -3,6 +3,8 @@ async fn get_debug(State(state): State) -> Json { } async fn update_debug(State(state): State, Json(input): Json) -> Result, AppError> { + let _configuration_guard = state.lock_configuration_operation().await; + let _house_guard = state.lock_house_operation().await; let mut settings = state.settings.write().await; settings.debug = input.clone(); state.db.save_runtime_settings(&settings)?; diff --git a/src/api/devices.rs b/src/api/devices.rs index 9b8630c..5e90095 100644 --- a/src/api/devices.rs +++ b/src/api/devices.rs @@ -1,4 +1,5 @@ async fn discover(State(state): State, Json(request): Json) -> Result, AppError> { + let _configuration_guard = state.lock_configuration_operation().await; let settings = state.settings.read().await.clone(); let timeout_ms = request.timeout_ms.unwrap_or(settings.discovery_timeout_ms).clamp(500, 30_000); let broadcast = request.broadcast.unwrap_or(settings.discovery_broadcast); @@ -50,6 +51,7 @@ async fn list_devices(State(state): State) -> Result> } async fn add_device(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), AppError> { + let _configuration_guard = state.lock_configuration_operation().await; if input.name.trim().is_empty() || input.mac.trim().is_empty() || input.ip.trim().is_empty() { return Err(AppError::BadRequest("name, mac and ip are required".into())); } @@ -114,6 +116,7 @@ async fn get_device(State(state): State, Path(id): Path) -> Re } async fn patch_device(State(state): State, Path(id): Path, Json(patch): Json) -> Result, AppError> { + let _configuration_guard = state.lock_configuration_operation().await; if patch.enabled == Some(false) { engine::disable_device_safely(&state, &id).await?; } @@ -145,6 +148,12 @@ async fn patch_device(State(state): State, Path(id): Path, Jso } async fn delete_device(State(state): State, Path(id): Path) -> Result { + let _configuration_guard = state.lock_configuration_operation().await; + // Keep reference validation and the destructive DB operation in one serialized window. + // Lock order for cross-resource destructive operations: configuration -> automation -> house -> schedule -> zones -> device. + let _automation_guard = state.lock_automation_operation().await; + let _house_guard = state.lock_house_operation().await; + let _schedule_guard = state.lock_schedule_operation().await; if state.db.get_device(&id)?.is_none() { return Err(AppError::NotFound(format!("device {id}"))); } if state.db.list_automations()?.iter().any(|item| { item.trigger_device_id.as_deref() == Some(id.as_str()) @@ -156,16 +165,24 @@ async fn delete_device(State(state): State, Path(id): Path) -> .filter(|zone| zone.device_id == id) .map(|zone| zone.id) .collect(); + let mut sorted_zone_ids: Vec = removed_zone_ids.iter().cloned().collect(); + sorted_zone_ids.sort(); + let mut zone_guards = Vec::with_capacity(sorted_zone_ids.len()); + for zone_id in &sorted_zone_ids { + zone_guards.push(state.lock_zone_operation(zone_id).await); + } ensure_zone_removal_safe(&state, &removed_zone_ids)?; ensure_device_stopped_for_detach(&state, &id, "device.deleted").await?; if !state.db.delete_device(&id)? { return Err(AppError::NotFound(format!("device {id}"))); } - remove_zone_ids_from_groups(&state, &removed_zone_ids)?; + drop(zone_guards); + remove_zone_ids_from_groups_locked(&state, &removed_zone_ids).await?; state.log("info", "device.deleted", "Device deleted", json!({"device_id": id})); state.broadcast("device.deleted", json!({"id": id})); Ok(StatusCode::NO_CONTENT) } async fn bind_device(State(state): State, Path(id): Path) -> Result, AppError> { + let _configuration_guard = state.lock_configuration_operation().await; let _device_guard = state.lock_device_operation(&id).await; let mut device = state.db.get_device(&id)?.ok_or_else(|| AppError::NotFound(format!("device {id}")))?; if device.simulated { return Ok(Json(device)); } diff --git a/src/api/events.rs b/src/api/events.rs index 5eadafd..c7dbba0 100644 --- a/src/api/events.rs +++ b/src/api/events.rs @@ -13,6 +13,8 @@ async fn get_event_retention(State(state): State) -> Json { } async fn update_event_retention(State(state): State, Json(input): Json) -> Result, AppError> { + let _configuration_guard = state.lock_configuration_operation().await; + let _house_guard = state.lock_house_operation().await; let mut settings = state.settings.write().await; settings.event_log_retention_days = input.days.clamp(1, 3650); state.db.save_runtime_settings(&settings)?; diff --git a/src/api/groups.rs b/src/api/groups.rs index 3988c50..c9d4dac 100644 --- a/src/api/groups.rs +++ b/src/api/groups.rs @@ -42,6 +42,9 @@ async fn get_group(State(state): State, Path(id): Path) -> Res } async fn create_group(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), AppError> { + let _configuration_guard = state.lock_configuration_operation().await; + let _reference_guard = state.lock_automation_operation().await; + let _house_guard = state.lock_house_operation().await; let zone_ids = validate_group_input(&state, &input)?; let now = Utc::now(); let group = ClimateGroup { @@ -59,6 +62,12 @@ async fn create_group(State(state): State, Json(input): Json, Path(id): Path, Json(input): Json) -> Result, AppError> { + let _configuration_guard = state.lock_configuration_operation().await; + // Membership changes alter the target set of group automations, so serialize them with + // automation execution/reference validation before taking the group lock. + let _automation_guard = state.lock_automation_operation().await; + let _house_guard = state.lock_house_operation().await; + let _group_guard = state.lock_group_operation(&id).await; let existing = state.db.get_group(&id)?.ok_or_else(|| AppError::NotFound(format!("group {id}")))?; let zone_ids = validate_group_input(&state, &input)?; let group = ClimateGroup { @@ -76,6 +85,10 @@ async fn update_group(State(state): State, Path(id): Path, Jso } async fn delete_group(State(state): State, Path(id): Path) -> Result { + let _configuration_guard = state.lock_configuration_operation().await; + let _automation_guard = state.lock_automation_operation().await; + let _house_guard = state.lock_house_operation().await; + let _group_guard = state.lock_group_operation(&id).await; if state.db.list_automations()?.iter().any(|item| item.action_group_id.as_deref() == Some(id.as_str())) { return Err(AppError::BadRequest("group is used by an automation; remove or retarget that automation first".into())); } @@ -99,9 +112,14 @@ fn ensure_zone_removal_safe(state: &AppState, zone_ids: &std::collections::HashS Ok(()) } -fn remove_zone_ids_from_groups(state: &AppState, zone_ids: &std::collections::HashSet) -> Result<(), AppError> { +async fn remove_zone_ids_from_groups_locked(state: &AppState, zone_ids: &std::collections::HashSet) -> Result<(), AppError> { if zone_ids.is_empty() { return Ok(()); } - for mut group in state.db.list_groups()? { + let mut group_ids: Vec = state.db.list_groups()?.into_iter().map(|group| group.id).collect(); + group_ids.sort(); + group_ids.dedup(); + for group_id in group_ids { + let _group_guard = state.lock_group_operation(&group_id).await; + let Some(mut group) = state.db.get_group(&group_id)? else { continue; }; let before = group.zone_ids.len(); group.zone_ids.retain(|zone_id| !zone_ids.contains(zone_id)); if group.zone_ids.len() == before { continue; } @@ -141,7 +159,7 @@ fn home_assistant_group_preset(zones: &[&Zone]) -> String { let mut value: Option<&str> = None; for zone in zones { let current = zone.manual_preset.as_deref().unwrap_or("auto"); - if !matches!(current, "auto" | "comfort" | "sleep" | "away") { + if !matches!(current, "auto" | "comfort" | "sleep" | "away" | "custom") { return "mixed".into(); } if let Some(previous) = value { @@ -153,6 +171,21 @@ fn home_assistant_group_preset(zones: &[&Zone]) -> String { value.unwrap_or("mixed").to_string() } + +fn home_assistant_group_custom_setpoint(zones: &[&Zone]) -> Option { + let mut value: Option = None; + for zone in zones { + if zone.manual_preset.as_deref() != Some("custom") { return None; } + let current = zone.manual_setpoint.or(zone.effective_setpoint)?; + if let Some(previous) = value { + if (previous - current).abs() > 0.05 { return None; } + } else { + value = Some(current); + } + } + value +} + async fn list_home_assistant_groups(State(state): State) -> Result>, AppError> { let groups = state.db.list_groups()?; let zones = state.db.list_zones()?; @@ -223,6 +256,7 @@ async fn list_home_assistant_groups(State(state): State) -> Result Result<(), AppError> { +async fn set_all_groups_power(state: &AppState, power: bool) -> Result<(), AppError> { + let mut group_ids: Vec = state.db.list_groups()?.into_iter().map(|group| group.id).collect(); + group_ids.sort(); + group_ids.dedup(); + let mut _group_guards = Vec::with_capacity(group_ids.len()); + for group_id in &group_ids { + _group_guards.push(state.lock_group_operation(group_id).await); + } for mut group in state.db.list_groups()? { if group.power_enabled == power { continue; } group.power_enabled = power; @@ -72,6 +79,7 @@ async fn command_all_enabled_devices_power(state: &AppState, power: bool, source } async fn update_house_control(State(state): State, Json(input): Json) -> Result, AppError> { + let _house_guard = state.lock_house_operation().await; if !matches!(input.mode.as_str(), "cool" | "heat" | "off") { return Err(AppError::BadRequest("house mode must be cool, heat or off".into())); } @@ -89,7 +97,7 @@ async fn update_house_control(State(state): State, Json(input): Json, Json(input): Json, Json(input): Json) -> Result, AppError> { + let _house_guard = state.lock_house_operation().await; // Whole-house power is independent from the thermostat mode. Publish/persist the master // first so the regulator becomes passive before the one-shot OFF cascade starts. { @@ -117,10 +126,27 @@ async fn update_house_power(State(state): State, Json(input): Json = zone_snapshot.iter().map(|zone| zone.id.clone()).collect(); + zone_ids.sort(); + zone_ids.dedup(); + let mut zone_guards = Vec::with_capacity(zone_ids.len()); + for zone_id in &zone_ids { + zone_guards.push(state.lock_zone_operation(zone_id).await); + } + let mut device_ids: Vec = state.db.list_zones()?.into_iter().map(|zone| zone.device_id).collect(); + device_ids.sort(); + device_ids.dedup(); + let mut device_guards = Vec::with_capacity(device_ids.len()); + for device_id in &device_ids { + device_guards.push(state.lock_device_operation(device_id).await); + } engine::clear_all_device_manual_overrides(&state, "house_power_off")?; clear_all_local_thermostat_overrides(&state)?; + drop(device_guards); + drop(zone_guards); } let failed = if input.power { state.wake_zone_control(); @@ -151,6 +177,7 @@ async fn update_house_power(State(state): State, Json(input): Json, Json(input): Json) -> Result, AppError> { + let _house_guard = state.lock_house_operation().await; if !matches!(input.preset.as_str(), "auto" | "comfort" | "sleep" | "away") { return Err(AppError::BadRequest("house preset must be auto, comfort, sleep or away".into())); } @@ -164,14 +191,25 @@ async fn update_house_preset(State(state): State, Json(input): Json = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect(); + zone_ids.sort(); + zone_ids.dedup(); + let mut _zone_guards = Vec::with_capacity(zone_ids.len()); + for zone_id in &zone_ids { + _zone_guards.push(state.lock_zone_operation(zone_id).await); + } + let mut zones = Vec::with_capacity(zone_ids.len()); + for zone_id in &zone_ids { + let Some(zone_snapshot) = state.db.get_zone(zone_id)? else { continue; }; + let _device_guard = state.lock_device_operation(&zone_snapshot.device_id).await; + let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; }; + if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { if let Some(session) = zone.temporary_quick_thermostat.as_mut() { session.deferred_preset = Some(input.preset.clone()); + session.deferred_setpoint = None; } } else if input.preset == "auto" { zone.manual_preset = None; @@ -183,8 +221,9 @@ async fn update_house_preset(State(state): State, Json(input): Json, Json(input): Json, Path(id): Path, Json(input): Json) -> Result, AppError> { + let _configuration_guard = state.lock_configuration_operation().await; + let _schedule_guard = state.lock_schedule_operation().await; let zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; let mut items: Vec = Vec::new(); let mut add = |name: &str, days: Vec, start: &str, end: &str, preset: &str| { @@ -251,7 +292,7 @@ async fn apply_schedule_template(State(state): State, Path(id): Path, Path( } async fn delete_zone(State(state): State, Path(id): Path) -> Result { + let _configuration_guard = state.lock_configuration_operation().await; + let _automation_guard = state.lock_automation_operation().await; + let _house_guard = state.lock_house_operation().await; + let _schedule_guard = state.lock_schedule_operation().await; + let zone_guard = state.lock_zone_operation(&id).await; let zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; let mut removed = std::collections::HashSet::new(); removed.insert(id.clone()); ensure_zone_removal_safe(&state, &removed)?; ensure_device_stopped_for_detach(&state, &zone.device_id, "zone.deleted").await?; if !state.db.delete_zone(&id)? { return Err(AppError::NotFound(format!("zone {id}"))); } - remove_zone_ids_from_groups(&state, &removed)?; + // Group control locks group first and zone second. Release the zone lock before taking + // group locks so deletion cannot form the inverse zone -> group lock order. + drop(zone_guard); + remove_zone_ids_from_groups_locked(&state, &removed).await?; state.broadcast("zone.deleted", json!({"id": id})); Ok(StatusCode::NO_CONTENT) } diff --git a/src/api/schedules.rs b/src/api/schedules.rs index 2207e8d..760b2ab 100644 --- a/src/api/schedules.rs +++ b/src/api/schedules.rs @@ -49,7 +49,14 @@ fn validate_schedule_conflicts(state: &AppState, item: &Schedule, exclude_id: Op Ok(()) } -fn refresh_zone_override_boundary(state: &AppState, zone_id: &str) -> Result<(), AppError> { +async fn refresh_zone_override_boundary(state: &AppState, zone_id: &str) -> Result<(), AppError> { + let _zone_guard = state.lock_zone_operation(zone_id).await; + let device_id = state.db.get_zone(zone_id)?.map(|zone| zone.device_id); + let _device_guard = if let Some(device_id) = device_id.as_deref() { + Some(state.lock_device_operation(device_id).await) + } else { + None + }; let Some(mut zone) = state.db.get_zone(zone_id)? else { return Ok(()); }; let has_temporary_schedule_boundary = zone.temporary_quick_thermostat.as_ref() .map(|session| session.finish_kind == "schedule_boundary") @@ -87,17 +94,21 @@ async fn get_schedule(State(state): State, Path(id): Path) -> state.db.get_schedule(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("schedule {id}"))) } async fn create_schedule(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), AppError> { + let _configuration_guard = state.lock_configuration_operation().await; + let _schedule_guard = state.lock_schedule_operation().await; input.validate()?; if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); } let item = input.into_schedule(Uuid::new_v4().to_string(), Utc::now()); validate_schedule_conflicts(&state, &item, None)?; state.db.save_schedule(&item)?; - refresh_zone_override_boundary(&state, &item.zone_id)?; + refresh_zone_override_boundary(&state, &item.zone_id).await?; state.broadcast("schedule.created", serde_json::to_value(&item)?); state.wake_zone_control(); Ok((StatusCode::CREATED, Json(item))) } async fn update_schedule(State(state): State, Path(id): Path, Json(input): Json) -> Result, AppError> { + let _configuration_guard = state.lock_configuration_operation().await; + let _schedule_guard = state.lock_schedule_operation().await; input.validate()?; let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?; if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); } @@ -105,16 +116,18 @@ async fn update_schedule(State(state): State, Path(id): Path, let item = input.into_schedule(id.clone(), existing.created_at); validate_schedule_conflicts(&state, &item, Some(&id))?; state.db.save_schedule(&item)?; - refresh_zone_override_boundary(&state, &old_zone_id)?; - if item.zone_id != old_zone_id { refresh_zone_override_boundary(&state, &item.zone_id)?; } + refresh_zone_override_boundary(&state, &old_zone_id).await?; + if item.zone_id != old_zone_id { refresh_zone_override_boundary(&state, &item.zone_id).await?; } state.broadcast("schedule.updated", serde_json::to_value(&item)?); state.wake_zone_control(); Ok(Json(item)) } async fn delete_schedule(State(state): State, Path(id): Path) -> Result { + let _configuration_guard = state.lock_configuration_operation().await; + let _schedule_guard = state.lock_schedule_operation().await; let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?; if !state.db.delete_schedule(&id)? { return Err(AppError::NotFound(format!("schedule {id}"))); } - refresh_zone_override_boundary(&state, &existing.zone_id)?; + refresh_zone_override_boundary(&state, &existing.zone_id).await?; state.broadcast("schedule.deleted", json!({"id": id})); state.wake_zone_control(); Ok(StatusCode::NO_CONTENT) diff --git a/src/api/settings.rs b/src/api/settings.rs index 0eddf9b..ad2a7ef 100644 --- a/src/api/settings.rs +++ b/src/api/settings.rs @@ -4,6 +4,8 @@ async fn get_settings(State(state): State) -> Json { } async fn update_settings(State(state): State, Json(mut input): Json) -> Result, AppError> { + let _configuration_guard = state.lock_configuration_operation().await; + let _house_guard = state.lock_house_operation().await; let old = state.settings.read().await.clone(); if input.house_power_enabled != old.house_power_enabled || input.house_mode != old.house_mode { return Err(AppError::BadRequest( @@ -46,7 +48,7 @@ async fn update_settings(State(state): State, Json(mut input): Json Result<(), AppError> { - for mut zone in state.db.list_zones()? { +async fn canonicalize_saved_zone_entities(state: &AppState, settings: &RuntimeSettings) -> Result<(), AppError> { + let mut zone_ids: Vec = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect(); + zone_ids.sort(); + zone_ids.dedup(); + for zone_id in zone_ids { + let _zone_guard = state.lock_zone_operation(&zone_id).await; + let Some(snapshot) = state.db.get_zone(&zone_id)? else { continue; }; + let _device_guard = state.lock_device_operation(&snapshot.device_id).await; + let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; }; let previous = zone.ha_entity_id.clone(); canonicalize_zone_ha_entity(&mut zone, settings); if zone.ha_entity_id != previous { @@ -314,6 +323,36 @@ async fn import_settings(State(state): State, Json(mut export): Json automation -> house -> schedule -> zones -> devices. + let _configuration_guard = state.lock_configuration_operation().await; + let _automation_guard = state.lock_automation_operation().await; + let _house_guard = state.lock_house_operation().await; + let _schedule_guard = state.lock_schedule_operation().await; + + let current_zones = state.db.list_zones()?; + let current_devices = state.db.list_devices()?; + let mut locked_zone_ids: Vec = current_zones.iter().map(|zone| zone.id.clone()) + .chain(export.zones.iter().map(|zone| zone.id.clone())) + .collect(); + locked_zone_ids.sort(); + locked_zone_ids.dedup(); + let mut _zone_guards = Vec::with_capacity(locked_zone_ids.len()); + for zone_id in &locked_zone_ids { + _zone_guards.push(state.lock_zone_operation(zone_id).await); + } + + let mut locked_device_ids: Vec = current_devices.iter().map(|device| device.id.clone()) + .chain(export.devices.iter().map(|device| device.id.clone())) + .collect(); + locked_device_ids.sort(); + locked_device_ids.dedup(); + let mut _device_guards = Vec::with_capacity(locked_device_ids.len()); + for device_id in &locked_device_ids { + _device_guards.push(state.lock_device_operation(device_id).await); + } + // Before replacing ownership, safely stop every currently managed device whose zone is // removed or rewired by the imported configuration. Otherwise an orphaned physical unit // could keep running after its database owner disappears. @@ -321,13 +360,20 @@ async fn import_settings(State(state): State, Json(mut export): Json, Json(mut export): Json, Json(mut export): Json, Path(id): Path) -> Resu state.db.get_zone(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("zone {id}"))) } async fn create_zone(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), AppError> { + let _configuration_guard = state.lock_configuration_operation().await; + let _reference_guard = state.lock_automation_operation().await; input.validate()?; if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); } validate_zone_device_assignment(&state, &input.device_id, None)?; + // Creating thermostat ownership must not overlap a poll of the device. Otherwise a poll + // that started before the zone existed could apply its old physical-control snapshot to + // the newly created zone without participating in the zone operation lock. + let _device_guard = state.lock_device_operation(&input.device_id).await; let mut zone = input.into_zone(Uuid::new_v4().to_string(), Utc::now()); let settings = state.settings.read().await.clone(); canonicalize_zone_ha_entity(&mut zone, &settings); @@ -139,6 +145,8 @@ async fn create_zone(State(state): State, Json(input): Json Ok((StatusCode::CREATED, Json(zone))) } async fn update_zone(State(state): State, Path(id): Path, Json(input): Json) -> Result, AppError> { + let _configuration_guard = state.lock_configuration_operation().await; + let _reference_guard = state.lock_automation_operation().await; input.validate()?; let _zone_guard = state.lock_zone_operation(&id).await; let mut existing = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; @@ -150,10 +158,16 @@ async fn update_zone(State(state): State, Path(id): Path, Json if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); } validate_zone_device_assignment(&state, &input.device_id, Some(&id))?; let device_changed = existing.device_id != input.device_id; - // Serialize a normal zone edit with polling/manual-takeover detection for its device. - // Device reassignment uses ensure_device_stopped_for_detach below, which acquires the - // old device lock itself while this zone lock is held. - let _device_guard = if !device_changed { Some(state.lock_device_operation(&existing.device_id).await) } else { None }; + // Keep the zone lock while taking all involved device locks in stable order. This makes a + // reassignment atomic against polling of both the old and the new unit and preserves the + // global zone -> device ordering used by live control paths. + let mut locked_device_ids = vec![existing.device_id.clone(), input.device_id.clone()]; + locked_device_ids.sort(); + locked_device_ids.dedup(); + let mut device_guards = Vec::with_capacity(locked_device_ids.len()); + for device_id in &locked_device_ids { + device_guards.push(state.lock_device_operation(device_id).await); + } if !device_changed { // Polling may have updated takeover/runtime state while we were waiting for the // device lock. Re-read under both locks before building the replacement Zone. @@ -203,7 +217,7 @@ async fn update_zone(State(state): State, Path(id): Path, Json } else { // A new physical unit starts with a clean ownership/runtime state. Never transfer // demand, sensor cache or remote-control takeover from the previous device. - ensure_device_stopped_for_detach(&state, &existing.device_id, "zone.device_reassigned").await?; + ensure_device_stopped_for_detach_locked(&state, &existing.device_id, "zone.device_reassigned").await?; zone.revision = existing.revision.saturating_add(1); } let settings = state.settings.read().await.clone(); @@ -223,7 +237,7 @@ async fn update_zone(State(state): State, Path(id): Path, Json } state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); - drop(_device_guard); + drop(device_guards); if power_off_device { power_off_zone_device(&state, &zone, "zone.disabled").await; } @@ -473,6 +487,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl } else { None }, deferred_mode: existing_session.as_ref().and_then(|session| session.deferred_mode.clone()), deferred_preset: existing_session.as_ref().and_then(|session| session.deferred_preset.clone()), + deferred_setpoint: existing_session.as_ref().and_then(|session| session.deferred_setpoint), safety_expires_at: if immediate_activation && is_temperature_condition { safety_duration_seconds.map(|seconds| now.clone() + ChronoDuration::seconds(seconds as i64)) } else { safety_expires_at }, @@ -546,6 +561,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { if let Some(session) = zone.temporary_quick_thermostat.as_mut() { session.deferred_preset = Some(value.to_string()); + if value != "custom" { session.deferred_setpoint = None; } } } else { match value { @@ -571,6 +587,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { if let Some(session) = zone.temporary_quick_thermostat.as_mut() { session.deferred_preset = Some("auto".into()); + session.deferred_setpoint = None; } } else { zone.manual_preset = None; @@ -639,20 +656,25 @@ async fn update_zone_control(State(state): State, Path(id): Path Result<(), AppError> { +async fn ensure_device_stopped_for_detach_locked(state: &AppState, device_id: &str, source: &str) -> Result<(), AppError> { let Some(device) = state.db.get_device(device_id)? else { return Ok(()); }; if !device.enabled { return Err(AppError::BadRequest("cannot safely detach a technically disabled device; enable it so the controller can confirm it is powered off first".into())); } // Force one OFF transition even when the cached state already says OFF. A remote change // may not have been polled yet and detaching must not leave a running unit without owner. - engine::force_power_off_device(state, device_id).await?; + engine::force_power_off_device_locked(state, device_id).await?; state.log("info", "zone.detach_power_off", &format!("Powered off {} before detaching thermostat ownership", device.name), json!({ "device_id": device.id, "source": source })); Ok(()) } +async fn ensure_device_stopped_for_detach(state: &AppState, device_id: &str, source: &str) -> Result<(), AppError> { + let _device_guard = state.lock_device_operation(device_id).await; + ensure_device_stopped_for_detach_locked(state, device_id, source).await +} + async fn power_off_zone_device(state: &AppState, zone: &Zone, source: &str) { let Ok(Some(device)) = state.db.get_device(&zone.device_id) else { return; }; if !device.enabled { return; } diff --git a/src/engine/automations.rs b/src/engine/automations.rs index 2f9c0fa..e4eaafc 100644 --- a/src/engine/automations.rs +++ b/src/engine/automations.rs @@ -1,8 +1,6 @@ async fn run_automations(state: &AppState) -> Result<()> { if !state.settings.read().await.house_power_enabled { return Ok(()); } let devices = state.db.list_devices()?; - let zones = state.db.list_zones()?; - let groups = state.db.list_groups()?; let mut automations = state.db.list_automations()?; // Stable arbitration for same-cycle conflicts: the oldest configured rule wins, then ID. // This avoids database row order deciding the physical outcome (M2). @@ -20,6 +18,20 @@ async fn run_automations(state: &AppState) -> Result<()> { _ => false, }; if !should_fire { continue; } + + // API edits/deletes and execution share one short ownership window. If the rule + // changed since this cycle snapshot was taken, skip it now and evaluate the new + // definition on the next cycle instead of firing stale configuration. + let _automation_guard = state.lock_automation_operation().await; + let Some(latest_item) = state.db.get_automation(&item.id)? else { continue; }; + if latest_item.updated_at != item.updated_at { continue; } + item = latest_item; + // Group membership and zone ownership may have changed after the cycle snapshot but + // before we acquired the automation lock. Reload them inside this serialized window so + // same-cycle conflict arbitration claims the actual current target set. + let zones = state.db.list_zones()?; + let groups = state.db.list_groups()?; + if item.action_group_id.is_none() && device_blocked_by_disabled_zone(&item.action_device_id, &zones) && item.action.power != Some(true) @@ -75,7 +87,8 @@ async fn run_automations(state: &AppState) -> Result<()> { power: item.action.power, mode: group_mode, preset: item.action_preset.clone(), - }, "automation.group").await.map(|_| true) + setpoint: None, + }, "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, item.action.clone()).await { Ok(Some(_)) => Ok(true), diff --git a/src/engine/groups.rs b/src/engine/groups.rs index c991649..4da3a6d 100644 --- a/src/engine/groups.rs +++ b/src/engine/groups.rs @@ -5,15 +5,52 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl } } if let Some(preset) = patch.preset.as_deref() { - if !matches!(preset, "auto" | "comfort" | "sleep" | "away") { - return Err(AppError::BadRequest("group preset must be auto, comfort, sleep or away".into())); + if !matches!(preset, "auto" | "comfort" | "sleep" | "away" | "custom") { + return Err(AppError::BadRequest("group preset must be auto, comfort, sleep, away or custom".into())); + } + } + if patch.preset.as_deref() == Some("custom") && patch.setpoint.is_none() { + return Err(AppError::BadRequest("group custom preset requires a setpoint".into())); + } + if let Some(setpoint) = patch.setpoint { + if !(8.0..=30.0).contains(&setpoint) { + return Err(AppError::BadRequest("group setpoint must be between 8 and 30 C".into())); + } + if patch.preset.as_deref() != Some("custom") { + return Err(AppError::BadRequest("group setpoint requires preset=custom".into())); } } + // House/group actions share one ordering domain. This prevents a concurrent group ON + // (especially from an automation) from resurrecting the master while whole-house OFF + // is being applied. Group and zone locks then make the member update atomic. + let _house_guard = state.lock_house_operation().await; + let _group_guard = state.lock_group_operation(group_id).await; let mut group = state.db.get_group(group_id)? .ok_or_else(|| AppError::NotFound(format!("group {group_id}")))?; + if source == "automation.group" && !state.settings.read().await.house_power_enabled { + state.log("info", "automation.blocked_by_house_power", &format!("Group automation suppressed while whole-house power is off for {}", group.name), json!({ + "group_id": group.id, "source": source + })); + return Ok(json!({ + "group": group, + "zones": [], + "devices": state.db.list_devices()?, + "failed": [], + "master_power_enabled": false, + "suppressed": true, + })); + } + let mut locked_zone_ids = group.zone_ids.clone(); + locked_zone_ids.sort(); + locked_zone_ids.dedup(); + let mut _zone_guards = Vec::with_capacity(locked_zone_ids.len()); + for zone_id in &locked_zone_ids { + _zone_guards.push(state.lock_zone_operation(zone_id).await); + } let schedules = state.db.list_schedules()?; - let climate_change = patch.mode.is_some() || patch.preset.is_some(); + let custom_setpoint = patch.setpoint.map(|value| (value * 10.0).round() / 10.0); + let climate_change = patch.mode.is_some() || patch.preset.is_some() || custom_setpoint.is_some(); if let Some(power) = patch.power { group.power_enabled = power; } @@ -24,7 +61,7 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl // Explicit group ON is a conscious request to run this group. Resume the global // master without changing the gates of any other groups. This makes group ON work // even after a previous whole-house OFF while preserving multi-group OFF priority. - if patch.power == Some(true) { + if patch.power == Some(true) && source != "automation.group" { let mut settings = state.settings.write().await; if !settings.house_power_enabled { settings.house_power_enabled = true; @@ -38,7 +75,6 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl let mut zones = Vec::new(); for zone_id in &group.zone_ids { - let _zone_guard = state.lock_zone_operation(zone_id).await; let Some(zone_snapshot) = state.db.get_zone(zone_id)? else { continue; }; let _device_guard = state.lock_device_operation(&zone_snapshot.device_id).await; let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; }; @@ -46,7 +82,11 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl if temporary_owns_zone { if let Some(session) = zone.temporary_quick_thermostat.as_mut() { if let Some(mode) = patch.mode.as_deref() { session.deferred_mode = Some(mode.to_string()); } - if let Some(preset) = patch.preset.as_deref() { session.deferred_preset = Some(preset.to_string()); } + if let Some(preset) = patch.preset.as_deref() { + session.deferred_preset = Some(preset.to_string()); + if preset != "custom" { session.deferred_setpoint = None; } + } + if let Some(setpoint) = custom_setpoint { session.deferred_setpoint = Some(setpoint); } } if climate_change { state.log("info", "group.control_deferred_by_temporary_thermostat", &format!("Group climate change deferred for {} while Temporary Quick Thermostat owns the zone", zone.name), json!({ @@ -71,10 +111,17 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl zone.manual_override_until = None; } else { zone.manual_preset = Some(preset.to_string()); - zone.manual_setpoint = None; + if preset != "custom" { zone.manual_setpoint = None; } zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &schedules, Local::now()); } } + if let Some(setpoint) = custom_setpoint { + zone.setpoint = setpoint; + zone.manual_preset = Some("custom".into()); + zone.manual_setpoint = Some(setpoint); + zone.effective_setpoint = Some(setpoint); + zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &schedules, Local::now()); + } } zone.revision = zone.revision.saturating_add(1); zone.updated_at = Utc::now(); @@ -115,7 +162,7 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl state.wake_zone_control(); } state.log("info", source, &format!("Updated group {}", group.name), json!({ - "group_id": group.id, "power_enabled": group.power_enabled, "mode": patch.mode, "preset": patch.preset, + "group_id": group.id, "power_enabled": group.power_enabled, "mode": patch.mode, "preset": patch.preset, "setpoint": custom_setpoint, "zones": zones.len(), "failed": failed.len(), "master_power_enabled": master_power_enabled, })); Ok(json!({ diff --git a/src/engine/ownership.rs b/src/engine/ownership.rs index cdca26f..c917942 100644 --- a/src/engine/ownership.rs +++ b/src/engine/ownership.rs @@ -233,7 +233,9 @@ async fn detect_external_device_control(state: &AppState, before: &Device, after pub async fn send_manual_command(state: &AppState, device_id: &str, command: DeviceCommand, source: &str) -> Result { // Keep zone -> device lock ordering consistent with Quick Thermostat/full-zone edits. // A device belongs to at most one thermostat zone, but keep this generic for legacy data. - let zone_ids: Vec = state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id).map(|zone| zone.id).collect(); + let mut zone_ids: Vec = state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id).map(|zone| zone.id).collect(); + zone_ids.sort(); + zone_ids.dedup(); let mut _zone_guards = Vec::new(); for zone_id in &zone_ids { _zone_guards.push(state.lock_zone_operation(zone_id).await); } // Keep the device lock until the zone takeover marker is persisted. Otherwise a poll @@ -282,6 +284,12 @@ pub async fn force_power_off_device(state: &AppState, device_id: &str) -> Result send_command_locked_forced(state, device_id, DeviceCommand { power: Some(false), ..Default::default() }).await } +/// Same safety transition for callers that already hold the per-device operation lock. +/// Keeping this separate avoids recursive lock acquisition during atomic configuration import. +pub async fn force_power_off_device_locked(state: &AppState, device_id: &str) -> Result { + send_command_locked_forced(state, device_id, DeviceCommand { power: Some(false), ..Default::default() }).await +} + /// Technical device disable is a safety transition, not just a database flag. The unit is /// explicitly powered off while it is still commandable, then removed from controller polling. pub async fn disable_device_safely(state: &AppState, device_id: &str) -> Result { diff --git a/src/engine/polling.rs b/src/engine/polling.rs index e1dc37b..4f4ea57 100644 --- a/src/engine/polling.rs +++ b/src/engine/polling.rs @@ -1,8 +1,27 @@ +async fn lock_poll_zone_operations(state: &AppState, device_id: &str) -> Result>, AppError> { + let mut zone_ids: Vec = state.db.list_zones()?.into_iter() + .filter(|zone| zone.device_id == device_id) + .map(|zone| zone.id) + .collect(); + zone_ids.sort(); + zone_ids.dedup(); + let mut guards = Vec::with_capacity(zone_ids.len()); + for zone_id in zone_ids { + guards.push(state.lock_zone_operation(&zone_id).await); + } + Ok(guards) +} + pub async fn poll_one(state: &AppState, device_id: &str) -> Result { + // Polling can update zone ownership when it detects physical/pilot control. Acquire + // the same zone -> device lock order used by thermostat/manual actions so those writes + // cannot race and overwrite a fresh override or hand-back state. + let _zone_guards = lock_poll_zone_operations(state, device_id).await?; let _device_guard = state.lock_device_operation(device_id).await; poll_one_locked(state, device_id).await } +// Caller must hold the device lock and every current zone lock associated with this device. async fn poll_one_locked(state: &AppState, device_id: &str) -> Result { let mut device = state.db.get_device(device_id)? .ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?; @@ -26,12 +45,26 @@ pub(crate) async fn poll_all(state: &AppState) -> Result<()> { .map(|device| device.id) .collect(); for device_id in device_ids { + let _zone_guards = lock_poll_zone_operations(state, &device_id).await?; let _device_guard = state.lock_device_operation(&device_id).await; let _ = poll_one_locked(state, &device_id).await?; } Ok(()) } +/// Poll all enabled devices when the caller already holds the corresponding zone and device locks. +/// Used by configuration import so no command/poll can interleave with the replacement. +pub(crate) async fn poll_all_locked(state: &AppState) -> Result<()> { + let device_ids: Vec = state.db.list_devices()?.into_iter() + .filter(|device| device.enabled) + .map(|device| device.id) + .collect(); + for device_id in device_ids { + let _ = poll_one_locked(state, &device_id).await?; + } + Ok(()) +} + async fn poll_device(state: &AppState, device: &mut Device) { if device.simulated { simulate_tick(device); diff --git a/src/engine/temporary_thermostat.rs b/src/engine/temporary_thermostat.rs index 6c4cfe7..d7421b8 100644 --- a/src/engine/temporary_thermostat.rs +++ b/src/engine/temporary_thermostat.rs @@ -73,8 +73,20 @@ pub fn finish_temporary_quick_thermostat(zone: &mut Zone, schedules: &[Schedule] zone.manual_preset = None; zone.manual_setpoint = None; zone.manual_override_until = None; - } else if matches!(preset, "comfort" | "sleep" | "away") { + } else if matches!(preset, "comfort" | "sleep" | "away" | "custom") { zone.manual_preset = Some(preset.to_string()); + if preset != "custom" { zone.manual_setpoint = None; } + zone.manual_override_until = next_schedule_boundary_utc(&zone.id, schedules, Local::now()); + } + } + // A deferred custom temperature is valid only while the final deferred preset is custom. + // This makes the hand-back robust even if an older persisted session contains a stale + // deferred_setpoint from a previously selected Custom action. + if session.deferred_preset.as_deref() == Some("custom") { + if let Some(setpoint) = session.deferred_setpoint { + zone.setpoint = setpoint; + zone.manual_preset = Some("custom".into()); + zone.manual_setpoint = Some(setpoint); zone.manual_override_until = next_schedule_boundary_utc(&zone.id, schedules, Local::now()); } } @@ -88,6 +100,8 @@ async fn expire_temporary_quick_thermostats(state: &AppState, zones: &mut [Zone] for zone in zones.iter_mut() { let zone_id = zone.id.clone(); let _zone_guard = state.lock_zone_operation(&zone_id).await; + let Some(snapshot) = state.db.get_zone(&zone_id)? else { continue; }; + let _device_guard = state.lock_device_operation(&snapshot.device_id).await; let Some(latest) = state.db.get_zone(&zone_id)? else { continue; }; *zone = latest; let active_under_manual = zone.device_manual_override @@ -153,6 +167,8 @@ async fn activate_due_temporary_quick_thermostats( for zone in zones.iter_mut() { let zone_id = zone.id.clone(); let _zone_guard = state.lock_zone_operation(&zone_id).await; + let Some(snapshot) = state.db.get_zone(&zone_id)? else { continue; }; + let _device_guard = state.lock_device_operation(&snapshot.device_id).await; let Some(latest) = state.db.get_zone(&zone_id)? else { continue; }; *zone = latest; let Some((session_state, started_at)) = zone.temporary_quick_thermostat.as_ref() @@ -330,6 +346,8 @@ async fn expire_local_thermostat_overrides(state: &AppState, zones: &mut [Zone], for zone in zones.iter_mut() { let zone_id = zone.id.clone(); let _zone_guard = state.lock_zone_operation(&zone_id).await; + let Some(snapshot) = state.db.get_zone(&zone_id)? else { continue; }; + let _device_guard = state.lock_device_operation(&snapshot.device_id).await; let Some(latest) = state.db.get_zone(&zone_id)? else { continue; }; *zone = latest; // A direct device/pilot takeover has higher priority than the local-OFF hand-back. diff --git a/src/engine/tests.rs b/src/engine/tests.rs index df69de5..2b29563 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -233,6 +233,7 @@ mod tests { paused_at: None, deferred_mode: None, deferred_preset: None, + deferred_setpoint: None, safety_expires_at: None, } } @@ -309,6 +310,20 @@ mod tests { assert_eq!(zone.manual_setpoint, Some(22.0)); } + #[test] + fn deferred_non_custom_preset_wins_over_stale_custom_setpoint() { + let now = Utc::now(); + let mut zone = test_zone("device"); + let mut session = temporary_session(now); + session.deferred_preset = Some("comfort".into()); + session.deferred_setpoint = Some(27.0); + zone.temporary_quick_thermostat = Some(session); + + assert!(finish_temporary_quick_thermostat(&mut zone, &[], "cool")); + assert_eq!(zone.manual_preset.as_deref(), Some("comfort")); + assert!(zone.manual_setpoint.is_none()); + } + #[test] fn temporary_session_without_activation_marker_is_never_active() { let now = Utc::now(); diff --git a/src/engine/zone_actions.rs b/src/engine/zone_actions.rs index 439f6f2..9b5f30c 100644 --- a/src/engine/zone_actions.rs +++ b/src/engine/zone_actions.rs @@ -123,6 +123,7 @@ async fn apply_automatic_device_action( }; let _zone_guard = state.lock_zone_operation(&zone_id).await; + let _device_guard = state.lock_device_operation(device_id).await; let mut zone = state.db.get_zone(&zone_id)?.ok_or_else(|| AppError::NotFound(format!("zone {zone_id}")))?; let settings = state.settings.read().await.clone(); if !settings.house_power_enabled || zone.device_manual_override || zone.local_thermostat_power.is_some() { @@ -173,7 +174,14 @@ async fn apply_automatic_device_action( } if command.power == Some(false) { - return force_power_off_device(state, device_id).await.map(Some); + // We already hold the device lock and have re-checked ownership under it. Keep that + // lock through the physical OFF so a poll/manual-takeover update cannot slip between + // the durable zone transition and the device command. + return send_command_locked_forced( + state, + device_id, + DeviceCommand { power: Some(false), ..Default::default() }, + ).await.map(Some); } // Climate fields above are durable zone state. Only non-climate device capabilities remain @@ -196,6 +204,7 @@ async fn apply_automatic_device_action( if residual.is_empty() { return state.db.get_device(device_id)?.map(Some).ok_or_else(|| AppError::NotFound(format!("device {device_id}"))); } + drop(_device_guard); send_automatic_device_command_if_owned(state, device_id, residual).await } diff --git a/src/main.rs b/src/main.rs index 25e8f0e..fb459dd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -70,6 +70,11 @@ async fn main() -> Result<()> { zone_control_wakeup: Arc::new(Notify::new()), device_operation_locks: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), zone_operation_locks: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + group_operation_locks: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + schedule_operation_lock: Arc::new(tokio::sync::Mutex::new(())), + automation_operation_lock: Arc::new(tokio::sync::Mutex::new(())), + house_operation_lock: Arc::new(tokio::sync::Mutex::new(())), + configuration_operation_lock: Arc::new(tokio::sync::Mutex::new(())), pending_controller_commands: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), started: Instant::now(), }; diff --git a/src/models/temporary_thermostat.rs b/src/models/temporary_thermostat.rs index 694a559..b51eb17 100644 --- a/src/models/temporary_thermostat.rs +++ b/src/models/temporary_thermostat.rs @@ -74,6 +74,9 @@ pub struct TemporaryQuickThermostat { pub deferred_mode: Option, #[serde(default)] pub deferred_preset: Option, + /// Group custom target received while the temporary thermostat owns the zone. + #[serde(default)] + pub deferred_setpoint: Option, /// Optional fail-safe for temperature-based modes. #[serde(default)] pub safety_expires_at: Option>, diff --git a/src/models/zone.rs b/src/models/zone.rs index fc628d2..596afe0 100644 --- a/src/models/zone.rs +++ b/src/models/zone.rs @@ -193,9 +193,12 @@ pub struct GroupControlPatch { /// house follows the global house mode; cool/heat set an explicit mode on every member zone. #[serde(default)] pub mode: Option, - /// auto clears temporary overrides; comfort/sleep/away apply a temporary preset to every member zone. + /// auto clears temporary overrides; comfort/sleep/away/custom apply a temporary preset to every member zone. #[serde(default)] pub preset: Option, + /// Optional custom target temperature for the whole group. Used together with preset=custom. + #[serde(default)] + pub setpoint: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] diff --git a/src/state.rs b/src/state.rs index 8c10620..b335170 100644 --- a/src/state.rs +++ b/src/state.rs @@ -32,6 +32,11 @@ pub struct AppState { pub zone_control_wakeup: Arc, pub(crate) device_operation_locks: Arc>>>>, pub(crate) zone_operation_locks: Arc>>>>, + pub(crate) group_operation_locks: Arc>>>>, + pub(crate) schedule_operation_lock: Arc>, + pub(crate) automation_operation_lock: Arc>, + pub(crate) house_operation_lock: Arc>, + pub(crate) configuration_operation_lock: Arc>, /// Short-lived expected climate state from controller-originated commands. It prevents /// a delayed GREE status update from being mistaken for remote/manual takeover. pub(crate) pending_controller_commands: Arc>>, @@ -55,6 +60,30 @@ impl AppState { lock.lock_owned().await } + pub async fn lock_group_operation(&self, group_id: &str) -> OwnedMutexGuard<()> { + let lock = { + let mut locks = self.group_operation_locks.lock().await; + locks.entry(group_id.to_string()).or_insert_with(|| Arc::new(Mutex::new(()))).clone() + }; + lock.lock_owned().await + } + + pub async fn lock_schedule_operation(&self) -> OwnedMutexGuard<()> { + self.schedule_operation_lock.clone().lock_owned().await + } + + pub async fn lock_automation_operation(&self) -> OwnedMutexGuard<()> { + self.automation_operation_lock.clone().lock_owned().await + } + + pub async fn lock_house_operation(&self) -> OwnedMutexGuard<()> { + self.house_operation_lock.clone().lock_owned().await + } + + pub async fn lock_configuration_operation(&self) -> OwnedMutexGuard<()> { + self.configuration_operation_lock.clone().lock_owned().await + } + pub fn wake_zone_control(&self) { self.zone_control_wakeup.notify_one(); } diff --git a/web/index.html b/web/index.html index bfa3ba3..e2b1b6f 100644 --- a/web/index.html +++ b/web/index.html @@ -437,7 +437,7 @@
Cooling
Heating
-
+
diff --git a/web/js/core.js b/web/js/core.js index 0da7dcf..b7e742b 100644 --- a/web/js/core.js +++ b/web/js/core.js @@ -31,7 +31,7 @@ const app = { languages: [], translations: {}, locales: {}, historyTab: 'overview', historyData: {zones:[], devices:[], sensors:[]}, historyCounts: {}, historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false, - customChartSeries: [], savedCharts: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, zoneTemperatureTimers: {}, + customChartSeries: [], savedCharts: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, groupControlSeq: {}, zoneControlQueue: {}, groupControlQueue: {}, zoneTemperatureTimers: {}, controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {}, simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false, dashboardTab: 'main', settingsTab: 'app', systemSnapshotAt: Date.now(), }; diff --git a/web/js/entities.js b/web/js/entities.js index ca87d73..42b15bc 100644 --- a/web/js/entities.js +++ b/web/js/entities.js @@ -208,10 +208,18 @@ function groupState(group) { const zones = groupZones(group); const modes = [...new Set(zones.map(zone => zone.inherit_house_mode ? 'house' : (zone.mode || 'cool')))]; const presets = [...new Set(zones.map(zone => zone.manual_preset || 'auto'))]; + const customTargets = zones + .filter(zone => (zone.manual_preset || 'auto') === 'custom') + .map(zone => Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint)) + .filter(Number.isFinite); + const customTemperature = customTargets.length === zones.length && customTargets.length && customTargets.every(value => Math.abs(value - customTargets[0]) < 0.05) + ? customTargets[0] + : null; return { zones, mode: modes.length === 1 ? modes[0] : (modes.length ? 'mixed' : 'house'), preset: presets.length === 1 ? presets[0] : (presets.length ? 'mixed' : 'auto'), + customTemperature, }; } @@ -223,12 +231,16 @@ function groupCard(group, detailed = false) { const masterOff = app.settings?.house_power_enabled === false; const modeLabelForGroup = value => value === 'house' ? tr('groups.followHouse') : (value === 'mixed' ? tr('groups.mixed') : modeLabel(value)); const presetLabelForGroup = value => value === 'mixed' ? tr('groups.mixed') : zonePresetLabel(value); + const fallbackTarget = Number(state.zones[0]?.manual_setpoint ?? state.zones[0]?.effective_setpoint ?? state.zones[0]?.setpoint ?? 23); + const customTarget = Number.isFinite(state.customTemperature) ? state.customTemperature : (Number.isFinite(fallbackTarget) ? fallbackTarget : 23); + const customEditorVisible = state.preset === 'custom'; return `
${esc(tr('groups.group'))}

${esc(group.name)}

${esc(memberText)}

${detailed ? `
${esc(tr('groups.mode'))}${esc(modeLabelForGroup(state.mode))}
${esc(tr('groups.profile'))}${esc(presetLabelForGroup(state.preset))}
${esc(tr('groups.members'))}${state.zones.length}
` : ''}
${esc(tr('common.power'))}
${esc(tr('groups.mode'))}
${['house','heat','cool'].map(mode => ``).join('')}
-
${esc(tr('groups.profile'))}
${['auto','comfort','sleep','away'].map(preset => ``).join('')}
+
${esc(tr('groups.profile'))}
${['auto','comfort','sleep','away'].map(preset => ``).join('')}
+
${masterOff ? `

${esc(tr('groups.masterOff'))}

` : ''} ${detailed ? `` : ''}
`; @@ -245,8 +257,16 @@ function renderGroups() { } async function sendGroupControl(id, patch) { + const sequence = (app.groupControlSeq[id] || 0) + 1; + app.groupControlSeq[id] = sequence; + const previous = app.groupControlQueue[id] || Promise.resolve(); + const request = previous.catch(() => {}).then(() => + api(`/api/groups/${encodeURIComponent(id)}/control`, {method:'POST', body:patch}) + ); + app.groupControlQueue[id] = request; try { - const result = await api(`/api/groups/${encodeURIComponent(id)}/control`, {method:'POST', body:patch}); + const result = await request; + if (app.groupControlSeq[id] !== sequence) return; if (result.group) { const index = app.groups.findIndex(group => group.id === result.group.id); if (index >= 0) app.groups[index] = result.group; else app.groups.push(result.group); @@ -261,7 +281,11 @@ async function sendGroupControl(id, patch) { const failed = Array.isArray(result.failed) ? result.failed.length : 0; if (failed) toast(tr('groups.partial', {count: failed}), true); else toast(tr('groups.controlUpdated')); - } catch (error) { toast(error.message, true); } + } catch (error) { + if (app.groupControlSeq[id] === sequence) { await loadBootstrap(); toast(error.message, true); } + } finally { + if (app.groupControlQueue[id] === request) delete app.groupControlQueue[id]; + } } function renderGroupZoneChoices(selectedIds = null) { diff --git a/web/js/events.js b/web/js/events.js index 4a7e9cd..428e7d2 100644 --- a/web/js/events.js +++ b/web/js/events.js @@ -116,6 +116,17 @@ document.addEventListener('click', async event => { if (action === 'group-power') return sendGroupControl(button.dataset.id,{power:button.dataset.value==='true'}); if (action === 'group-mode') return sendGroupControl(button.dataset.id,{mode:button.dataset.value}); if (action === 'group-preset') return sendGroupControl(button.dataset.id,{preset:button.dataset.value}); + if (action === 'group-custom-open') { + const editor=button.closest('.group-card')?.querySelector('[data-group-custom-editor]'); + if(editor){ editor.hidden=false; const input=editor.querySelector('[data-group-custom-temperature]'); input?.focus(); input?.select(); } + return; + } + if (action === 'group-custom-temperature') { + const input=button.closest('.group-card')?.querySelector('[data-group-custom-temperature]'); + const value=parseDecimal(input?.value); + if(!Number.isFinite(value) || value<8 || value>30) return toast(tr('groups.customTemperatureRange'),true); + return sendGroupControl(button.dataset.id,{preset:'custom',setpoint:Math.round(value*10)/10}); + } if (action === 'zone-device-power') return sendZoneLocalPower(button.dataset.id, button.dataset.value === 'true'); if (action === 'zone-open-temporary') return populateTemporaryThermostat(button.dataset.id); if (action === 'zone-temperature') { const zone=app.zones.find(v=>v.id===button.dataset.id); if(zone) { const base=Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint); queueZoneTemperature(zone, base+Number(button.dataset.delta)); } return; } @@ -183,6 +194,13 @@ $('#themeSelect')?.addEventListener('change', event => setTheme(event.target.val $('#logLevelFilter')?.addEventListener('change', loadLogs); $('#logCategoryFilter')?.addEventListener('change', loadLogs); $('#settingsForm [name=notifications_provider]')?.addEventListener('change', updateNotificationFields); $('#zoneForm [name=sensor_source]').addEventListener('change', updateZoneSensorFields); +document.addEventListener('keydown', event => { + const input=event.target.closest?.('[data-group-custom-temperature]'); + if(!input || event.key!=='Enter') return; + event.preventDefault(); + input.closest('.group-card')?.querySelector('[data-action="group-custom-temperature"]')?.click(); +}); + $('#zoneForm [name=separate_hysteresis]')?.addEventListener('change', () => updateZoneHysteresisFields({syncFromCommon:true})); $('#scheduleForm [name=preset]').addEventListener('change', updateSchedulePresetField); $('#automationForm [name=action_target_kind]')?.addEventListener('change', updateAutomationTargetFields); @@ -317,7 +335,7 @@ $('#temporaryThermostatStop')?.addEventListener('click', async event => { const id = event.currentTarget.dataset.id; if (!id) return; try { event.currentTarget.disabled = true; - const zone = await api(`/api/zones/${encodeURIComponent(id)}/control`, {method:'POST', body:{clear_temporary_quick_thermostat:true}}); + const zone = await enqueueZoneControlRequest(id, {clear_temporary_quick_thermostat:true}); const index = app.zones.findIndex(item => item.id === zone.id); if (index >= 0) app.zones[index] = zone; $('#temporaryThermostatDialog').close(); renderAll(); scheduleControlPlanLoad(); toast(tr('zones.temporaryStopped')); } catch (error) { toast(error.message, true); } @@ -356,7 +374,7 @@ $('#temporaryThermostatForm')?.addEventListener('submit', async event => { if (Number.isFinite(safety) && safety > 0) request.max_duration_minutes = safety; } await runFormTask(form, async () => { - const updatedZone = await api(`/api/zones/${encodeURIComponent(raw.zone_id)}/control`, {method:'POST', body:{temporary_quick_thermostat:request}}); + const updatedZone = await enqueueZoneControlRequest(raw.zone_id, {temporary_quick_thermostat:request}); const index = app.zones.findIndex(item => item.id === updatedZone.id); if (index >= 0) app.zones[index] = updatedZone; form.closest('dialog').close(); renderAll(); scheduleControlPlanLoad(); toast(tr(activeSession || startKind === 'now' ? 'zones.temporaryStarted' : 'zones.temporaryScheduledToast')); }, {busyKey: activeSession ? 'actions.saving' : 'zones.temporaryStarting'}); diff --git a/web/js/navigation.js b/web/js/navigation.js index 835259a..6085871 100644 --- a/web/js/navigation.js +++ b/web/js/navigation.js @@ -46,20 +46,37 @@ function updateDevice(device) { if (index >= 0) app.devices[index] = device; else app.devices.push(device); } +function enqueueZoneControlRequest(id, patch) { + const previous = app.zoneControlQueue[id] || Promise.resolve(); + const request = previous.catch(() => {}).then(() => + api(`/api/zones/${encodeURIComponent(id)}/control`, {method:'POST', body:patch}) + ); + app.zoneControlQueue[id] = request; + request.finally(() => { + if (app.zoneControlQueue[id] === request) delete app.zoneControlQueue[id]; + }).catch(() => {}); + return request; +} + async function sendZoneLocalPower(id, power) { + const sequence = (app.zoneControlSeq[id] || 0) + 1; + app.zoneControlSeq[id] = sequence; try { - const zone = await api(`/api/zones/${encodeURIComponent(id)}/control`, {method:'POST', body:{power}}); + const zone = await enqueueZoneControlRequest(id, {power}); + if (app.zoneControlSeq[id] !== sequence) return; const index = app.zones.findIndex(item => item.id === zone.id); if (index >= 0) app.zones[index] = zone; else app.zones.push(zone); renderAll(); scheduleControlPlanLoad(); toast(tr('zones.localPowerUpdated')); - } catch (error) { toast(error.message, true); } + } catch (error) { + if (app.zoneControlSeq[id] === sequence) { await loadBootstrap(); toast(error.message, true); } + } } async function sendZoneControl(id, patch) { const sequence = (app.zoneControlSeq[id] || 0) + 1; app.zoneControlSeq[id] = sequence; try { - const zone = await api(`/api/zones/${encodeURIComponent(id)}/control`, {method:'POST', body:patch}); + const zone = await enqueueZoneControlRequest(id, patch); if (app.zoneControlSeq[id] !== sequence) return; const index = app.zones.findIndex(item => item.id === zone.id); if (index >= 0) app.zones[index] = zone; else app.zones.push(zone); @@ -69,6 +86,7 @@ async function sendZoneControl(id, patch) { } } + function queueZoneTemperature(zone, value, {snapToHalf=true}={}) { const clamped = clamp(value, 8, 30); const next = snapToHalf ? Math.round(clamped * 2) / 2 : Math.round(clamped * 10) / 10; @@ -93,6 +111,7 @@ function beginInlineTemperatureEdit(target) { input.inputMode = 'decimal'; input.value = value.toFixed(decimals); input.setAttribute('aria-label', tr('common.targetTemperature')); + input.title = tr('common.targetTemperature'); target.classList.add('editing'); target.replaceChildren(input); input.focus(); diff --git a/web/styles.css b/web/styles.css index d57d440..412c0b7 100644 --- a/web/styles.css +++ b/web/styles.css @@ -710,6 +710,13 @@ html[data-theme='light'] .simulation-metrics > div, html[data-theme='light'] .si .group-button-row { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; } .group-button-row button,.group-mode-row button,.group-preset-row button { min-height:36px; } .group-button-row button.active { color:var(--accent-text); background:var(--accent); font-weight:800; } +.group-custom-temperature { padding:8px 9px; border:1px solid var(--line); border-radius:11px; background:var(--surface-muted); } +.group-custom-temperature label > span { display:block; margin-bottom:5px; color:var(--muted); font-size:10px; } +.group-custom-temperature-row { display:grid; grid-template-columns:minmax(0,1fr) auto auto; align-items:center; gap:6px; } +.group-custom-temperature-row input { min-width:0; height:36px; text-align:center; font-weight:700; } +.group-custom-temperature-row > span { color:var(--muted); font-size:12px; } +.group-custom-temperature-row button { min-height:36px; white-space:nowrap; } + .group-zone-choices { display:grid; gap:7px; max-height:300px; overflow:auto; } .group-zone-choice { display:grid; grid-template-columns:auto 1fr; align-items:center; gap:10px; padding:10px; border:1px solid var(--line); border-radius:12px; background:var(--surface-muted); } .group-zone-choice input { width:18px; min-height:18px; accent-color:var(--accent); } @@ -772,7 +779,7 @@ body.simulation-standalone [data-view="simulation"] { min-height:calc(100vh - 36 .dashboard-group-strip .group-mode-row, .dashboard-group-strip .group-preset-row { display:grid; gap:5px; overflow:visible; padding:0; } .dashboard-group-strip .group-mode-row { grid-template-columns:repeat(3,minmax(0,1fr)); } -.dashboard-group-strip .group-preset-row { grid-template-columns:repeat(4,minmax(0,1fr)); } +.dashboard-group-strip .group-preset-row { grid-template-columns:repeat(5,minmax(0,1fr)); } .dashboard-group-strip .group-button-row button, .dashboard-group-strip .group-mode-row button, .dashboard-group-strip .group-preset-row button { min-width:0; min-height:32px; padding:6px 5px; font-size:10px; line-height:1.15; }