v0.8.19
This commit is contained in:
Generated
+1
-1
@@ -633,7 +633,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gree-controller"
|
name = "gree-controller"
|
||||||
version = "0.8.17"
|
version = "0.8.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "gree-controller"
|
name = "gree-controller"
|
||||||
version = "0.8.17"
|
version = "0.8.19"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["GREE Controller contributors"]
|
authors = ["GREE Controller contributors"]
|
||||||
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
|
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
|
||||||
|
|||||||
+33
-33
@@ -1,11 +1,11 @@
|
|||||||
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
|
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
|
||||||
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
|
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
|
||||||
d3ee62701b27064418df4f62f43ef4a00528275e8338c2f48b4ea2010e11c10d ./Cargo.lock
|
6efa62dde5c1b169e60a30bcfaa43ec20c7ada84546f58d33d61e3aba7e56ed1 ./Cargo.lock
|
||||||
6ab182c92c822b5f1685538d1d7f9e22e17ad1a748932fdda6e04444b4eefaf8 ./Cargo.toml
|
245b9ca11d543ab2bf475e46d24f076f9ac80f4f9f0726f0dcb8356e3099af61 ./Cargo.toml
|
||||||
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
||||||
4a40bef9464c7da8e46c3a7396b417bafee37fea8e0d89406d025eb4e8e6c47e ./README.md
|
030f04d570b57bb7d427007125230564fc85ed9037a22908f231a51c9375e7f3 ./README.md
|
||||||
ec0f495ee6a618bdec10b260ee7838f7a872d8c5b0aa91c546226c1ad818f601 ./build.rs
|
ec0f495ee6a618bdec10b260ee7838f7a872d8c5b0aa91c546226c1ad818f601 ./build.rs
|
||||||
bbac52c61e28b554027902f97044ab5039acf3f58aeca1d5fd204013ffcc0cbf ./docs/API.md
|
4cc13a859ec285cabcf339a9c817c0aef6da32ce2b79e1c7af43d793e514ece7 ./docs/API.md
|
||||||
95e3c8c99ba439a86a2fb05cfd8ef00c75cb959a024a349a082da935b726f500 ./home-assistant/README.md
|
95e3c8c99ba439a86a2fb05cfd8ef00c75cb959a024a349a082da935b726f500 ./home-assistant/README.md
|
||||||
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
|
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
|
||||||
6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.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
|
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py
|
||||||
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.png
|
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.png
|
||||||
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.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
|
38d659d0273c0d1428679e44574a2666c605d1b3796d323b626baa40560788cf ./home-assistant/custom_components/gree_controller/number.py
|
||||||
39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py
|
39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py
|
||||||
1cae12876eb81085910907cf1fcf340a18dcba4f8c967ef222c9496325057849 ./home-assistant/custom_components/gree_controller/sensor.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
|
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json
|
||||||
4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json
|
4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json
|
||||||
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
|
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
|
||||||
4b1b820432bd96ba16c0c3a9f363cf6d3a6bdd12b58bdbf7b45a0611ad56324b ./lang/en.json
|
78bc797f28939a0e0372fe497db44d53a26d31d3a0cae593d2a0319ab1205085 ./lang/en.json
|
||||||
a1eb1b3cab4d9f23c6121259157060ba3375e29c6507bc94095f033170242244 ./lang/pl.json
|
c24adedfe0f66f8518ed5d175de853582000900b8a732e9dac84ebce4ff0c616 ./lang/pl.json
|
||||||
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
|
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
|
||||||
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
|
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
|
||||||
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
|
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
|
||||||
@@ -42,21 +42,21 @@ b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/upda
|
|||||||
ffa4d44d5f9f7a3e0e80e34c61ee510ab0f6b4b80a8d6c8137aae2ea74fbab02 ./src/api.rs
|
ffa4d44d5f9f7a3e0e80e34c61ee510ab0f6b4b80a8d6c8137aae2ea74fbab02 ./src/api.rs
|
||||||
50b653f5ae1c9d24eb2e158fb4af713f5ccabe259c83053fa3cd2a310c7780da ./src/api/assets.rs
|
50b653f5ae1c9d24eb2e158fb4af713f5ccabe259c83053fa3cd2a310c7780da ./src/api/assets.rs
|
||||||
6c34b1294d76b0eba4c56bfd0bf69bdd502de0e40838104f6ddf2db4e922683d ./src/api/auth.rs
|
6c34b1294d76b0eba4c56bfd0bf69bdd502de0e40838104f6ddf2db4e922683d ./src/api/auth.rs
|
||||||
bc6dbb4b8fb6972ee9065f3caf36881e4316e70f2b7a7d04e7848839fd0801f3 ./src/api/automations.rs
|
387bd4e2d9f73ad19acadf388a898d45c8039c4f6cf364681ce3402f0a684f38 ./src/api/automations.rs
|
||||||
05fdfca0e5d0eca7ace3267c4d95409ef12ead087bb9aad73d5a09b5cc7c3de1 ./src/api/debug_tokens.rs
|
2317b7cd0c2437c128ac4982d64412c8184ecb5e774825761d61aab6e45fae4a ./src/api/debug_tokens.rs
|
||||||
ffa0a35a0c6badbbbaad88dfaedd46049a0bc4ce2f7e8b930dbdbffe5df4cffb ./src/api/devices.rs
|
d048cc3bff5b1817a4155308c9bdc92644bbbf2bf6d75f3d531c51a26035276a ./src/api/devices.rs
|
||||||
1f13d0fffc9d0d0ff0c008645da57291d924a855de4b8fcb98756026d098e60d ./src/api/events.rs
|
417940b467c6f00bbf01cc47eb4479880dee9b2f284a80e3662a6d433dc11b92 ./src/api/events.rs
|
||||||
28e6ae79a22ef87037dc6fb43d515878151abe9200a318903b3140fe06f9c2ca ./src/api/groups.rs
|
242ba0d2267ce009e90d3e593110a116c7ccfb755b676bab44067110f3db92e2 ./src/api/groups.rs
|
||||||
a13d4e217fe3ddaa73873ba6e0d1bc93750cf61764d867ff7372274ed21d9599 ./src/api/history.rs
|
a13d4e217fe3ddaa73873ba6e0d1bc93750cf61764d867ff7372274ed21d9599 ./src/api/history.rs
|
||||||
f312f8a91d7ce1e60b189e684632edbd68e5496723f49d07aef885699a9f60f3 ./src/api/house.rs
|
53cb0c85729961d3665840b278889444bc8e5008d3e671922ab41a30b05636dd ./src/api/house.rs
|
||||||
0d7d598f7ed24315ed0547a60a72a06f20ebcf926489e1c454986f42d22cc305 ./src/api/integrations.rs
|
0d7d598f7ed24315ed0547a60a72a06f20ebcf926489e1c454986f42d22cc305 ./src/api/integrations.rs
|
||||||
bb2a746ecdcc2da5fe54e18b455c7bd19453486dd5c008e71951a2c81d0e7d64 ./src/api/middleware.rs
|
bb2a746ecdcc2da5fe54e18b455c7bd19453486dd5c008e71951a2c81d0e7d64 ./src/api/middleware.rs
|
||||||
2abb8d155b832bd7bde575b12ef37fbbf09c246255d9abd2adae0d56016ce170 ./src/api/public_settings.rs
|
2abb8d155b832bd7bde575b12ef37fbbf09c246255d9abd2adae0d56016ce170 ./src/api/public_settings.rs
|
||||||
eed70af84e9ff9f0ec1b2005836e9ab9aa6a4e73c5c876ec161e5d5c96965b26 ./src/api/schedules.rs
|
e47da65c53ac271ea1f3ed21eb939f6ff1d6e9d87271de194c6b05fa63bd26d8 ./src/api/schedules.rs
|
||||||
a4d0d6c6ffb7944d336a2cf749a27ee5ed966fd94ab29464ce641873783ce756 ./src/api/settings.rs
|
0f1c14e611db08e33b88e467fd7957b0b0479cd2ee25ea424696cb2039238b88 ./src/api/settings.rs
|
||||||
74c156717b37e83d329f7c52ac622e02f690621544c4eb07c4502194db55e6e9 ./src/api/system.rs
|
74c156717b37e83d329f7c52ac622e02f690621544c4eb07c4502194db55e6e9 ./src/api/system.rs
|
||||||
681cccb8d09f4ad9c2e6467a4dbe2f9d991125775a5d2bfc9043daf940fecd29 ./src/api/websocket.rs
|
681cccb8d09f4ad9c2e6467a4dbe2f9d991125775a5d2bfc9043daf940fecd29 ./src/api/websocket.rs
|
||||||
29b6503aa80dd4ad4e444c4f25ca763adfb60c2de099afbb4fc1131e9d080cfd ./src/api/zones.rs
|
d1d6b49ab6051d6b16aa4648606781843824d41af1b727bf8b12b86b44ea4706 ./src/api/zones.rs
|
||||||
9040e8cb6647c76a875148b7591abcccfe4e2d4708ef462a1e5d25dd4ccac911 ./src/config.rs
|
9040e8cb6647c76a875148b7591abcccfe4e2d4708ef462a1e5d25dd4ccac911 ./src/config.rs
|
||||||
369455b5ffb125f1a80b767f62fd8c84a953adb69548dbd8adb653a6fbaaa45b ./src/db.rs
|
369455b5ffb125f1a80b767f62fd8c84a953adb69548dbd8adb653a6fbaaa45b ./src/db.rs
|
||||||
ae0f7635ba0523c1be980a28314d45445be1354444bcf74a6397da1d6d7c79df ./src/db/climate.rs
|
ae0f7635ba0523c1be980a28314d45445be1354444bcf74a6397da1d6d7c79df ./src/db/climate.rs
|
||||||
@@ -69,22 +69,22 @@ f3bd91d0bdb7699f319b9341d928d3cc3b25f759653fcd2095a5c5f695d08fac ./src/db/event
|
|||||||
35cbb521a8ec456756496b8aee71f9dfebad528a2cf2653ba8b48f6035be0c01 ./src/db/tests.rs
|
35cbb521a8ec456756496b8aee71f9dfebad528a2cf2653ba8b48f6035be0c01 ./src/db/tests.rs
|
||||||
a384ea4042d1d11692b5f8693df1f54850dcc4e80726780b44b2b096bf667b78 ./src/db/zone_history.rs
|
a384ea4042d1d11692b5f8693df1f54850dcc4e80726780b44b2b096bf667b78 ./src/db/zone_history.rs
|
||||||
952ea5906d39ef917ad71edc345f76b29c07c63079644fd5699b12aa254cb35f ./src/engine.rs
|
952ea5906d39ef917ad71edc345f76b29c07c63079644fd5699b12aa254cb35f ./src/engine.rs
|
||||||
2d9476fb5d3071b0f49c5844df93768f17f882b0bf05182763ab79628f50e63d ./src/engine/automations.rs
|
808a65705b31a0887873601c8066958d3b6dc6e876e5b85949ba9cea5a49ddff ./src/engine/automations.rs
|
||||||
832fe59c59a0ab1a0d949b656b194eab7aae578fa1eb30c6d55753d97a508c3b ./src/engine/commands.rs
|
832fe59c59a0ab1a0d949b656b194eab7aae578fa1eb30c6d55753d97a508c3b ./src/engine/commands.rs
|
||||||
3967cc60a108c71bd7e5d6a99c7caf32ed8f6cbbb63282a5a5d3357e7c07f933 ./src/engine/control_plan.rs
|
3967cc60a108c71bd7e5d6a99c7caf32ed8f6cbbb63282a5a5d3357e7c07f933 ./src/engine/control_plan.rs
|
||||||
47acda24f31ec6af5ac94b00f52e232f0fdd443106abb9eeca7cc28d26b994bd ./src/engine/deadlines.rs
|
47acda24f31ec6af5ac94b00f52e232f0fdd443106abb9eeca7cc28d26b994bd ./src/engine/deadlines.rs
|
||||||
445c1f7503c6f01adefa10b5b4414431aa630b739678de7fe6161f3d938b4de2 ./src/engine/groups.rs
|
fabb590ab74c37d6d75b8bcae30b16900dcb6cd97df30937308f765ec5d36528 ./src/engine/groups.rs
|
||||||
18a7669318949e533f206d25cf7b63771d7f0f2c0b375a72ff78a2d921baaa61 ./src/engine/history.rs
|
18a7669318949e533f206d25cf7b63771d7f0f2c0b375a72ff78a2d921baaa61 ./src/engine/history.rs
|
||||||
dad8930c3d811f02cfeced3aa4b9a404fe273ba1b99539bb174b9430575f3e22 ./src/engine/local_thermostat.rs
|
dad8930c3d811f02cfeced3aa4b9a404fe273ba1b99539bb174b9430575f3e22 ./src/engine/local_thermostat.rs
|
||||||
92b8c4a386fb883f93eb774f3e94545cdf6e48cc674ad7ba40a4c3821c454980 ./src/engine/ownership.rs
|
2e91d677fa8bef74cd5a56c46b72b3d101b07629724d124b0bbfcc4e7bafaa30 ./src/engine/ownership.rs
|
||||||
88b88dc1e7778bd65d04d58172cfc01b473687264742fd50896cb5eb5f2080f1 ./src/engine/polling.rs
|
9c37adf514c96840fd6a5c715c579350fa86d6dcc0655a2aa97d1fd7c7e04299 ./src/engine/polling.rs
|
||||||
3d48608a065aa52a1fd8b9c59356a74d8216ecee3d47f8ff8ef7d3d47170c617 ./src/engine/runtime.rs
|
3d48608a065aa52a1fd8b9c59356a74d8216ecee3d47f8ff8ef7d3d47170c617 ./src/engine/runtime.rs
|
||||||
ffa72b93502eece187a96adb4ec94aec6048d4a6dc7af66e7c7af0931318787f ./src/engine/schedules.rs
|
ffa72b93502eece187a96adb4ec94aec6048d4a6dc7af66e7c7af0931318787f ./src/engine/schedules.rs
|
||||||
e0d5347f73066404f9df9d211e059fd10cf054466ae35bdad6533a862b5a816a ./src/engine/targets.rs
|
e0d5347f73066404f9df9d211e059fd10cf054466ae35bdad6533a862b5a816a ./src/engine/targets.rs
|
||||||
a9188b588b2617ce1547a45d46141f7e5f1bb0027a48675dd86b274f807d0875 ./src/engine/temperature.rs
|
a9188b588b2617ce1547a45d46141f7e5f1bb0027a48675dd86b274f807d0875 ./src/engine/temperature.rs
|
||||||
b453a8841494fd82d8fa4133dcd47dc59715a6a327375b5e13ffbaef431be700 ./src/engine/temporary_thermostat.rs
|
fcd40789045b7cbf168880ff4a7fee12fb4afdc6fd376c8cca9c0cf63390e5a0 ./src/engine/temporary_thermostat.rs
|
||||||
36038bf3f6d5c5ef3a146e99caacfbf20f7636872a8bdfe08866e3037db82f11 ./src/engine/tests.rs
|
5efbf22c557679fbf88c8c1ade75bc8a65070c92ee69bd23ce1e72f6c40a0b6a ./src/engine/tests.rs
|
||||||
c7e07dcf4419a4a64566620ba2006c5532a6d4576c90cb92855bca4c2deddcf6 ./src/engine/zone_actions.rs
|
8333115324fea27f52908a00766a56d31138fa2d63574c9ca20d9ea27977d059 ./src/engine/zone_actions.rs
|
||||||
9f95022773670a9c369bac90cf1514a13b289cbd7b497d8faf91d0fe6b33c353 ./src/engine/zone_control.rs
|
9f95022773670a9c369bac90cf1514a13b289cbd7b497d8faf91d0fe6b33c353 ./src/engine/zone_control.rs
|
||||||
4b271b6fc365b1078c01d6178eb563841b2ecaed5d8639196f58e1312d2236fe ./src/error.rs
|
4b271b6fc365b1078c01d6178eb563841b2ecaed5d8639196f58e1312d2236fe ./src/error.rs
|
||||||
c6ff66da9ad08506f839ec56a869ead3abc824b5311e0ea9244517d11f7f4207 ./src/home_assistant.rs
|
c6ff66da9ad08506f839ec56a869ead3abc824b5311e0ea9244517d11f7f4207 ./src/home_assistant.rs
|
||||||
@@ -92,7 +92,7 @@ c6ff66da9ad08506f839ec56a869ead3abc824b5311e0ea9244517d11f7f4207 ./src/home_ass
|
|||||||
feb50ecd60b9a9fe9de1f7d8e1e26caab5435d59347468f6266a318f431bc3d3 ./src/influxdb/codec.rs
|
feb50ecd60b9a9fe9de1f7d8e1e26caab5435d59347468f6266a318f431bc3d3 ./src/influxdb/codec.rs
|
||||||
553b44ec0321e62480059bc2451fd34ec7aabe9fc87fc717daba0c4b55679d53 ./src/influxdb/query.rs
|
553b44ec0321e62480059bc2451fd34ec7aabe9fc87fc717daba0c4b55679d53 ./src/influxdb/query.rs
|
||||||
5e4d94f8a010df05bae9acab72c518080d2dba94d054df95b87e492305357222 ./src/influxdb/write.rs
|
5e4d94f8a010df05bae9acab72c518080d2dba94d054df95b87e492305357222 ./src/influxdb/write.rs
|
||||||
c67212da6a2bd5c2933ece31586ca4048b484fda3105f23a6e06e881b079d129 ./src/main.rs
|
4d45200b03d43d1869760b7cf3281776d268bbaa7860167ea2a533d2f4f12d3d ./src/main.rs
|
||||||
e935e1fcf0d379b627df5d5055dbdad3dd55786fde7191376918b05afdde1727 ./src/models.rs
|
e935e1fcf0d379b627df5d5055dbdad3dd55786fde7191376918b05afdde1727 ./src/models.rs
|
||||||
fca124ec30b5c9286f0f44cc18e8953bb2809f5009d4d521deaebfdeb00c1bd1 ./src/models/automation.rs
|
fca124ec30b5c9286f0f44cc18e8953bb2809f5009d4d521deaebfdeb00c1bd1 ./src/models/automation.rs
|
||||||
9a100f778d1f71697dca7ae4db9991a1b7b2da4f26e006ce0612f98c5406acec ./src/models/control_plan.rs
|
9a100f778d1f71697dca7ae4db9991a1b7b2da4f26e006ce0612f98c5406acec ./src/models/control_plan.rs
|
||||||
@@ -101,8 +101,8 @@ fca124ec30b5c9286f0f44cc18e8953bb2809f5009d4d521deaebfdeb00c1bd1 ./src/models/a
|
|||||||
cfbaa853a5185c4be572588577b313a2d618837f7c07a3860d0e8d2cca04ee60 ./src/models/history.rs
|
cfbaa853a5185c4be572588577b313a2d618837f7c07a3860d0e8d2cca04ee60 ./src/models/history.rs
|
||||||
668c9cf157679f8c9fe72846f743629c79e263b14827110834b4addbdf9d13ec ./src/models/integrations.rs
|
668c9cf157679f8c9fe72846f743629c79e263b14827110834b4addbdf9d13ec ./src/models/integrations.rs
|
||||||
c19f243d709acb59db7bc4a91c02c538146fc5a7c014d90c53e8d8c62c4d6008 ./src/models/runtime.rs
|
c19f243d709acb59db7bc4a91c02c538146fc5a7c014d90c53e8d8c62c4d6008 ./src/models/runtime.rs
|
||||||
0e0d47d88bb6b7b446fe9a89a69cf46dfe041f8f279eda2f5d8f91c296e19312 ./src/models/temporary_thermostat.rs
|
e77f8cdcb5fc0e51cdbf2527a69585a2f6f9089e159d1e45ec4a48735ab0e304 ./src/models/temporary_thermostat.rs
|
||||||
799113b1209cc40c99a463a49fc28aca4d49d10d3e8dc6861bb579f1e3f637d0 ./src/models/zone.rs
|
c78d7f10437c92d9fa9d6227e1de336dd22684c14fd20ee3b50dec28e23b56a7 ./src/models/zone.rs
|
||||||
9c735d7c475f21f2f388c21be784376f7184f0bf18af1886544ef51585a3b290 ./src/notifications.rs
|
9c735d7c475f21f2f388c21be784376f7184f0bf18af1886544ef51585a3b290 ./src/notifications.rs
|
||||||
7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs
|
7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs
|
||||||
f1f765331469f4219a551414833c19b6de413114c22fdd0468e1f417659553c9 ./src/protocol/gree.rs
|
f1f765331469f4219a551414833c19b6de413114c22fdd0468e1f417659553c9 ./src/protocol/gree.rs
|
||||||
@@ -123,27 +123,27 @@ cd5d0befe9f51a7067201a6eaa8d8ed72182d359e42cbdc67c80aa12d722654b ./src/queries/
|
|||||||
2b46a8cdc5efb5f930dd8ff7b9f6c0e720cbca096a51d9ae047711dd280fa3b8 ./src/queries/maintenance.rs
|
2b46a8cdc5efb5f930dd8ff7b9f6c0e720cbca096a51d9ae047711dd280fa3b8 ./src/queries/maintenance.rs
|
||||||
f688db6f440ee7b0129ccc22838de0ee0e0cf2b6376173b05f853ca2555c2c1e ./src/queries/schema.rs
|
f688db6f440ee7b0129ccc22838de0ee0e0cf2b6376173b05f853ca2555c2c1e ./src/queries/schema.rs
|
||||||
f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs
|
f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs
|
||||||
2d69811db832c90ce06035ee29665205a04e4c514a21ecae9623991a9b14d825 ./src/state.rs
|
5a6d9134310c801a6378598b2057ac1369f8e7c0f42b3eedd8352bb3e5c222ec ./src/state.rs
|
||||||
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
|
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
|
||||||
de66cab9b650cb448814bfeda6d216e168dd3e6f07a1a438e4a9de9acbe6b798 ./web/404.html
|
de66cab9b650cb448814bfeda6d216e168dd3e6f07a1a438e4a9de9acbe6b798 ./web/404.html
|
||||||
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
|
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
|
||||||
4df1b4c17f178d8edff61394da9195961f850db4d926d30ae44b04c3120e7a12 ./web/index.html
|
68fbf1630c68f28e94008e609d33ba2d5b4a922a021c7386714e49e913f289d2 ./web/index.html
|
||||||
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
|
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
|
||||||
b3d5cb473bf9771de78e14c7de730cb86c73f8cdbb6344da67ba7d6abac879ea ./web/js/bootstrap.js
|
b3d5cb473bf9771de78e14c7de730cb86c73f8cdbb6344da67ba7d6abac879ea ./web/js/bootstrap.js
|
||||||
64a43e2142a69ebddf4160c0d0d3b573fa379c650431090fd15c9ba3b62bc4e7 ./web/js/charts.js
|
64a43e2142a69ebddf4160c0d0d3b573fa379c650431090fd15c9ba3b62bc4e7 ./web/js/charts.js
|
||||||
0f743533728e3be60a30cb7955afc8dcefc13b68f967538e3a6ffbe76cfa601c ./web/js/core.js
|
c14eb49105d83d02f4ca77ee68044ee18304e3a074fb69c3124cf00b45d75c69 ./web/js/core.js
|
||||||
6e421388c7f6deb2aa42f1205dfdc52839f9f178fbd672129972528b8517a870 ./web/js/dashboard.js
|
6e421388c7f6deb2aa42f1205dfdc52839f9f178fbd672129972528b8517a870 ./web/js/dashboard.js
|
||||||
04247ee5a716376dcb959f6f9d2005b0b058da7bacca2cd7721bccfe0123b07e ./web/js/entities.js
|
e8f1d1966acad8864af001e92e02044c629692f42ab26df700d962b010f4090b ./web/js/entities.js
|
||||||
668b52056d6e083b6adaa72e483cf70eca680144d1f821ca6485b601e340b4aa ./web/js/events.js
|
44957aa415047f7cce6f55cf4f1f8ea1439159a27512e8426792d4192074e89a ./web/js/events.js
|
||||||
2fe6c804f86b0613a50a56c54f7a0cbee77273759edf3e6fec36ac77d35edbd7 ./web/js/forms.js
|
2fe6c804f86b0613a50a56c54f7a0cbee77273759edf3e6fec36ac77d35edbd7 ./web/js/forms.js
|
||||||
be07a341ba4b1f514a5e13553494194b22aa87108cb04de40635a0158921780b ./web/js/history.js
|
be07a341ba4b1f514a5e13553494194b22aa87108cb04de40635a0158921780b ./web/js/history.js
|
||||||
6d4a9f17ff241eaf44e8d2260163d9226fca3c8d6e1a287d31a2c96b976532f9 ./web/js/main.js
|
6d4a9f17ff241eaf44e8d2260163d9226fca3c8d6e1a287d31a2c96b976532f9 ./web/js/main.js
|
||||||
963282960efbaf4f52954f828304ed1f452a8b661c500a6fe7c230c0ff3b210a ./web/js/navigation.js
|
900f1b083bfcef7782a35d1e708d1cd9fd975d89c03d828290400a85b1006826 ./web/js/navigation.js
|
||||||
d375868ded6ffcd6b18d4c661bb2051e7cc5b136f95d4d68355cdd6e0caaf278 ./web/js/realtime.js
|
d375868ded6ffcd6b18d4c661bb2051e7cc5b136f95d4d68355cdd6e0caaf278 ./web/js/realtime.js
|
||||||
45a666dbc1cddcc0ece507130cde7719f6232c710b4288d6a16e1232791f2a3d ./web/js/router.js
|
45a666dbc1cddcc0ece507130cde7719f6232c710b4288d6a16e1232791f2a3d ./web/js/router.js
|
||||||
28ff1324f4495b7bcd431eb94f51012c1b1e558f8acb931ac35b71b48374c200 ./web/js/settings-ui.js
|
28ff1324f4495b7bcd431eb94f51012c1b1e558f8acb931ac35b71b48374c200 ./web/js/settings-ui.js
|
||||||
9d44596929f67400f5f09d82330af4452914f2b4729eadcd4f695672d2cd849c ./web/js/settings.js
|
9d44596929f67400f5f09d82330af4452914f2b4729eadcd4f695672d2cd849c ./web/js/settings.js
|
||||||
fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest
|
fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest
|
||||||
98fe010f13bec1f408bcd1a0015e4d866ae2a966812d6a5e76ec47fbf8b0bdfd ./web/styles.css
|
a27f36816edbe82780cac97683ed574cbd4e5e8f782df3eadf930dfbdbd25fd5 ./web/styles.css
|
||||||
03ac3a5918539904ae829ee24999d8d59efb41bdf6c0e30f76bab4cee98c3130 ./web/sw.js
|
03ac3a5918539904ae829ee24999d8d59efb41bdf6c0e30f76bab4cee98c3130 ./web/sw.js
|
||||||
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
|
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
|
||||||
|
|||||||
@@ -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.
|
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.
|
> [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. |
|
| Dashboard | Whole-house summary and Quick Thermostat controls. |
|
||||||
| Devices | Discovery, binding and direct/technical GREE device control. |
|
| Devices | Discovery, binding and direct/technical GREE device control. |
|
||||||
| Zones | Thermostat configuration, profiles, sensors and control ownership. |
|
| 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. |
|
| Schedules | Weekly thermostat schedules and ready-made templates. |
|
||||||
| Automations | Time/temperature triggered device or group actions. |
|
| Automations | Time/temperature triggered device or group actions. |
|
||||||
| History | Overview, zones, devices and HA sensors with precise chart tooltips and zoom. |
|
| History | Overview, zones, devices and HA sensors with precise chart tooltips and zoom. |
|
||||||
|
|||||||
+9
-7
@@ -1,6 +1,6 @@
|
|||||||
# GREE Controller API reference
|
# 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)
|
[← Main documentation](../README.md)
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ Common statuses:
|
|||||||
| GET | `/api/groups/{id}` | Read a group. |
|
| GET | `/api/groups/{id}` | Read a group. |
|
||||||
| PUT | `/api/groups/{id}` | Replace group definition. |
|
| PUT | `/api/groups/{id}` | Replace group definition. |
|
||||||
| DELETE | `/api/groups/{id}` | Delete a group. |
|
| 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/control` | Set global thermostat mode. |
|
||||||
| POST | `/api/house/power` | Set whole-house master power. |
|
| POST | `/api/house/power` | Set whole-house master power. |
|
||||||
| POST | `/api/house/preset` | Set/clear whole-house preset override. |
|
| POST | `/api/house/preset` | Set/clear whole-house preset override. |
|
||||||
@@ -212,7 +212,7 @@ Response:
|
|||||||
{
|
{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"name": "gree-controller",
|
"name": "gree-controller",
|
||||||
"version": "0.8.17",
|
"version": "0.8.19",
|
||||||
"uptime_seconds": 1234,
|
"uptime_seconds": 1234,
|
||||||
"control_ready": true,
|
"control_ready": true,
|
||||||
"time": "2026-08-30T06:54:00Z"
|
"time": "2026-08-30T06:54:00Z"
|
||||||
@@ -236,7 +236,7 @@ Returns the initial Web UI snapshot:
|
|||||||
"settings": {},
|
"settings": {},
|
||||||
"outdoor_temperature": null,
|
"outdoor_temperature": null,
|
||||||
"system": {
|
"system": {
|
||||||
"version": "0.8.17",
|
"version": "0.8.19",
|
||||||
"uptime_seconds": 1234,
|
"uptime_seconds": 1234,
|
||||||
"auth_required": false,
|
"auth_required": false,
|
||||||
"control_ready": true,
|
"control_ready": true,
|
||||||
@@ -655,7 +655,8 @@ Read, replace or delete a group. `DELETE` returns `204`.
|
|||||||
{
|
{
|
||||||
"power": true,
|
"power": true,
|
||||||
"mode": "house",
|
"mode": "house",
|
||||||
"preset": "comfort"
|
"preset": "custom",
|
||||||
|
"setpoint": 22.3
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -663,9 +664,10 @@ All fields optional:
|
|||||||
|
|
||||||
- `power`: group gate,
|
- `power`: group gate,
|
||||||
- `mode`: `house`, `cool`, `heat`,
|
- `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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "gree_controller",
|
"domain": "gree_controller",
|
||||||
"name": "GREE Controller",
|
"name": "GREE Controller",
|
||||||
"version": "0.8.17",
|
"version": "0.8.19",
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
|
|||||||
+8
-3
@@ -559,10 +559,10 @@
|
|||||||
"nav.groups": "Groups",
|
"nav.groups": "Groups",
|
||||||
"groups.eyebrow": "Group control",
|
"groups.eyebrow": "Group control",
|
||||||
"groups.dashboardTitle": "Selected air conditioners",
|
"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.manage": "Manage groups",
|
||||||
"groups.new": "New group",
|
"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.group": "Group",
|
||||||
"groups.namePlaceholder": "Upstairs",
|
"groups.namePlaceholder": "Upstairs",
|
||||||
"groups.members": "Group members",
|
"groups.members": "Group members",
|
||||||
@@ -759,6 +759,11 @@
|
|||||||
"debug.emptyRequests": "No HTTP requests captured yet.",
|
"debug.emptyRequests": "No HTTP requests captured yet.",
|
||||||
"debug.emptyGree": "No GREE frames captured yet.",
|
"debug.emptyGree": "No GREE frames captured yet.",
|
||||||
"simulation.bannerTitle": "SIMULATION MODE",
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-3
@@ -559,10 +559,10 @@
|
|||||||
"nav.groups": "Grupy",
|
"nav.groups": "Grupy",
|
||||||
"groups.eyebrow": "Sterowanie grupami",
|
"groups.eyebrow": "Sterowanie grupami",
|
||||||
"groups.dashboardTitle": "Wybrane klimatyzatory",
|
"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.manage": "Zarządzaj grupami",
|
||||||
"groups.new": "Nowa grupa",
|
"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.group": "Grupa",
|
||||||
"groups.namePlaceholder": "Góra",
|
"groups.namePlaceholder": "Góra",
|
||||||
"groups.members": "Członkowie grupy",
|
"groups.members": "Członkowie grupy",
|
||||||
@@ -759,6 +759,11 @@
|
|||||||
"debug.emptyRequests": "Brak przechwyconych requestów HTTP.",
|
"debug.emptyRequests": "Brak przechwyconych requestów HTTP.",
|
||||||
"debug.emptyGree": "Brak przechwyconych ramek GREE.",
|
"debug.emptyGree": "Brak przechwyconych ramek GREE.",
|
||||||
"simulation.bannerTitle": "TRYB SYMULACJI",
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,7 +112,13 @@ async fn get_automation(State(state): State<AppState>, Path(id): Path<String>) -
|
|||||||
state.db.get_automation(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("automation {id}")))
|
state.db.get_automation(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("automation {id}")))
|
||||||
}
|
}
|
||||||
async fn create_automation(State(state): State<AppState>, Json(input): Json<AutomationInput>) -> Result<(StatusCode, Json<Automation>), AppError> {
|
async fn create_automation(State(state): State<AppState>, Json(input): Json<AutomationInput>) -> Result<(StatusCode, Json<Automation>), AppError> {
|
||||||
|
let _configuration_guard = state.lock_configuration_operation().await;
|
||||||
|
let _automation_guard = state.lock_automation_operation().await;
|
||||||
input.validate()?;
|
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)?;
|
validate_automation_references(&state, &input)?;
|
||||||
let item = input.into_automation(Uuid::new_v4().to_string(), Utc::now(), None);
|
let item = input.into_automation(Uuid::new_v4().to_string(), Utc::now(), None);
|
||||||
state.db.save_automation(&item)?;
|
state.db.save_automation(&item)?;
|
||||||
@@ -120,8 +126,14 @@ async fn create_automation(State(state): State<AppState>, Json(input): Json<Auto
|
|||||||
Ok((StatusCode::CREATED, Json(item)))
|
Ok((StatusCode::CREATED, Json(item)))
|
||||||
}
|
}
|
||||||
async fn update_automation(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<AutomationInput>) -> Result<Json<Automation>, AppError> {
|
async fn update_automation(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<AutomationInput>) -> Result<Json<Automation>, AppError> {
|
||||||
|
let _configuration_guard = state.lock_configuration_operation().await;
|
||||||
|
let _automation_guard = state.lock_automation_operation().await;
|
||||||
input.validate()?;
|
input.validate()?;
|
||||||
let existing = state.db.get_automation(&id)?.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?;
|
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)?;
|
validate_automation_references(&state, &input)?;
|
||||||
let item = input.into_automation(id, existing.created_at, existing.last_fired_at);
|
let item = input.into_automation(id, existing.created_at, existing.last_fired_at);
|
||||||
state.db.save_automation(&item)?;
|
state.db.save_automation(&item)?;
|
||||||
@@ -129,6 +141,8 @@ async fn update_automation(State(state): State<AppState>, Path(id): Path<String>
|
|||||||
Ok(Json(item))
|
Ok(Json(item))
|
||||||
}
|
}
|
||||||
async fn delete_automation(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
async fn delete_automation(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||||
|
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}"))); }
|
if !state.db.delete_automation(&id)? { return Err(AppError::NotFound(format!("automation {id}"))); }
|
||||||
state.broadcast("automation.deleted", json!({"id": id}));
|
state.broadcast("automation.deleted", json!({"id": id}));
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ async fn get_debug(State(state): State<AppState>) -> Json<DebugSettings> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn update_debug(State(state): State<AppState>, Json(input): Json<DebugSettings>) -> Result<Json<DebugSettings>, AppError> {
|
async fn update_debug(State(state): State<AppState>, Json(input): Json<DebugSettings>) -> Result<Json<DebugSettings>, AppError> {
|
||||||
|
let _configuration_guard = state.lock_configuration_operation().await;
|
||||||
|
let _house_guard = state.lock_house_operation().await;
|
||||||
let mut settings = state.settings.write().await;
|
let mut settings = state.settings.write().await;
|
||||||
settings.debug = input.clone();
|
settings.debug = input.clone();
|
||||||
state.db.save_runtime_settings(&settings)?;
|
state.db.save_runtime_settings(&settings)?;
|
||||||
|
|||||||
+18
-1
@@ -1,4 +1,5 @@
|
|||||||
async fn discover(State(state): State<AppState>, Json(request): Json<DiscoveryRequest>) -> Result<Json<Value>, AppError> {
|
async fn discover(State(state): State<AppState>, Json(request): Json<DiscoveryRequest>) -> Result<Json<Value>, AppError> {
|
||||||
|
let _configuration_guard = state.lock_configuration_operation().await;
|
||||||
let settings = state.settings.read().await.clone();
|
let settings = state.settings.read().await.clone();
|
||||||
let timeout_ms = request.timeout_ms.unwrap_or(settings.discovery_timeout_ms).clamp(500, 30_000);
|
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);
|
let broadcast = request.broadcast.unwrap_or(settings.discovery_broadcast);
|
||||||
@@ -50,6 +51,7 @@ async fn list_devices(State(state): State<AppState>) -> Result<Json<Vec<Device>>
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn add_device(State(state): State<AppState>, Json(input): Json<ManualDeviceRequest>) -> Result<(StatusCode, Json<Device>), AppError> {
|
async fn add_device(State(state): State<AppState>, Json(input): Json<ManualDeviceRequest>) -> Result<(StatusCode, Json<Device>), 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() {
|
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()));
|
return Err(AppError::BadRequest("name, mac and ip are required".into()));
|
||||||
}
|
}
|
||||||
@@ -114,6 +116,7 @@ async fn get_device(State(state): State<AppState>, Path(id): Path<String>) -> Re
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn patch_device(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<DevicePatch>) -> Result<Json<Device>, AppError> {
|
async fn patch_device(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<DevicePatch>) -> Result<Json<Device>, AppError> {
|
||||||
|
let _configuration_guard = state.lock_configuration_operation().await;
|
||||||
if patch.enabled == Some(false) {
|
if patch.enabled == Some(false) {
|
||||||
engine::disable_device_safely(&state, &id).await?;
|
engine::disable_device_safely(&state, &id).await?;
|
||||||
}
|
}
|
||||||
@@ -145,6 +148,12 @@ async fn patch_device(State(state): State<AppState>, Path(id): Path<String>, Jso
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
async fn delete_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||||
|
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.get_device(&id)?.is_none() { return Err(AppError::NotFound(format!("device {id}"))); }
|
||||||
if state.db.list_automations()?.iter().any(|item| {
|
if state.db.list_automations()?.iter().any(|item| {
|
||||||
item.trigger_device_id.as_deref() == Some(id.as_str())
|
item.trigger_device_id.as_deref() == Some(id.as_str())
|
||||||
@@ -156,16 +165,24 @@ async fn delete_device(State(state): State<AppState>, Path(id): Path<String>) ->
|
|||||||
.filter(|zone| zone.device_id == id)
|
.filter(|zone| zone.device_id == id)
|
||||||
.map(|zone| zone.id)
|
.map(|zone| zone.id)
|
||||||
.collect();
|
.collect();
|
||||||
|
let mut sorted_zone_ids: Vec<String> = 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_zone_removal_safe(&state, &removed_zone_ids)?;
|
||||||
ensure_device_stopped_for_detach(&state, &id, "device.deleted").await?;
|
ensure_device_stopped_for_detach(&state, &id, "device.deleted").await?;
|
||||||
if !state.db.delete_device(&id)? { return Err(AppError::NotFound(format!("device {id}"))); }
|
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.log("info", "device.deleted", "Device deleted", json!({"device_id": id}));
|
||||||
state.broadcast("device.deleted", json!({"id": id}));
|
state.broadcast("device.deleted", json!({"id": id}));
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn bind_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Device>, AppError> {
|
async fn bind_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Device>, AppError> {
|
||||||
|
let _configuration_guard = state.lock_configuration_operation().await;
|
||||||
let _device_guard = state.lock_device_operation(&id).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}")))?;
|
let mut device = state.db.get_device(&id)?.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
|
||||||
if device.simulated { return Ok(Json(device)); }
|
if device.simulated { return Ok(Json(device)); }
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ async fn get_event_retention(State(state): State<AppState>) -> Json<Value> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn update_event_retention(State(state): State<AppState>, Json(input): Json<EventRetentionInput>) -> Result<Json<Value>, AppError> {
|
async fn update_event_retention(State(state): State<AppState>, Json(input): Json<EventRetentionInput>) -> Result<Json<Value>, AppError> {
|
||||||
|
let _configuration_guard = state.lock_configuration_operation().await;
|
||||||
|
let _house_guard = state.lock_house_operation().await;
|
||||||
let mut settings = state.settings.write().await;
|
let mut settings = state.settings.write().await;
|
||||||
settings.event_log_retention_days = input.days.clamp(1, 3650);
|
settings.event_log_retention_days = input.days.clamp(1, 3650);
|
||||||
state.db.save_runtime_settings(&settings)?;
|
state.db.save_runtime_settings(&settings)?;
|
||||||
|
|||||||
+37
-3
@@ -42,6 +42,9 @@ async fn get_group(State(state): State<AppState>, Path(id): Path<String>) -> Res
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn create_group(State(state): State<AppState>, Json(input): Json<GroupInput>) -> Result<(StatusCode, Json<ClimateGroup>), AppError> {
|
async fn create_group(State(state): State<AppState>, Json(input): Json<GroupInput>) -> Result<(StatusCode, Json<ClimateGroup>), 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 zone_ids = validate_group_input(&state, &input)?;
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let group = ClimateGroup {
|
let group = ClimateGroup {
|
||||||
@@ -59,6 +62,12 @@ async fn create_group(State(state): State<AppState>, Json(input): Json<GroupInpu
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn update_group(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<GroupInput>) -> Result<Json<ClimateGroup>, AppError> {
|
async fn update_group(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<GroupInput>) -> Result<Json<ClimateGroup>, 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 existing = state.db.get_group(&id)?.ok_or_else(|| AppError::NotFound(format!("group {id}")))?;
|
||||||
let zone_ids = validate_group_input(&state, &input)?;
|
let zone_ids = validate_group_input(&state, &input)?;
|
||||||
let group = ClimateGroup {
|
let group = ClimateGroup {
|
||||||
@@ -76,6 +85,10 @@ async fn update_group(State(state): State<AppState>, Path(id): Path<String>, Jso
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_group(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
async fn delete_group(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||||
|
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())) {
|
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()));
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove_zone_ids_from_groups(state: &AppState, zone_ids: &std::collections::HashSet<String>) -> Result<(), AppError> {
|
async fn remove_zone_ids_from_groups_locked(state: &AppState, zone_ids: &std::collections::HashSet<String>) -> Result<(), AppError> {
|
||||||
if zone_ids.is_empty() { return Ok(()); }
|
if zone_ids.is_empty() { return Ok(()); }
|
||||||
for mut group in state.db.list_groups()? {
|
let mut group_ids: Vec<String> = 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();
|
let before = group.zone_ids.len();
|
||||||
group.zone_ids.retain(|zone_id| !zone_ids.contains(zone_id));
|
group.zone_ids.retain(|zone_id| !zone_ids.contains(zone_id));
|
||||||
if group.zone_ids.len() == before { continue; }
|
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;
|
let mut value: Option<&str> = None;
|
||||||
for zone in zones {
|
for zone in zones {
|
||||||
let current = zone.manual_preset.as_deref().unwrap_or("auto");
|
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();
|
return "mixed".into();
|
||||||
}
|
}
|
||||||
if let Some(previous) = value {
|
if let Some(previous) = value {
|
||||||
@@ -153,6 +171,21 @@ fn home_assistant_group_preset(zones: &[&Zone]) -> String {
|
|||||||
value.unwrap_or("mixed").to_string()
|
value.unwrap_or("mixed").to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn home_assistant_group_custom_setpoint(zones: &[&Zone]) -> Option<f64> {
|
||||||
|
let mut value: Option<f64> = 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<AppState>) -> Result<Json<Vec<Value>>, AppError> {
|
async fn list_home_assistant_groups(State(state): State<AppState>) -> Result<Json<Vec<Value>>, AppError> {
|
||||||
let groups = state.db.list_groups()?;
|
let groups = state.db.list_groups()?;
|
||||||
let zones = state.db.list_zones()?;
|
let zones = state.db.list_zones()?;
|
||||||
@@ -223,6 +256,7 @@ async fn list_home_assistant_groups(State(state): State<AppState>) -> Result<Jso
|
|||||||
"effective_power": settings.house_power_enabled && group.power_enabled,
|
"effective_power": settings.house_power_enabled && group.power_enabled,
|
||||||
"mode": home_assistant_group_mode(&members),
|
"mode": home_assistant_group_mode(&members),
|
||||||
"preset": home_assistant_group_preset(&members),
|
"preset": home_assistant_group_preset(&members),
|
||||||
|
"custom_setpoint": home_assistant_group_custom_setpoint(&members),
|
||||||
"house_mode": settings.house_mode,
|
"house_mode": settings.house_mode,
|
||||||
"zone_count": members.len(),
|
"zone_count": members.len(),
|
||||||
"enabled_zones": members.iter().filter(|zone| zone.enabled).count(),
|
"enabled_zones": members.iter().filter(|zone| zone.enabled).count(),
|
||||||
|
|||||||
+60
-11
@@ -1,7 +1,14 @@
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct HouseControlPatch { mode: String }
|
struct HouseControlPatch { mode: String }
|
||||||
|
|
||||||
fn set_all_groups_power(state: &AppState, power: bool) -> Result<(), AppError> {
|
async fn set_all_groups_power(state: &AppState, power: bool) -> Result<(), AppError> {
|
||||||
|
let mut group_ids: Vec<String> = 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()? {
|
for mut group in state.db.list_groups()? {
|
||||||
if group.power_enabled == power { continue; }
|
if group.power_enabled == power { continue; }
|
||||||
group.power_enabled = power;
|
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<AppState>, Json(input): Json<HouseControlPatch>) -> Result<Json<Value>, AppError> {
|
async fn update_house_control(State(state): State<AppState>, Json(input): Json<HouseControlPatch>) -> Result<Json<Value>, AppError> {
|
||||||
|
let _house_guard = state.lock_house_operation().await;
|
||||||
if !matches!(input.mode.as_str(), "cool" | "heat" | "off") {
|
if !matches!(input.mode.as_str(), "cool" | "heat" | "off") {
|
||||||
return Err(AppError::BadRequest("house mode must be cool, heat or off".into()));
|
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<AppState>, Json(input): Json<H
|
|||||||
};
|
};
|
||||||
state.broadcast("settings.updated", payload.clone());
|
state.broadcast("settings.updated", payload.clone());
|
||||||
if activate_all {
|
if activate_all {
|
||||||
set_all_groups_power(&state, true)?;
|
set_all_groups_power(&state, true).await?;
|
||||||
// Never send a bare power=true frame. Wake the thermostat arbiter so every unit
|
// Never send a bare power=true frame. Wake the thermostat arbiter so every unit
|
||||||
// starts only with a valid effective Heat/Cool mode and compressor lockout policy.
|
// starts only with a valid effective Heat/Cool mode and compressor lockout policy.
|
||||||
state.wake_zone_control();
|
state.wake_zone_control();
|
||||||
@@ -102,6 +110,7 @@ async fn update_house_control(State(state): State<AppState>, Json(input): Json<H
|
|||||||
struct HousePowerPatch { power: bool }
|
struct HousePowerPatch { power: bool }
|
||||||
|
|
||||||
async fn update_house_power(State(state): State<AppState>, Json(input): Json<HousePowerPatch>) -> Result<Json<Value>, AppError> {
|
async fn update_house_power(State(state): State<AppState>, Json(input): Json<HousePowerPatch>) -> Result<Json<Value>, AppError> {
|
||||||
|
let _house_guard = state.lock_house_operation().await;
|
||||||
// Whole-house power is independent from the thermostat mode. Publish/persist the master
|
// 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.
|
// 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<AppState>, Json(input): Json<Hou
|
|||||||
// Global power is a true cascade across group gates. OFF clears the current takeover
|
// Global power is a true cascade across group gates. OFF clears the current takeover
|
||||||
// markers once, then each enabled device is re-cleared atomically with its OFF command.
|
// markers once, then each enabled device is re-cleared atomically with its OFF command.
|
||||||
// A later pilot action is therefore not erased by subsequent controller cycles.
|
// A later pilot action is therefore not erased by subsequent controller cycles.
|
||||||
set_all_groups_power(&state, input.power)?;
|
set_all_groups_power(&state, input.power).await?;
|
||||||
if !input.power {
|
if !input.power {
|
||||||
|
let zone_snapshot = state.db.list_zones()?;
|
||||||
|
let mut zone_ids: Vec<String> = 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<String> = 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")?;
|
engine::clear_all_device_manual_overrides(&state, "house_power_off")?;
|
||||||
clear_all_local_thermostat_overrides(&state)?;
|
clear_all_local_thermostat_overrides(&state)?;
|
||||||
|
drop(device_guards);
|
||||||
|
drop(zone_guards);
|
||||||
}
|
}
|
||||||
let failed = if input.power {
|
let failed = if input.power {
|
||||||
state.wake_zone_control();
|
state.wake_zone_control();
|
||||||
@@ -151,6 +177,7 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
|
|||||||
struct HousePresetPatch { preset: String }
|
struct HousePresetPatch { preset: String }
|
||||||
|
|
||||||
async fn update_house_preset(State(state): State<AppState>, Json(input): Json<HousePresetPatch>) -> Result<Json<Value>, AppError> {
|
async fn update_house_preset(State(state): State<AppState>, Json(input): Json<HousePresetPatch>) -> Result<Json<Value>, AppError> {
|
||||||
|
let _house_guard = state.lock_house_operation().await;
|
||||||
if !matches!(input.preset.as_str(), "auto" | "comfort" | "sleep" | "away") {
|
if !matches!(input.preset.as_str(), "auto" | "comfort" | "sleep" | "away") {
|
||||||
return Err(AppError::BadRequest("house preset must be auto, comfort, sleep or away".into()));
|
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<AppState>, Json(input): Json<Ho
|
|||||||
public_settings(&settings)
|
public_settings(&settings)
|
||||||
};
|
};
|
||||||
state.broadcast("settings.updated", settings_payload.clone());
|
state.broadcast("settings.updated", settings_payload.clone());
|
||||||
set_all_groups_power(&state, true)?;
|
set_all_groups_power(&state, true).await?;
|
||||||
|
|
||||||
let schedules = state.db.list_schedules()?;
|
let schedules = state.db.list_schedules()?;
|
||||||
let mut zones = state.db.list_zones()?;
|
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
|
||||||
for zone in &mut zones {
|
zone_ids.sort();
|
||||||
if engine::temporary_quick_thermostat_is_active(zone, Utc::now()) {
|
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() {
|
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
||||||
session.deferred_preset = Some(input.preset.clone());
|
session.deferred_preset = Some(input.preset.clone());
|
||||||
|
session.deferred_setpoint = None;
|
||||||
}
|
}
|
||||||
} else if input.preset == "auto" {
|
} else if input.preset == "auto" {
|
||||||
zone.manual_preset = None;
|
zone.manual_preset = None;
|
||||||
@@ -183,8 +221,9 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
|
|||||||
zone.manual_override_until = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
|
zone.manual_override_until = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
|
||||||
}
|
}
|
||||||
zone.updated_at = Utc::now();
|
zone.updated_at = Utc::now();
|
||||||
state.db.save_zone(zone)?;
|
state.db.save_zone(&zone)?;
|
||||||
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
|
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||||
|
zones.push(zone);
|
||||||
}
|
}
|
||||||
|
|
||||||
// As with house mode/power ON, the central thermostat arbiter performs the physical
|
// As with house mode/power ON, the central thermostat arbiter performs the physical
|
||||||
@@ -210,6 +249,8 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
|
|||||||
struct ScheduleTemplateRequest { template: String }
|
struct ScheduleTemplateRequest { template: String }
|
||||||
|
|
||||||
async fn apply_schedule_template(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<ScheduleTemplateRequest>) -> Result<Json<Value>, AppError> {
|
async fn apply_schedule_template(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<ScheduleTemplateRequest>) -> Result<Json<Value>, 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 zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
|
||||||
let mut items: Vec<Schedule> = Vec::new();
|
let mut items: Vec<Schedule> = Vec::new();
|
||||||
let mut add = |name: &str, days: Vec<u32>, start: &str, end: &str, preset: &str| {
|
let mut add = |name: &str, days: Vec<u32>, start: &str, end: &str, preset: &str| {
|
||||||
@@ -251,7 +292,7 @@ async fn apply_schedule_template(State(state): State<AppState>, Path(id): Path<S
|
|||||||
}
|
}
|
||||||
validate_schedule_set(&items)?;
|
validate_schedule_set(&items)?;
|
||||||
state.db.replace_schedules_for_zone(&id, &items)?;
|
state.db.replace_schedules_for_zone(&id, &items)?;
|
||||||
refresh_zone_override_boundary(&state, &id)?;
|
refresh_zone_override_boundary(&state, &id).await?;
|
||||||
state.broadcast("schedule.template_applied", json!({"zone_id": id, "template": input.template, "count": items.len()}));
|
state.broadcast("schedule.template_applied", json!({"zone_id": id, "template": input.template, "count": items.len()}));
|
||||||
Ok(Json(json!({"zone": zone, "schedules": items})))
|
Ok(Json(json!({"zone": zone, "schedules": items})))
|
||||||
}
|
}
|
||||||
@@ -261,13 +302,21 @@ async fn update_home_assistant_zone_control(State(state): State<AppState>, Path(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_zone(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
async fn delete_zone(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||||
|
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 zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
|
||||||
let mut removed = std::collections::HashSet::new();
|
let mut removed = std::collections::HashSet::new();
|
||||||
removed.insert(id.clone());
|
removed.insert(id.clone());
|
||||||
ensure_zone_removal_safe(&state, &removed)?;
|
ensure_zone_removal_safe(&state, &removed)?;
|
||||||
ensure_device_stopped_for_detach(&state, &zone.device_id, "zone.deleted").await?;
|
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}"))); }
|
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}));
|
state.broadcast("zone.deleted", json!({"id": id}));
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-5
@@ -49,7 +49,14 @@ fn validate_schedule_conflicts(state: &AppState, item: &Schedule, exclude_id: Op
|
|||||||
Ok(())
|
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 Some(mut zone) = state.db.get_zone(zone_id)? else { return Ok(()); };
|
||||||
let has_temporary_schedule_boundary = zone.temporary_quick_thermostat.as_ref()
|
let has_temporary_schedule_boundary = zone.temporary_quick_thermostat.as_ref()
|
||||||
.map(|session| session.finish_kind == "schedule_boundary")
|
.map(|session| session.finish_kind == "schedule_boundary")
|
||||||
@@ -87,17 +94,21 @@ async fn get_schedule(State(state): State<AppState>, Path(id): Path<String>) ->
|
|||||||
state.db.get_schedule(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("schedule {id}")))
|
state.db.get_schedule(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("schedule {id}")))
|
||||||
}
|
}
|
||||||
async fn create_schedule(State(state): State<AppState>, Json(input): Json<ScheduleInput>) -> Result<(StatusCode, Json<Schedule>), AppError> {
|
async fn create_schedule(State(state): State<AppState>, Json(input): Json<ScheduleInput>) -> Result<(StatusCode, Json<Schedule>), AppError> {
|
||||||
|
let _configuration_guard = state.lock_configuration_operation().await;
|
||||||
|
let _schedule_guard = state.lock_schedule_operation().await;
|
||||||
input.validate()?;
|
input.validate()?;
|
||||||
if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); }
|
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());
|
let item = input.into_schedule(Uuid::new_v4().to_string(), Utc::now());
|
||||||
validate_schedule_conflicts(&state, &item, None)?;
|
validate_schedule_conflicts(&state, &item, None)?;
|
||||||
state.db.save_schedule(&item)?;
|
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.broadcast("schedule.created", serde_json::to_value(&item)?);
|
||||||
state.wake_zone_control();
|
state.wake_zone_control();
|
||||||
Ok((StatusCode::CREATED, Json(item)))
|
Ok((StatusCode::CREATED, Json(item)))
|
||||||
}
|
}
|
||||||
async fn update_schedule(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<ScheduleInput>) -> Result<Json<Schedule>, AppError> {
|
async fn update_schedule(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<ScheduleInput>) -> Result<Json<Schedule>, AppError> {
|
||||||
|
let _configuration_guard = state.lock_configuration_operation().await;
|
||||||
|
let _schedule_guard = state.lock_schedule_operation().await;
|
||||||
input.validate()?;
|
input.validate()?;
|
||||||
let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?;
|
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())); }
|
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<AppState>, Path(id): Path<String>,
|
|||||||
let item = input.into_schedule(id.clone(), existing.created_at);
|
let item = input.into_schedule(id.clone(), existing.created_at);
|
||||||
validate_schedule_conflicts(&state, &item, Some(&id))?;
|
validate_schedule_conflicts(&state, &item, Some(&id))?;
|
||||||
state.db.save_schedule(&item)?;
|
state.db.save_schedule(&item)?;
|
||||||
refresh_zone_override_boundary(&state, &old_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)?; }
|
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.broadcast("schedule.updated", serde_json::to_value(&item)?);
|
||||||
state.wake_zone_control();
|
state.wake_zone_control();
|
||||||
Ok(Json(item))
|
Ok(Json(item))
|
||||||
}
|
}
|
||||||
async fn delete_schedule(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
async fn delete_schedule(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||||
|
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}")))?;
|
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}"))); }
|
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.broadcast("schedule.deleted", json!({"id": id}));
|
||||||
state.wake_zone_control();
|
state.wake_zone_control();
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
|||||||
+54
-8
@@ -4,6 +4,8 @@ async fn get_settings(State(state): State<AppState>) -> Json<Value> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn update_settings(State(state): State<AppState>, Json(mut input): Json<RuntimeSettings>) -> Result<Json<Value>, AppError> {
|
async fn update_settings(State(state): State<AppState>, Json(mut input): Json<RuntimeSettings>) -> Result<Json<Value>, AppError> {
|
||||||
|
let _configuration_guard = state.lock_configuration_operation().await;
|
||||||
|
let _house_guard = state.lock_house_operation().await;
|
||||||
let old = state.settings.read().await.clone();
|
let old = state.settings.read().await.clone();
|
||||||
if input.house_power_enabled != old.house_power_enabled || input.house_mode != old.house_mode {
|
if input.house_power_enabled != old.house_power_enabled || input.house_mode != old.house_mode {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
@@ -46,7 +48,7 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
|
|||||||
if !matches!(parsed.scheme(), "http" | "https") { return Err(AppError::BadRequest("Home Assistant URL must use http or https".into())); }
|
if !matches!(parsed.scheme(), "http" | "https") { return Err(AppError::BadRequest("Home Assistant URL must use http or https".into())); }
|
||||||
}
|
}
|
||||||
state.db.save_runtime_settings(&input)?;
|
state.db.save_runtime_settings(&input)?;
|
||||||
canonicalize_saved_zone_entities(&state, &input)?;
|
canonicalize_saved_zone_entities(&state, &input).await?;
|
||||||
state.debug_gree_frames.store(input.debug.gree_frames, Ordering::Relaxed);
|
state.debug_gree_frames.store(input.debug.gree_frames, Ordering::Relaxed);
|
||||||
*state.settings.write().await = input.clone();
|
*state.settings.write().await = input.clone();
|
||||||
state.log("info", "settings.updated", "Settings updated", json!({}));
|
state.log("info", "settings.updated", "Settings updated", json!({}));
|
||||||
@@ -84,8 +86,15 @@ fn canonicalize_zone_ha_entity(zone: &mut Zone, settings: &RuntimeSettings) {
|
|||||||
zone.ha_entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&configured));
|
zone.ha_entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&configured));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn canonicalize_saved_zone_entities(state: &AppState, settings: &RuntimeSettings) -> Result<(), AppError> {
|
async fn canonicalize_saved_zone_entities(state: &AppState, settings: &RuntimeSettings) -> Result<(), AppError> {
|
||||||
for mut zone in state.db.list_zones()? {
|
let mut zone_ids: Vec<String> = 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();
|
let previous = zone.ha_entity_id.clone();
|
||||||
canonicalize_zone_ha_entity(&mut zone, settings);
|
canonicalize_zone_ha_entity(&mut zone, settings);
|
||||||
if zone.ha_entity_id != previous {
|
if zone.ha_entity_id != previous {
|
||||||
@@ -314,6 +323,36 @@ async fn import_settings(State(state): State<AppState>, Json(mut export): Json<C
|
|||||||
validate_night_mode(&mut export.settings)?;
|
validate_night_mode(&mut export.settings)?;
|
||||||
export.settings.influxdb.history_threshold_days = export.settings.influxdb.history_threshold_days.clamp(1, 3650);
|
export.settings.influxdb.history_threshold_days = export.settings.influxdb.history_threshold_days.clamp(1, 3650);
|
||||||
|
|
||||||
|
// Configuration replacement is the broadest mutation in the application. Serialize it
|
||||||
|
// against every structural editor and every live owner that can write zone/device state.
|
||||||
|
// Global lock order: configuration -> 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<String> = 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<String> = 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
|
// Before replacing ownership, safely stop every currently managed device whose zone is
|
||||||
// removed or rewired by the imported configuration. Otherwise an orphaned physical unit
|
// removed or rewired by the imported configuration. Otherwise an orphaned physical unit
|
||||||
// could keep running after its database owner disappears.
|
// could keep running after its database owner disappears.
|
||||||
@@ -321,13 +360,20 @@ async fn import_settings(State(state): State<AppState>, Json(mut export): Json<C
|
|||||||
.map(|zone| (zone.id.clone(), zone.device_id.clone()))
|
.map(|zone| (zone.id.clone(), zone.device_id.clone()))
|
||||||
.collect();
|
.collect();
|
||||||
let mut detach_devices = std::collections::HashSet::new();
|
let mut detach_devices = std::collections::HashSet::new();
|
||||||
for current in state.db.list_zones()? {
|
for current in ¤t_zones {
|
||||||
if imported_zone_map.get(¤t.id).map(String::as_str) != Some(current.device_id.as_str()) {
|
if imported_zone_map.get(¤t.id).map(String::as_str) != Some(current.device_id.as_str()) {
|
||||||
detach_devices.insert(current.device_id);
|
detach_devices.insert(current.device_id.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for device_id in detach_devices {
|
for device_id in detach_devices {
|
||||||
ensure_device_stopped_for_detach(&state, &device_id, "configuration.import").await?;
|
let Some(device) = state.db.get_device(&device_id)? else { continue; };
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
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": "configuration.import"
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configuration import never restores ephemeral owners/timers or cached physical state.
|
// Configuration import never restores ephemeral owners/timers or cached physical state.
|
||||||
@@ -353,7 +399,7 @@ async fn import_settings(State(state): State<AppState>, Json(mut export): Json<C
|
|||||||
.map(|zone| zone.device_id.clone())
|
.map(|zone| zone.device_id.clone())
|
||||||
.collect();
|
.collect();
|
||||||
for device in export.devices.iter().filter(|device| device.enabled && !controllable_devices.contains(&device.id)) {
|
for device in export.devices.iter().filter(|device| device.enabled && !controllable_devices.contains(&device.id)) {
|
||||||
if let Err(err) = engine::force_power_off_device(&state, &device.id).await {
|
if let Err(err) = engine::force_power_off_device_locked(&state, &device.id).await {
|
||||||
state.log("error", "settings.import_reconcile_error", &err.to_string(), json!({"device_id": device.id}));
|
state.log("error", "settings.import_reconcile_error", &err.to_string(), json!({"device_id": device.id}));
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
@@ -361,7 +407,7 @@ async fn import_settings(State(state): State<AppState>, Json(mut export): Json<C
|
|||||||
// Rebuild live device snapshots before allowing the thermostat loop to make decisions.
|
// Rebuild live device snapshots before allowing the thermostat loop to make decisions.
|
||||||
// Network failures are represented in device health by poll_one rather than reviving
|
// Network failures are represented in device health by poll_one rather than reviving
|
||||||
// imported cache values.
|
// imported cache values.
|
||||||
engine::poll_all(&state).await?;
|
engine::poll_all_locked(&state).await?;
|
||||||
state.initial_device_sync_complete.store(true, Ordering::Release);
|
state.initial_device_sync_complete.store(true, Ordering::Release);
|
||||||
state.wake_zone_control();
|
state.wake_zone_control();
|
||||||
state.log("info", "settings.imported", "Application configuration imported", json!({"format_version": export.format_version}));
|
state.log("info", "settings.imported", "Application configuration imported", json!({"format_version": export.format_version}));
|
||||||
|
|||||||
+30
-8
@@ -128,9 +128,15 @@ async fn get_zone(State(state): State<AppState>, Path(id): Path<String>) -> Resu
|
|||||||
state.db.get_zone(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("zone {id}")))
|
state.db.get_zone(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("zone {id}")))
|
||||||
}
|
}
|
||||||
async fn create_zone(State(state): State<AppState>, Json(input): Json<ZoneInput>) -> Result<(StatusCode, Json<Zone>), AppError> {
|
async fn create_zone(State(state): State<AppState>, Json(input): Json<ZoneInput>) -> Result<(StatusCode, Json<Zone>), AppError> {
|
||||||
|
let _configuration_guard = state.lock_configuration_operation().await;
|
||||||
|
let _reference_guard = state.lock_automation_operation().await;
|
||||||
input.validate()?;
|
input.validate()?;
|
||||||
if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); }
|
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)?;
|
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 mut zone = input.into_zone(Uuid::new_v4().to_string(), Utc::now());
|
||||||
let settings = state.settings.read().await.clone();
|
let settings = state.settings.read().await.clone();
|
||||||
canonicalize_zone_ha_entity(&mut zone, &settings);
|
canonicalize_zone_ha_entity(&mut zone, &settings);
|
||||||
@@ -139,6 +145,8 @@ async fn create_zone(State(state): State<AppState>, Json(input): Json<ZoneInput>
|
|||||||
Ok((StatusCode::CREATED, Json(zone)))
|
Ok((StatusCode::CREATED, Json(zone)))
|
||||||
}
|
}
|
||||||
async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<ZoneInput>) -> Result<Json<Zone>, AppError> {
|
async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<ZoneInput>) -> Result<Json<Zone>, AppError> {
|
||||||
|
let _configuration_guard = state.lock_configuration_operation().await;
|
||||||
|
let _reference_guard = state.lock_automation_operation().await;
|
||||||
input.validate()?;
|
input.validate()?;
|
||||||
let _zone_guard = state.lock_zone_operation(&id).await;
|
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}")))?;
|
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<AppState>, Path(id): Path<String>, Json
|
|||||||
if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); }
|
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))?;
|
validate_zone_device_assignment(&state, &input.device_id, Some(&id))?;
|
||||||
let device_changed = existing.device_id != input.device_id;
|
let device_changed = existing.device_id != input.device_id;
|
||||||
// Serialize a normal zone edit with polling/manual-takeover detection for its device.
|
// Keep the zone lock while taking all involved device locks in stable order. This makes a
|
||||||
// Device reassignment uses ensure_device_stopped_for_detach below, which acquires the
|
// reassignment atomic against polling of both the old and the new unit and preserves the
|
||||||
// old device lock itself while this zone lock is held.
|
// global zone -> device ordering used by live control paths.
|
||||||
let _device_guard = if !device_changed { Some(state.lock_device_operation(&existing.device_id).await) } else { None };
|
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 {
|
if !device_changed {
|
||||||
// Polling may have updated takeover/runtime state while we were waiting for the
|
// 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.
|
// device lock. Re-read under both locks before building the replacement Zone.
|
||||||
@@ -203,7 +217,7 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
|
|||||||
} else {
|
} else {
|
||||||
// A new physical unit starts with a clean ownership/runtime state. Never transfer
|
// A new physical unit starts with a clean ownership/runtime state. Never transfer
|
||||||
// demand, sensor cache or remote-control takeover from the previous device.
|
// 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);
|
zone.revision = existing.revision.saturating_add(1);
|
||||||
}
|
}
|
||||||
let settings = state.settings.read().await.clone();
|
let settings = state.settings.read().await.clone();
|
||||||
@@ -223,7 +237,7 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
|
|||||||
}
|
}
|
||||||
state.db.save_zone(&zone)?;
|
state.db.save_zone(&zone)?;
|
||||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||||
drop(_device_guard);
|
drop(device_guards);
|
||||||
if power_off_device {
|
if power_off_device {
|
||||||
power_off_zone_device(&state, &zone, "zone.disabled").await;
|
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 },
|
} else { None },
|
||||||
deferred_mode: existing_session.as_ref().and_then(|session| session.deferred_mode.clone()),
|
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_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_expires_at: if immediate_activation && is_temperature_condition {
|
||||||
safety_duration_seconds.map(|seconds| now.clone() + ChronoDuration::seconds(seconds as i64))
|
safety_duration_seconds.map(|seconds| now.clone() + ChronoDuration::seconds(seconds as i64))
|
||||||
} else { safety_expires_at },
|
} 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 engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) {
|
||||||
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
||||||
session.deferred_preset = Some(value.to_string());
|
session.deferred_preset = Some(value.to_string());
|
||||||
|
if value != "custom" { session.deferred_setpoint = None; }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
match value {
|
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 engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) {
|
||||||
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
||||||
session.deferred_preset = Some("auto".into());
|
session.deferred_preset = Some("auto".into());
|
||||||
|
session.deferred_setpoint = None;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
zone.manual_preset = None;
|
zone.manual_preset = None;
|
||||||
@@ -639,20 +656,25 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
async fn ensure_device_stopped_for_detach(state: &AppState, device_id: &str, source: &str) -> 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(()); };
|
let Some(device) = state.db.get_device(device_id)? else { return Ok(()); };
|
||||||
if !device.enabled {
|
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()));
|
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
|
// 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.
|
// 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!({
|
state.log("info", "zone.detach_power_off", &format!("Powered off {} before detaching thermostat ownership", device.name), json!({
|
||||||
"device_id": device.id, "source": source
|
"device_id": device.id, "source": source
|
||||||
}));
|
}));
|
||||||
Ok(())
|
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) {
|
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; };
|
let Ok(Some(device)) = state.db.get_device(&zone.device_id) else { return; };
|
||||||
if !device.enabled { return; }
|
if !device.enabled { return; }
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
async fn run_automations(state: &AppState) -> Result<()> {
|
async fn run_automations(state: &AppState) -> Result<()> {
|
||||||
if !state.settings.read().await.house_power_enabled { return Ok(()); }
|
if !state.settings.read().await.house_power_enabled { return Ok(()); }
|
||||||
let devices = state.db.list_devices()?;
|
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()?;
|
let mut automations = state.db.list_automations()?;
|
||||||
// Stable arbitration for same-cycle conflicts: the oldest configured rule wins, then ID.
|
// Stable arbitration for same-cycle conflicts: the oldest configured rule wins, then ID.
|
||||||
// This avoids database row order deciding the physical outcome (M2).
|
// This avoids database row order deciding the physical outcome (M2).
|
||||||
@@ -20,6 +18,20 @@ async fn run_automations(state: &AppState) -> Result<()> {
|
|||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
if !should_fire { continue; }
|
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()
|
if item.action_group_id.is_none()
|
||||||
&& device_blocked_by_disabled_zone(&item.action_device_id, &zones)
|
&& device_blocked_by_disabled_zone(&item.action_device_id, &zones)
|
||||||
&& item.action.power != Some(true)
|
&& item.action.power != Some(true)
|
||||||
@@ -75,7 +87,8 @@ async fn run_automations(state: &AppState) -> Result<()> {
|
|||||||
power: item.action.power,
|
power: item.action.power,
|
||||||
mode: group_mode,
|
mode: group_mode,
|
||||||
preset: item.action_preset.clone(),
|
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 {
|
} else {
|
||||||
match apply_automatic_device_action(state, &item.action_device_id, item.action.clone()).await {
|
match apply_automatic_device_action(state, &item.action_device_id, item.action.clone()).await {
|
||||||
Ok(Some(_)) => Ok(true),
|
Ok(Some(_)) => Ok(true),
|
||||||
|
|||||||
+55
-8
@@ -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 let Some(preset) = patch.preset.as_deref() {
|
||||||
if !matches!(preset, "auto" | "comfort" | "sleep" | "away") {
|
if !matches!(preset, "auto" | "comfort" | "sleep" | "away" | "custom") {
|
||||||
return Err(AppError::BadRequest("group preset must be auto, comfort, sleep or away".into()));
|
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)?
|
let mut group = state.db.get_group(group_id)?
|
||||||
.ok_or_else(|| AppError::NotFound(format!("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 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 {
|
if let Some(power) = patch.power {
|
||||||
group.power_enabled = 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
|
// 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
|
// 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.
|
// 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;
|
let mut settings = state.settings.write().await;
|
||||||
if !settings.house_power_enabled {
|
if !settings.house_power_enabled {
|
||||||
settings.house_power_enabled = true;
|
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();
|
let mut zones = Vec::new();
|
||||||
for zone_id in &group.zone_ids {
|
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 Some(zone_snapshot) = state.db.get_zone(zone_id)? else { continue; };
|
||||||
let _device_guard = state.lock_device_operation(&zone_snapshot.device_id).await;
|
let _device_guard = state.lock_device_operation(&zone_snapshot.device_id).await;
|
||||||
let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; };
|
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 temporary_owns_zone {
|
||||||
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
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(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 {
|
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!({
|
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;
|
zone.manual_override_until = None;
|
||||||
} else {
|
} else {
|
||||||
zone.manual_preset = Some(preset.to_string());
|
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());
|
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.revision = zone.revision.saturating_add(1);
|
||||||
zone.updated_at = Utc::now();
|
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.wake_zone_control();
|
||||||
}
|
}
|
||||||
state.log("info", source, &format!("Updated group {}", group.name), json!({
|
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,
|
"zones": zones.len(), "failed": failed.len(), "master_power_enabled": master_power_enabled,
|
||||||
}));
|
}));
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
|
|||||||
@@ -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<Device, AppError> {
|
pub async fn send_manual_command(state: &AppState, device_id: &str, command: DeviceCommand, source: &str) -> Result<Device, AppError> {
|
||||||
// Keep zone -> device lock ordering consistent with Quick Thermostat/full-zone edits.
|
// 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.
|
// A device belongs to at most one thermostat zone, but keep this generic for legacy data.
|
||||||
let zone_ids: Vec<String> = state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id).map(|zone| zone.id).collect();
|
let mut zone_ids: Vec<String> = 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();
|
let mut _zone_guards = Vec::new();
|
||||||
for zone_id in &zone_ids { _zone_guards.push(state.lock_zone_operation(zone_id).await); }
|
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
|
// 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
|
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<Device, AppError> {
|
||||||
|
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
|
/// 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.
|
/// 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<Device, AppError> {
|
pub async fn disable_device_safely(state: &AppState, device_id: &str) -> Result<Device, AppError> {
|
||||||
|
|||||||
@@ -1,8 +1,27 @@
|
|||||||
|
async fn lock_poll_zone_operations(state: &AppState, device_id: &str) -> Result<Vec<tokio::sync::OwnedMutexGuard<()>>, AppError> {
|
||||||
|
let mut zone_ids: Vec<String> = 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<Device, AppError> {
|
pub async fn poll_one(state: &AppState, device_id: &str) -> Result<Device, AppError> {
|
||||||
|
// 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;
|
let _device_guard = state.lock_device_operation(device_id).await;
|
||||||
poll_one_locked(state, 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<Device, AppError> {
|
async fn poll_one_locked(state: &AppState, device_id: &str) -> Result<Device, AppError> {
|
||||||
let mut device = state.db.get_device(device_id)?
|
let mut device = state.db.get_device(device_id)?
|
||||||
.ok_or_else(|| AppError::NotFound(format!("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)
|
.map(|device| device.id)
|
||||||
.collect();
|
.collect();
|
||||||
for device_id in device_ids {
|
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 _device_guard = state.lock_device_operation(&device_id).await;
|
||||||
let _ = poll_one_locked(state, &device_id).await?;
|
let _ = poll_one_locked(state, &device_id).await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
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<String> = 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) {
|
async fn poll_device(state: &AppState, device: &mut Device) {
|
||||||
if device.simulated {
|
if device.simulated {
|
||||||
simulate_tick(device);
|
simulate_tick(device);
|
||||||
|
|||||||
@@ -73,8 +73,20 @@ pub fn finish_temporary_quick_thermostat(zone: &mut Zone, schedules: &[Schedule]
|
|||||||
zone.manual_preset = None;
|
zone.manual_preset = None;
|
||||||
zone.manual_setpoint = None;
|
zone.manual_setpoint = None;
|
||||||
zone.manual_override_until = 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());
|
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());
|
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() {
|
for zone in zones.iter_mut() {
|
||||||
let zone_id = zone.id.clone();
|
let zone_id = zone.id.clone();
|
||||||
let _zone_guard = state.lock_zone_operation(&zone_id).await;
|
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; };
|
let Some(latest) = state.db.get_zone(&zone_id)? else { continue; };
|
||||||
*zone = latest;
|
*zone = latest;
|
||||||
let active_under_manual = zone.device_manual_override
|
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() {
|
for zone in zones.iter_mut() {
|
||||||
let zone_id = zone.id.clone();
|
let zone_id = zone.id.clone();
|
||||||
let _zone_guard = state.lock_zone_operation(&zone_id).await;
|
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; };
|
let Some(latest) = state.db.get_zone(&zone_id)? else { continue; };
|
||||||
*zone = latest;
|
*zone = latest;
|
||||||
let Some((session_state, started_at)) = zone.temporary_quick_thermostat.as_ref()
|
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() {
|
for zone in zones.iter_mut() {
|
||||||
let zone_id = zone.id.clone();
|
let zone_id = zone.id.clone();
|
||||||
let _zone_guard = state.lock_zone_operation(&zone_id).await;
|
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; };
|
let Some(latest) = state.db.get_zone(&zone_id)? else { continue; };
|
||||||
*zone = latest;
|
*zone = latest;
|
||||||
// A direct device/pilot takeover has higher priority than the local-OFF hand-back.
|
// A direct device/pilot takeover has higher priority than the local-OFF hand-back.
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ mod tests {
|
|||||||
paused_at: None,
|
paused_at: None,
|
||||||
deferred_mode: None,
|
deferred_mode: None,
|
||||||
deferred_preset: None,
|
deferred_preset: None,
|
||||||
|
deferred_setpoint: None,
|
||||||
safety_expires_at: None,
|
safety_expires_at: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -309,6 +310,20 @@ mod tests {
|
|||||||
assert_eq!(zone.manual_setpoint, Some(22.0));
|
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]
|
#[test]
|
||||||
fn temporary_session_without_activation_marker_is_never_active() {
|
fn temporary_session_without_activation_marker_is_never_active() {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ async fn apply_automatic_device_action(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let _zone_guard = state.lock_zone_operation(&zone_id).await;
|
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 mut zone = state.db.get_zone(&zone_id)?.ok_or_else(|| AppError::NotFound(format!("zone {zone_id}")))?;
|
||||||
let settings = state.settings.read().await.clone();
|
let settings = state.settings.read().await.clone();
|
||||||
if !settings.house_power_enabled || zone.device_manual_override || zone.local_thermostat_power.is_some() {
|
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) {
|
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
|
// 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() {
|
if residual.is_empty() {
|
||||||
return state.db.get_device(device_id)?.map(Some).ok_or_else(|| AppError::NotFound(format!("device {device_id}")));
|
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
|
send_automatic_device_command_if_owned(state, device_id, residual).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,11 @@ async fn main() -> Result<()> {
|
|||||||
zone_control_wakeup: Arc::new(Notify::new()),
|
zone_control_wakeup: Arc::new(Notify::new()),
|
||||||
device_operation_locks: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
|
device_operation_locks: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
|
||||||
zone_operation_locks: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
|
zone_operation_locks: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
|
||||||
|
group_operation_locks: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
|
||||||
|
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())),
|
pending_controller_commands: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
|
||||||
started: Instant::now(),
|
started: Instant::now(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ pub struct TemporaryQuickThermostat {
|
|||||||
pub deferred_mode: Option<String>,
|
pub deferred_mode: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub deferred_preset: Option<String>,
|
pub deferred_preset: Option<String>,
|
||||||
|
/// Group custom target received while the temporary thermostat owns the zone.
|
||||||
|
#[serde(default)]
|
||||||
|
pub deferred_setpoint: Option<f64>,
|
||||||
/// Optional fail-safe for temperature-based modes.
|
/// Optional fail-safe for temperature-based modes.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub safety_expires_at: Option<DateTime<Utc>>,
|
pub safety_expires_at: Option<DateTime<Utc>>,
|
||||||
|
|||||||
+4
-1
@@ -193,9 +193,12 @@ pub struct GroupControlPatch {
|
|||||||
/// house follows the global house mode; cool/heat set an explicit mode on every member zone.
|
/// house follows the global house mode; cool/heat set an explicit mode on every member zone.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mode: Option<String>,
|
pub mode: Option<String>,
|
||||||
/// 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)]
|
#[serde(default)]
|
||||||
pub preset: Option<String>,
|
pub preset: Option<String>,
|
||||||
|
/// Optional custom target temperature for the whole group. Used together with preset=custom.
|
||||||
|
#[serde(default)]
|
||||||
|
pub setpoint: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ pub struct AppState {
|
|||||||
pub zone_control_wakeup: Arc<Notify>,
|
pub zone_control_wakeup: Arc<Notify>,
|
||||||
pub(crate) device_operation_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
|
pub(crate) device_operation_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
|
||||||
pub(crate) zone_operation_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
|
pub(crate) zone_operation_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
|
||||||
|
pub(crate) group_operation_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
|
||||||
|
pub(crate) schedule_operation_lock: Arc<Mutex<()>>,
|
||||||
|
pub(crate) automation_operation_lock: Arc<Mutex<()>>,
|
||||||
|
pub(crate) house_operation_lock: Arc<Mutex<()>>,
|
||||||
|
pub(crate) configuration_operation_lock: Arc<Mutex<()>>,
|
||||||
/// Short-lived expected climate state from controller-originated commands. It prevents
|
/// Short-lived expected climate state from controller-originated commands. It prevents
|
||||||
/// a delayed GREE status update from being mistaken for remote/manual takeover.
|
/// a delayed GREE status update from being mistaken for remote/manual takeover.
|
||||||
pub(crate) pending_controller_commands: Arc<Mutex<HashMap<String, PendingControllerCommand>>>,
|
pub(crate) pending_controller_commands: Arc<Mutex<HashMap<String, PendingControllerCommand>>>,
|
||||||
@@ -55,6 +60,30 @@ impl AppState {
|
|||||||
lock.lock_owned().await
|
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) {
|
pub fn wake_zone_control(&self) {
|
||||||
self.zone_control_wakeup.notify_one();
|
self.zone_control_wakeup.notify_one();
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -437,7 +437,7 @@
|
|||||||
<div class="profile-grid"><strong data-i18n="mode.cool">Cooling</strong><label><span data-i18n="preset.comfort">Comfort</span><input type="text" inputmode="decimal" step="0.5" name="cool_comfort_setpoint" value="23"></label><label><span data-i18n="preset.sleep">Sleep</span><input type="text" inputmode="decimal" step="0.5" name="cool_sleep_setpoint" value="24.5"></label><label><span data-i18n="preset.away">Away</span><input type="text" inputmode="decimal" step="0.5" name="cool_away_setpoint" value="27"></label></div>
|
<div class="profile-grid"><strong data-i18n="mode.cool">Cooling</strong><label><span data-i18n="preset.comfort">Comfort</span><input type="text" inputmode="decimal" step="0.5" name="cool_comfort_setpoint" value="23"></label><label><span data-i18n="preset.sleep">Sleep</span><input type="text" inputmode="decimal" step="0.5" name="cool_sleep_setpoint" value="24.5"></label><label><span data-i18n="preset.away">Away</span><input type="text" inputmode="decimal" step="0.5" name="cool_away_setpoint" value="27"></label></div>
|
||||||
<div class="profile-grid"><strong data-i18n="mode.heat">Heating</strong><label><span data-i18n="preset.comfort">Comfort</span><input type="text" inputmode="decimal" step="0.5" name="heat_comfort_setpoint" value="21"></label><label><span data-i18n="preset.sleep">Sleep</span><input type="text" inputmode="decimal" step="0.5" name="heat_sleep_setpoint" value="19"></label><label><span data-i18n="preset.away">Away</span><input type="text" inputmode="decimal" step="0.5" name="heat_away_setpoint" value="17"></label></div>
|
<div class="profile-grid"><strong data-i18n="mode.heat">Heating</strong><label><span data-i18n="preset.comfort">Comfort</span><input type="text" inputmode="decimal" step="0.5" name="heat_comfort_setpoint" value="21"></label><label><span data-i18n="preset.sleep">Sleep</span><input type="text" inputmode="decimal" step="0.5" name="heat_sleep_setpoint" value="19"></label><label><span data-i18n="preset.away">Away</span><input type="text" inputmode="decimal" step="0.5" name="heat_away_setpoint" value="17"></label></div>
|
||||||
</details>
|
</details>
|
||||||
<div class="two"><div class="hysteresis-config"><label id="commonHysteresisField"><span data-i18n="zones.hysteresis">Hysteresis °C</span><input type="text" inputmode="decimal" step="0.1" min="0.1" max="5" name="hysteresis" value="0.6"></label><label class="check"><input type="checkbox" name="separate_hysteresis"> <span data-i18n="zones.separateHysteresis">Set heating and cooling hysteresis separately</span></label></div><label><span data-i18n="zones.source">Temperature strategy</span><select name="sensor_source"><option value="combined" selected data-i18n="zones.combinedSensor">GREE + room sensor</option><option value="device" data-i18n="zones.greeSensor">GREE only</option><option value="home_assistant" data-i18n="zones.externalSensor">Room sensor only</option></select></label></div>
|
<div class="two"><div class="hysteresis-config"><label id="commonHysteresisField"><span data-i18n="zones.hysteresis">Hysteresis °C</span><input type="text" inputmode="decimal" step="0.1" min="0.1" max="5" name="hysteresis" value="0.6"></label><label class="check" data-i18n-title="zones.separateHysteresisHint" title="Use different hysteresis values for heating and cooling"><input type="checkbox" name="separate_hysteresis"> <span data-i18n="zones.separateHysteresis">Set heating and cooling hysteresis separately</span></label></div><label><span data-i18n="zones.source">Temperature strategy</span><select name="sensor_source"><option value="combined" selected data-i18n="zones.combinedSensor">GREE + room sensor</option><option value="device" data-i18n="zones.greeSensor">GREE only</option><option value="home_assistant" data-i18n="zones.externalSensor">Room sensor only</option></select></label></div>
|
||||||
<div id="separateHysteresisFields" class="two" hidden><label><span data-i18n="zones.coolHysteresis">Cooling hysteresis °C</span><input type="text" inputmode="decimal" step="0.1" min="0.1" max="5" name="cool_hysteresis" value="0.6"></label><label><span data-i18n="zones.heatHysteresis">Heating hysteresis °C</span><input type="text" inputmode="decimal" step="0.1" min="0.1" max="5" name="heat_hysteresis" value="0.6"></label></div>
|
<div id="separateHysteresisFields" class="two" hidden><label><span data-i18n="zones.coolHysteresis">Cooling hysteresis °C</span><input type="text" inputmode="decimal" step="0.1" min="0.1" max="5" name="cool_hysteresis" value="0.6"></label><label><span data-i18n="zones.heatHysteresis">Heating hysteresis °C</span><input type="text" inputmode="decimal" step="0.1" min="0.1" max="5" name="heat_hysteresis" value="0.6"></label></div>
|
||||||
<div id="externalSensorFields">
|
<div id="externalSensorFields">
|
||||||
<label><span data-i18n="zones.roomSensorEntity">Room sensor entity_id</span><input name="ha_entity_id" placeholder="sensor.living_room_temperature"></label>
|
<label><span data-i18n="zones.roomSensorEntity">Room sensor entity_id</span><input name="ha_entity_id" placeholder="sensor.living_room_temperature"></label>
|
||||||
|
|||||||
+1
-1
@@ -31,7 +31,7 @@ const app = {
|
|||||||
languages: [], translations: {}, locales: {},
|
languages: [], translations: {}, locales: {},
|
||||||
historyTab: 'overview', historyData: {zones:[], devices:[], sensors:[]}, historyCounts: {},
|
historyTab: 'overview', historyData: {zones:[], devices:[], sensors:[]}, historyCounts: {},
|
||||||
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false,
|
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false,
|
||||||
customChartSeries: [], savedCharts: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, zoneTemperatureTimers: {},
|
customChartSeries: [], savedCharts: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, groupControlSeq: {}, zoneControlQueue: {}, groupControlQueue: {}, zoneTemperatureTimers: {},
|
||||||
controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {},
|
controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {},
|
||||||
simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false, dashboardTab: 'main', settingsTab: 'app', systemSnapshotAt: Date.now(),
|
simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false, dashboardTab: 'main', settingsTab: 'app', systemSnapshotAt: Date.now(),
|
||||||
};
|
};
|
||||||
|
|||||||
+27
-3
@@ -208,10 +208,18 @@ function groupState(group) {
|
|||||||
const zones = groupZones(group);
|
const zones = groupZones(group);
|
||||||
const modes = [...new Set(zones.map(zone => zone.inherit_house_mode ? 'house' : (zone.mode || 'cool')))];
|
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 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 {
|
return {
|
||||||
zones,
|
zones,
|
||||||
mode: modes.length === 1 ? modes[0] : (modes.length ? 'mixed' : 'house'),
|
mode: modes.length === 1 ? modes[0] : (modes.length ? 'mixed' : 'house'),
|
||||||
preset: presets.length === 1 ? presets[0] : (presets.length ? 'mixed' : 'auto'),
|
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 masterOff = app.settings?.house_power_enabled === false;
|
||||||
const modeLabelForGroup = value => value === 'house' ? tr('groups.followHouse') : (value === 'mixed' ? tr('groups.mixed') : modeLabel(value));
|
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 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 `<article class="list-card group-card ${powerEnabled ? '' : 'group-off'}">
|
return `<article class="list-card group-card ${powerEnabled ? '' : 'group-off'}">
|
||||||
<div class="list-card-head"><div><span class="eyebrow">${esc(tr('groups.group'))}</span><h3>${esc(group.name)}</h3><p>${esc(memberText)}</p></div></div>
|
<div class="list-card-head"><div><span class="eyebrow">${esc(tr('groups.group'))}</span><h3>${esc(group.name)}</h3><p>${esc(memberText)}</p></div></div>
|
||||||
${detailed ? `<div class="group-state-summary"><div><small>${esc(tr('groups.mode'))}</small><strong>${esc(modeLabelForGroup(state.mode))}</strong></div><div><small>${esc(tr('groups.profile'))}</small><strong>${esc(presetLabelForGroup(state.preset))}</strong></div><div><small>${esc(tr('groups.members'))}</small><strong>${state.zones.length}</strong></div></div>` : ''}
|
${detailed ? `<div class="group-state-summary"><div><small>${esc(tr('groups.mode'))}</small><strong>${esc(modeLabelForGroup(state.mode))}</strong></div><div><small>${esc(tr('groups.profile'))}</small><strong>${esc(presetLabelForGroup(state.preset))}</strong></div><div><small>${esc(tr('groups.members'))}</small><strong>${state.zones.length}</strong></div></div>` : ''}
|
||||||
<div class="group-control-block"><small>${esc(tr('common.power'))}</small><div class="group-button-row"><button class="${powerEnabled ? 'active' : ''}" data-action="group-power" data-id="${esc(group.id)}" data-value="true">${esc(tr('common.on'))}</button><button class="${!powerEnabled ? 'active' : ''}" data-action="group-power" data-id="${esc(group.id)}" data-value="false">${esc(tr('common.off'))}</button></div></div>
|
<div class="group-control-block"><small>${esc(tr('common.power'))}</small><div class="group-button-row"><button class="${powerEnabled ? 'active' : ''}" data-action="group-power" data-id="${esc(group.id)}" data-value="true">${esc(tr('common.on'))}</button><button class="${!powerEnabled ? 'active' : ''}" data-action="group-power" data-id="${esc(group.id)}" data-value="false">${esc(tr('common.off'))}</button></div></div>
|
||||||
<div class="group-control-block"><small>${esc(tr('groups.mode'))}</small><div class="mode-row group-mode-row">${['house','heat','cool'].map(mode => `<button class="${state.mode === mode ? 'active' : ''}" data-action="group-mode" data-id="${esc(group.id)}" data-value="${mode}">${esc(mode === 'house' ? tr('groups.followHouse') : modeLabel(mode))}</button>`).join('')}</div></div>
|
<div class="group-control-block"><small>${esc(tr('groups.mode'))}</small><div class="mode-row group-mode-row">${['house','heat','cool'].map(mode => `<button class="${state.mode === mode ? 'active' : ''}" data-action="group-mode" data-id="${esc(group.id)}" data-value="${mode}">${esc(mode === 'house' ? tr('groups.followHouse') : modeLabel(mode))}</button>`).join('')}</div></div>
|
||||||
<div class="group-control-block"><small>${esc(tr('groups.profile'))}</small><div class="preset-row group-preset-row">${['auto','comfort','sleep','away'].map(preset => `<button class="${state.preset === preset ? 'active' : ''}" data-action="group-preset" data-id="${esc(group.id)}" data-value="${preset}">${esc(zonePresetLabel(preset))}</button>`).join('')}</div></div>
|
<div class="group-control-block"><small>${esc(tr('groups.profile'))}</small><div class="preset-row group-preset-row">${['auto','comfort','sleep','away'].map(preset => `<button class="${state.preset === preset ? 'active' : ''}" data-action="group-preset" data-id="${esc(group.id)}" data-value="${preset}">${esc(zonePresetLabel(preset))}</button>`).join('')}<button class="${state.preset === 'custom' ? 'active' : ''}" data-action="group-custom-open" data-id="${esc(group.id)}">${esc(tr('preset.custom'))}</button></div>
|
||||||
|
<div class="group-custom-temperature" data-group-custom-editor="${esc(group.id)}" ${customEditorVisible ? '' : 'hidden'}><label><span>${esc(tr('groups.customTemperature'))}</span><div class="group-custom-temperature-row"><input type="text" inputmode="decimal" min="8" max="30" step="0.1" value="${customTarget.toFixed(1)}" data-group-custom-temperature="${esc(group.id)}" aria-label="${esc(tr('groups.customTemperature'))}" title="${esc(tr('groups.customTemperatureHint'))}"><span>°C</span><button type="button" class="primary" data-action="group-custom-temperature" data-id="${esc(group.id)}" title="${esc(tr('groups.applyCustomTemperatureHint'))}">${esc(tr('actions.apply'))}</button></div></label></div></div>
|
||||||
${masterOff ? `<p class="field-note warning-note">${esc(tr('groups.masterOff'))}</p>` : ''}
|
${masterOff ? `<p class="field-note warning-note">${esc(tr('groups.masterOff'))}</p>` : ''}
|
||||||
${detailed ? `<div class="card-footer"><small>${esc(tr('groups.memberCount', {count: state.zones.length}))}</small><div class="card-menu"><button data-action="edit-group" data-id="${esc(group.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-group" data-id="${esc(group.id)}">${esc(tr('actions.delete'))}</button></div></div>` : ''}
|
${detailed ? `<div class="card-footer"><small>${esc(tr('groups.memberCount', {count: state.zones.length}))}</small><div class="card-menu"><button data-action="edit-group" data-id="${esc(group.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-group" data-id="${esc(group.id)}">${esc(tr('actions.delete'))}</button></div></div>` : ''}
|
||||||
</article>`;
|
</article>`;
|
||||||
@@ -245,8 +257,16 @@ function renderGroups() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function sendGroupControl(id, patch) {
|
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 {
|
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) {
|
if (result.group) {
|
||||||
const index = app.groups.findIndex(group => group.id === result.group.id);
|
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);
|
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;
|
const failed = Array.isArray(result.failed) ? result.failed.length : 0;
|
||||||
if (failed) toast(tr('groups.partial', {count: failed}), true);
|
if (failed) toast(tr('groups.partial', {count: failed}), true);
|
||||||
else toast(tr('groups.controlUpdated'));
|
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) {
|
function renderGroupZoneChoices(selectedIds = null) {
|
||||||
|
|||||||
+20
-2
@@ -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-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-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-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-device-power') return sendZoneLocalPower(button.dataset.id, button.dataset.value === 'true');
|
||||||
if (action === 'zone-open-temporary') return populateTemporaryThermostat(button.dataset.id);
|
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; }
|
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);
|
$('#logLevelFilter')?.addEventListener('change', loadLogs); $('#logCategoryFilter')?.addEventListener('change', loadLogs);
|
||||||
$('#settingsForm [name=notifications_provider]')?.addEventListener('change', updateNotificationFields);
|
$('#settingsForm [name=notifications_provider]')?.addEventListener('change', updateNotificationFields);
|
||||||
$('#zoneForm [name=sensor_source]').addEventListener('change', updateZoneSensorFields);
|
$('#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}));
|
$('#zoneForm [name=separate_hysteresis]')?.addEventListener('change', () => updateZoneHysteresisFields({syncFromCommon:true}));
|
||||||
$('#scheduleForm [name=preset]').addEventListener('change', updateSchedulePresetField);
|
$('#scheduleForm [name=preset]').addEventListener('change', updateSchedulePresetField);
|
||||||
$('#automationForm [name=action_target_kind]')?.addEventListener('change', updateAutomationTargetFields);
|
$('#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;
|
const id = event.currentTarget.dataset.id; if (!id) return;
|
||||||
try {
|
try {
|
||||||
event.currentTarget.disabled = true;
|
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;
|
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'));
|
$('#temporaryThermostatDialog').close(); renderAll(); scheduleControlPlanLoad(); toast(tr('zones.temporaryStopped'));
|
||||||
} catch (error) { toast(error.message, true); }
|
} 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;
|
if (Number.isFinite(safety) && safety > 0) request.max_duration_minutes = safety;
|
||||||
}
|
}
|
||||||
await runFormTask(form, async () => {
|
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;
|
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'));
|
form.closest('dialog').close(); renderAll(); scheduleControlPlanLoad(); toast(tr(activeSession || startKind === 'now' ? 'zones.temporaryStarted' : 'zones.temporaryScheduledToast'));
|
||||||
}, {busyKey: activeSession ? 'actions.saving' : 'zones.temporaryStarting'});
|
}, {busyKey: activeSession ? 'actions.saving' : 'zones.temporaryStarting'});
|
||||||
|
|||||||
+22
-3
@@ -46,20 +46,37 @@ function updateDevice(device) {
|
|||||||
if (index >= 0) app.devices[index] = device; else app.devices.push(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) {
|
async function sendZoneLocalPower(id, power) {
|
||||||
|
const sequence = (app.zoneControlSeq[id] || 0) + 1;
|
||||||
|
app.zoneControlSeq[id] = sequence;
|
||||||
try {
|
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);
|
const index = app.zones.findIndex(item => item.id === zone.id);
|
||||||
if (index >= 0) app.zones[index] = zone; else app.zones.push(zone);
|
if (index >= 0) app.zones[index] = zone; else app.zones.push(zone);
|
||||||
renderAll(); scheduleControlPlanLoad(); toast(tr('zones.localPowerUpdated'));
|
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) {
|
async function sendZoneControl(id, patch) {
|
||||||
const sequence = (app.zoneControlSeq[id] || 0) + 1;
|
const sequence = (app.zoneControlSeq[id] || 0) + 1;
|
||||||
app.zoneControlSeq[id] = sequence;
|
app.zoneControlSeq[id] = sequence;
|
||||||
try {
|
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;
|
if (app.zoneControlSeq[id] !== sequence) return;
|
||||||
const index = app.zones.findIndex(item => item.id === zone.id);
|
const index = app.zones.findIndex(item => item.id === zone.id);
|
||||||
if (index >= 0) app.zones[index] = zone; else app.zones.push(zone);
|
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}={}) {
|
function queueZoneTemperature(zone, value, {snapToHalf=true}={}) {
|
||||||
const clamped = clamp(value, 8, 30);
|
const clamped = clamp(value, 8, 30);
|
||||||
const next = snapToHalf ? Math.round(clamped * 2) / 2 : Math.round(clamped * 10) / 10;
|
const next = snapToHalf ? Math.round(clamped * 2) / 2 : Math.round(clamped * 10) / 10;
|
||||||
@@ -93,6 +111,7 @@ function beginInlineTemperatureEdit(target) {
|
|||||||
input.inputMode = 'decimal';
|
input.inputMode = 'decimal';
|
||||||
input.value = value.toFixed(decimals);
|
input.value = value.toFixed(decimals);
|
||||||
input.setAttribute('aria-label', tr('common.targetTemperature'));
|
input.setAttribute('aria-label', tr('common.targetTemperature'));
|
||||||
|
input.title = tr('common.targetTemperature');
|
||||||
target.classList.add('editing');
|
target.classList.add('editing');
|
||||||
target.replaceChildren(input);
|
target.replaceChildren(input);
|
||||||
input.focus();
|
input.focus();
|
||||||
|
|||||||
+8
-1
@@ -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 { 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,.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-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-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 { 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); }
|
.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-mode-row,
|
||||||
.dashboard-group-strip .group-preset-row { display:grid; gap:5px; overflow:visible; padding:0; }
|
.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-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-button-row button,
|
||||||
.dashboard-group-strip .group-mode-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; }
|
.dashboard-group-strip .group-preset-row button { min-width:0; min-height:32px; padding:6px 5px; font-size:10px; line-height:1.15; }
|
||||||
|
|||||||
Reference in New Issue
Block a user