From b8ff31f205d3363a31454aa0a3fd0635f5b98b96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 3 Sep 2026 13:08:28 +0200 Subject: [PATCH] v0.12.0 --- Cargo.lock | 292 ++- Cargo.toml | 2 +- FILE_MANIFEST.sha256 | 76 +- README.md | 8 +- docs/API.md | 260 +-- docs/CODE_AUDIT_0.12.0.md | 75 + docs/openapi.json | 1699 +++++++++++++---- .../gree_controller/manifest.json | 2 +- make_zip.py | 0 mock/styles.css | 4 +- regenerate-sha.sh | 0 scripts/FILE_MANIFEST.sha256 | 4 +- scripts/common.sh | 0 scripts/configure-gree-network.sh | 0 scripts/dev.sh | 0 scripts/generate_ha_migration.py | 0 scripts/install-lxc.sh | 0 scripts/install.sh | 0 scripts/network-debug.sh | 0 scripts/regenerate-sha.sh | 0 scripts/service.sh | 0 scripts/smoke.sh | 14 +- scripts/update.sh | 0 scripts/verify_flow_logic.py | 4 +- src/api.rs | 19 +- src/api/configuration.rs | 521 +++++ src/api/debug_tokens.rs | 15 - src/api/events.rs | 24 - src/api/flows.rs | 71 +- src/api/house.rs | 31 +- src/api/settings.rs | 834 ++++---- src/api/system.rs | 2 +- src/api/zones.rs | 439 +++-- src/config.rs | 1 - src/db/configuration.rs | 2 +- src/engine.rs | 2 +- src/engine/automations.rs | 2 +- src/engine/control_plan.rs | 2 +- src/engine/groups.rs | 316 ++- src/engine/ownership.rs | 2 +- src/engine/temporary_thermostat.rs | 5 - src/engine/tests.rs | 10 +- src/engine/zone_control.rs | 534 +++--- src/main.rs | 4 - src/models.rs | 1 + src/models/runtime.rs | 4 - src/models/settings_api.rs | 113 ++ web/js/bootstrap.js | 8 +- web/js/entities.js | 1 - web/js/realtime.js | 89 +- web/js/settings.js | 290 +-- web/styles.css | 4 +- 52 files changed, 3874 insertions(+), 1912 deletions(-) create mode 100644 docs/CODE_AUDIT_0.12.0.md mode change 100644 => 100755 make_zip.py mode change 100644 => 100755 regenerate-sha.sh mode change 100644 => 100755 scripts/common.sh mode change 100644 => 100755 scripts/configure-gree-network.sh mode change 100644 => 100755 scripts/dev.sh mode change 100644 => 100755 scripts/generate_ha_migration.py mode change 100644 => 100755 scripts/install-lxc.sh mode change 100644 => 100755 scripts/install.sh mode change 100644 => 100755 scripts/network-debug.sh mode change 100644 => 100755 scripts/regenerate-sha.sh mode change 100644 => 100755 scripts/service.sh mode change 100644 => 100755 scripts/smoke.sh mode change 100644 => 100755 scripts/update.sh mode change 100644 => 100755 scripts/verify_flow_logic.py create mode 100644 src/api/configuration.rs create mode 100644 src/models/settings_api.rs diff --git a/Cargo.lock b/Cargo.lock index ea477ac..08513df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,7 +14,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -129,6 +129,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "async-compression" version = "0.4.43" @@ -255,6 +264,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -326,7 +344,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] @@ -393,6 +411,12 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -426,6 +450,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + [[package]] name = "crypto-common" version = "0.1.7" @@ -437,6 +467,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ctr" version = "0.9.2" @@ -452,14 +491,36 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", ] [[package]] @@ -479,6 +540,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -633,7 +700,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.11.6" +version = "0.12.0" dependencies = [ "aes", "aes-gcm", @@ -650,7 +717,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "thiserror 2.0.20", "tokio", @@ -658,6 +725,7 @@ dependencies = [ "tracing", "tracing-subscriber", "url", + "utoipa-swagger-ui", "uuid", ] @@ -670,13 +738,19 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hashlink" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" dependencies = [ - "hashbrown", + "hashbrown 0.14.5", ] [[package]] @@ -730,6 +804,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.11.0" @@ -918,6 +1001,18 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + [[package]] name = "inout" version = "0.1.4" @@ -1039,6 +1134,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1312,6 +1417,18 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.18" @@ -1395,6 +1512,41 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rust-embed" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.119", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" +dependencies = [ + "sha2 0.11.0", + "walkdir", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -1461,6 +1613,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1541,7 +1702,7 @@ checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", ] [[package]] @@ -1552,7 +1713,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -1961,6 +2133,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1973,7 +2151,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -2013,6 +2191,52 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "utoipa" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "utoipa-swagger-ui" +version = "8.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5c80b4dd79ea382e8374d67dcce22b5c6663fa13a82ad3886441d1bbede5e35" +dependencies = [ + "axum", + "mime_guess", + "regex", + "rust-embed", + "serde", + "serde_json", + "utoipa", + "utoipa-swagger-ui-vendored", + "zip", +] + +[[package]] +name = "utoipa-swagger-ui-vendored" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2eebbbfe4093922c2b6734d7c679ebfebd704a0d7e56dfcb0d05818ce28977d" + [[package]] name = "uuid" version = "1.25.0" @@ -2043,6 +2267,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -2142,6 +2376,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -2392,8 +2635,37 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror 2.0.20", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml index 19a44bf..26cc0cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.11.6" +version = "0.12.0" edition = "2021" authors = ["GREE Controller contributors"] description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support" diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index fed51be..3a24ee8 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,13 +1,14 @@ d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -d3c8a734104ca6d68b1a07c41ef6832ca475e3fc257ccbbaa0afc3a7ad85bd17 ./Cargo.lock -31eed008472815e59f3a6b7eaa1f9186c407f49a15d50d77d0aaa136d1c3ca82 ./Cargo.toml +6877eacd3931f7922f3f379d46e8cd9de7a609a65060ce2285c09b9c95f230aa ./Cargo.lock +ebe64386eb553c298994df40f73a56b4980eedcd88a84cb310134070c1753eb3 ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -5467c49d65a990966d3778fb928f6c67cbca7be94d2d54022d479821f0457816 ./README.md +92633f07b839dcdeafa96cb3ffdaaa600f65387a9113ed595987e68982fed0d6 ./README.md 41dfdc6d099b54f87d4dd51f696122b3c88d3bf3ddf420bea8f4bc621ba920b0 ./build.rs -34a90343baf4d189c58887bc0cf6f65e824614676afc94b5ea8363e9d262ccc6 ./docs/API.md +588657a906ebabbc248509a472aaf7eb8d5fb6549de5065027a75f61afe91708 ./docs/API.md +1b522e1b61643733accf0a45b8b862cfb96e1468343c9819600e263fe67a2b96 ./docs/CODE_AUDIT_0.12.0.md 2e1e18fd8167dabfe2469c26e85cce62486c7cb6a502c63c6f6b0cd74d5885e0 ./docs/FLOW.md -f10b54081d00e6d20aac14d351cad767b874544196628d71631b14125f1e88ba ./docs/openapi.json +3ba861e16d523f962574334ed74d53d6be2669dcb05851578d7a5434e8856f4e ./docs/openapi.json a0893b2a56eb1523f1a72871842e9be2139a5fafba1f51ae942fc407a6e4ca34 ./home-assistant/README.md f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py 6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py @@ -18,7 +19,7 @@ ab08fbe40e9bb48ebcbfff98760aaa0c9434b61b705aaaadea0c09c255d99b0e ./home-assista 5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.png c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png -b088946ee13497f546363fda022000f0ce7476e78f04558a4af65374cb8c8e94 ./home-assistant/custom_components/gree_controller/manifest.json +9538b4a08e6c76dbd5850f3720f582253fd8e4542871891a2cf5552759fb334a ./home-assistant/custom_components/gree_controller/manifest.json 38d659d0273c0d1428679e44574a2666c605d1b3796d323b626baa40560788cf ./home-assistant/custom_components/gree_controller/number.py 39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./home-assistant/custom_components/gree_controller/sensor.py @@ -32,7 +33,7 @@ cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./home-assista d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py 1e7b1bcf4194abfb8bad0d601268af0547d58541731e98b74a3500d7629d0be0 ./mock/index.html 007c78e0cb404778985980b23bb5f8bbc7a8f04c80015204e24c321970e3b15e ./mock/mock.js -5ae5a29dee689e1ab0f433339d1499eb232cae81749ff083dd5be5c62d72fa0b ./mock/styles.css +c5d515b987d96ebfbc7593e9bac9aff64c8d54420f23cc8df5e2c84b14a69852 ./mock/styles.css d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./mock/theme-init.js b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json 1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json @@ -72,7 +73,7 @@ ae21459a261712bcb8d57594528b1648432e8d03a8a38502234829c0dbfef774 ./presets/week fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/window_available_guard.json 804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh -a35385734b1855dbea6d895075d54b17dce1621797cd65581a9404acff2ce645 ./scripts/FILE_MANIFEST.sha256 +e8fc6cb061f27fb81b069de1f08cfa2d3eb26557ec2221abda64b621b8092750 ./scripts/FILE_MANIFEST.sha256 bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh 6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh @@ -83,33 +84,33 @@ bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./scripts/inst e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./scripts/regenerate-sha.sh 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh -c714e3cf2fba84ded718d102bd620cf879c1a8f3a5ae8f435be7863d8a0cd8e0 ./scripts/smoke.sh +7e8f558b284362f55f50bd7223a89754a2a4960e746a27035cc61180f4a2da6f ./scripts/smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh -4877f9e8217b6a77fb416722c3778373edac874ed3a583bb016ea76d4ffee7d4 ./scripts/verify_flow_logic.py -3256e9b9a34e4ef9b69c27d630dad4a9b17a29afc894f56c65206e7aa78eab21 ./src/api.rs +0790b7b388467d9e75b621188cca473e22d73e01bcfa74e92fa23f92b0695855 ./scripts/verify_flow_logic.py +11a2bca9c4f349fb5fb5090e02607f26bddf692a739fea0384779820935fcea7 ./src/api.rs 825370f409124a719043200742c01c3441bc65f483108106140d13fe7bbcba91 ./src/api/assets.rs 6c34b1294d76b0eba4c56bfd0bf69bdd502de0e40838104f6ddf2db4e922683d ./src/api/auth.rs 63cb241c4536abed402f2a0ad37e7f299343df264eab6a33bb5354c15421e045 ./src/api/automations.rs -2317b7cd0c2437c128ac4982d64412c8184ecb5e774825761d61aab6e45fae4a ./src/api/debug_tokens.rs +f1e378922ac2aab93b93b13d2e4cda6fb866de6178429b9658705116263724f8 ./src/api/configuration.rs +6a913e9c6deca6ad8564ebad72f0f22bfe9feb1a0a6d30ebe639e9a6bf9652a6 ./src/api/debug_tokens.rs e43d25b0b65fe307cce209522ab9618f9385210720ed2c3f24abbbc43d09db45 ./src/api/devices.rs -417940b467c6f00bbf01cc47eb4479880dee9b2f284a80e3662a6d433dc11b92 ./src/api/events.rs -1bbe45c11b176d1595007942ef7723c7954c60fe20cc5aa18c4dbab00b6f09ff ./src/api/flows.rs +b1c0861dfc568f88ec0f15d133d8ac4aacc699f3e6df1cd026e21f9c3814b1fe ./src/api/events.rs +5708667d1d009af3982414614aea6ed98014d11a41c9984c7c89283092963994 ./src/api/flows.rs d096000e94997cf9aaf9d3cc567214a78b8b2da7da57a76e2b3519247af6e592 ./src/api/groups.rs a13d4e217fe3ddaa73873ba6e0d1bc93750cf61764d867ff7372274ed21d9599 ./src/api/history.rs -b3ed82de9d885a6325647bff3d0ead6ac46fc74d0250d5e14d75319555ad88d7 ./src/api/house.rs +791c99a2cc3219f9db7f600df48bd2d839dcae3392e937fc7b31dd9c024afd3d ./src/api/house.rs 88e04a59bfedd5e6c54e6a2938b1031964be3a1505db2ca67d6290aa042cbe3b ./src/api/integrations.rs bb2a746ecdcc2da5fe54e18b455c7bd19453486dd5c008e71951a2c81d0e7d64 ./src/api/middleware.rs 45e34a447ff384d99a3c42c489ca9fa75849c74d4d246caa91c63d927de17396 ./src/api/openapi.rs -50cfd47e44e22cc802f22f97267c157fa5a6d19b51be921a6ca6b1ea7c9799d1 ./src/api/public_settings.rs a1d1a4ac071493b052e6bf20b03be67cf75726de95649e9a73aeba8d22395a15 ./src/api/schedules.rs -430f446371489a2b2f1532d589c71f60c98c1a92765ccb5e486b08e2da8f31c3 ./src/api/settings.rs -bc1614b9948b8904d0f678c79cc9fffecb75977fbec951aca1ba087527808836 ./src/api/system.rs +f07425bad97da3fdc1a1a430ef8cf58651c6946fbc1fe8adaa48650567469fbe ./src/api/settings.rs +6f53b89c96a99a85c3badd6af169fda9c978630516279b57b756b5468caef7d7 ./src/api/system.rs 681cccb8d09f4ad9c2e6467a4dbe2f9d991125775a5d2bfc9043daf940fecd29 ./src/api/websocket.rs -923e7434e8f5789150eb34ffc4d18d25fa701f4a9040bf856d7926016f1936aa ./src/api/zones.rs -7eaeb0552a51dc34e964a315cc4024e8373622d7b32746010f89881268ccf389 ./src/config.rs +20e0a7f8170247613fad996d5341d54b31a497a12893f2831d9fddd12583508d ./src/api/zones.rs +3cec9426989034a6a5074aead64e8c0239ae942a96a04910c57d1593129c5bb8 ./src/config.rs c8b8b4a04c94b9d91f5931057d69ad38344730bffbf68e2f6541b59013441b57 ./src/db.rs 24580b279cfcd5388ba584eaba2c9bb729b8aae81e77b781c23fb804c5b5fa5d ./src/db/climate.rs -3eded80d8dbf13729b2ae1f31baa47aa59c01bcfa550c90874031d25c74d2eac ./src/db/configuration.rs +6998f88a6a030ef9a1fa3a46466f6dfb79c6df086411d94c82ecd69fa1690e71 ./src/db/configuration.rs 56b0efe84dbf6bd3ee2397b099220a68966311c187f4d5b53007a1693a5ef327 ./src/db/core_devices.rs d8a2b323e864f2ec38ee15e3023be37a6aa05387f1ddbeedafe1f51556ad8c7d ./src/db/device_history.rs f3bd91d0bdb7699f319b9341d928d3cc3b25f759653fcd2095a5c5f695d08fac ./src/db/events_tokens.rs @@ -118,32 +119,32 @@ f3bd91d0bdb7699f319b9341d928d3cc3b25f759653fcd2095a5c5f695d08fac ./src/db/event 7e2fbce70f40aef360534ba38c6700163db77cb7288b3bf58728fce72a126435 ./src/db/schedules_automations.rs 35cbb521a8ec456756496b8aee71f9dfebad528a2cf2653ba8b48f6035be0c01 ./src/db/tests.rs a384ea4042d1d11692b5f8693df1f54850dcc4e80726780b44b2b096bf667b78 ./src/db/zone_history.rs -952ea5906d39ef917ad71edc345f76b29c07c63079644fd5699b12aa254cb35f ./src/engine.rs -c803180a341d27f4e5165add623d6548cb43ffc23fa167f7235ce9cf2423ea68 ./src/engine/automations.rs +fbebb9815bdabbad9ff4e979f8ea9dc3dda9c77790f30a64fa0b392fb1944d00 ./src/engine.rs +ec6034df6667b3ac6ec39851443048a56e87fa861f5846950a5e79fa89ee5a30 ./src/engine/automations.rs 77931af524e9402fc826050b5fcec422d01b02b625c8ce7f2004f1f6520c9b4d ./src/engine/commands.rs -ce7e809765c8ad7dc4819b7f775ba7a632031d4d0e68780cc6ea953f2fd890e7 ./src/engine/control_plan.rs +750c5c219c7c8a72f51eb6dfc679d21c5821c122861d9cfbaba57485b66a33a4 ./src/engine/control_plan.rs 38f42c86c31048780ca414f2c328f363ef803a0edbc1e1ed85503909a10b69ae ./src/engine/deadlines.rs -d15148382f0c84bdef36750a10b463378e2de6a0fb70eecadeee2b07b8648681 ./src/engine/groups.rs +c1410aae910b8239e48857df1f0484f08261ad6cf385b596f7c538e920907437 ./src/engine/groups.rs 18a7669318949e533f206d25cf7b63771d7f0f2c0b375a72ff78a2d921baaa61 ./src/engine/history.rs 4972c199274647bff47122c367e51bae8dab24d7ec4d652288e227a5a64c2310 ./src/engine/local_thermostat.rs -abf67cb6c9ac393d7f2b51852bcc52cdade02944ac50e71d14c9d9ece5e31a56 ./src/engine/ownership.rs +abf9eb5c2444f540f1601682d4c7f3e86f6742690f6af621999406cd81f6a2e4 ./src/engine/ownership.rs 9c37adf514c96840fd6a5c715c579350fa86d6dcc0655a2aa97d1fd7c7e04299 ./src/engine/polling.rs 3d48608a065aa52a1fd8b9c59356a74d8216ecee3d47f8ff8ef7d3d47170c617 ./src/engine/runtime.rs ffa72b93502eece187a96adb4ec94aec6048d4a6dc7af66e7c7af0931318787f ./src/engine/schedules.rs 7742046c0067c7cd5a74e2b81f8a8bdef9b76fcacf9b4af0b7eede1b050b9036 ./src/engine/targets.rs a9188b588b2617ce1547a45d46141f7e5f1bb0027a48675dd86b274f807d0875 ./src/engine/temperature.rs -c2fe371f9245ce299015f0b4e43f6625a207016abf73d2419acb6bd7437f045a ./src/engine/temporary_thermostat.rs -cdf6f444b959ef1dcafe06e4cf48bdff35ddc4f4b34bd95169660fa4a535a1da ./src/engine/tests.rs +8502aed5bc180a1e2601c33c41e125edea30fdea93a870c5237cf85054fbf686 ./src/engine/temporary_thermostat.rs +9da37fb636e2c342c4e86b6e0ecf07a5a6850431b1227cf4b8f02b4d05b02eaa ./src/engine/tests.rs 986c5ce1d7d5e79975b0089807408109afdef5c5a173b427b58e34f534faf411 ./src/engine/zone_actions.rs -900947200973b8e4490600f33738424a89aadf0b59c0316f4cd46deb997730b6 ./src/engine/zone_control.rs +e7ba1c1708865bc49e26d07ad84faa1803c0d4f1c23be5b11f61154de660858c ./src/engine/zone_control.rs 4b271b6fc365b1078c01d6178eb563841b2ecaed5d8639196f58e1312d2236fe ./src/error.rs 9eef7820fcf0c98b08fb1bf5e0a4a244b1dbdfa700d8b930d3ff40379babdada ./src/home_assistant.rs 6f9ef85cd53ba030b477bc3c186f0f7e762674124210eabcb0686f2806eaada1 ./src/influxdb.rs feb50ecd60b9a9fe9de1f7d8e1e26caab5435d59347468f6266a318f431bc3d3 ./src/influxdb/codec.rs 553b44ec0321e62480059bc2451fd34ec7aabe9fc87fc717daba0c4b55679d53 ./src/influxdb/query.rs 5e4d94f8a010df05bae9acab72c518080d2dba94d054df95b87e492305357222 ./src/influxdb/write.rs -ef8ad176eb2ee6ceb02bbf3d63b65460013475908fced05388b29727af67ef7e ./src/main.rs -c63bd6257386969a763f44d0e4fd7b6b19078ec7a7f46e7ec2d73580d2274cab ./src/models.rs +c3ff7232a2fa1d29428872a10cfd72ee60a3a1faaa662eff6c2d8c7dffc1ed3a ./src/main.rs +1030e49498f8d595cf8fdee60651e7fec79d78061eb8561f9b29948b28c2f95f ./src/models.rs 67f189c195426c39669178dd92d8602c519806acca7d325665076720f9fee8a9 ./src/models/automation.rs 377fc9697a70a03ad35fc3701adf4dbe8687760dc90de9f5c532a4f3b612c762 ./src/models/control_plan.rs 8228b9b6d9e8b1d13de29ce88500beacd88c8877893b7a3e41a4bd1e237ca36c ./src/models/defaults.rs @@ -151,7 +152,8 @@ c63bd6257386969a763f44d0e4fd7b6b19078ec7a7f46e7ec2d73580d2274cab ./src/models.r aeab5b5acf35d38a54fd1d82e964fe56635ade69c0149ad423df317095d01555 ./src/models/flow.rs cfbaa853a5185c4be572588577b313a2d618837f7c07a3860d0e8d2cca04ee60 ./src/models/history.rs 552dfc1717ea88f3a23b1fb7705f1dbb0df7792ca9a6ae7ad7bd275eacef27af ./src/models/integrations.rs -268b4211df93f08423e39374ab9ffe93a7eba647c173168821736af299b36549 ./src/models/runtime.rs +8294749dd3af524925309c51ad641ffd32ff1fd9c164cfa71660aa88c2d13d6e ./src/models/runtime.rs +887eb80a58998e90023e952c1fde67435eb843493387ae64536613b74c084534 ./src/models/settings_api.rs e77f8cdcb5fc0e51cdbf2527a69585a2f6f9089e159d1e45ec4a48735ab0e304 ./src/models/temporary_thermostat.rs 5b5c80e25ae2aad1b511bd3f0a82b8fbae2440abf8c656ecb90814587d0be137 ./src/models/zone.rs 9c735d7c475f21f2f388c21be784376f7184f0bf18af1886544ef51585a3b290 ./src/notifications.rs @@ -180,22 +182,22 @@ b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg f78622afea899a5e904910217eceeeee306d5fc8094bdccc049bd84752780720 ./web/index.html 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md -21b662914590e720dafefe4dc3dccc91dbeb963cce9ae91f007c835d2d0c1fe5 ./web/js/bootstrap.js +8fb20fdba11df68feb0b096bb5b4c8f936db974aed5d345a0d01455054f31f95 ./web/js/bootstrap.js dee7c4292889ddd721c220e86fae909f0d662b585e6d9c5d9e2f8fa85d9ed2ad ./web/js/charts.js f75449450a179f5853b8b08c69dec93205985e0dcbc806d355fd6060237761f6 ./web/js/core.js dfc123885a9b6b5c1d07c12d2d2625ff15aa0844e5cd3736092ccd0a360da39c ./web/js/dashboard.js -e797a71616fbdc2b651c0b5174f67a581beeb22c6a8c9441dd728bf444e6162d ./web/js/entities.js +563ce41a849574ddfbed281828f34cdcb498bbd04009056c73a378b84bad822a ./web/js/entities.js e332b3f811958a938105272c1e8e28d6ca8ba69303001da165b4b0b85727695c ./web/js/events.js d4aea1d6ec595000eaddca3b6f0e3d4003901365ece7788147a6ed207cffcc78 ./web/js/flows.js f06557c6d338259059b990f8f79c91d68ac8ec1f245c302468f08929b5506901 ./web/js/forms.js dbf36223863ba882c03eccea4516ba8db7acf0c2e72c6c64490cb994f1e20f96 ./web/js/history.js 7eaa04992ea828dc89f5eaebf674cbbfd160f53eb8d0d6ef0d4e47bd8c28c435 ./web/js/main.js a0959f9caf84f1873e3549261de8b1a069f4df8326b17bfe1f8c3de120299875 ./web/js/navigation.js -c56059693d09e35261dc5dd1940289ffc67c82ea7c547750d57e6c79fe75b05b ./web/js/realtime.js +2bc9cfa306cdeafff39a7bfc8c5744568e0130d88432351cba8b062178aefce3 ./web/js/realtime.js c8c82e88c3715b1bfabf155e36266a4c94f5e2fc03eb39dcdd9d08a1985a997c ./web/js/router.js bdeb55546a1dff5e5bdfde4b114d6330f025df1b62e528bf18a93ad6dd4c3e83 ./web/js/settings-ui.js -aa6a2ba22648de44547efd09f41c4335ebe38ed564f4684e942d7827df40f1a2 ./web/js/settings.js +3c4a96bbc73cdfaf2f76798201f88ef43776875dce42f2b75d6a1808cef496d9 ./web/js/settings.js 6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest -5ae5a29dee689e1ab0f433339d1499eb232cae81749ff083dd5be5c62d72fa0b ./web/styles.css +c5d515b987d96ebfbc7593e9bac9aff64c8d54420f23cc8df5e2c84b14a69852 ./web/styles.css ae1b03f30b494f474a781a5d32072f1e73eba2b7c768109fc1cc8028cb6662a4 ./web/sw.js d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js diff --git a/README.md b/README.md index 5b92dd4..6e34b08 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Self-hosted controller for GREE-compatible air conditioners with a local Web UI, thermostat zones, schedules, Home Assistant integration, history, notifications and a documented HTTP/WebSocket API. -**Current release: 0.11.6** +**Current release: 0.12.0** > [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples. @@ -306,7 +306,7 @@ For Linux/network issues also use: ## Backup and restore -**Settings → Application → Configuration backup** exports settings, devices, zones, groups, schedules and automations. +**Settings → Application → Configuration backup** exports settings, devices, zones, groups, schedules, automations and Flows. The export intentionally does **not** contain metric history, event history or generated API-token records. It **does contain** GREE binding keys and configured integration secrets, so store it like a credential file. @@ -369,7 +369,9 @@ Runtime assets are available at `/lang/index.json` and `/lang/.json`. ## API -The HTTP API is the same backend used by the Web UI. It includes devices, zones, groups, house control, schedules, automations, history, control plan, events, settings, backup/restore, debug, tokens and integration tests. +The HTTP API is the same backend used by the Web UI. It includes devices, zones, groups, house control, schedules, automations, history, control plan, events, functional settings resources, configuration backup/restore, tokens and integration tests. + +**Breaking in 0.12.0:** the monolithic `/api/settings`, `/api/debug`, `/api/events/retention` and `/api/settings/{export,import}` routes were removed. Settings now live under `/api/settings/application`, `/gree`, `/history`, `/influxdb`, `/notifications`, `/night`, `/home-assistant` and `/debug`; configuration backup uses `/api/configuration/export` and `/api/configuration/import`. Start here: diff --git a/docs/API.md b/docs/API.md index 3d5a70f..3139559 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,6 +1,6 @@ # GREE Controller API reference -HTTP and WebSocket API for GREE Controller **0.11.6**. +HTTP and WebSocket API for GREE Controller **0.12.0**. [← Main documentation](../README.md) @@ -180,19 +180,21 @@ Common statuses: | GET | `/api/history` | Rich device/zone/HA history. | | GET | `/api/control-plan` | Current resolved thermostat plan. | | GET | `/api/events` | Event/debug log. | -| GET | `/api/events/retention` | Current event retention. | -| PUT | `/api/events/retention` | Update retention and prune immediately. | -### Settings, backup and diagnostics +### Settings, configuration and diagnostics | Method | Endpoint | Description | | --- | --- | --- | -| GET | `/api/settings` | Public-safe runtime settings. | -| PUT | `/api/settings` | Update runtime settings. | -| GET | `/api/settings/export` | Export full application configuration. | -| POST | `/api/settings/import` | Import/replace application configuration. | -| GET | `/api/debug` | Read debug overlay settings. | -| PUT | `/api/debug` | Update debug overlay settings. | +| GET/PUT | `/api/settings/application` | Application runtime switches. | +| GET/PUT | `/api/settings/gree` | GREE controller, polling, discovery and compressor settings. | +| GET/PUT | `/api/settings/history` | Metric/event retention and compaction settings. | +| GET/PUT | `/api/settings/influxdb` | InfluxDB history settings. | +| GET/PUT | `/api/settings/notifications` | Notification provider and alert settings. | +| GET/PUT | `/api/settings/night` | Night mode settings. | +| GET/PUT | `/api/settings/home-assistant` | Home Assistant, aliases, shared Flow inputs and outdoor assist. | +| GET/PUT | `/api/settings/debug` | Debug overlay/GREE frame settings. | +| GET | `/api/configuration/export` | Export full application configuration. | +| POST | `/api/configuration/import` | Import/replace application configuration. | | POST | `/api/integrations/home-assistant/test` | Test HA temperature read. | | POST | `/api/integrations/home-assistant/entity` | Read raw HA entity state/attributes for shared Flow input diagnostics. | | POST | `/api/integrations/notifications/test` | Send a test notification. | @@ -228,7 +230,7 @@ Response: { "status": "ok", "name": "gree-controller", - "version": "0.11.6", + "version": "0.12.0", "uptime_seconds": 1234, "control_ready": true, "time": "2026-08-30T06:54:00Z" @@ -249,10 +251,10 @@ Returns the initial Web UI snapshot: "schedules": [], "automations": [], "access_tokens": [], - "settings": {}, + "house": {"mode": "cool"}, "outdoor_temperature": null, "system": { - "version": "0.11.6", + "version": "0.12.0", "uptime_seconds": 1234, "auth_required": false, "control_ready": true, @@ -1003,88 +1005,62 @@ Response: } ``` -### `GET /api/events/retention` - -```json -{ - "days": 30 -} -``` - -### `PUT /api/events/retention` - -```json -{ - "days": 30 -} -``` - -Value is clamped to `1..3650`; pruning happens immediately. Response includes `days` and number of removed rows. - --- -## Runtime settings +## Runtime settings — 0.12.0 -### `GET /api/settings` +Version `0.12.0` replaces the monolithic settings document with functional resources. There are no compatibility aliases for the removed `/api/settings`, `/api/debug` or `/api/events/retention` endpoints. -Returns a public-safe settings document. Secrets are blanked and accompanied by `*_configured` booleans where relevant. +Every settings resource supports `GET` and `PUT`. A `PUT` replaces only that functional section; it never requires or overwrites unrelated settings. -Shape: +### `/api/settings/application` + +```json +{ + "simulator_enabled": false +} +``` + +### `/api/settings/gree` ```json { "controller_id": "gree-controller", - "simulator_enabled": false, "poll_interval_seconds": 15, "zone_interval_seconds": 5, "discovery_timeout_ms": 3000, "discovery_broadcast": "255.255.255.255:7000", - "house_mode": "cool", - "house_power_enabled": true, - "control_strategy": "setpoint", - "outdoor_assist_enabled": true, - "history_retention_days": 30, - "history_compaction_enabled": true, - "event_log_retention_days": 30, "suppress_device_beep": false, - "debug": { - "overlay_enabled": false, - "gree_frames": false - }, - "night_mode": { - "enabled": false, - "start_time": "22:00", - "end_time": "06:00", - "max_fan_speed": 1, - "force_quiet": true, - "use_native_sleep": true - }, - "notifications": {}, - "influxdb": {}, - "home_assistant": {} + "compressor_protection_enabled": true, + "compressor_protection_seconds": 180 } ``` -#### Home Assistant settings +Validation/normalization: + +- `controller_id` cannot be empty; +- polling and zone intervals: `2..3600` seconds; +- discovery timeout: `300..30000` ms; +- discovery broadcast: `auto`, `auto:*` or a socket address; +- compressor protection: `30..1800` seconds. + +Changing compressor protection clears pending compressor runtime queues before thermostat control continues. + +### `/api/settings/history` ```json { - "url": "http://homeassistant.local:8123", - "token": "", - "token_configured": true, - "default_entity_id": "sensor.room_temperature", - "outdoor_entity_id": "sensor.outdoor_temperature", - "sensor_stale_after_seconds": 300, - "allow_invalid_tls": false, - "sensor_aliases": { - "sensor.room_temperature": "Living room" - } + "retention_days": 30, + "compaction_enabled": true, + "event_retention_days": 30 } ``` -`default_entity_id` is primarily the entity used by the Home Assistant connection test when the request does not provide another entity. Thermostat zones use their own configured `ha_entity_id`; this setting does not automatically become a zone temperature source. +Retention values are clamped to `1..3650` days. Updating this section immediately prunes expired event rows. The former `/api/events/retention` endpoint no longer exists. -#### InfluxDB settings +### `/api/settings/influxdb` + +`GET` returns a secret-safe view: ```json { @@ -1093,31 +1069,27 @@ Shape: "url": "http://influxdb:8086", "database": "gree_controller", "username": "", - "password": "", "password_configured": false, "org": "home", "bucket": "gree_controller", - "token": "", "token_configured": true, "history_threshold_days": 30 } ``` -Version `1` uses database/optional username/password. Version `2` uses org/bucket/token. +`PUT` uses the same non-secret fields plus optional `password` and `token`. Omitting either field (or sending `null`) preserves the stored secret. Sending an explicit empty string clears it. `history_threshold_days` is clamped to `1..3650`; the complete InfluxDB configuration is validated before persistence. -#### Notification settings +### `/api/settings/notifications` + +`GET` returns provider state without secret values: ```json { "enabled": true, "mode": "problems", "provider": "pushover", - "pushover_app_token": "", - "pushover_user_key": "", "pushover_configured": true, - "slack_webhook_url": "", "slack_configured": false, - "discord_webhook_url": "", "discord_configured": false, "cooldown_seconds": 300, "communication_failure_threshold": 3, @@ -1135,33 +1107,80 @@ Version `1` uses database/optional username/password. Version `2` uses org/bucke } ``` -Modes: `problems`, `important`. Providers: `pushover`, `slack`, `discord`. +`PUT` accepts the same behavioral fields plus optional `pushover_app_token`, `pushover_user_key`, `slack_webhook_url` and `discord_webhook_url`. Omitted/`null` secrets are preserved; an explicit empty string clears them. Modes: `problems`, `important`. Providers: `pushover`, `slack`, `discord`. Cooldown is clamped to `30..86400` seconds, failure threshold to `2..100`, target timeout to `5..1440` minutes. -### `PUT /api/settings` +### `/api/settings/night` -Accepts the complete `RuntimeSettings` document. Important behavior: +```json +{ + "enabled": false, + "start_time": "22:00", + "end_time": "06:00", + "max_fan_speed": 1, + "force_quiet": true, + "use_native_sleep": true +} +``` -- `house_mode` cannot be changed here; use House Control API. `house_power_enabled` is a legacy compatibility field and is normalized to `true`; global ON/OFF no longer uses a persistent master gate. -- polling interval is clamped `2..3600` seconds. -- zone interval is clamped `2..3600` seconds. -- discovery timeout is clamped `300..30000` ms. -- `control_strategy` is normalized to `setpoint`. -- discovery broadcast must be `auto`, `auto:*` or a valid socket address. -- blank HA token preserves the saved token. -- blank Influx token/password preserve saved secrets. -- blank Pushover/Slack/Discord secret fields preserve saved secrets. -- HA sensor age is clamped `30..86400` seconds. -- notification cooldown: `30..86400` seconds. -- communication failure threshold: `2..100`. -- target timeout: `5..1440` minutes. -- retention windows: `1..3650` days. -- night mode times must be `HH:MM`; max fan is clamped `1..5`. +Times must use `HH:MM`; maximum fan speed is clamped to `1..5`. -Returns the safe public settings form. +### `/api/settings/home-assistant` -### `GET /api/settings/export` +`GET` returns: -Returns configuration format version `1`: +```json +{ + "url": "http://homeassistant.local:8123", + "token_configured": true, + "default_entity_id": "sensor.room_temperature", + "outdoor_entity_id": "sensor.outdoor_temperature", + "sensor_stale_after_seconds": 300, + "allow_invalid_tls": false, + "sensor_aliases": { + "sensor.room_temperature": "Living room" + }, + "flow_inputs": [], + "outdoor_assist_enabled": true +} +``` + +`PUT` replaces `token_configured` with optional `token`. Omitted/`null` token preserves the saved token; an explicit empty string clears it. Sensor age is clamped to `30..86400` seconds. URLs, aliases, entity IDs and shared Flow inputs are normalized/validated before persistence. Shared inputs are value sources only; comparison operators and thresholds belong to Flow nodes. + +### `/api/settings/debug` + +```json +{ + "overlay_enabled": true, + "gree_frames": true +} +``` + +When `overlay_enabled=true`, live HTTP diagnostics can emit `api.request`; `gree_frames=true` enables sanitized `gree.frame` events. + +### Settings WebSocket events + +Each section has its own event and payload: + +```text +settings.application.updated +settings.gree.updated +settings.history.updated +settings.influxdb.updated +settings.notifications.updated +settings.night.updated +settings.home_assistant.updated +settings.debug.updated +``` + +The generic `settings.updated` and `debug.settings` events were removed in `0.12.0`. + +--- + +## Configuration backup and restore + +### `GET /api/configuration/export` + +Returns configuration format version `3`: ```text format_version @@ -1172,13 +1191,14 @@ zones[] groups[] schedules[] automations[] +flows[] ``` -Export includes GREE binding keys and integration credentials. It excludes metric history, event rows and generated API-token records. Treat the export as a secret. +The export contains GREE binding keys and integration credentials. It excludes metric history, event rows and generated API-token records. Treat it as a secret backup. -### `POST /api/settings/import` +### `POST /api/configuration/import` -Accepts exactly the export document. The backend validates IDs/references/schedules/settings, safely stops devices whose ownership is being removed, clears transient ownership/timers/stale live state, replaces configuration, re-polls devices, then re-enables thermostat control. +Accepts only format version `3` and the `setpoint` control strategy used by `0.12.0`. The backend validates IDs/references (including shared Flow inputs against resources inside the backup), Flow draft safety, schedules, settings and ownership relationships; safely powers off devices being detached; clears transient runtime/ownership state; replaces configuration; re-polls imported devices; then resumes thermostat control. Metric/event history and generated access-token records are preserved. @@ -1192,27 +1212,6 @@ Response: --- -## Debug API - -### `GET /api/debug` - -```json -{ - "overlay_enabled": true, - "gree_frames": true -} -``` - -### `PUT /api/debug` - -Accepts the same object, persists it and broadcasts `debug.settings`. - -When overlay diagnostics are enabled, live HTTP requests generate `api.request` WebSocket events containing method, path, status and duration. When `gree_frames=true`, sanitized GREE protocol events are also sent as `gree.frame`. - -The Web UI can display **All**, **Requests** or **GREE** subsets. - ---- - ## Integration tests ### `POST /api/integrations/home-assistant/test` @@ -1424,9 +1423,16 @@ schedule.template_applied automation.created automation.updated automation.deleted -settings.updated +settings.application.updated +settings.gree.updated +settings.history.updated +settings.influxdb.updated +settings.notifications.updated +settings.night.updated +settings.home_assistant.updated +settings.debug.updated +house.mode_changed configuration.imported -debug.settings api.request gree.frame log.created @@ -1545,7 +1551,7 @@ Flow is the source-of-truth representation for visual schedule/automation logic. Saving an executable Flow validates the DAG and compiles it atomically. If the editor receives HTTP 400 during normal save, it can offer to retry with `draft=true`; draft validation preserves the editable graph while allowing missing actions or unfinished semantic wiring. Drafts never execute, and converting an existing Flow to a draft atomically removes its previously generated outputs. A thermostat action driven only by one weekday block and one time-range block is emitted as a native schedule when its settings are schedule-compatible. More complex graphs are emitted as Flow-triggered automations. Generated schedules and automations expose `flow_id` / `flow_node_id`, use stable names in the form `flow-`, and cannot be edited or deleted through their legacy endpoints; edit the owning Flow instead. -Condition blocks support weekday, time/date ranges, optional 5-field CRON, application Night mode, outdoor/device/zone temperatures, house/device/zone/group state, arbitrary Home Assistant state/numeric/attribute/availability sources, rolling mean/median, oscillation detection, and `shared_input` references. Stateful gate blocks include `stable_for`, `delay`, `state_duration` (`min_seconds`, optional `max_seconds`), `on_change` (`mode: result|value`) and `rate_limit` (`max_count`, `period_seconds`). `on_change` establishes a baseline on its first observation and does not fire immediately. `rate_limit` must feed an action directly and consumes quota only after a successful action execution. Shared inputs are stored in `home_assistant.flow_inputs` and resolve dynamically at evaluation time. Shared inputs store reusable value sources only. They never store a comparison operator or threshold. For comparison-capable source kinds, each `shared_input` Flow node defines its own `operator` and `value`. `PUT /api/settings` rejects shared-input configs that contain an `operator`, and rejects comparison `value` fields for comparison-capable source kinds. Logic blocks support AND, OR and NOT. Action blocks target thermostat zones, GREE devices, climate groups or a generic Home Assistant service. Direct GREE actions map to the existing `DeviceCommand` fields including fan, swing, quiet, turbo, light, air, xfan, health and sleep; ownership rules may suppress fields that would fight an active thermostat. Flow group actions use the existing group controller and support power, house/heat/cool mode, auto/comfort/sleep/away and the existing custom group target. +Condition blocks support weekday, time/date ranges, optional 5-field CRON, application Night mode, outdoor/device/zone temperatures, house/device/zone/group state, arbitrary Home Assistant state/numeric/attribute/availability sources, rolling mean/median, oscillation detection, and `shared_input` references. Stateful gate blocks include `stable_for`, `delay`, `state_duration` (`min_seconds`, optional `max_seconds`), `on_change` (`mode: result|value`) and `rate_limit` (`max_count`, `period_seconds`). `on_change` establishes a baseline on its first observation and does not fire immediately. `rate_limit` must feed an action directly and consumes quota only after a successful action execution. Shared inputs are stored in `home_assistant.flow_inputs` and resolve dynamically at evaluation time. Shared inputs store reusable value sources only. They never store a comparison operator or threshold. For comparison-capable source kinds, each `shared_input` Flow node defines its own `operator` and `value`. `PUT /api/settings/home-assistant` rejects shared-input configs that contain an `operator`, and rejects comparison `value` fields for comparison-capable source kinds. Logic blocks support AND, OR and NOT. Action blocks target thermostat zones, GREE devices, climate groups or a generic Home Assistant service. Direct GREE actions map to the existing `DeviceCommand` fields including fan, swing, quiet, turbo, light, air, xfan, health and sleep; ownership rules may suppress fields that would fight an active thermostat. Flow group actions use the existing group controller and support power, house/heat/cool mode, auto/comfort/sleep/away and the existing custom group target. ## Compressor protection queue diff --git a/docs/CODE_AUDIT_0.12.0.md b/docs/CODE_AUDIT_0.12.0.md new file mode 100644 index 0000000..ec249e9 --- /dev/null +++ b/docs/CODE_AUDIT_0.12.0.md @@ -0,0 +1,75 @@ +# Code audit — 0.12.0 + +Scope: settings/configuration API, frontend consumers, configuration import/export, and the largest control handlers touched by the API/runtime ownership changes. + +## Breaking API redesign + +0.12.0 intentionally removes the previous monolithic/compatibility routes. There are no aliases. + +| Removed | Replacement | +| --- | --- | +| `GET/PUT /api/settings` | functional `/api/settings/*` resources | +| `GET/PUT /api/debug` | `GET/PUT /api/settings/debug` | +| `PUT /api/events/retention` | `GET/PUT /api/settings/history` | +| `GET /api/settings/export` | `GET /api/configuration/export` | +| `POST /api/settings/import` | `POST /api/configuration/import` | + +Functional settings resources: + +- `/api/settings/application` +- `/api/settings/gree` +- `/api/settings/history` +- `/api/settings/influxdb` +- `/api/settings/notifications` +- `/api/settings/night` +- `/api/settings/home-assistant` +- `/api/settings/debug` + +The bootstrap payload no longer exposes runtime settings. The Web UI fetches each settings resource explicitly. + +## Correctness and security findings addressed + +- Settings writes no longer require a full `RuntimeSettings` document, so changing one section cannot overwrite unrelated settings. +- Integration secrets are never returned by settings GET endpoints. Secret-bearing PUT fields are optional: omitted/null preserves the stored value; an explicit empty string clears it. +- Configuration backup/restore was separated from runtime settings into `/api/configuration/*`. +- Configuration format was raised to version `3`; imports accept only version `3`. +- Format-v3 imports reject unsupported control strategies; `0.12.0` accepts only `setpoint`. +- Import validates and normalizes the same GREE/history/InfluxDB/notification/night/Home Assistant constraints used by functional settings endpoints. +- Shared Flow inputs in an imported backup are validated against devices/zones/groups from that backup, not against the configuration currently stored in the database. +- Obsolete `house_power_enabled` and `master_power_enabled` compatibility state was removed from runtime/settings/group responses. +- Settings WebSocket broadcasts are section-specific (`settings.
.updated`) instead of the former whole-settings event. + +## Refactoring performed + +Approximate function lengths are shown to make the audit measurable; line count is only a hotspot signal, not a quality metric by itself. + +| Area | Before | After | +| --- | ---: | ---: | +| `engine::control_group` | 292 | 137 | +| `api::apply_zone_control_patch` | 415 | 214 | +| `engine::control_zones` | 666 | 413 | +| monolithic `api::update_settings` | 83 | removed | +| monolithic configuration validator | 155 | removed/split | +| monolithic settings import handler | 98 | removed/split | + +New smaller responsibilities include: + +- GREE settings normalization and compressor-runtime cleanup; +- InfluxDB and notification update/secret merge helpers; +- Home Assistant alias/entity/input validation; +- configuration header, IDs, flows, schedules and automation validation; +- configuration runtime sanitation, resource locking, detached-device shutdown and reconciliation; +- group patch validation, climate changes, power changes and ownership updates; +- Temporary Quick Thermostat start, target and finish resolution; +- control-cycle outdoor sensor resolution, parallel room sensor reads, temperature-source selection and pre-control state handling. + +## Remaining hotspots + +`control_zones` remains the largest function because it is the ordered thermostat-cycle orchestrator and contains compressor/setpoint command planning. It was reduced substantially without changing lock ordering or splitting the core state machine across opaque abstractions. Flow evaluation/compiler functions remain domain-heavy but were not changed as part of this API release because they are independent of the settings redesign and already have dedicated logical verification. + +## Frontend/documentation + +- Web UI settings loading/saving uses only the new functional endpoints. +- Configuration import/export uses `/api/configuration/*`. +- Realtime settings handling uses section-specific events and serializes WebSocket message processing so reconnect bootstrap cannot race later updates. +- `docs/API.md`, `docs/openapi.json`, README and smoke/static Flow checks were updated for 0.12.0. diff --git a/docs/openapi.json b/docs/openapi.json index 615c7db..7e46205 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -2,9 +2,9 @@ "openapi": "3.1.0", "info": { "title": "GREE Controller API", - "version": "0.0.0", + "version": "0.12.0", "summary": "Local HTTP/WebSocket API for GREE HVAC control", - "description": "Self-hosted API used by the GREE Controller Web UI and Home Assistant integration.\n\n### Authentication\nNormal administrator endpoints accept `Authorization: Bearer ` or `x-api-token: ` when `GREE_CONTROLLER_APP_TOKEN` is configured. If the app token is empty, those endpoints intentionally run in trusted-LAN mode without authentication.\n\nThe restricted `/api/integrations/home-assistant/*` control surface always requires either a generated access token or the administrator app token. Generated tokens do **not** grant normal administrator API access.\n\nIn Swagger UI use **Authorize** and enter the token in either the bearer field or `x-api-token` field.\n\n### Control ownership\nThermostat zones, groups, house rules, direct device control, schedules and visual Flows share an ownership/arbitration model. Prefer zone/group/house endpoints for thermostat intent; direct device commands are technical/manual control and can deliberately take ownership.", + "description": "Self-hosted API used by the GREE Controller Web UI and Home Assistant integration.\n\n### Authentication\nNormal administrator endpoints accept `Authorization: Bearer ` or `x-api-token: ` when `GREE_CONTROLLER_APP_TOKEN` is configured. If the app token is empty, those endpoints intentionally run in trusted-LAN mode without authentication.\n\nThe restricted `/api/integrations/home-assistant/*` control surface always requires either a generated access token or the administrator app token. Generated tokens do **not** grant normal administrator API access.\n\nIn Swagger UI use **Authorize** and enter the token in either the bearer field or `x-api-token` field.\n\n### Control ownership\nThermostat zones, groups, house rules, direct device control, schedules and visual Flows share an ownership/arbitration model. Prefer zone/group/house endpoints for thermostat intent; direct device commands are technical/manual control and can deliberately take ownership.\n\n### 0.12.0 breaking API\nRuntime settings are split into functional `/api/settings/*` resources. The former `/api/settings`, `/api/debug`, `/api/events/retention`, `/api/settings/export` and `/api/settings/import` endpoints were removed without compatibility aliases.", "license": { "name": "MIT" } @@ -58,15 +58,19 @@ }, { "name": "Events", - "description": "Event log and retention." + "description": "Event log." }, { "name": "Settings", - "description": "Runtime settings and full configuration backup/restore." + "description": "Functional runtime settings resources. Each endpoint reads/writes only one settings section." + }, + { + "name": "Configuration", + "description": "Versioned full configuration backup and restore. Backups contain secrets." }, { "name": "Diagnostics", - "description": "Debug settings and integration connection tests." + "description": "Integration connection tests and diagnostics." }, { "name": "Access tokens", @@ -118,7 +122,7 @@ "System" ], "summary": "Get application bootstrap", - "description": "Returns the complete initial Web UI snapshot: devices, zones, groups, schedules, automations, access-token metadata, public-safe settings, outdoor temperature and system diagnostics.", + "description": "Returns the initial Web UI entity snapshot: devices, zones, groups, schedules, automations, Flows, access-token metadata, house mode, outdoor temperature and system diagnostics. Runtime settings are loaded from the functional `/api/settings/*` resources.", "operationId": "bootstrap", "security": [ { @@ -3059,360 +3063,6 @@ } } }, - "/api/events/retention": { - "get": { - "tags": [ - "Events" - ], - "summary": "Get event retention", - "description": "Returns event-log retention in days.", - "operationId": "getEventRetention", - "security": [ - { - "BearerToken": [] - }, - { - "ApiTokenHeader": [] - } - ], - "responses": { - "200": { - "description": "Retention", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EventRetention" - } - } - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - }, - "put": { - "tags": [ - "Events" - ], - "summary": "Update event retention", - "description": "Updates retention, clamps it to 1..3650 days and prunes old rows immediately. The response also reports removed rows.", - "operationId": "updateEventRetention", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EventRetention" - } - } - } - }, - "security": [ - { - "BearerToken": [] - }, - { - "ApiTokenHeader": [] - } - ], - "responses": { - "200": { - "description": "Updated retention/prune result", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/settings": { - "get": { - "tags": [ - "Settings" - ], - "summary": "Get runtime settings", - "description": "Returns the public-safe runtime settings document. Integration/provider secrets are blanked and represented by `*_configured` flags where relevant.", - "operationId": "getSettings", - "security": [ - { - "BearerToken": [] - }, - { - "ApiTokenHeader": [] - } - ], - "responses": { - "200": { - "description": "Public runtime settings", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RuntimeSettings" - } - } - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - }, - "put": { - "tags": [ - "Settings" - ], - "summary": "Update runtime settings", - "description": "Accepts the complete RuntimeSettings document. Secret fields may be left blank to preserve saved secrets. House mode/power are controlled by dedicated House endpoints; intervals, retention and safety values are validated/clamped.", - "operationId": "updateSettings", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RuntimeSettings" - } - } - } - }, - "security": [ - { - "BearerToken": [] - }, - { - "ApiTokenHeader": [] - } - ], - "responses": { - "200": { - "description": "Public runtime settings", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RuntimeSettings" - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "502": { - "$ref": "#/components/responses/BadGateway" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/settings/export": { - "get": { - "tags": [ - "Settings" - ], - "summary": "Export configuration", - "description": "Exports versioned controller configuration including binding keys and integration credentials. Metric history, event rows and generated access-token records are excluded. Treat the response as a credential/secret backup.", - "operationId": "exportSettings", - "security": [ - { - "BearerToken": [] - }, - { - "ApiTokenHeader": [] - } - ], - "responses": { - "200": { - "description": "Configuration backup", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConfigurationExport" - } - } - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/settings/import": { - "post": { - "tags": [ - "Settings" - ], - "summary": "Import configuration", - "description": "Validates and atomically replaces controller configuration while preserving metric/event history and generated access-token records. Devices losing thermostat ownership are stopped safely; transient ownership/live state is reconciled before control resumes.", - "operationId": "importSettings", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConfigurationExport" - } - } - } - }, - "security": [ - { - "BearerToken": [] - }, - { - "ApiTokenHeader": [] - } - ], - "responses": { - "200": { - "description": "Import result", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "example": true - } - }, - "required": [ - "ok" - ], - "additionalProperties": false - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "502": { - "$ref": "#/components/responses/BadGateway" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/debug": { - "get": { - "tags": [ - "Diagnostics" - ], - "summary": "Get debug settings", - "description": "Returns live API/GREE diagnostics settings.", - "operationId": "getDebug", - "security": [ - { - "BearerToken": [] - }, - { - "ApiTokenHeader": [] - } - ], - "responses": { - "200": { - "description": "Debug settings", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DebugSettings" - } - } - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - }, - "put": { - "tags": [ - "Diagnostics" - ], - "summary": "Update debug settings", - "description": "Persists debug settings. `overlay_enabled` emits `api.request` WebSocket events; `gree_frames` emits sanitized GREE protocol debug events.", - "operationId": "updateDebug", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DebugSettings" - } - } - } - }, - "security": [ - { - "BearerToken": [] - }, - { - "ApiTokenHeader": [] - } - ], - "responses": { - "200": { - "description": "Debug settings", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DebugSettings" - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, "/api/integrations/home-assistant/test": { "post": { "tags": [ @@ -4399,6 +4049,756 @@ } } } + }, + "/api/settings/application": { + "get": { + "tags": [ + "Settings" + ], + "summary": "Get application settings", + "description": "Application-level runtime switches.", + "operationId": "getApplicationSettings", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "application settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApplicationSettings" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "tags": [ + "Settings" + ], + "summary": "Update application settings", + "description": "Application-level runtime switches.", + "operationId": "updateApplicationSettings", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApplicationSettings" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Updated application settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApplicationSettings" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/settings/gree": { + "get": { + "tags": [ + "Settings" + ], + "summary": "Get GREE settings", + "description": "Controller identity, polling/discovery and GREE command safety settings.", + "operationId": "getGREESettings", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "GREE settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GreeSettings" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "tags": [ + "Settings" + ], + "summary": "Update GREE settings", + "description": "Controller identity, polling/discovery and GREE command safety settings.", + "operationId": "updateGREESettings", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GreeSettings" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Updated GREE settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GreeSettings" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/settings/history": { + "get": { + "tags": [ + "Settings" + ], + "summary": "Get history settings", + "description": "Metric and event-log retention settings. Updating event retention prunes expired event rows immediately.", + "operationId": "getHistorySettings", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "history settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HistorySettings" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "tags": [ + "Settings" + ], + "summary": "Update history settings", + "description": "Metric and event-log retention settings. Updating event retention prunes expired event rows immediately.", + "operationId": "updateHistorySettings", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HistorySettings" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Updated history settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HistorySettings" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/settings/influxdb": { + "get": { + "tags": [ + "Settings" + ], + "summary": "Get InfluxDB settings", + "description": "InfluxDB history configuration. GET never returns password/token values.", + "operationId": "getInfluxDBSettings", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "InfluxDB settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InfluxDbSettingsView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "tags": [ + "Settings" + ], + "summary": "Update InfluxDB settings", + "description": "Updates only InfluxDB settings. Omit `password` or `token` to keep the stored secret; send an explicit empty string to clear it.", + "operationId": "updateInfluxDBSettings", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InfluxDbSettingsUpdate" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Updated InfluxDB settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InfluxDbSettingsView" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/settings/notifications": { + "get": { + "tags": [ + "Settings" + ], + "summary": "Get notification settings", + "description": "Notification behavior and provider status. GET never returns provider secrets.", + "operationId": "getNotificationSettings", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "notification settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationSettingsView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "tags": [ + "Settings" + ], + "summary": "Update notification settings", + "description": "Updates only notification settings. Omit secret/webhook fields to keep stored values; send an explicit empty string to clear a value.", + "operationId": "updateNotificationSettings", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationSettingsUpdate" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Updated notification settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationSettingsView" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/settings/night": { + "get": { + "tags": [ + "Settings" + ], + "summary": "Get night mode settings", + "description": "Night-mode schedule and fan/quiet behavior.", + "operationId": "getNightModeSettings", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "night mode settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NightModeSettings" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "tags": [ + "Settings" + ], + "summary": "Update night mode settings", + "description": "Night-mode schedule and fan/quiet behavior.", + "operationId": "updateNightModeSettings", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NightModeSettings" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Updated night mode settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NightModeSettings" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/settings/home-assistant": { + "get": { + "tags": [ + "Settings" + ], + "summary": "Get Home Assistant settings", + "description": "Home Assistant connection, sensor aliases, shared Flow inputs and outdoor-assist switch. GET never returns the token.", + "operationId": "getHomeAssistantSettings", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Home Assistant settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HomeAssistantSettingsView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "tags": [ + "Settings" + ], + "summary": "Update Home Assistant settings", + "description": "Updates only Home Assistant settings. Omit `token` to keep the stored token; send an explicit empty string to clear it.", + "operationId": "updateHomeAssistantSettings", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HomeAssistantSettingsUpdate" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Updated Home Assistant settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HomeAssistantSettingsView" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/settings/debug": { + "get": { + "tags": [ + "Settings" + ], + "summary": "Get debug settings", + "description": "Live Web UI/API/GREE debug switches.", + "operationId": "getDebugSettings", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "debug settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DebugSettings" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "tags": [ + "Settings" + ], + "summary": "Update debug settings", + "description": "Live Web UI/API/GREE debug switches.", + "operationId": "updateDebugSettings", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DebugSettings" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Updated debug settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DebugSettings" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/configuration/export": { + "get": { + "tags": [ + "Configuration" + ], + "summary": "Export configuration", + "description": "Exports format v3 full controller configuration including device binding keys and integration credentials. Metric history, event rows and generated access-token records are excluded. Treat this response as a secret backup.", + "operationId": "exportConfiguration", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Configuration backup", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigurationExport" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/configuration/import": { + "post": { + "tags": [ + "Configuration" + ], + "summary": "Import configuration", + "description": "Validates and atomically replaces controller configuration from format v3 using the setpoint control strategy required by 0.12.0. Shared Flow input references are resolved against resources inside the backup. Runtime device/zone ownership and telemetry state are sanitized during import.", + "operationId": "importConfiguration", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigurationExport" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Imported configuration summary", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } } }, "components": { @@ -4454,7 +4854,7 @@ }, "version": { "type": "string", - "example": "0.11.6" + "example": "0.12.0" }, "uptime_seconds": { "type": "integer", @@ -6526,21 +6926,6 @@ "metadata" ] }, - "EventRetention": { - "type": "object", - "properties": { - "days": { - "type": "integer", - "minimum": 1, - "maximum": 3650, - "example": 30 - } - }, - "required": [ - "days" - ], - "additionalProperties": false - }, "DebugSettings": { "type": "object", "properties": { @@ -6561,7 +6946,7 @@ }, "RuntimeSettings": { "type": "object", - "description": "Public-safe runtime settings. Secret fields are returned blank with corresponding *_configured flags where applicable.", + "description": "Internal full runtime-settings representation embedded in ConfigurationExport. Unlike `/api/settings/*` views, this backup object may contain integration secrets.", "properties": { "controller_id": { "type": "string", @@ -6600,10 +6985,6 @@ "off" ] }, - "house_power_enabled": { - "type": "boolean", - "description": "Legacy compatibility field; normalized true. Use /api/house/power for bulk ON/OFF." - }, "control_strategy": { "type": "string", "enum": [ @@ -6633,23 +7014,31 @@ "$ref": "#/components/schemas/DebugSettings" }, "night_mode": { - "type": "object", - "additionalProperties": true + "$ref": "#/components/schemas/NightModeSettings" }, "notifications": { "type": "object", "additionalProperties": true, - "description": "Notification provider settings; secrets are blanked in public responses." + "description": "Full notification settings including secrets in configuration backups." }, "influxdb": { "type": "object", "additionalProperties": true, - "description": "InfluxDB v1/v2 history settings; secrets are blanked in public responses." + "description": "Full InfluxDB settings including secrets in configuration backups." }, "home_assistant": { "type": "object", "additionalProperties": true, - "description": "Home Assistant connection, sensor aliases and shared Flow inputs; token is blanked in public responses." + "description": "Full Home Assistant settings including token in configuration backups." + }, + "outdoor_assist_enabled": { + "type": "boolean" + }, + "history_compaction_enabled": { + "type": "boolean" + }, + "suppress_device_beep": { + "type": "boolean" } } }, @@ -6659,7 +7048,10 @@ "properties": { "format_version": { "type": "integer", - "example": 1 + "enum": [ + 3 + ], + "example": 3 }, "exported_at": { "type": "string", @@ -6697,6 +7089,12 @@ "items": { "$ref": "#/components/schemas/Automation" } + }, + "flows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Flow" + } } }, "required": [ @@ -6707,7 +7105,8 @@ "zones", "groups", "schedules", - "automations" + "automations", + "flows" ] }, "ApiTokenInfo": { @@ -6831,6 +7230,530 @@ "maximum": 1440 } } + }, + "ApplicationSettings": { + "type": "object", + "properties": { + "simulator_enabled": { + "type": "boolean" + } + }, + "required": [ + "simulator_enabled" + ], + "additionalProperties": false + }, + "GreeSettings": { + "type": "object", + "properties": { + "controller_id": { + "type": "string", + "minLength": 1, + "example": "gree-controller" + }, + "poll_interval_seconds": { + "type": "integer", + "minimum": 2, + "maximum": 3600 + }, + "zone_interval_seconds": { + "type": "integer", + "minimum": 2, + "maximum": 3600 + }, + "discovery_timeout_ms": { + "type": "integer", + "minimum": 300, + "maximum": 30000 + }, + "discovery_broadcast": { + "type": "string", + "example": "255.255.255.255:7000" + }, + "suppress_device_beep": { + "type": "boolean" + }, + "compressor_protection_enabled": { + "type": "boolean" + }, + "compressor_protection_seconds": { + "type": "integer", + "minimum": 30, + "maximum": 1800 + } + }, + "required": [ + "controller_id", + "poll_interval_seconds", + "zone_interval_seconds", + "discovery_timeout_ms", + "discovery_broadcast", + "suppress_device_beep", + "compressor_protection_enabled", + "compressor_protection_seconds" + ], + "additionalProperties": false + }, + "HistorySettings": { + "type": "object", + "properties": { + "retention_days": { + "type": "integer", + "minimum": 1, + "maximum": 3650 + }, + "compaction_enabled": { + "type": "boolean" + }, + "event_retention_days": { + "type": "integer", + "minimum": 1, + "maximum": 3650 + } + }, + "required": [ + "retention_days", + "compaction_enabled", + "event_retention_days" + ], + "additionalProperties": false + }, + "InfluxDbSettingsView": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "version": { + "type": "string", + "enum": [ + "1", + "2" + ] + }, + "url": { + "type": "string" + }, + "database": { + "type": "string" + }, + "username": { + "type": "string" + }, + "org": { + "type": "string" + }, + "bucket": { + "type": "string" + }, + "history_threshold_days": { + "type": "integer", + "minimum": 1, + "maximum": 3650 + }, + "password_configured": { + "type": "boolean" + }, + "token_configured": { + "type": "boolean" + } + }, + "required": [ + "enabled", + "version", + "url", + "database", + "username", + "org", + "bucket", + "history_threshold_days", + "password_configured", + "token_configured" + ], + "additionalProperties": false + }, + "InfluxDbSettingsUpdate": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "version": { + "type": "string", + "enum": [ + "1", + "2" + ] + }, + "url": { + "type": "string" + }, + "database": { + "type": "string" + }, + "username": { + "type": "string" + }, + "org": { + "type": "string" + }, + "bucket": { + "type": "string" + }, + "history_threshold_days": { + "type": "integer", + "minimum": 1, + "maximum": 3650 + }, + "password": { + "type": [ + "string", + "null" + ], + "description": "Omit/null to preserve the stored value; empty string clears it." + }, + "token": { + "type": [ + "string", + "null" + ], + "description": "Omit/null to preserve the stored value; empty string clears it." + } + }, + "required": [ + "enabled", + "version", + "url", + "database", + "username", + "org", + "bucket", + "history_threshold_days" + ], + "additionalProperties": false + }, + "NotificationAlertTypes": { + "type": "object", + "properties": { + "stale_sensor": { + "type": "boolean" + }, + "sensor_errors": { + "type": "boolean" + }, + "communication": { + "type": "boolean" + }, + "target_timeout": { + "type": "boolean" + }, + "automation": { + "type": "boolean" + }, + "control_errors": { + "type": "boolean" + }, + "important_events": { + "type": "boolean" + }, + "other": { + "type": "boolean" + } + }, + "required": [ + "stale_sensor", + "sensor_errors", + "communication", + "target_timeout", + "automation", + "control_errors", + "important_events", + "other" + ], + "additionalProperties": false + }, + "NotificationSettingsView": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "mode": { + "type": "string", + "enum": [ + "problems", + "important" + ] + }, + "provider": { + "type": "string", + "enum": [ + "pushover", + "slack", + "discord" + ] + }, + "cooldown_seconds": { + "type": "integer", + "minimum": 30, + "maximum": 86400 + }, + "communication_failure_threshold": { + "type": "integer", + "minimum": 2, + "maximum": 100 + }, + "target_timeout_minutes": { + "type": "integer", + "minimum": 5, + "maximum": 1440 + }, + "alert_types": { + "$ref": "#/components/schemas/NotificationAlertTypes" + }, + "pushover_configured": { + "type": "boolean" + }, + "slack_configured": { + "type": "boolean" + }, + "discord_configured": { + "type": "boolean" + } + }, + "required": [ + "enabled", + "mode", + "provider", + "cooldown_seconds", + "communication_failure_threshold", + "target_timeout_minutes", + "alert_types", + "pushover_configured", + "slack_configured", + "discord_configured" + ], + "additionalProperties": false + }, + "NotificationSettingsUpdate": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "mode": { + "type": "string", + "enum": [ + "problems", + "important" + ] + }, + "provider": { + "type": "string", + "enum": [ + "pushover", + "slack", + "discord" + ] + }, + "cooldown_seconds": { + "type": "integer", + "minimum": 30, + "maximum": 86400 + }, + "communication_failure_threshold": { + "type": "integer", + "minimum": 2, + "maximum": 100 + }, + "target_timeout_minutes": { + "type": "integer", + "minimum": 5, + "maximum": 1440 + }, + "alert_types": { + "$ref": "#/components/schemas/NotificationAlertTypes" + }, + "pushover_app_token": { + "type": [ + "string", + "null" + ], + "description": "Omit/null to preserve the stored secret; empty string clears it." + }, + "pushover_user_key": { + "type": [ + "string", + "null" + ], + "description": "Omit/null to preserve the stored secret; empty string clears it." + }, + "slack_webhook_url": { + "type": [ + "string", + "null" + ], + "description": "Omit/null to preserve the stored secret; empty string clears it." + }, + "discord_webhook_url": { + "type": [ + "string", + "null" + ], + "description": "Omit/null to preserve the stored secret; empty string clears it." + } + }, + "required": [ + "enabled", + "mode", + "provider", + "cooldown_seconds", + "communication_failure_threshold", + "target_timeout_minutes", + "alert_types" + ], + "additionalProperties": false + }, + "NightModeSettings": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "start_time": { + "type": "string", + "pattern": "^[0-2][0-9]:[0-5][0-9]$" + }, + "end_time": { + "type": "string", + "pattern": "^[0-2][0-9]:[0-5][0-9]$" + }, + "max_fan_speed": { + "type": "integer", + "minimum": 1, + "maximum": 5 + }, + "force_quiet": { + "type": "boolean" + }, + "use_native_sleep": { + "type": "boolean" + } + }, + "required": [ + "enabled", + "start_time", + "end_time", + "max_fan_speed", + "force_quiet", + "use_native_sleep" + ], + "additionalProperties": false + }, + "HomeAssistantSettingsView": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "default_entity_id": { + "type": "string" + }, + "outdoor_entity_id": { + "type": "string" + }, + "sensor_stale_after_seconds": { + "type": "integer" + }, + "allow_invalid_tls": { + "type": "boolean" + }, + "sensor_aliases": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "flow_inputs": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "outdoor_assist_enabled": { + "type": "boolean" + }, + "token_configured": { + "type": "boolean" + } + }, + "required": [ + "url", + "default_entity_id", + "outdoor_entity_id", + "sensor_stale_after_seconds", + "allow_invalid_tls", + "sensor_aliases", + "flow_inputs", + "outdoor_assist_enabled", + "token_configured" + ], + "additionalProperties": false + }, + "HomeAssistantSettingsUpdate": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "default_entity_id": { + "type": "string" + }, + "outdoor_entity_id": { + "type": "string" + }, + "sensor_stale_after_seconds": { + "type": "integer" + }, + "allow_invalid_tls": { + "type": "boolean" + }, + "sensor_aliases": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "flow_inputs": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "outdoor_assist_enabled": { + "type": "boolean" + }, + "token": { + "type": [ + "string", + "null" + ], + "description": "Omit/null to preserve the stored token; empty string clears it." + } + }, + "required": [ + "url", + "default_entity_id", + "outdoor_entity_id", + "sensor_stale_after_seconds", + "allow_invalid_tls", + "sensor_aliases", + "flow_inputs", + "outdoor_assist_enabled" + ], + "additionalProperties": false } }, "responses": { diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index f4ccfb3..37cc856 100644 --- a/home-assistant/custom_components/gree_controller/manifest.json +++ b/home-assistant/custom_components/gree_controller/manifest.json @@ -1,7 +1,7 @@ { "domain": "gree_controller", "name": "GREE Controller", - "version": "0.11.6", + "version": "0.12.0", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/make_zip.py b/make_zip.py old mode 100644 new mode 100755 diff --git a/mock/styles.css b/mock/styles.css index 7e0da6d..346d069 100644 --- a/mock/styles.css +++ b/mock/styles.css @@ -6731,7 +6731,7 @@ textarea:focus { border-color:var(--accent); outline:2px solid color-mix(in srgb .flow-generated-card .card-footer > small { color:var(--accent); } -/* 0.11.6 production layout guards and in-app chart preview. */ +/* 0.12.0 production layout guards and in-app chart preview. */ html,body { max-width:100%; overflow-x:clip; } main,.view { min-width:0; max-width:100%; } .flow-info-panel,.flow-info-panel > div { min-width:0; } @@ -7113,7 +7113,7 @@ body.chart-fullscreen-open::before { content:""; position:fixed; inset:0; z-inde } -/* 0.11.6 diagnostics, manual-control safety and technical connection checks. */ +/* 0.12.0 diagnostics, manual-control safety and technical connection checks. */ .manual-device-problem { display: grid; gap: 4px; diff --git a/regenerate-sha.sh b/regenerate-sha.sh old mode 100644 new mode 100755 diff --git a/scripts/FILE_MANIFEST.sha256 b/scripts/FILE_MANIFEST.sha256 index fefd757..18a3024 100644 --- a/scripts/FILE_MANIFEST.sha256 +++ b/scripts/FILE_MANIFEST.sha256 @@ -8,6 +8,6 @@ bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./install.sh e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./network-debug.sh 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./service.sh -c714e3cf2fba84ded718d102bd620cf879c1a8f3a5ae8f435be7863d8a0cd8e0 ./smoke.sh +7e8f558b284362f55f50bd7223a89754a2a4960e746a27035cc61180f4a2da6f ./smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./update.sh -4877f9e8217b6a77fb416722c3778373edac874ed3a583bb016ea76d4ffee7d4 ./verify_flow_logic.py +0790b7b388467d9e75b621188cca473e22d73e01bcfa74e92fa23f92b0695855 ./verify_flow_logic.py diff --git a/scripts/common.sh b/scripts/common.sh old mode 100644 new mode 100755 diff --git a/scripts/configure-gree-network.sh b/scripts/configure-gree-network.sh old mode 100644 new mode 100755 diff --git a/scripts/dev.sh b/scripts/dev.sh old mode 100644 new mode 100755 diff --git a/scripts/generate_ha_migration.py b/scripts/generate_ha_migration.py old mode 100644 new mode 100755 diff --git a/scripts/install-lxc.sh b/scripts/install-lxc.sh old mode 100644 new mode 100755 diff --git a/scripts/install.sh b/scripts/install.sh old mode 100644 new mode 100755 diff --git a/scripts/network-debug.sh b/scripts/network-debug.sh old mode 100644 new mode 100755 diff --git a/scripts/regenerate-sha.sh b/scripts/regenerate-sha.sh old mode 100644 new mode 100755 diff --git a/scripts/service.sh b/scripts/service.sh old mode 100644 new mode 100755 diff --git a/scripts/smoke.sh b/scripts/smoke.sh old mode 100644 new mode 100755 index 36c2657..a24207a --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -102,32 +102,32 @@ STALE_STATUS="$(curl -sS -o /dev/null -w '%{http_code}' -X PUT -H 'Content-Type: [[ "$STALE_STATUS" == "409" ]] # Shared Flow inputs are source-only; comparisons belong exclusively to Flow references. -curl -fsS "http://127.0.0.1:$PORT/api/settings" >"$TMP/settings-shared-input.json" +curl -fsS "http://127.0.0.1:$PORT/api/settings/home-assistant" >"$TMP/settings-shared-input.json" python3 - "$TMP/settings-shared-input.json" "$TMP/settings-shared-input-put.json" <<'PYFLOW' import json,sys settings=json.load(open(sys.argv[1])) -items=settings.setdefault("home_assistant",{}).setdefault("flow_inputs",[]) +items=settings.setdefault("flow_inputs",[]) items=[item for item in items if item.get("id") != "smoke-shared-outdoor"] items.append({"id":"smoke-shared-outdoor","name":"Smoke outdoor value","kind":"outdoor_temperature","config":{}}) -settings["home_assistant"]["flow_inputs"]=items +settings["flow_inputs"]=items json.dump(settings,open(sys.argv[2],"w")) PYFLOW curl -fsS -X PUT -H 'Content-Type: application/json' --data-binary @"$TMP/settings-shared-input-put.json" \ - "http://127.0.0.1:$PORT/api/settings" >"$TMP/settings-shared-input-result.json" + "http://127.0.0.1:$PORT/api/settings/home-assistant" >"$TMP/settings-shared-input-result.json" python3 - "$TMP/settings-shared-input-result.json" <<'PYFLOW' import json,sys settings=json.load(open(sys.argv[1])) -item=next(item for item in settings["home_assistant"]["flow_inputs"] if item["id"] == "smoke-shared-outdoor") +item=next(item for item in settings["flow_inputs"] if item["id"] == "smoke-shared-outdoor") assert item["config"] == {}, item PYFLOW python3 - "$TMP/settings-shared-input-put.json" "$TMP/settings-shared-input-invalid.json" <<'PYFLOW' import json,sys settings=json.load(open(sys.argv[1])) -item=next(item for item in settings["home_assistant"]["flow_inputs"] if item["id"] == "smoke-shared-outdoor") +item=next(item for item in settings["flow_inputs"] if item["id"] == "smoke-shared-outdoor") item["config"]={"operator":"lt","value":20} json.dump(settings,open(sys.argv[2],"w")) PYFLOW -INVALID_SHARED_STATUS="$(curl -sS -o /dev/null -w '%{http_code}' -X PUT -H 'Content-Type: application/json' --data-binary @"$TMP/settings-shared-input-invalid.json" "http://127.0.0.1:$PORT/api/settings")" +INVALID_SHARED_STATUS="$(curl -sS -o /dev/null -w '%{http_code}' -X PUT -H 'Content-Type: application/json' --data-binary @"$TMP/settings-shared-input-invalid.json" "http://127.0.0.1:$PORT/api/settings/home-assistant")" [[ "$INVALID_SHARED_STATUS" == "400" ]] cat >"$TMP/flow-shared-value.json" <= 3, 'draft create/update/import validation split missing') @@ -431,7 +431,7 @@ def main(): check(api.count('(prepare_draft_flow(flow), vec![], vec![])') >= 3, 'draft save/import may compile executable outputs') check('flow.compiled_schedule_ids.clear()' in api and 'flow.compiled_automation_ids.clear()' in api, 'draft output clearing missing') check('"draft": flow.draft' in api, 'draft state missing from Flow export') - check('import contains an executable Flow draft' in settings_api, 'configuration import does not enforce draft safety invariant') + check('import contains an executable Flow draft' in configuration_api, 'configuration import does not enforce draft safety invariant') check('error.status = response.status' in core, 'frontend API errors do not expose HTTP validation status') check("error.status !== 400" in js and "draft:true" in js and "enabled:false" in js, 'save-as-draft retry flow missing') check("if (flow.draft) return toast(tr('flow.draftCannotEnable')" in js, 'draft quick-enable guard missing') diff --git a/src/api.rs b/src/api.rs index 5da02fe..d03d246 100644 --- a/src/api.rs +++ b/src/api.rs @@ -23,7 +23,7 @@ use crate::{ home_assistant, influxdb, notifications, - models::{ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, GroupControlPatch, ManualDeviceRequest, HaReading, NotificationSettings, Reading, RuntimeSettings, Schedule, TemporaryQuickThermostat, Zone, ZoneControlPatch, ZoneReading}, + models::{ApiTokenInfo, ApplicationSettings, Automation, ClimateGroup, ConfigurationExport, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, GreeSettings, GroupControlPatch, HaReading, HistorySettings, HomeAssistantSettings, HomeAssistantSettingsUpdate, HomeAssistantSettingsView, InfluxDbSettings, InfluxDbSettingsUpdate, InfluxDbSettingsView, ManualDeviceRequest, NightModeSettings, NotificationSettings, NotificationSettingsUpdate, NotificationSettingsView, Reading, RuntimeSettings, Schedule, TemporaryQuickThermostat, TemporaryQuickThermostatRequest, Zone, ZoneControlPatch, ZoneReading}, protocol::merge_discovered, state::AppState, }; @@ -98,11 +98,16 @@ pub fn router(state: AppState) -> Router { .route("/api/history", get(history)) .route("/api/control-plan", get(control_plan)) .route("/api/events", get(events)) - .route("/api/events/retention", get(get_event_retention).put(update_event_retention)) - .route("/api/settings", get(get_settings).put(update_settings)) - .route("/api/settings/export", get(export_settings)) - .route("/api/settings/import", post(import_settings)) - .route("/api/debug", get(get_debug).put(update_debug)) + .route("/api/settings/application", get(get_application_settings).put(update_application_settings)) + .route("/api/settings/gree", get(get_gree_settings).put(update_gree_settings)) + .route("/api/settings/history", get(get_history_settings).put(update_history_settings)) + .route("/api/settings/influxdb", get(get_influxdb_settings).put(update_influxdb_settings)) + .route("/api/settings/notifications", get(get_notification_settings).put(update_notification_settings)) + .route("/api/settings/night", get(get_night_settings).put(update_night_settings)) + .route("/api/settings/home-assistant", get(get_home_assistant_settings).put(update_home_assistant_settings)) + .route("/api/settings/debug", get(get_debug_settings).put(update_debug_settings)) + .route("/api/configuration/export", get(export_configuration)) + .route("/api/configuration/import", post(import_configuration)) .route("/api/access-tokens", get(list_access_tokens).post(create_access_token)) .route("/api/access-tokens/:id", axum::routing::delete(delete_access_token)) .route("/api/integrations/home-assistant/test", post(test_home_assistant)) @@ -186,9 +191,9 @@ include!("api/flows.rs"); include!("api/history.rs"); include!("api/events.rs"); include!("api/settings.rs"); +include!("api/configuration.rs"); include!("api/debug_tokens.rs"); include!("api/integrations.rs"); include!("api/middleware.rs"); -include!("api/public_settings.rs"); include!("api/websocket.rs"); include!("api/assets.rs"); diff --git a/src/api/configuration.rs b/src/api/configuration.rs new file mode 100644 index 0000000..f71318e --- /dev/null +++ b/src/api/configuration.rs @@ -0,0 +1,521 @@ +struct ConfigurationIds<'a> { + devices: std::collections::HashSet<&'a str>, + zones: std::collections::HashSet<&'a str>, + schedules: std::collections::HashSet<&'a str>, + automations: std::collections::HashSet<&'a str>, + flows: std::collections::HashSet<&'a str>, +} + +struct ConfigurationResourceGuards { + _zones: Vec>, + _devices: Vec>, +} + +async fn export_configuration(State(state): State) -> Result, AppError> { + let settings = state.settings.read().await.clone(); + let mut export = state.db.export_configuration(settings)?; + sanitize_configuration_runtime(&mut export); + Ok(Json(export)) +} + +fn validate_configuration_header(export: &ConfigurationExport) -> Result<(), AppError> { + if export.format_version != 3 { + return Err(AppError::BadRequest("unsupported configuration export version; version 3 is required by GREE Controller 0.12.x".into())); + } + if export.settings.control_strategy != "setpoint" { + return Err(AppError::BadRequest("import contains an unsupported control strategy".into())); + } + influxdb::validate(&export.settings.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?; + if !matches!(export.settings.house_mode.as_str(), "cool" | "heat" | "off") { + return Err(AppError::BadRequest("import contains an invalid house mode".into())); + } + Ok(()) +} + +fn collect_configuration_ids(export: &ConfigurationExport) -> Result, AppError> { + let ids = ConfigurationIds { + devices: export.devices.iter().map(|item| item.id.as_str()).collect(), + zones: export.zones.iter().map(|item| item.id.as_str()).collect(), + schedules: export.schedules.iter().map(|item| item.id.as_str()).collect(), + automations: export.automations.iter().map(|item| item.id.as_str()).collect(), + flows: export.flows.iter().map(|item| item.id.as_str()).collect(), + }; + let duplicate_or_empty = ids.devices.len() != export.devices.len() + || ids.zones.len() != export.zones.len() + || ids.schedules.len() != export.schedules.len() + || ids.automations.len() != export.automations.len() + || ids.flows.len() != export.flows.len() + || ids.devices.contains("") + || ids.zones.contains("") + || ids.schedules.contains("") + || ids.automations.contains("") + || ids.flows.contains(""); + if duplicate_or_empty { + return Err(AppError::BadRequest("import contains duplicate or empty resource IDs".into())); + } + Ok(ids) +} + +fn validate_configuration_flows(export: &ConfigurationExport) -> Result<(), AppError> { + let draft_flows: std::collections::HashSet<&str> = export.flows.iter() + .filter(|item| item.draft) + .map(|item| item.id.as_str()) + .collect(); + let executable_draft = export.flows.iter().any(|item| item.draft + && (item.enabled || !item.compiled_schedule_ids.is_empty() || !item.compiled_automation_ids.is_empty())) + || export.schedules.iter().any(|item| item.flow_id.as_deref().is_some_and(|id| draft_flows.contains(id))) + || export.automations.iter().any(|item| item.flow_id.as_deref().is_some_and(|id| draft_flows.contains(id))); + if executable_draft { + return Err(AppError::BadRequest("import contains an executable Flow draft".into())); + } + Ok(()) +} + +fn validate_configuration_devices_and_zones( + export: &ConfigurationExport, + ids: &ConfigurationIds<'_>, +) -> Result<(), AppError> { + let device_macs: std::collections::HashSet<&str> = export.devices.iter().map(|item| item.mac.as_str()).collect(); + if device_macs.len() != export.devices.len() { + return Err(AppError::BadRequest("import contains duplicate device MAC addresses".into())); + } + if export.zones.iter().any(|item| !ids.devices.contains(item.device_id.as_str())) { + return Err(AppError::BadRequest("import contains a zone referencing a missing device".into())); + } + let mut zone_devices = std::collections::HashSet::new(); + for zone in &export.zones { + if !zone_devices.insert(zone.device_id.as_str()) { + return Err(AppError::BadRequest("import assigns one device to more than one thermostat zone".into())); + } + if !matches!(zone.mode.as_str(), "cool" | "heat") { + return Err(AppError::BadRequest("import contains an invalid zone mode".into())); + } + if !matches!(zone.sensor_source.as_str(), "device" | "home_assistant" | "combined") { + return Err(AppError::BadRequest("import contains an invalid zone sensor source".into())); + } + } + Ok(()) +} + +fn validate_configuration_schedules( + export: &ConfigurationExport, + ids: &ConfigurationIds<'_>, +) -> Result<(), AppError> { + if export.schedules.iter().any(|item| !ids.zones.contains(item.zone_id.as_str())) { + return Err(AppError::BadRequest("import contains a schedule referencing a missing zone".into())); + } + for item in &export.schedules { + if item.flow_id.as_deref().is_some_and(|flow_id| !ids.flows.contains(flow_id)) { + return Err(AppError::BadRequest("import contains a Flow-generated schedule referencing a missing Flow".into())); + } + if item.weekdays.is_empty() || item.weekdays.iter().any(|day| !(1..=7).contains(day)) { + return Err(AppError::BadRequest("import contains invalid schedule weekdays".into())); + } + NaiveTime::parse_from_str(&item.start_time, "%H:%M") + .map_err(|_| AppError::BadRequest("import contains an invalid schedule start time".into()))?; + NaiveTime::parse_from_str(&item.end_time, "%H:%M") + .map_err(|_| AppError::BadRequest("import contains an invalid schedule end time".into()))?; + if !matches!(item.preset.as_str(), "comfort" | "sleep" | "away" | "custom") { + return Err(AppError::BadRequest("import contains an invalid schedule preset".into())); + } + if item.preset == "custom" && !(8.0..=30.0).contains(&item.setpoint) { + return Err(AppError::BadRequest("import contains an invalid schedule setpoint".into())); + } + } + validate_schedule_set(&export.schedules)?; + Ok(()) +} + +fn validate_configuration_groups<'a>( + export: &'a ConfigurationExport, + ids: &ConfigurationIds<'_>, +) -> Result, AppError> { + if export.groups.iter().any(|group| { + let members: std::collections::HashSet<&str> = group.zone_ids.iter().map(String::as_str).collect(); + group.id.trim().is_empty() + || group.zone_ids.is_empty() + || members.len() != group.zone_ids.len() + || group.zone_ids.iter().any(|zone_id| !ids.zones.contains(zone_id.as_str())) + }) { + return Err(AppError::BadRequest("import contains an invalid group, duplicate members or a missing zone reference".into())); + } + let groups: std::collections::HashSet<&str> = export.groups.iter().map(|item| item.id.as_str()).collect(); + if groups.len() != export.groups.len() { + return Err(AppError::BadRequest("import contains duplicate group IDs".into())); + } + Ok(groups) +} + +fn validate_configuration_automation_trigger( + item: &Automation, + ids: &ConfigurationIds<'_>, +) -> Result<(), AppError> { + match item.trigger_kind.as_str() { + "temperature_above" | "temperature_below" => { + let Some(trigger_id) = item.trigger_device_id.as_deref() else { + return Err(AppError::BadRequest("import contains a temperature automation without a trigger device".into())); + }; + if !ids.devices.contains(trigger_id) || item.threshold.is_none() { + return Err(AppError::BadRequest("import contains an invalid temperature automation trigger".into())); + } + } + "time" => { + let at = item.at_time.as_deref() + .ok_or_else(|| AppError::BadRequest("import contains a time automation without at_time".into()))?; + NaiveTime::parse_from_str(at, "%H:%M") + .map_err(|_| AppError::BadRequest("import contains an invalid automation time".into()))?; + } + "flow" => { + if item.flow_id.as_deref().filter(|id| ids.flows.contains(*id)).is_none() || item.flow_conditions.is_empty() { + return Err(AppError::BadRequest("import contains an invalid Flow-generated automation".into())); + } + } + _ => return Err(AppError::BadRequest("import contains an unsupported automation trigger".into())), + } + Ok(()) +} + +fn validate_configuration_zone_automation(item: &Automation, ids: &ConfigurationIds<'_>) -> Result<(), AppError> { + let Some(zone_id) = item.action_zone_id.as_deref().filter(|value| !value.is_empty()) else { return Ok(()); }; + if !ids.zones.contains(zone_id) { + return Err(AppError::BadRequest("import contains a Flow automation referencing a missing zone".into())); + } + if let Some(preset) = item.action_zone_preset.as_deref() { + if !matches!(preset, "auto" | "custom" | "comfort" | "sleep" | "away") { + return Err(AppError::BadRequest("import contains an invalid Flow thermostat preset".into())); + } + } + if item.action_zone_preset.as_deref() == Some("custom") + && item.action.target_temperature.is_some_and(|value| !(8.0..=30.0).contains(&value)) + { + return Err(AppError::BadRequest("import contains an invalid Flow thermostat target".into())); + } + Ok(()) +} + +fn validate_configuration_group_automation( + item: &Automation, + groups: &std::collections::HashSet<&str>, +) -> Result<(), AppError> { + let Some(group_id) = item.action_group_id.as_deref().filter(|value| !value.is_empty()) else { return Ok(()); }; + if !groups.contains(group_id) { + return Err(AppError::BadRequest("import contains an automation referencing a missing group".into())); + } + if let Some(mode) = item.action.mode.as_deref() { + if !matches!(mode, "auto" | "house" | "cool" | "heat") { + return Err(AppError::BadRequest("import contains an invalid group automation mode".into())); + } + } + let flow_custom_group = item.flow_id.is_some() && item.action_preset.as_deref() == Some("custom"); + if let Some(preset) = item.action_preset.as_deref() { + if !matches!(preset, "auto" | "comfort" | "sleep" | "away") && !(flow_custom_group && preset == "custom") { + return Err(AppError::BadRequest("import contains an invalid group automation preset".into())); + } + } + if flow_custom_group { + let Some(target) = item.action.target_temperature else { + return Err(AppError::BadRequest("import contains a Flow custom group preset without a target".into())); + }; + if !(8.0..=30.0).contains(&target) { + return Err(AppError::BadRequest("import contains an invalid Flow group target".into())); + } + } else if item.action.target_temperature.is_some() { + return Err(AppError::BadRequest("import contains unsupported target temperature in a group automation".into())); + } + if item.action.fan_speed.is_some() + || item.action.swing_vertical.is_some() + || item.action.swing_horizontal.is_some() + || item.action.quiet.is_some() + || item.action.turbo.is_some() + || item.action.light.is_some() + || item.action.air.is_some() + || item.action.xfan.is_some() + || item.action.health.is_some() + || item.action.sleep.is_some() + { + return Err(AppError::BadRequest("import contains unsupported device fields in a group automation".into())); + } + if item.action.power.is_none() + && item.action.mode.is_none() + && item.action_preset.as_deref().filter(|value| !value.is_empty()).is_none() + { + return Err(AppError::BadRequest("import contains an empty group automation action".into())); + } + Ok(()) +} + +fn validate_configuration_shared_inputs( + export: &ConfigurationExport, + ids: &ConfigurationIds<'_>, + groups: &std::collections::HashSet<&str>, +) -> Result<(), AppError> { + for item in &export.settings.home_assistant.flow_inputs { + let Some(reference) = shared_input_resource_reference(&item.kind, &item.config)? else { continue; }; + let exists = match reference { + SharedInputResourceReference::Device(id) => ids.devices.contains(id.as_str()), + SharedInputResourceReference::Zone(id) => ids.zones.contains(id.as_str()), + SharedInputResourceReference::Group(id) => groups.contains(id.as_str()), + }; + if !exists { + return Err(AppError::BadRequest("import contains a shared Flow input referencing a missing resource".into())); + } + } + Ok(()) +} + +fn validate_configuration_automations( + export: &ConfigurationExport, + ids: &ConfigurationIds<'_>, + groups: &std::collections::HashSet<&str>, +) -> Result<(), AppError> { + for item in &export.automations { + validate_configuration_automation_trigger(item, ids)?; + if item.action_zone_id.as_deref().is_some_and(|value| !value.is_empty()) { + validate_configuration_zone_automation(item, ids)?; + } else if item.action_group_id.as_deref().is_some_and(|value| !value.is_empty()) { + validate_configuration_group_automation(item, groups)?; + } else { + if !ids.devices.contains(item.action_device_id.as_str()) { + return Err(AppError::BadRequest("import contains an automation referencing a missing device".into())); + } + engine::validate_command(&item.action)?; + if item.action.is_empty() { + return Err(AppError::BadRequest("import contains an empty automation action".into())); + } + } + } + Ok(()) +} + +fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), AppError> { + validate_configuration_header(export)?; + validate_configuration_flows(export)?; + let ids = collect_configuration_ids(export)?; + validate_configuration_devices_and_zones(export, &ids)?; + validate_configuration_schedules(export, &ids)?; + let groups = validate_configuration_groups(export, &ids)?; + validate_configuration_shared_inputs(export, &ids, &groups)?; + validate_configuration_automations(export, &ids, &groups) +} + +fn sanitize_imported_device(device: &mut Device, now: chrono::DateTime) { + device.power = false; + device.mode = "cool".into(); + device.target_temperature = 23.0; + device.fan_speed = 0; + device.swing_vertical = false; + device.swing_horizontal = false; + device.quiet = false; + device.turbo = false; + device.light = false; + device.air = false; + device.xfan = false; + device.health = false; + device.sleep = false; + device.current_temperature = None; + device.outdoor_temperature = None; + device.online = false; + device.response_time_ms = None; + device.last_seen = None; + device.last_error = None; + device.communication_failures = 0; + device.updated_at = now; +} + +fn sanitize_imported_zone(zone: &mut Zone, now: chrono::DateTime) { + zone.device_temperature = None; + zone.external_temperature = None; + zone.current_temperature = None; + zone.control_temperature_source = "device".into(); + zone.active_preset = "comfort".into(); + zone.manual_preset = None; + zone.manual_setpoint = None; + zone.manual_override_until = None; + zone.local_thermostat_power = None; + zone.local_thermostat_resume_at = None; + zone.local_thermostat_restore_zone_enabled = None; + zone.temporary_quick_thermostat = None; + zone.device_manual_override = false; + zone.device_manual_override_since = None; + zone.device_manual_override_until = None; + zone.device_manual_override_fields.clear(); + zone.device_manual_override_baseline = None; + zone.control_owner = "automation".into(); + zone.control_source = "automation".into(); + zone.control_since = None; + zone.control_resume_at = None; + zone.control_reason = "Imported configuration; runtime ownership reset".into(); + zone.last_power_change_at = None; + zone.last_mode_change_at = None; + zone.lockout_until = None; + zone.lockout_reason = None; + zone.compressor_pending_action = None; + zone.compressor_pending_since = None; + zone.compressor_pending_until = None; + zone.compressor_cancelled_action = None; + zone.effective_mode.clear(); + zone.effective_setpoint = None; + zone.device_setpoint = None; + zone.demand = false; + zone.demand_since = None; + zone.target_alerted_at = None; + zone.last_action_at = None; + zone.revision = 0; + zone.updated_at = now; +} + +fn sanitize_configuration_runtime(export: &mut ConfigurationExport) { + let now = Utc::now(); + for device in &mut export.devices { sanitize_imported_device(device, now.clone()); } + for zone in &mut export.zones { sanitize_imported_zone(zone, now.clone()); } + for automation in &mut export.automations { + automation.last_fired_at = None; + automation.updated_at = now.clone(); + } +} + +fn normalize_imported_runtime_settings(settings: &mut RuntimeSettings) -> Result<(), AppError> { + let gree = normalize_gree_settings(gree_settings(settings))?; + settings.controller_id = gree.controller_id; + settings.poll_interval_seconds = gree.poll_interval_seconds; + settings.zone_interval_seconds = gree.zone_interval_seconds; + settings.discovery_timeout_ms = gree.discovery_timeout_ms; + settings.discovery_broadcast = gree.discovery_broadcast; + settings.suppress_device_beep = gree.suppress_device_beep; + settings.compressor_protection_enabled = gree.compressor_protection_enabled; + settings.compressor_protection_seconds = gree.compressor_protection_seconds; + + settings.history_retention_days = settings.history_retention_days.clamp(1, 3650); + settings.event_log_retention_days = settings.event_log_retention_days.clamp(1, 3650); + settings.influxdb.history_threshold_days = settings.influxdb.history_threshold_days.clamp(1, 3650); + influxdb::validate(&settings.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?; + + let current_notifications = settings.notifications.clone(); + settings.notifications = apply_notification_update(¤t_notifications, NotificationSettingsUpdate { + enabled: current_notifications.enabled, + mode: current_notifications.mode.clone(), + provider: current_notifications.provider.clone(), + pushover_app_token: Some(current_notifications.pushover_app_token.clone()), + pushover_user_key: Some(current_notifications.pushover_user_key.clone()), + slack_webhook_url: Some(current_notifications.slack_webhook_url.clone()), + discord_webhook_url: Some(current_notifications.discord_webhook_url.clone()), + cooldown_seconds: current_notifications.cooldown_seconds, + communication_failure_threshold: current_notifications.communication_failure_threshold, + target_timeout_minutes: current_notifications.target_timeout_minutes, + alert_types: current_notifications.alert_types.clone(), + })?; + + settings.home_assistant.sensor_stale_after_seconds = settings.home_assistant.sensor_stale_after_seconds.clamp(30, 86_400); + normalize_sensor_aliases(&mut settings.home_assistant); + normalize_flow_shared_inputs(&mut settings.home_assistant)?; + canonicalize_home_assistant_entities(&mut settings.home_assistant); + validate_home_assistant_url(&settings.home_assistant)?; + validate_night_mode(&mut settings.night_mode)?; + Ok(()) +} + +fn prepare_configuration_import(export: &mut ConfigurationExport) -> Result<(), AppError> { + normalize_imported_runtime_settings(&mut export.settings)?; + for zone in &mut export.zones { + canonicalize_zone_ha_entity(zone, &export.settings.home_assistant); + } + Ok(()) +} + +async fn lock_configuration_resources( + state: &AppState, + current_zones: &[Zone], + current_devices: &[Device], + export: &ConfigurationExport, +) -> ConfigurationResourceGuards { + let mut zone_ids: Vec = current_zones.iter().map(|zone| zone.id.clone()) + .chain(export.zones.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 = current_devices.iter().map(|device| device.id.clone()) + .chain(export.devices.iter().map(|device| device.id.clone())) + .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); } + + ConfigurationResourceGuards { _zones: zone_guards, _devices: device_guards } +} + +async fn power_off_detached_devices( + state: &AppState, + current_zones: &[Zone], + export: &ConfigurationExport, +) -> Result<(), AppError> { + let imported_zone_map: std::collections::HashMap = export.zones.iter() + .map(|zone| (zone.id.clone(), zone.device_id.clone())) + .collect(); + let detach_devices: std::collections::HashSet = current_zones.iter() + .filter(|current| imported_zone_map.get(¤t.id).map(String::as_str) != Some(current.device_id.as_str())) + .map(|current| current.device_id.clone()) + .collect(); + for device_id in detach_devices { + 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" + })); + } + Ok(()) +} + +async fn reconcile_imported_devices(state: &AppState, export: &ConfigurationExport) -> Result<(), AppError> { + let controllable_devices: std::collections::HashSet = export.zones.iter() + .filter(|zone| { + let effective_mode = if zone.inherit_house_mode { export.settings.house_mode.as_str() } else { zone.mode.as_str() }; + zone.enabled && effective_mode != "off" + }) + .map(|zone| zone.device_id.clone()) + .collect(); + for device in export.devices.iter().filter(|device| device.enabled && !controllable_devices.contains(&device.id)) { + if let Err(err) = engine::force_power_off_device_locked(state, &device.id).await { + state.log("error", "configuration.import_reconcile_error", &err.to_string(), json!({"device_id": device.id})); + return Err(err); + } + } + engine::poll_all_locked(state).await?; + Ok(()) +} + +async fn import_configuration( + State(state): State, + Json(mut export): Json, +) -> Result, AppError> { + validate_configuration_export(&export)?; + prepare_configuration_import(&mut export)?; + + 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 _cycle_guard = state.lock_zone_control_cycle().await; + let current_zones = state.db.list_zones()?; + let current_devices = state.db.list_devices()?; + let _resource_guards = lock_configuration_resources(&state, ¤t_zones, ¤t_devices, &export).await; + + power_off_detached_devices(&state, ¤t_zones, &export).await?; + sanitize_configuration_runtime(&mut export); + state.initial_device_sync_complete.store(false, Ordering::Release); + state.db.replace_configuration(&export)?; + state.debug_gree_frames.store(export.settings.debug.gree_frames, Ordering::Relaxed); + *state.settings.write().await = export.settings.clone(); + reconcile_imported_devices(&state, &export).await?; + state.initial_device_sync_complete.store(true, Ordering::Release); + state.wake_zone_control(); + state.log("info", "configuration.imported", "Application configuration imported", json!({"format_version": export.format_version})); + state.broadcast("configuration.imported", json!({"at": Utc::now()})); + Ok(Json(json!({"ok": true}))) +} diff --git a/src/api/debug_tokens.rs b/src/api/debug_tokens.rs index 1de0a21..bc1ab52 100644 --- a/src/api/debug_tokens.rs +++ b/src/api/debug_tokens.rs @@ -1,18 +1,3 @@ -async fn get_debug(State(state): State) -> Json { - Json(state.settings.read().await.debug.clone()) -} - -async fn update_debug(State(state): State, Json(input): Json) -> Result, AppError> { - let _configuration_guard = state.lock_configuration_operation().await; - let _house_guard = state.lock_house_operation().await; - let mut settings = state.settings.write().await; - settings.debug = input.clone(); - state.db.save_runtime_settings(&settings)?; - state.debug_gree_frames.store(input.gree_frames, Ordering::Relaxed); - state.broadcast("debug.settings", serde_json::to_value(&input)?); - Ok(Json(input)) -} - #[derive(Debug, Deserialize)] struct CreateAccessTokenRequest { name: Option, diff --git a/src/api/events.rs b/src/api/events.rs index c7dbba0..e58d583 100644 --- a/src/api/events.rs +++ b/src/api/events.rs @@ -3,27 +3,3 @@ struct EventsQuery { limit: Option } async fn events(State(state): State, Query(query): Query) -> Result, AppError> { Ok(Json(json!({"events": state.db.list_events(query.limit.unwrap_or(100))?}))) } - -#[derive(Debug, Deserialize)] -struct EventRetentionInput { days: u32 } - -async fn get_event_retention(State(state): State) -> Json { - let days = state.settings.read().await.event_log_retention_days; - Json(json!({"days": days})) -} - -async fn update_event_retention(State(state): State, Json(input): Json) -> Result, AppError> { - let _configuration_guard = state.lock_configuration_operation().await; - let _house_guard = state.lock_house_operation().await; - let mut settings = state.settings.write().await; - settings.event_log_retention_days = input.days.clamp(1, 3650); - state.db.save_runtime_settings(&settings)?; - let days = settings.event_log_retention_days; - drop(settings); - let removed = state.db.prune_events(days as i64)?; - state.log("info", "events.retention_updated", "Event log retention updated", json!({"days": days, "removed": removed})); - let public = { let settings = state.settings.read().await; public_settings(&*settings) }; - state.broadcast("settings.updated", public); - Ok(Json(json!({"days": days, "removed": removed}))) -} - diff --git a/src/api/flows.rs b/src/api/flows.rs index f26c667..b56042f 100644 --- a/src/api/flows.rs +++ b/src/api/flows.rs @@ -61,50 +61,85 @@ fn shared_input_comparison_kind(kind: &str) -> bool { ) } -fn validate_shared_input_source(kind: &str, config: &Value, state: &AppState) -> Result<(), AppError> { - match kind { - "outdoor_temperature" | "house_mode" | "night_mode" => {} +enum SharedInputResourceReference { + Device(String), + Zone(String), + Group(String), +} + +fn shared_input_resource_reference(kind: &str, config: &Value) -> Result, AppError> { + let reference = match kind { + "outdoor_temperature" | "house_mode" | "night_mode" => None, "device_temperature" => { - let id = flow_string(config, "device_id").ok_or_else(|| AppError::BadRequest("shared device temperature input needs device".into()))?; - if state.db.get_device(&id)?.is_none() { return Err(AppError::BadRequest("shared Flow input references a missing device".into())); } + let id = flow_string(config, "device_id") + .ok_or_else(|| AppError::BadRequest("shared device temperature input needs device".into()))?; + Some(SharedInputResourceReference::Device(id)) } "zone_temperature" => { - let id = flow_string(config, "zone_id").ok_or_else(|| AppError::BadRequest("shared zone temperature input needs zone".into()))?; - if state.db.get_zone(&id)?.is_none() { return Err(AppError::BadRequest("shared Flow input references a missing zone".into())); } + let id = flow_string(config, "zone_id") + .ok_or_else(|| AppError::BadRequest("shared zone temperature input needs zone".into()))?; + Some(SharedInputResourceReference::Zone(id)) } "ha_state" | "ha_numeric" | "ha_available" => { - if flow_string(config, "entity_id").is_none() { return Err(AppError::BadRequest("shared Home Assistant input needs entity_id".into())); } + if flow_string(config, "entity_id").is_none() { + return Err(AppError::BadRequest("shared Home Assistant input needs entity_id".into())); + } + None } "ha_attribute" => { if flow_string(config, "entity_id").is_none() || flow_string(config, "attribute").is_none() { return Err(AppError::BadRequest("shared Home Assistant attribute input needs entity_id and attribute".into())); } + None } "device_state" => { - let id = flow_string(config, "device_id").ok_or_else(|| AppError::BadRequest("shared device state input needs device".into()))?; - if state.db.get_device(&id)?.is_none() { return Err(AppError::BadRequest("shared Flow input references a missing device".into())); } - let field = flow_string(config, "field").ok_or_else(|| AppError::BadRequest("shared device state input needs a field".into()))?; + let id = flow_string(config, "device_id") + .ok_or_else(|| AppError::BadRequest("shared device state input needs device".into()))?; + let field = flow_string(config, "field") + .ok_or_else(|| AppError::BadRequest("shared device state input needs a field".into()))?; if !matches!(field.as_str(), "enabled" | "online" | "power" | "mode" | "fan_speed" | "swing_vertical" | "swing_horizontal" | "quiet" | "turbo" | "light" | "air" | "xfan" | "health" | "sleep") { return Err(AppError::BadRequest("unsupported shared device state field".into())); } + Some(SharedInputResourceReference::Device(id)) } "zone_state" => { - let id = flow_string(config, "zone_id").ok_or_else(|| AppError::BadRequest("shared zone state input needs zone".into()))?; - if state.db.get_zone(&id)?.is_none() { return Err(AppError::BadRequest("shared Flow input references a missing zone".into())); } - let field = flow_string(config, "field").ok_or_else(|| AppError::BadRequest("shared zone state input needs a field".into()))?; + let id = flow_string(config, "zone_id") + .ok_or_else(|| AppError::BadRequest("shared zone state input needs zone".into()))?; + let field = flow_string(config, "field") + .ok_or_else(|| AppError::BadRequest("shared zone state input needs a field".into()))?; if !matches!(field.as_str(), "enabled" | "mode" | "active_preset" | "demand" | "control_owner" | "device_manual_override" | "local_thermostat_power") { return Err(AppError::BadRequest("unsupported shared zone state field".into())); } + Some(SharedInputResourceReference::Zone(id)) } "group_state" => { - let id = flow_string(config, "group_id").ok_or_else(|| AppError::BadRequest("shared group state input needs group".into()))?; - if state.db.get_group(&id)?.is_none() { return Err(AppError::BadRequest("shared Flow input references a missing group".into())); } - if flow_string(config, "field").as_deref() != Some("power_enabled") { return Err(AppError::BadRequest("unsupported shared group state field".into())); } + let id = flow_string(config, "group_id") + .ok_or_else(|| AppError::BadRequest("shared group state input needs group".into()))?; + if flow_string(config, "field").as_deref() != Some("power_enabled") { + return Err(AppError::BadRequest("unsupported shared group state field".into())); + } + Some(SharedInputResourceReference::Group(id)) } "constant" => { - if config.get("value").and_then(Value::as_bool).is_none() { return Err(AppError::BadRequest("shared constant input needs a boolean value".into())); } + if config.get("value").and_then(Value::as_bool).is_none() { + return Err(AppError::BadRequest("shared constant input needs a boolean value".into())); + } + None } _ => return Err(AppError::BadRequest(format!("unsupported shared Flow input kind: {kind}"))), + }; + Ok(reference) +} + +fn validate_shared_input_source(kind: &str, config: &Value, state: &AppState) -> Result<(), AppError> { + let Some(reference) = shared_input_resource_reference(kind, config)? else { return Ok(()); }; + let exists = match reference { + SharedInputResourceReference::Device(id) => state.db.get_device(&id)?.is_some(), + SharedInputResourceReference::Zone(id) => state.db.get_zone(&id)?.is_some(), + SharedInputResourceReference::Group(id) => state.db.get_group(&id)?.is_some(), + }; + if !exists { + return Err(AppError::BadRequest("shared Flow input references a missing resource".into())); } Ok(()) } diff --git a/src/api/house.rs b/src/api/house.rs index ad4d86a..63ff722 100644 --- a/src/api/house.rs +++ b/src/api/house.rs @@ -34,7 +34,7 @@ async fn set_all_thermostat_power_state(state: &AppState, power: bool) -> Result let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; }; engine::rearm_compressor_queue(&mut zone); if engine::set_house_bulk_thermostat_power(&mut zone, power) { changed += 1; } - engine::refresh_control_ownership(&mut zone, true); + engine::refresh_control_ownership(&mut zone); zone.revision = zone.revision.saturating_add(1); zone.updated_at = Utc::now(); state.db.save_zone(&zone)?; @@ -82,14 +82,13 @@ async fn update_house_control(State(state): State, Json(input): Json, Json(input): Json, Json(input): Json, Json(input): Json, Json(input): Json) -> Json { - let settings = state.settings.read().await; - Json(public_settings(&*settings)) +fn application_settings(settings: &RuntimeSettings) -> ApplicationSettings { + ApplicationSettings { simulator_enabled: settings.simulator_enabled } } -async fn update_settings(State(state): State, Json(mut input): Json) -> Result, AppError> { - let _configuration_guard = state.lock_configuration_operation().await; - let _house_guard = state.lock_house_operation().await; - let _cycle_guard = state.lock_zone_control_cycle().await; - let old = state.settings.read().await.clone(); - if input.house_mode != old.house_mode { - return Err(AppError::BadRequest( - "house_mode must be changed through the House Control API".into(), - )); +fn gree_settings(settings: &RuntimeSettings) -> GreeSettings { + GreeSettings { + controller_id: settings.controller_id.clone(), + poll_interval_seconds: settings.poll_interval_seconds, + zone_interval_seconds: settings.zone_interval_seconds, + discovery_timeout_ms: settings.discovery_timeout_ms, + discovery_broadcast: settings.discovery_broadcast.clone(), + suppress_device_beep: settings.suppress_device_beep, + compressor_protection_enabled: settings.compressor_protection_enabled, + compressor_protection_seconds: settings.compressor_protection_seconds, + } +} + +fn history_settings(settings: &RuntimeSettings) -> HistorySettings { + HistorySettings { + retention_days: settings.history_retention_days, + compaction_enabled: settings.history_compaction_enabled, + event_retention_days: settings.event_log_retention_days, + } +} + +fn influxdb_settings(settings: &RuntimeSettings) -> InfluxDbSettingsView { + InfluxDbSettingsView { + enabled: settings.influxdb.enabled, + version: settings.influxdb.version.clone(), + url: settings.influxdb.url.clone(), + database: settings.influxdb.database.clone(), + username: settings.influxdb.username.clone(), + password_configured: !settings.influxdb.password.trim().is_empty(), + org: settings.influxdb.org.clone(), + bucket: settings.influxdb.bucket.clone(), + token_configured: !settings.influxdb.token.trim().is_empty(), + history_threshold_days: settings.influxdb.history_threshold_days, + } +} + +fn notification_settings(settings: &RuntimeSettings) -> NotificationSettingsView { + NotificationSettingsView { + enabled: settings.notifications.enabled, + mode: settings.notifications.mode.clone(), + provider: settings.notifications.provider.clone(), + pushover_configured: !settings.notifications.pushover_app_token.trim().is_empty() + && !settings.notifications.pushover_user_key.trim().is_empty(), + slack_configured: !settings.notifications.slack_webhook_url.trim().is_empty(), + discord_configured: !settings.notifications.discord_webhook_url.trim().is_empty(), + cooldown_seconds: settings.notifications.cooldown_seconds, + communication_failure_threshold: settings.notifications.communication_failure_threshold, + target_timeout_minutes: settings.notifications.target_timeout_minutes, + alert_types: settings.notifications.alert_types.clone(), + } +} + +fn home_assistant_settings(settings: &RuntimeSettings) -> HomeAssistantSettingsView { + HomeAssistantSettingsView { + url: settings.home_assistant.url.clone(), + token_configured: !settings.home_assistant.token.trim().is_empty(), + default_entity_id: settings.home_assistant.default_entity_id.clone(), + outdoor_entity_id: settings.home_assistant.outdoor_entity_id.clone(), + sensor_stale_after_seconds: settings.home_assistant.sensor_stale_after_seconds, + allow_invalid_tls: settings.home_assistant.allow_invalid_tls, + sensor_aliases: settings.home_assistant.sensor_aliases.clone(), + flow_inputs: settings.home_assistant.flow_inputs.clone(), + outdoor_assist_enabled: settings.outdoor_assist_enabled, + } +} + +async fn get_application_settings(State(state): State) -> Json { + Json(application_settings(&state.settings.read().await)) +} + +async fn update_application_settings( + State(state): State, + Json(input): Json, +) -> Result, AppError> { + let _configuration_guard = state.lock_configuration_operation().await; + let payload = { + let mut settings = state.settings.write().await; + settings.simulator_enabled = input.simulator_enabled; + state.db.save_runtime_settings(&settings)?; + application_settings(&settings) + }; + state.log("info", "settings.application.updated", "Application settings updated", json!({"simulator_enabled": payload.simulator_enabled})); + state.broadcast("settings.application.updated", serde_json::to_value(&payload)?); + Ok(Json(payload)) +} + +async fn get_gree_settings(State(state): State) -> Json { + Json(gree_settings(&state.settings.read().await)) +} + +fn normalize_gree_settings(mut input: GreeSettings) -> Result { + input.controller_id = input.controller_id.trim().to_string(); + if input.controller_id.is_empty() { + return Err(AppError::BadRequest("controller_id cannot be empty".into())); } - // Compatibility-only field: global ON/OFF no longer uses a persistent master automation gate. - input.house_power_enabled = true; input.poll_interval_seconds = input.poll_interval_seconds.clamp(2, 3600); input.zone_interval_seconds = input.zone_interval_seconds.clamp(2, 3600); input.discovery_timeout_ms = input.discovery_timeout_ms.clamp(300, 30_000); - if !matches!(input.house_mode.as_str(), "cool" | "heat" | "off") { return Err(AppError::BadRequest("house mode must be cool, heat or off".into())); } - if input.control_strategy != "setpoint" { input.control_strategy = "setpoint".into(); } + input.compressor_protection_seconds = input.compressor_protection_seconds.clamp(30, 1800); if !(input.discovery_broadcast.eq_ignore_ascii_case("auto") - || input.discovery_broadcast.to_ascii_lowercase().starts_with("auto:")) { + || input.discovery_broadcast.to_ascii_lowercase().starts_with("auto:")) + { input.discovery_broadcast.parse::() .map_err(|_| AppError::BadRequest("invalid discovery broadcast address".into()))?; } - if input.controller_id.trim().is_empty() { input.controller_id = old.controller_id; } - if input.home_assistant.token.trim().is_empty() { input.home_assistant.token = old.home_assistant.token; } - input.home_assistant.sensor_stale_after_seconds = input.home_assistant.sensor_stale_after_seconds.clamp(30, 86_400); - if input.notifications.pushover_app_token.trim().is_empty() { input.notifications.pushover_app_token = old.notifications.pushover_app_token; } - if input.notifications.pushover_user_key.trim().is_empty() { input.notifications.pushover_user_key = old.notifications.pushover_user_key; } - if input.notifications.slack_webhook_url.trim().is_empty() { input.notifications.slack_webhook_url = old.notifications.slack_webhook_url; } - if input.notifications.discord_webhook_url.trim().is_empty() { input.notifications.discord_webhook_url = old.notifications.discord_webhook_url; } - input.notifications.cooldown_seconds = input.notifications.cooldown_seconds.clamp(30, 86_400); - input.notifications.communication_failure_threshold = input.notifications.communication_failure_threshold.clamp(2, 100); - input.notifications.target_timeout_minutes = input.notifications.target_timeout_minutes.clamp(5, 24 * 60); - if !matches!(input.notifications.mode.as_str(), "problems" | "important") { return Err(AppError::BadRequest("notification mode must be problems or important".into())); } - if !matches!(input.notifications.provider.as_str(), "pushover" | "slack" | "discord") { return Err(AppError::BadRequest("unsupported notification provider".into())); } - input.history_retention_days = input.history_retention_days.clamp(1, 3650); - input.event_log_retention_days = input.event_log_retention_days.clamp(1, 3650); - input.compressor_protection_seconds = input.compressor_protection_seconds.clamp(30, 1800); - let compressor_settings_changed = input.compressor_protection_enabled != old.compressor_protection_enabled - || input.compressor_protection_seconds != old.compressor_protection_seconds; - normalize_sensor_aliases(&mut input); - validate_flow_shared_inputs(&mut input, &state)?; - canonicalize_home_assistant_entities(&mut input); - validate_night_mode(&mut input)?; - input.influxdb.history_threshold_days = input.influxdb.history_threshold_days.clamp(1, 3650); - if input.influxdb.token.trim().is_empty() { input.influxdb.token = old.influxdb.token; } - if input.influxdb.password.trim().is_empty() { input.influxdb.password = old.influxdb.password; } - influxdb::validate(&input.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?; - if !input.home_assistant.url.trim().is_empty() { - let parsed = url::Url::parse(&input.home_assistant.url).map_err(|_| AppError::BadRequest("invalid Home Assistant URL".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)?; - canonicalize_saved_zone_entities(&state, &input).await?; - state.debug_gree_frames.store(input.debug.gree_frames, Ordering::Relaxed); - *state.settings.write().await = input.clone(); - if compressor_settings_changed { - // A changed protection policy invalidates old deadlines. Clear transient tasks under - // the same control-cycle exclusion; the next thermostat pass re-evaluates intent - // against the new enabled flag/duration. - let mut zone_ids: Vec = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect(); - zone_ids.sort(); - zone_ids.dedup(); - for zone_id in zone_ids { - let _zone_guard = state.lock_zone_operation(&zone_id).await; - let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; }; - if zone.compressor_pending_action.is_none() && zone.compressor_cancelled_action.is_none() - && zone.lockout_until.is_none() && zone.lockout_reason.is_none() { continue; } - engine::clear_compressor_pending(&mut zone, true); - zone.revision = zone.revision.saturating_add(1); - zone.updated_at = Utc::now(); - state.db.save_zone(&zone)?; - state.broadcast("zone.updated", serde_json::to_value(&zone)?); - } - } - state.log("info", "settings.updated", "Settings updated", json!({ - "compressor_protection_enabled": input.compressor_protection_enabled, - "compressor_protection_seconds": input.compressor_protection_seconds - })); - state.broadcast("settings.updated", public_settings(&input)); - state.wake_zone_control(); - Ok(Json(public_settings(&input))) + Ok(input) } -fn normalize_sensor_aliases(settings: &mut RuntimeSettings) { - settings.home_assistant.sensor_aliases = settings.home_assistant.sensor_aliases +async fn clear_compressor_runtime_after_settings_change(state: &AppState) -> Result<(), AppError> { + let mut zone_ids: Vec = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect(); + zone_ids.sort(); + zone_ids.dedup(); + for zone_id in zone_ids { + let _zone_guard = state.lock_zone_operation(&zone_id).await; + let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; }; + if zone.compressor_pending_action.is_none() + && zone.compressor_cancelled_action.is_none() + && zone.lockout_until.is_none() + && zone.lockout_reason.is_none() + { + continue; + } + engine::clear_compressor_pending(&mut zone, true); + zone.revision = zone.revision.saturating_add(1); + zone.updated_at = Utc::now(); + state.db.save_zone(&zone)?; + state.broadcast("zone.updated", serde_json::to_value(&zone)?); + } + Ok(()) +} + +async fn update_gree_settings( + State(state): State, + Json(input): Json, +) -> Result, AppError> { + let input = normalize_gree_settings(input)?; + let _configuration_guard = state.lock_configuration_operation().await; + let _house_guard = state.lock_house_operation().await; + let _cycle_guard = state.lock_zone_control_cycle().await; + let (payload, compressor_changed) = { + let mut settings = state.settings.write().await; + let compressor_changed = settings.compressor_protection_enabled != input.compressor_protection_enabled + || settings.compressor_protection_seconds != input.compressor_protection_seconds; + settings.controller_id = input.controller_id; + settings.poll_interval_seconds = input.poll_interval_seconds; + settings.zone_interval_seconds = input.zone_interval_seconds; + settings.discovery_timeout_ms = input.discovery_timeout_ms; + settings.discovery_broadcast = input.discovery_broadcast; + settings.suppress_device_beep = input.suppress_device_beep; + settings.compressor_protection_enabled = input.compressor_protection_enabled; + settings.compressor_protection_seconds = input.compressor_protection_seconds; + state.db.save_runtime_settings(&settings)?; + (gree_settings(&settings), compressor_changed) + }; + if compressor_changed { + clear_compressor_runtime_after_settings_change(&state).await?; + } + state.log("info", "settings.gree.updated", "GREE settings updated", json!({ + "compressor_protection_enabled": payload.compressor_protection_enabled, + "compressor_protection_seconds": payload.compressor_protection_seconds + })); + state.broadcast("settings.gree.updated", serde_json::to_value(&payload)?); + state.wake_zone_control(); + Ok(Json(payload)) +} + +async fn get_history_settings(State(state): State) -> Json { + Json(history_settings(&state.settings.read().await)) +} + +async fn update_history_settings( + State(state): State, + Json(mut input): Json, +) -> Result, AppError> { + input.retention_days = input.retention_days.clamp(1, 3650); + input.event_retention_days = input.event_retention_days.clamp(1, 3650); + let _configuration_guard = state.lock_configuration_operation().await; + let payload = { + let mut settings = state.settings.write().await; + settings.history_retention_days = input.retention_days; + settings.history_compaction_enabled = input.compaction_enabled; + settings.event_log_retention_days = input.event_retention_days; + state.db.save_runtime_settings(&settings)?; + history_settings(&settings) + }; + let removed = state.db.prune_events(payload.event_retention_days as i64)?; + state.log("info", "settings.history.updated", "History settings updated", json!({ + "retention_days": payload.retention_days, + "event_retention_days": payload.event_retention_days, + "event_rows_removed": removed + })); + state.broadcast("settings.history.updated", serde_json::to_value(&payload)?); + Ok(Json(payload)) +} + +async fn get_influxdb_settings(State(state): State) -> Json { + Json(influxdb_settings(&state.settings.read().await)) +} + +fn apply_influxdb_update(current: &InfluxDbSettings, input: InfluxDbSettingsUpdate) -> Result { + let mut next = InfluxDbSettings { + enabled: input.enabled, + version: input.version, + url: input.url, + database: input.database, + username: input.username, + password: current.password.clone(), + org: input.org, + bucket: input.bucket, + token: current.token.clone(), + history_threshold_days: input.history_threshold_days.clamp(1, 3650), + }; + if let Some(password) = input.password { next.password = password; } + if let Some(token) = input.token { next.token = token; } + influxdb::validate(&next).map_err(|err| AppError::BadRequest(err.to_string()))?; + Ok(next) +} + +async fn update_influxdb_settings( + State(state): State, + Json(input): Json, +) -> Result, AppError> { + let _configuration_guard = state.lock_configuration_operation().await; + let payload = { + let mut settings = state.settings.write().await; + let next = apply_influxdb_update(&settings.influxdb, input)?; + settings.influxdb = next; + state.db.save_runtime_settings(&settings)?; + influxdb_settings(&settings) + }; + state.log("info", "settings.influxdb.updated", "InfluxDB settings updated", json!({"enabled": payload.enabled, "version": payload.version})); + state.broadcast("settings.influxdb.updated", serde_json::to_value(&payload)?); + Ok(Json(payload)) +} + +async fn get_notification_settings(State(state): State) -> Json { + Json(notification_settings(&state.settings.read().await)) +} + +fn apply_notification_update(current: &NotificationSettings, mut input: NotificationSettingsUpdate) -> Result { + input.cooldown_seconds = input.cooldown_seconds.clamp(30, 86_400); + input.communication_failure_threshold = input.communication_failure_threshold.clamp(2, 100); + input.target_timeout_minutes = input.target_timeout_minutes.clamp(5, 24 * 60); + if !matches!(input.mode.as_str(), "problems" | "important") { + return Err(AppError::BadRequest("notification mode must be problems or important".into())); + } + if !matches!(input.provider.as_str(), "pushover" | "slack" | "discord") { + return Err(AppError::BadRequest("unsupported notification provider".into())); + } + let mut next = NotificationSettings { + enabled: input.enabled, + mode: input.mode, + provider: input.provider, + pushover_app_token: current.pushover_app_token.clone(), + pushover_user_key: current.pushover_user_key.clone(), + slack_webhook_url: current.slack_webhook_url.clone(), + discord_webhook_url: current.discord_webhook_url.clone(), + cooldown_seconds: input.cooldown_seconds, + communication_failure_threshold: input.communication_failure_threshold, + target_timeout_minutes: input.target_timeout_minutes, + alert_types: input.alert_types, + }; + if let Some(value) = input.pushover_app_token { next.pushover_app_token = value; } + if let Some(value) = input.pushover_user_key { next.pushover_user_key = value; } + if let Some(value) = input.slack_webhook_url { next.slack_webhook_url = value; } + if let Some(value) = input.discord_webhook_url { next.discord_webhook_url = value; } + Ok(next) +} + +async fn update_notification_settings( + State(state): State, + Json(input): Json, +) -> Result, AppError> { + let _configuration_guard = state.lock_configuration_operation().await; + let payload = { + let mut settings = state.settings.write().await; + let next = apply_notification_update(&settings.notifications, input)?; + settings.notifications = next; + state.db.save_runtime_settings(&settings)?; + notification_settings(&settings) + }; + state.log("info", "settings.notifications.updated", "Notification settings updated", json!({"enabled": payload.enabled, "provider": payload.provider})); + state.broadcast("settings.notifications.updated", serde_json::to_value(&payload)?); + Ok(Json(payload)) +} + +async fn get_night_settings(State(state): State) -> Json { + Json(state.settings.read().await.night_mode.clone()) +} + +fn validate_night_mode(settings: &mut NightModeSettings) -> Result<(), AppError> { + NaiveTime::parse_from_str(&settings.start_time, "%H:%M") + .map_err(|_| AppError::BadRequest("night mode start time must use HH:MM".into()))?; + NaiveTime::parse_from_str(&settings.end_time, "%H:%M") + .map_err(|_| AppError::BadRequest("night mode end time must use HH:MM".into()))?; + settings.max_fan_speed = settings.max_fan_speed.clamp(1, 5); + Ok(()) +} + +async fn update_night_settings( + State(state): State, + Json(mut input): Json, +) -> Result, AppError> { + validate_night_mode(&mut input)?; + let _configuration_guard = state.lock_configuration_operation().await; + let _cycle_guard = state.lock_zone_control_cycle().await; + { + let mut settings = state.settings.write().await; + settings.night_mode = input.clone(); + state.db.save_runtime_settings(&settings)?; + } + state.log("info", "settings.night.updated", "Night mode settings updated", json!({"enabled": input.enabled})); + state.broadcast("settings.night.updated", serde_json::to_value(&input)?); + state.wake_zone_control(); + Ok(Json(input)) +} + +async fn get_home_assistant_settings(State(state): State) -> Json { + Json(home_assistant_settings(&state.settings.read().await)) +} + +fn normalize_sensor_aliases(settings: &mut HomeAssistantSettings) { + settings.sensor_aliases = settings.sensor_aliases .iter() .filter_map(|(entity, alias)| { let entity = entity.trim(); @@ -98,12 +335,12 @@ fn normalize_sensor_aliases(settings: &mut RuntimeSettings) { .collect(); } -fn validate_flow_shared_inputs(settings: &mut RuntimeSettings, state: &AppState) -> Result<(), AppError> { +fn normalize_flow_shared_inputs(settings: &mut HomeAssistantSettings) -> Result<(), AppError> { let mut ids = std::collections::HashSet::new(); - if settings.home_assistant.flow_inputs.len() > 128 { + if settings.flow_inputs.len() > 128 { return Err(AppError::BadRequest("too many shared Flow inputs (max 128)".into())); } - for item in &mut settings.home_assistant.flow_inputs { + for item in &mut settings.flow_inputs { item.id = item.id.trim().chars().take(120).collect(); item.name = item.name.trim().chars().take(100).collect(); item.kind = item.kind.trim().to_string(); @@ -129,30 +366,47 @@ fn validate_flow_shared_inputs(settings: &mut RuntimeSettings, state: &AppState) if shared_input_comparison_kind(&item.kind) && item.config.get("value").is_some() { return Err(AppError::BadRequest("shared Flow inputs are value sources; comparison value belongs to the Flow block".into())); } + } + Ok(()) +} + +fn validate_flow_shared_inputs(settings: &mut HomeAssistantSettings, state: &AppState) -> Result<(), AppError> { + normalize_flow_shared_inputs(settings)?; + for item in &settings.flow_inputs { validate_shared_input_source(&item.kind, &item.config, state)?; } Ok(()) } -fn canonicalize_home_assistant_entities(settings: &mut RuntimeSettings) { - let default_entity = settings.home_assistant.default_entity_id.clone(); - if let Some(entity_id) = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&default_entity)) { - settings.home_assistant.default_entity_id = entity_id; +fn canonicalize_home_assistant_entities(settings: &mut HomeAssistantSettings) { + let default_entity = settings.default_entity_id.clone(); + if let Some(entity_id) = home_assistant::resolve_entity_id(settings, Some(&default_entity)) { + settings.default_entity_id = entity_id; } - let outdoor_entity = settings.home_assistant.outdoor_entity_id.clone(); + let outdoor_entity = settings.outdoor_entity_id.clone(); if !outdoor_entity.trim().is_empty() { - if let Some(entity_id) = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&outdoor_entity)) { - settings.home_assistant.outdoor_entity_id = entity_id; + if let Some(entity_id) = home_assistant::resolve_entity_id(settings, Some(&outdoor_entity)) { + settings.outdoor_entity_id = entity_id; } } } -fn canonicalize_zone_ha_entity(zone: &mut Zone, settings: &RuntimeSettings) { - let Some(configured) = zone.ha_entity_id.clone() else { return; }; - zone.ha_entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&configured)); +fn validate_home_assistant_url(settings: &HomeAssistantSettings) -> Result<(), AppError> { + if settings.url.trim().is_empty() { return Ok(()); } + let parsed = url::Url::parse(&settings.url) + .map_err(|_| AppError::BadRequest("invalid Home Assistant URL".into()))?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err(AppError::BadRequest("Home Assistant URL must use http or https".into())); + } + Ok(()) } -async fn canonicalize_saved_zone_entities(state: &AppState, settings: &RuntimeSettings) -> Result<(), AppError> { +fn canonicalize_zone_ha_entity(zone: &mut Zone, settings: &HomeAssistantSettings) { + let Some(configured) = zone.ha_entity_id.clone() else { return; }; + zone.ha_entity_id = home_assistant::resolve_entity_id(settings, Some(&configured)); +} + +async fn canonicalize_saved_zone_entities(state: &AppState, settings: &HomeAssistantSettings) -> Result<(), AppError> { let mut zone_ids: Vec = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect(); zone_ids.sort(); zone_ids.dedup(); @@ -172,348 +426,68 @@ async fn canonicalize_saved_zone_entities(state: &AppState, settings: &RuntimeSe Ok(()) } -fn validate_night_mode(settings: &mut RuntimeSettings) -> Result<(), AppError> { - NaiveTime::parse_from_str(&settings.night_mode.start_time, "%H:%M") - .map_err(|_| AppError::BadRequest("night mode start time must use HH:MM".into()))?; - NaiveTime::parse_from_str(&settings.night_mode.end_time, "%H:%M") - .map_err(|_| AppError::BadRequest("night mode end time must use HH:MM".into()))?; - settings.night_mode.max_fan_speed = settings.night_mode.max_fan_speed.clamp(1, 5); - Ok(()) +fn apply_home_assistant_update( + current: &HomeAssistantSettings, + input: HomeAssistantSettingsUpdate, +) -> HomeAssistantSettings { + let mut next = HomeAssistantSettings { + url: input.url, + token: current.token.clone(), + default_entity_id: input.default_entity_id, + outdoor_entity_id: input.outdoor_entity_id, + sensor_stale_after_seconds: input.sensor_stale_after_seconds.clamp(30, 86_400), + allow_invalid_tls: input.allow_invalid_tls, + sensor_aliases: input.sensor_aliases, + flow_inputs: input.flow_inputs, + }; + if let Some(token) = input.token { next.token = token; } + next } -async fn export_settings(State(state): State) -> Result, AppError> { - let settings = state.settings.read().await.clone(); - let mut export = state.db.export_configuration(settings)?; - // Backups are configuration snapshots, not a way to resurrect transient ownership, - // timers or a stale physical device state after restore (K9). - sanitize_configuration_runtime(&mut export); - Ok(Json(export)) -} - -fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), AppError> { - if !matches!(export.format_version, 1 | 2) { return Err(AppError::BadRequest("unsupported configuration export version".into())); } - influxdb::validate(&export.settings.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?; - if !matches!(export.settings.house_mode.as_str(), "cool" | "heat" | "off") { - return Err(AppError::BadRequest("import contains an invalid house mode".into())); - } - - let devices: std::collections::HashSet<&str> = export.devices.iter().map(|item| item.id.as_str()).collect(); - let zones: std::collections::HashSet<&str> = export.zones.iter().map(|item| item.id.as_str()).collect(); - let schedules: std::collections::HashSet<&str> = export.schedules.iter().map(|item| item.id.as_str()).collect(); - let automations: std::collections::HashSet<&str> = export.automations.iter().map(|item| item.id.as_str()).collect(); - let flows: std::collections::HashSet<&str> = export.flows.iter().map(|item| item.id.as_str()).collect(); - let draft_flows: std::collections::HashSet<&str> = export.flows.iter().filter(|item| item.draft).map(|item| item.id.as_str()).collect(); - if export.flows.iter().any(|item| item.draft && (item.enabled || !item.compiled_schedule_ids.is_empty() || !item.compiled_automation_ids.is_empty())) - || export.schedules.iter().any(|item| item.flow_id.as_deref().is_some_and(|id| draft_flows.contains(id))) - || export.automations.iter().any(|item| item.flow_id.as_deref().is_some_and(|id| draft_flows.contains(id))) - { - return Err(AppError::BadRequest("import contains an executable Flow draft".into())); - } - if devices.len() != export.devices.len() || zones.len() != export.zones.len() - || schedules.len() != export.schedules.len() || automations.len() != export.automations.len() || flows.len() != export.flows.len() - || devices.contains("") || zones.contains("") || schedules.contains("") || automations.contains("") || flows.contains("") - { - return Err(AppError::BadRequest("import contains duplicate or empty resource IDs".into())); - } - let device_macs: std::collections::HashSet<&str> = export.devices.iter().map(|item| item.mac.as_str()).collect(); - if device_macs.len() != export.devices.len() { - return Err(AppError::BadRequest("import contains duplicate device MAC addresses".into())); - } - if export.zones.iter().any(|item| !devices.contains(item.device_id.as_str())) { - return Err(AppError::BadRequest("import contains a zone referencing a missing device".into())); - } - let mut zone_devices = std::collections::HashSet::new(); - for zone in &export.zones { - if !zone_devices.insert(zone.device_id.as_str()) { - return Err(AppError::BadRequest("import assigns one device to more than one thermostat zone".into())); - } - if !matches!(zone.mode.as_str(), "cool" | "heat") { - return Err(AppError::BadRequest("import contains an invalid zone mode".into())); - } - if !matches!(zone.sensor_source.as_str(), "device" | "home_assistant" | "combined") { - return Err(AppError::BadRequest("import contains an invalid zone sensor source".into())); - } - } - - if export.schedules.iter().any(|item| !zones.contains(item.zone_id.as_str())) { - return Err(AppError::BadRequest("import contains a schedule referencing a missing zone".into())); - } - for item in &export.schedules { - if item.flow_id.as_deref().is_some_and(|flow_id| !flows.contains(flow_id)) { - return Err(AppError::BadRequest("import contains a Flow-generated schedule referencing a missing Flow".into())); - } - if item.weekdays.is_empty() || item.weekdays.iter().any(|day| !(1..=7).contains(day)) { - return Err(AppError::BadRequest("import contains invalid schedule weekdays".into())); - } - NaiveTime::parse_from_str(&item.start_time, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid schedule start time".into()))?; - NaiveTime::parse_from_str(&item.end_time, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid schedule end time".into()))?; - if !matches!(item.preset.as_str(), "comfort" | "sleep" | "away" | "custom") { - return Err(AppError::BadRequest("import contains an invalid schedule preset".into())); - } - if item.preset == "custom" && !(8.0..=30.0).contains(&item.setpoint) { - return Err(AppError::BadRequest("import contains an invalid schedule setpoint".into())); - } - } - validate_schedule_set(&export.schedules)?; - - if export.groups.iter().any(|group| { - let members: std::collections::HashSet<&str> = group.zone_ids.iter().map(String::as_str).collect(); - group.id.trim().is_empty() || group.zone_ids.is_empty() || members.len() != group.zone_ids.len() - || group.zone_ids.iter().any(|zone_id| !zones.contains(zone_id.as_str())) - }) { - return Err(AppError::BadRequest("import contains an invalid group, duplicate members or a missing zone reference".into())); - } - let groups: std::collections::HashSet<&str> = export.groups.iter().map(|item| item.id.as_str()).collect(); - if groups.len() != export.groups.len() { - return Err(AppError::BadRequest("import contains duplicate group IDs".into())); - } - - for item in &export.automations { - match item.trigger_kind.as_str() { - "temperature_above" | "temperature_below" => { - let Some(trigger_id) = item.trigger_device_id.as_deref() else { - return Err(AppError::BadRequest("import contains a temperature automation without a trigger device".into())); - }; - if !devices.contains(trigger_id) || item.threshold.is_none() { - return Err(AppError::BadRequest("import contains an invalid temperature automation trigger".into())); - } - } - "time" => { - let at = item.at_time.as_deref().ok_or_else(|| AppError::BadRequest("import contains a time automation without at_time".into()))?; - NaiveTime::parse_from_str(at, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid automation time".into()))?; - } - "flow" => { - if item.flow_id.as_deref().filter(|id| flows.contains(*id)).is_none() || item.flow_conditions.is_empty() { - return Err(AppError::BadRequest("import contains an invalid Flow-generated automation".into())); - } - } - _ => return Err(AppError::BadRequest("import contains an unsupported automation trigger".into())), - } - - if let Some(zone_id) = item.action_zone_id.as_deref().filter(|value| !value.is_empty()) { - if !zones.contains(zone_id) { return Err(AppError::BadRequest("import contains a Flow automation referencing a missing zone".into())); } - if let Some(preset) = item.action_zone_preset.as_deref() { - if !matches!(preset, "auto" | "custom" | "comfort" | "sleep" | "away") { - return Err(AppError::BadRequest("import contains an invalid Flow thermostat preset".into())); - } - } - if item.action_zone_preset.as_deref() == Some("custom") - && item.action.target_temperature.is_some_and(|value| !(8.0..=30.0).contains(&value)) - { - return Err(AppError::BadRequest("import contains an invalid Flow thermostat target".into())); - } - } else if let Some(group_id) = item.action_group_id.as_deref().filter(|value| !value.is_empty()) { - if !groups.contains(group_id) { - return Err(AppError::BadRequest("import contains an automation referencing a missing group".into())); - } - if let Some(mode) = item.action.mode.as_deref() { - if !matches!(mode, "auto" | "house" | "cool" | "heat") { - return Err(AppError::BadRequest("import contains an invalid group automation mode".into())); - } - } - let flow_custom_group = item.flow_id.is_some() && item.action_preset.as_deref() == Some("custom"); - if let Some(preset) = item.action_preset.as_deref() { - if !matches!(preset, "auto" | "comfort" | "sleep" | "away") && !(flow_custom_group && preset == "custom") { - return Err(AppError::BadRequest("import contains an invalid group automation preset".into())); - } - } - if flow_custom_group { - let Some(target) = item.action.target_temperature else { return Err(AppError::BadRequest("import contains a Flow custom group preset without a target".into())); }; - if !(8.0..=30.0).contains(&target) { return Err(AppError::BadRequest("import contains an invalid Flow group target".into())); } - } else if item.action.target_temperature.is_some() { - return Err(AppError::BadRequest("import contains unsupported target temperature in a group automation".into())); - } - if item.action.fan_speed.is_some() || item.action.swing_vertical.is_some() || item.action.swing_horizontal.is_some() - || item.action.quiet.is_some() || item.action.turbo.is_some() || item.action.light.is_some() - || item.action.air.is_some() || item.action.xfan.is_some() || item.action.health.is_some() || item.action.sleep.is_some() - { - return Err(AppError::BadRequest("import contains unsupported device fields in a group automation".into())); - } - if item.action.power.is_none() && item.action.mode.is_none() && item.action_preset.as_deref().filter(|v| !v.is_empty()).is_none() { - return Err(AppError::BadRequest("import contains an empty group automation action".into())); - } - } else { - if !devices.contains(item.action_device_id.as_str()) { - return Err(AppError::BadRequest("import contains an automation referencing a missing device".into())); - } - engine::validate_command(&item.action)?; - if item.action.is_empty() { - return Err(AppError::BadRequest("import contains an empty automation action".into())); - } - } - } - Ok(()) -} - -fn sanitize_configuration_runtime(export: &mut ConfigurationExport) { - let now = Utc::now(); - // Do not restore the pre-v0.9.3 persistent global-OFF gate from backups. - export.settings.house_power_enabled = true; - for device in &mut export.devices { - device.power = false; - device.mode = "cool".into(); - device.target_temperature = 23.0; - device.fan_speed = 0; - device.swing_vertical = false; - device.swing_horizontal = false; - device.quiet = false; - device.turbo = false; - device.light = false; - device.air = false; - device.xfan = false; - device.health = false; - device.sleep = false; - device.current_temperature = None; - device.outdoor_temperature = None; - device.online = false; - device.response_time_ms = None; - device.last_seen = None; - device.last_error = None; - device.communication_failures = 0; - device.updated_at = now; - } - for zone in &mut export.zones { - zone.device_temperature = None; - zone.external_temperature = None; - zone.current_temperature = None; - zone.control_temperature_source = "device".into(); - zone.active_preset = "comfort".into(); - zone.manual_preset = None; - zone.manual_setpoint = None; - zone.manual_override_until = None; - zone.local_thermostat_power = None; - zone.local_thermostat_resume_at = None; - zone.local_thermostat_restore_zone_enabled = None; - zone.temporary_quick_thermostat = None; - zone.device_manual_override = false; - zone.device_manual_override_since = None; - zone.device_manual_override_until = None; - zone.device_manual_override_fields.clear(); - zone.device_manual_override_baseline = None; - zone.control_owner = "automation".into(); - zone.control_source = "automation".into(); - zone.control_since = None; - zone.control_resume_at = None; - zone.control_reason = "Imported configuration; runtime ownership reset".into(); - zone.last_power_change_at = None; - zone.last_mode_change_at = None; - zone.lockout_until = None; - zone.lockout_reason = None; - zone.compressor_pending_action = None; - zone.compressor_pending_since = None; - zone.compressor_pending_until = None; - zone.compressor_cancelled_action = None; - zone.effective_mode.clear(); - zone.effective_setpoint = None; - zone.device_setpoint = None; - zone.demand = false; - zone.demand_since = None; - zone.target_alerted_at = None; - zone.last_action_at = None; - zone.revision = 0; - zone.updated_at = now; - } - for automation in &mut export.automations { - automation.last_fired_at = None; - automation.updated_at = now; - } -} - -async fn import_settings(State(state): State, Json(mut export): Json) -> Result, AppError> { - validate_configuration_export(&export)?; - export.settings.history_retention_days = export.settings.history_retention_days.clamp(1, 3650); - export.settings.event_log_retention_days = export.settings.event_log_retention_days.clamp(1, 3650); - export.settings.compressor_protection_seconds = export.settings.compressor_protection_seconds.clamp(30, 1800); - normalize_sensor_aliases(&mut export.settings); - canonicalize_home_assistant_entities(&mut export.settings); - for zone in &mut export.zones { canonicalize_zone_ha_entity(zone, &export.settings); } - validate_night_mode(&mut export.settings)?; - 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 -> thermostat-cycle -> zones -> devices. +async fn update_home_assistant_settings( + State(state): State, + Json(input): Json, +) -> Result, 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 _cycle_guard = state.lock_zone_control_cycle().await; - - let current_zones = state.db.list_zones()?; - let current_devices = state.db.list_devices()?; - let mut locked_zone_ids: Vec = current_zones.iter().map(|zone| zone.id.clone()) - .chain(export.zones.iter().map(|zone| zone.id.clone())) - .collect(); - locked_zone_ids.sort(); - locked_zone_ids.dedup(); - let mut _zone_guards = Vec::with_capacity(locked_zone_ids.len()); - for zone_id in &locked_zone_ids { - _zone_guards.push(state.lock_zone_operation(zone_id).await); - } - - let mut locked_device_ids: Vec = current_devices.iter().map(|device| device.id.clone()) - .chain(export.devices.iter().map(|device| device.id.clone())) - .collect(); - locked_device_ids.sort(); - locked_device_ids.dedup(); - let mut _device_guards = Vec::with_capacity(locked_device_ids.len()); - for device_id in &locked_device_ids { - _device_guards.push(state.lock_device_operation(device_id).await); - } - - // Before replacing ownership, safely stop every currently managed device whose zone is - // removed or rewired by the imported configuration. Otherwise an orphaned physical unit - // could keep running after its database owner disappears. - let imported_zone_map: std::collections::HashMap = export.zones.iter() - .map(|zone| (zone.id.clone(), zone.device_id.clone())) - .collect(); - let mut detach_devices = std::collections::HashSet::new(); - for current in ¤t_zones { - if imported_zone_map.get(¤t.id).map(String::as_str) != Some(current.device_id.as_str()) { - detach_devices.insert(current.device_id.clone()); - } - } - for device_id in detach_devices { - 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. - // Imported devices are reconciled from a fresh poll and current house/zone gates. - sanitize_configuration_runtime(&mut export); - state.initial_device_sync_complete.store(false, Ordering::Release); - state.db.replace_configuration(&export)?; - state.debug_gree_frames.store(export.settings.debug.gree_frames, Ordering::Relaxed); - *state.settings.write().await = export.settings.clone(); - - let controllable_devices: std::collections::HashSet = export.zones.iter() - .filter(|zone| { - let effective_mode = if zone.inherit_house_mode { export.settings.house_mode.as_str() } else { zone.mode.as_str() }; - zone.enabled - && effective_mode != "off" - }) - .map(|zone| zone.device_id.clone()) - .collect(); - for device in export.devices.iter().filter(|device| device.enabled && !controllable_devices.contains(&device.id)) { - 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})); - return Err(err); - } - } - // 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 - // imported cache values. - engine::poll_all_locked(&state).await?; - state.initial_device_sync_complete.store(true, Ordering::Release); + let payload = { + let mut settings = state.settings.write().await; + let mut next = apply_home_assistant_update(&settings.home_assistant, input.clone()); + normalize_sensor_aliases(&mut next); + validate_flow_shared_inputs(&mut next, &state)?; + canonicalize_home_assistant_entities(&mut next); + validate_home_assistant_url(&next)?; + settings.home_assistant = next.clone(); + settings.outdoor_assist_enabled = input.outdoor_assist_enabled; + state.db.save_runtime_settings(&settings)?; + home_assistant_settings(&settings) + }; + let saved = state.settings.read().await.home_assistant.clone(); + canonicalize_saved_zone_entities(&state, &saved).await?; + state.log("info", "settings.home_assistant.updated", "Home Assistant settings updated", json!({ + "configured": payload.token_configured, + "flow_inputs": payload.flow_inputs.len() + })); + state.broadcast("settings.home_assistant.updated", serde_json::to_value(&payload)?); state.wake_zone_control(); - state.log("info", "settings.imported", "Application configuration imported", json!({"format_version": export.format_version})); - state.broadcast("configuration.imported", json!({"at": Utc::now()})); - Ok(Json(json!({"ok": true}))) + Ok(Json(payload)) } +async fn get_debug_settings(State(state): State) -> Json { + Json(state.settings.read().await.debug.clone()) +} + +async fn update_debug_settings( + State(state): State, + Json(input): Json, +) -> Result, AppError> { + let _configuration_guard = state.lock_configuration_operation().await; + { + let mut settings = state.settings.write().await; + settings.debug = input.clone(); + state.db.save_runtime_settings(&settings)?; + } + state.debug_gree_frames.store(input.gree_frames, Ordering::Relaxed); + state.broadcast("settings.debug.updated", serde_json::to_value(&input)?); + Ok(Json(input)) +} diff --git a/src/api/system.rs b/src/api/system.rs index 4aa0fa3..e67cc3f 100644 --- a/src/api/system.rs +++ b/src/api/system.rs @@ -28,7 +28,7 @@ async fn build_bootstrap(state: &AppState) -> Result { "automations": state.db.list_automations()?, "flows": state.db.list_flows()?, "access_tokens": state.db.list_api_tokens()?, - "settings": public_settings(&settings), + "house": {"mode": settings.house_mode}, "outdoor_temperature": *state.outdoor_temperature.read().await, "system": { "version": env!("CARGO_PKG_VERSION"), diff --git a/src/api/zones.rs b/src/api/zones.rs index fb1a5ee..a1f6b3f 100644 --- a/src/api/zones.rs +++ b/src/api/zones.rs @@ -252,6 +252,240 @@ async fn update_zone(State(state): State, Path(id): Path, Json Ok(Json(zone)) } +struct TemporaryStartTiming { + start_kind: String, + started_at: chrono::DateTime, + activated_at: Option>, + editing_active: bool, +} + +struct TemporaryTargetSettings { + target: f64, + tolerance: f64, + temperature_operator: String, + is_temperature_condition: bool, +} + +struct TemporaryFinishTiming { + duration_seconds: Option, + hold_seconds: u64, + safety_duration_seconds: Option, + expires_at: Option>, + safety_expires_at: Option>, +} + +fn resolve_temporary_start( + zone: &Zone, + request: &TemporaryQuickThermostatRequest, + now: chrono::DateTime, +) -> Result { + let existing = zone.temporary_quick_thermostat.as_ref(); + let editing_active = engine::temporary_quick_thermostat_is_active(zone, now.clone()); + if !matches!(request.start_kind.as_str(), "now" | "delay" | "at") { + return Err(AppError::BadRequest("unsupported temporary thermostat start kind".into())); + } + if editing_active { + let existing = existing.expect("active temporary session must exist"); + return Ok(TemporaryStartTiming { + start_kind: existing.start_kind.clone(), + started_at: existing.started_at.clone(), + activated_at: existing.activated_at.clone(), + editing_active: true, + }); + } + let started_at = match request.start_kind.as_str() { + "now" => now, + "delay" => { + let minutes = request.start_delay_minutes.ok_or_else(|| AppError::BadRequest("temporary thermostat start delay is required".into()))?; + if !(1..=43_200).contains(&minutes) { + return Err(AppError::BadRequest("temporary thermostat start delay must be between 1 minute and 30 days".into())); + } + now + ChronoDuration::minutes(minutes as i64) + } + "at" => { + let at = request.start_at.clone().ok_or_else(|| AppError::BadRequest("temporary thermostat start time is required".into()))?; + if at <= now { return Err(AppError::BadRequest("temporary thermostat start time must be in the future".into())); } + if at > now + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat start time cannot be more than 30 days away".into())); } + at + } + _ => unreachable!(), + }; + Ok(TemporaryStartTiming { + start_kind: request.start_kind.clone(), + started_at, + activated_at: None, + editing_active: false, + }) +} + +fn resolve_temporary_target( + zone: &Zone, + request: &TemporaryQuickThermostatRequest, +) -> Result { + let finish_kind = request.finish_kind.as_str(); + if !matches!(finish_kind, "duration" | "until" | "temperature_reached" | "temperature_stable" | "schedule_boundary") { + return Err(AppError::BadRequest("unsupported temporary thermostat finish kind".into())); + } + let target = request.target_temperature.unwrap_or(zone.manual_setpoint.unwrap_or(zone.effective_setpoint.unwrap_or(zone.setpoint))); + if !(8.0..=30.0).contains(&target) { + return Err(AppError::BadRequest("temporary thermostat target must be between 8 and 30 C".into())); + } + let target = (target * 2.0).round() / 2.0; + let tolerance_mode = if zone.effective_mode.is_empty() { zone.mode.as_str() } else { zone.effective_mode.as_str() }; + let min_stable_tolerance = (zone.hysteresis_for_mode(tolerance_mode) / 2.0 + 0.1).min(3.0); + let requested_tolerance = request.tolerance_c.unwrap_or(min_stable_tolerance.max(0.3)); + if !(0.1..=3.0).contains(&requested_tolerance) { + return Err(AppError::BadRequest("temporary thermostat tolerance must be between 0.1 and 3 C".into())); + } + let temperature_operator = request.temperature_operator.as_deref().unwrap_or("within"); + if !matches!(temperature_operator, "within" | "at_or_below" | "at_or_above") { + return Err(AppError::BadRequest("unsupported temporary thermostat temperature operator".into())); + } + Ok(TemporaryTargetSettings { + target, + tolerance: if finish_kind == "temperature_stable" { requested_tolerance.max(min_stable_tolerance) } else { requested_tolerance }, + temperature_operator: temperature_operator.to_string(), + is_temperature_condition: matches!(finish_kind, "temperature_reached" | "temperature_stable"), + }) +} + +fn resolve_temporary_finish( + zone: &Zone, + schedules: &[Schedule], + request: &TemporaryQuickThermostatRequest, + start: &TemporaryStartTiming, + now: chrono::DateTime, + is_temperature_condition: bool, +) -> Result { + let finish_kind = request.finish_kind.as_str(); + let duration_seconds = if finish_kind == "duration" { + let minutes = request.duration_minutes.ok_or_else(|| AppError::BadRequest("temporary thermostat duration is required".into()))?; + if !(1..=14_400).contains(&minutes) { return Err(AppError::BadRequest("temporary thermostat duration must be between 1 minute and 10 days".into())); } + Some(minutes.saturating_mul(60)) + } else { None }; + let hold_seconds = if finish_kind == "temperature_stable" { + let minutes = request.hold_minutes.ok_or_else(|| AppError::BadRequest("temperature hold time is required".into()))?; + if !(1..=1_440).contains(&minutes) { return Err(AppError::BadRequest("temperature hold time must be between 1 minute and 24 hours".into())); } + minutes.saturating_mul(60) + } else { 0 }; + let safety_duration_seconds = if is_temperature_condition { + request.max_duration_minutes.map(|minutes| { + if !(1..=14_400).contains(&minutes) { + return Err(AppError::BadRequest("temporary thermostat safety limit must be between 1 minute and 10 days".into())); + } + Ok(minutes.saturating_mul(60)) + }).transpose()? + } else { None }; + let active_base = start.activated_at.clone().unwrap_or(now.clone()); + let expires_at = match finish_kind { + "duration" if start.editing_active => duration_seconds.map(|seconds| active_base + ChronoDuration::seconds(seconds as i64)), + "until" => { + let until = request.until.clone().ok_or_else(|| AppError::BadRequest("temporary thermostat end time is required".into()))?; + let comparison_start = if start.editing_active { now.clone() } else { start.started_at.clone() }; + if until <= comparison_start { return Err(AppError::BadRequest("temporary thermostat end time must be in the future and after its start".into())); } + if until > comparison_start + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat end time cannot be more than 30 days after start".into())); } + Some(until) + } + "schedule_boundary" => { + let reference = if start.editing_active { chrono::Local::now() } else { start.started_at.clone().with_timezone(&chrono::Local) }; + Some(engine::next_schedule_boundary_utc(&zone.id, schedules, reference) + .ok_or_else(|| AppError::BadRequest("this zone has no future schedule transition".into()))?) + } + _ => None, + }; + let safety_expires_at = if start.editing_active { + safety_duration_seconds.map(|seconds| active_base + ChronoDuration::seconds(seconds as i64)) + } else { None }; + Ok(TemporaryFinishTiming { duration_seconds, hold_seconds, safety_duration_seconds, expires_at, safety_expires_at }) +} + +async fn apply_temporary_quick_thermostat_request( + state: &AppState, + zone: &mut Zone, + schedules: &[Schedule], + request: &TemporaryQuickThermostatRequest, +) -> Result<(), AppError> { + let now = Utc::now(); + let runtime = state.settings.read().await.clone(); + let existing_session = zone.temporary_quick_thermostat.clone(); + let start = resolve_temporary_start(zone, request, now.clone())?; + let target = resolve_temporary_target(zone, request)?; + let finish = resolve_temporary_finish(zone, schedules, request, &start, now.clone(), target.is_temperature_condition)?; + let starts_now = start.start_kind == "now"; + let immediate_activation = !start.editing_active && starts_now && !zone.device_manual_override; + let restore_zone_enabled = if start.editing_active { + existing_session.as_ref().and_then(|session| session.restore_zone_enabled) + } else if immediate_activation { Some(zone.enabled) } else { None }; + let configured_mode = if zone.inherit_house_mode { runtime.house_mode.as_str() } else { zone.mode.as_str() }; + let captured_mode = if configured_mode == "off" { zone.mode.clone() } else { configured_mode.to_string() }; + let active_mode = if start.editing_active { + existing_session.as_ref().and_then(|session| session.active_mode.clone()) + } else if immediate_activation { Some(captured_mode.clone()) } else { None }; + let condition_mode = active_mode.as_deref().unwrap_or(captured_mode.as_str()); + if target.is_temperature_condition + && ((condition_mode == "heat" && target.temperature_operator == "at_or_below") + || (condition_mode == "cool" && target.temperature_operator == "at_or_above")) + { + return Err(AppError::BadRequest("temporary thermostat temperature condition conflicts with the active heating/cooling direction".into())); + } + let state_value = if start.editing_active { + if zone.device_manual_override { "paused_manual" } else { "active" } + } else if starts_now && zone.device_manual_override { "paused_manual" } else { "scheduled" }; + + let underlying_local_power = zone.local_thermostat_power; + let underlying_local_resume_at = zone.local_thermostat_resume_at; + let underlying_local_zone_enabled = zone.local_thermostat_restore_zone_enabled; + let underlying_manual_preset = zone.manual_preset.clone(); + let underlying_manual_setpoint = zone.manual_setpoint; + let underlying_manual_override_until = zone.manual_override_until; + if immediate_activation { + zone.local_thermostat_power = Some(true); + zone.local_thermostat_resume_at = None; + zone.local_thermostat_restore_zone_enabled = None; + zone.enabled = true; + zone.manual_setpoint = Some(target.target); + zone.effective_setpoint = Some(target.target); + zone.manual_override_until = None; + } else if start.editing_active { + zone.manual_setpoint = Some(target.target); + zone.effective_setpoint = Some(target.target); + zone.manual_override_until = None; + } + + zone.temporary_quick_thermostat = Some(TemporaryQuickThermostat { + start_kind: start.start_kind, + finish_kind: request.finish_kind.clone(), + started_at: start.started_at.clone(), + activated_at: if immediate_activation { Some(now.clone()) } else { start.activated_at.clone() }, + state: state_value.into(), + active_mode, + restore_zone_enabled, + restore_local_thermostat_power: if start.editing_active { existing_session.as_ref().and_then(|session| session.restore_local_thermostat_power) } else if immediate_activation { underlying_local_power } else { None }, + restore_local_thermostat_resume_at: if start.editing_active { existing_session.as_ref().and_then(|session| session.restore_local_thermostat_resume_at) } else if immediate_activation { underlying_local_resume_at } else { None }, + restore_local_thermostat_zone_enabled: if start.editing_active { existing_session.as_ref().and_then(|session| session.restore_local_thermostat_zone_enabled) } else if immediate_activation { underlying_local_zone_enabled } else { None }, + restore_manual_preset: if start.editing_active { existing_session.as_ref().and_then(|session| session.restore_manual_preset.clone()) } else if immediate_activation { underlying_manual_preset } else { None }, + restore_manual_setpoint: if start.editing_active { existing_session.as_ref().and_then(|session| session.restore_manual_setpoint) } else if immediate_activation { underlying_manual_setpoint } else { None }, + restore_manual_override_until: if start.editing_active { existing_session.as_ref().and_then(|session| session.restore_manual_override_until) } else if immediate_activation { underlying_manual_override_until } else { None }, + expires_at: if immediate_activation && request.finish_kind == "duration" { finish.duration_seconds.map(|seconds| now + ChronoDuration::seconds(seconds as i64)) } else { finish.expires_at }, + duration_seconds: finish.duration_seconds, + safety_duration_seconds: finish.safety_duration_seconds, + temperature_target: Some(target.target), + temperature_operator: target.is_temperature_condition.then(|| target.temperature_operator.clone()), + tolerance_c: target.tolerance, + hold_seconds: finish.hold_seconds, + condition_started_at: None, + condition_last_observed_at: None, + paused_at: if zone.device_manual_override && (start.editing_active || starts_now) { + existing_session.as_ref().and_then(|session| session.paused_at.clone()).or(Some(now.clone())) + } else { None }, + deferred_mode: existing_session.as_ref().and_then(|session| session.deferred_mode.clone()), + deferred_preset: existing_session.as_ref().and_then(|session| session.deferred_preset.clone()), + deferred_setpoint: existing_session.as_ref().and_then(|session| session.deferred_setpoint), + safety_expires_at: if immediate_activation && target.is_temperature_condition { finish.safety_duration_seconds.map(|seconds| now + ChronoDuration::seconds(seconds as i64)) } else { finish.safety_expires_at }, + }); + Ok(()) +} + async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControlPatch, source: &str) -> Result { // A quick preset/setpoint derives its resume boundary from schedules. Take the schedule // lock before the per-zone lock so a concurrent schedule edit cannot leave an override @@ -310,208 +544,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl } } if let Some(request) = patch.temporary_quick_thermostat.as_ref() { - let now = Utc::now(); - let runtime = state.settings.read().await.clone(); - let existing_session = zone.temporary_quick_thermostat.clone(); - let editing_active = engine::temporary_quick_thermostat_is_active(&zone, now); - let requested_start_kind = request.start_kind.as_str(); - if !matches!(requested_start_kind, "now" | "delay" | "at") { - return Err(AppError::BadRequest("unsupported temporary thermostat start kind".into())); - } - - // Editing an already active session changes only target/finish rules. Its historical - // start and activated_at are preserved, so delay/at sessions cannot be accidentally - // rescheduled or rejected because their original start is now in the past. - let (start_kind, started_at, activated_at) = if editing_active { - let existing = existing_session.as_ref().expect("active temporary session must exist"); - (existing.start_kind.clone(), existing.started_at.clone(), existing.activated_at.clone()) - } else { - let started_at = match requested_start_kind { - "now" => now.clone(), - "delay" => { - let minutes = request.start_delay_minutes.ok_or_else(|| AppError::BadRequest("temporary thermostat start delay is required".into()))?; - if !(1..=43_200).contains(&minutes) { - return Err(AppError::BadRequest("temporary thermostat start delay must be between 1 minute and 30 days".into())); - } - now.clone() + ChronoDuration::minutes(minutes as i64) - } - "at" => { - let at = request.start_at.clone().ok_or_else(|| AppError::BadRequest("temporary thermostat start time is required".into()))?; - if at <= now { return Err(AppError::BadRequest("temporary thermostat start time must be in the future".into())); } - if at > now.clone() + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat start time cannot be more than 30 days away".into())); } - at - } - _ => unreachable!(), - }; - (requested_start_kind.to_string(), started_at, None) - }; - - let finish_kind = request.finish_kind.as_str(); - if !matches!(finish_kind, "duration" | "until" | "temperature_reached" | "temperature_stable" | "schedule_boundary") { - return Err(AppError::BadRequest("unsupported temporary thermostat finish kind".into())); - } - - let target = request.target_temperature.unwrap_or(zone.manual_setpoint.unwrap_or(zone.effective_setpoint.unwrap_or(zone.setpoint))); - if !(8.0..=30.0).contains(&target) { - return Err(AppError::BadRequest("temporary thermostat target must be between 8 and 30 C".into())); - } - let target = (target * 2.0).round() / 2.0; - let tolerance_mode = if zone.effective_mode.is_empty() { zone.mode.as_str() } else { zone.effective_mode.as_str() }; - let min_stable_tolerance = (zone.hysteresis_for_mode(tolerance_mode) / 2.0 + 0.1).min(3.0); - let requested_tolerance = request.tolerance_c.unwrap_or(min_stable_tolerance.max(0.3)); - if !(0.1..=3.0).contains(&requested_tolerance) { - return Err(AppError::BadRequest("temporary thermostat tolerance must be between 0.1 and 3 C".into())); - } - let tolerance = if finish_kind == "temperature_stable" { requested_tolerance.max(min_stable_tolerance) } else { requested_tolerance }; - let temperature_operator = request.temperature_operator.as_deref().unwrap_or("within"); - if !matches!(temperature_operator, "within" | "at_or_below" | "at_or_above") { - return Err(AppError::BadRequest("unsupported temporary thermostat temperature operator".into())); - } - - let duration_seconds = if finish_kind == "duration" { - let minutes = request.duration_minutes.ok_or_else(|| AppError::BadRequest("temporary thermostat duration is required".into()))?; - if !(1..=14_400).contains(&minutes) { return Err(AppError::BadRequest("temporary thermostat duration must be between 1 minute and 10 days".into())); } - Some(minutes.saturating_mul(60)) - } else { None }; - let is_temperature_condition = matches!(finish_kind, "temperature_reached" | "temperature_stable"); - let hold_seconds = if finish_kind == "temperature_stable" { - let minutes = request.hold_minutes.ok_or_else(|| AppError::BadRequest("temperature hold time is required".into()))?; - if !(1..=1_440).contains(&minutes) { return Err(AppError::BadRequest("temperature hold time must be between 1 minute and 24 hours".into())); } - minutes.saturating_mul(60) - } else { 0 }; - let safety_duration_seconds = if is_temperature_condition { - request.max_duration_minutes.map(|minutes| { - if !(1..=14_400).contains(&minutes) { - return Err(AppError::BadRequest("temporary thermostat safety limit must be between 1 minute and 10 days".into())); - } - Ok(minutes.saturating_mul(60)) - }).transpose()? - } else { None }; - - let active_base = activated_at.clone().unwrap_or(now.clone()); - let expires_at = match finish_kind { - "duration" => if editing_active { duration_seconds.map(|seconds| active_base.clone() + ChronoDuration::seconds(seconds as i64)) } else { None }, - "until" => { - let until = request.until.clone().ok_or_else(|| AppError::BadRequest("temporary thermostat end time is required".into()))?; - let comparison_start = if editing_active { now.clone() } else { started_at.clone() }; - if until <= comparison_start { return Err(AppError::BadRequest("temporary thermostat end time must be in the future and after its start".into())); } - if until > comparison_start.clone() + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat end time cannot be more than 30 days after start".into())); } - Some(until) - } - "schedule_boundary" => { - let reference = if editing_active { chrono::Local::now() } else { started_at.clone().with_timezone(&chrono::Local) }; - Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, reference) - .ok_or_else(|| AppError::BadRequest("this zone has no future schedule transition".into()))?) - } - _ => None, - }; - let safety_expires_at = if editing_active { - safety_duration_seconds.map(|seconds| active_base.clone() + ChronoDuration::seconds(seconds as i64)) - } else { None }; - - let starts_now = start_kind == "now"; - let immediate_activation = !editing_active && starts_now && !zone.device_manual_override; - let restore_zone_enabled = if editing_active { - existing_session.as_ref().and_then(|session| session.restore_zone_enabled) - } else if immediate_activation { - Some(zone.enabled) - } else { - // Delayed sessions capture this at actual takeover time (H12), not planning time. - None - }; - let configured_mode = if zone.inherit_house_mode { runtime.house_mode.as_str() } else { zone.mode.as_str() }; - let captured_mode = if configured_mode == "off" { zone.mode.clone() } else { configured_mode.to_string() }; - let active_mode = if editing_active { - existing_session.as_ref().and_then(|session| session.active_mode.clone()) - } else if immediate_activation { - Some(captured_mode.clone()) - } else { None }; - let condition_mode = active_mode.as_deref().unwrap_or(captured_mode.as_str()); - if is_temperature_condition { - if (condition_mode == "heat" && temperature_operator == "at_or_below") - || (condition_mode == "cool" && temperature_operator == "at_or_above") - { - return Err(AppError::BadRequest( - "temporary thermostat temperature condition conflicts with the active heating/cooling direction".into(), - )); - } - } - let state_value = if editing_active { - if zone.device_manual_override { "paused_manual" } else { "active" } - } else if starts_now && zone.device_manual_override { - "paused_manual" - } else { - "scheduled" - }; - let underlying_local_power = zone.local_thermostat_power; - let underlying_local_resume_at = zone.local_thermostat_resume_at; - let underlying_local_zone_enabled = zone.local_thermostat_restore_zone_enabled; - let underlying_manual_preset = zone.manual_preset.clone(); - let underlying_manual_setpoint = zone.manual_setpoint; - let underlying_manual_override_until = zone.manual_override_until; - - if immediate_activation { - zone.local_thermostat_power = Some(true); - zone.local_thermostat_resume_at = None; - zone.local_thermostat_restore_zone_enabled = None; - zone.enabled = true; - zone.manual_setpoint = Some(target); - zone.effective_setpoint = Some(target); - zone.manual_override_until = None; - } else if editing_active { - // Keep current ownership and update the live target without restarting the session. - zone.manual_setpoint = Some(target); - zone.effective_setpoint = Some(target); - zone.manual_override_until = None; - } - - zone.temporary_quick_thermostat = Some(TemporaryQuickThermostat { - start_kind, - finish_kind: finish_kind.into(), - started_at, - activated_at: if immediate_activation { Some(now.clone()) } else { activated_at.clone() }, - state: state_value.into(), - active_mode, - restore_zone_enabled, - restore_local_thermostat_power: if editing_active { - existing_session.as_ref().and_then(|session| session.restore_local_thermostat_power) - } else if immediate_activation { underlying_local_power } else { None }, - restore_local_thermostat_resume_at: if editing_active { - existing_session.as_ref().and_then(|session| session.restore_local_thermostat_resume_at) - } else if immediate_activation { underlying_local_resume_at } else { None }, - restore_local_thermostat_zone_enabled: if editing_active { - existing_session.as_ref().and_then(|session| session.restore_local_thermostat_zone_enabled) - } else if immediate_activation { underlying_local_zone_enabled } else { None }, - restore_manual_preset: if editing_active { - existing_session.as_ref().and_then(|session| session.restore_manual_preset.clone()) - } else if immediate_activation { underlying_manual_preset } else { None }, - restore_manual_setpoint: if editing_active { - existing_session.as_ref().and_then(|session| session.restore_manual_setpoint) - } else if immediate_activation { underlying_manual_setpoint } else { None }, - restore_manual_override_until: if editing_active { - existing_session.as_ref().and_then(|session| session.restore_manual_override_until) - } else if immediate_activation { underlying_manual_override_until } else { None }, - expires_at: if immediate_activation && finish_kind == "duration" { - duration_seconds.map(|seconds| now.clone() + ChronoDuration::seconds(seconds as i64)) - } else { expires_at }, - duration_seconds, - safety_duration_seconds, - temperature_target: Some(target), - temperature_operator: is_temperature_condition.then(|| temperature_operator.to_string()), - tolerance_c: tolerance, - hold_seconds, - condition_started_at: None, - condition_last_observed_at: None, - paused_at: if zone.device_manual_override && (editing_active || starts_now) { - existing_session.as_ref().and_then(|session| session.paused_at.clone()).or(Some(now.clone())) - } else { None }, - deferred_mode: existing_session.as_ref().and_then(|session| session.deferred_mode.clone()), - deferred_preset: existing_session.as_ref().and_then(|session| session.deferred_preset.clone()), - deferred_setpoint: existing_session.as_ref().and_then(|session| session.deferred_setpoint), - safety_expires_at: if immediate_activation && is_temperature_condition { - safety_duration_seconds.map(|seconds| now.clone() + ChronoDuration::seconds(seconds as i64)) - } else { safety_expires_at }, - }); + apply_temporary_quick_thermostat_request(state, &mut zone, &schedules, request).await?; } if let Some(power) = patch.power { // The neighbouring quick-power control and the explicit Stop button must use the @@ -632,7 +665,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl let device_override_cleared = if resume_device_automation { engine::reset_device_manual_override(&mut zone) } else { false }; let runtime = state.settings.read().await.clone(); let house_mode = runtime.house_mode.clone(); - engine::refresh_control_ownership(&mut zone, true); + engine::refresh_control_ownership(&mut zone); engine::refresh_zone_runtime_target(&mut zone, &schedules, &house_mode); zone.revision = zone.revision.saturating_add(1); zone.updated_at = Utc::now(); diff --git a/src/config.rs b/src/config.rs index 003c917..af37f46 100644 --- a/src/config.rs +++ b/src/config.rs @@ -53,7 +53,6 @@ impl Config { discovery_timeout_ms: self.discovery_timeout_ms.clamp(300, 30_000), discovery_broadcast: self.discovery_broadcast.clone(), house_mode: env::var("GREE_CONTROLLER_HOUSE_MODE").unwrap_or_else(|_| "cool".into()), - house_power_enabled: true, control_strategy: "setpoint".into(), outdoor_assist_enabled: env_bool("GREE_CONTROLLER_OUTDOOR_ASSIST_ENABLED").unwrap_or(true), history_retention_days: env_u32("GREE_CONTROLLER_HISTORY_RETENTION_DAYS").unwrap_or(30).clamp(1, 3650), diff --git a/src/db/configuration.rs b/src/db/configuration.rs index d703a3a..f873a5a 100644 --- a/src/db/configuration.rs +++ b/src/db/configuration.rs @@ -1,7 +1,7 @@ impl Db { pub fn export_configuration(&self, settings: RuntimeSettings) -> Result { Ok(ConfigurationExport { - format_version: 2, + format_version: 3, exported_at: Utc::now(), settings, devices: self.list_devices()?, diff --git a/src/engine.rs b/src/engine.rs index 42c7ded..a787dbb 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -7,7 +7,7 @@ use crate::{ error::AppError, home_assistant, influxdb, - models::{Automation, AutomationPlanRule, ControlPlan, ControlPlanEvent, Device, DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan, ZoneReading}, + models::{Automation, AutomationPlanRule, ClimateGroup, ControlPlan, ControlPlanEvent, Device, DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, RuntimeSettings, Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan, ZoneReading}, state::{AppState, PendingControllerCommand}, }; diff --git a/src/engine/automations.rs b/src/engine/automations.rs index 42c58b1..34c6574 100644 --- a/src/engine/automations.rs +++ b/src/engine/automations.rs @@ -887,7 +887,7 @@ async fn apply_flow_zone_action(state: &AppState, zone_id: &str, preset: Option< zone.control_reason = "Visual Flow automation".into(); let schedules = state.db.list_schedules()?; let house_mode = state.settings.read().await.house_mode.clone(); - refresh_control_ownership(&mut zone, true); + refresh_control_ownership(&mut zone); if zone.control_owner == "automation" { zone.control_source = "automation.flow".into(); zone.control_reason = "Visual Flow automation".into(); diff --git a/src/engine/control_plan.rs b/src/engine/control_plan.rs index 5cddf0a..6b4957e 100644 --- a/src/engine/control_plan.rs +++ b/src/engine/control_plan.rs @@ -19,7 +19,7 @@ pub async fn build_control_plan(state: &AppState) -> Result Result { +fn validate_group_control_patch(patch: &GroupControlPatch) -> Result, AppError> { if let Some(mode) = patch.mode.as_deref() { if !matches!(mode, "house" | "auto" | "cool" | "heat") { return Err(AppError::BadRequest("group mode must be house, cool or heat".into())); @@ -20,14 +20,138 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl return Err(AppError::BadRequest("group setpoint requires preset=custom".into())); } } + Ok(patch.setpoint.map(|value| (value * 10.0).round() / 10.0)) +} + +fn defer_group_climate_change(zone: &mut Zone, patch: &GroupControlPatch, custom_setpoint: Option) { + let Some(session) = zone.temporary_quick_thermostat.as_mut() else { return; }; + 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 preset != "custom" { session.deferred_setpoint = None; } + } + if let Some(setpoint) = custom_setpoint { session.deferred_setpoint = Some(setpoint); } +} + +fn apply_group_climate_change( + zone: &mut Zone, + patch: &GroupControlPatch, + custom_setpoint: Option, + manual_group_control: bool, + schedules: &[Schedule], +) { + if let Some(mode) = patch.mode.as_deref() { + match mode { + "house" | "auto" => zone.inherit_house_mode = true, + "cool" | "heat" => { + zone.inherit_house_mode = false; + zone.mode = mode.to_string(); + } + _ => {} + } + } + if let Some(preset) = patch.preset.as_deref() { + if preset == "auto" { + zone.manual_preset = None; + zone.manual_setpoint = None; + zone.manual_override_until = None; + } else { + zone.manual_preset = Some(preset.to_string()); + if preset != "custom" { zone.manual_setpoint = None; } + zone.manual_override_until = if manual_group_control { + None + } else { + 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 = if manual_group_control { + None + } else { + next_schedule_boundary_utc(&zone.id, schedules, Local::now()) + }; + } +} + +fn apply_group_member_power( + zone: &mut Zone, + requested_power: Option, + source: &str, + temporary_owns_zone: bool, + group_handback_at: Option>, +) -> Result<(Option, bool), Value> { + let Some(power) = requested_power else { return Ok((None, false)); }; + let automatic_power_blocked = source == "automation.group" + && (zone.device_manual_override || zone.local_thermostat_power.is_some() || temporary_owns_zone); + if automatic_power_blocked { + return Err(json!({ + "scope": "ownership", + "zone_id": zone.id, + "device_id": zone.device_id, + "error": "manual/local thermostat ownership has priority over group automation", + })); + } + if power { + zone.local_thermostat_power = None; + zone.local_thermostat_resume_at = None; + zone.local_thermostat_restore_zone_enabled = None; + return Ok((Some(true), false)); + } + zone.local_thermostat_power = Some(false); + zone.local_thermostat_resume_at = group_handback_at; + zone.local_thermostat_restore_zone_enabled = None; + zone.demand = false; + zone.demand_since = None; + zone.effective_mode = "off".into(); + zone.device_setpoint = None; + Ok((Some(false), true)) +} + +fn update_group_member_ownership( + zone: &mut Zone, + group: &ClimateGroup, + temporary_owns_zone: bool, + applied_group_power: Option, + group_handback_at: Option>, +) { + if temporary_owns_zone || zone.device_manual_override { return; } + if applied_group_power == Some(false) { + zone.control_owner = "local_thermostat".into(); + zone.control_source = "local_thermostat".into(); + zone.control_since = Some(Utc::now()); + zone.control_resume_at = group_handback_at.clone(); + zone.control_reason = if group_handback_at.is_some() { + format!("Group {} powered the thermostat off; automation resumes after {} minutes", group.name, LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES) + } else { + format!("Group {} powered the thermostat off; group ownership released", group.name) + }; + } else if group.power_enabled && zone.local_thermostat_power.is_none() { + zone.control_owner = "automation".into(); + zone.control_source = format!("group:{}", group.id); + zone.control_since = Some(Utc::now()); + zone.control_resume_at = zone.manual_override_until; + zone.control_reason = format!("Controlled by group {}", group.name); + } else if !group.power_enabled && zone.control_source == format!("group:{}", group.id) { + zone.control_owner = "automation".into(); + zone.control_source = "automation".into(); + zone.control_since = Some(Utc::now()); + zone.control_resume_at = zone.manual_override_until; + zone.control_reason = format!("Group {} ownership released; zone automation resumed", group.name); + } +} + +pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControlPatch, source: &str) -> Result { + let custom_setpoint = validate_group_control_patch(&patch)?; + let climate_change = patch.mode.is_some() || patch.preset.is_some() || custom_setpoint.is_some(); // 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. + // from racing with whole-house OFF. Lock order stays house -> cycle -> group -> zone -> device. let _house_guard = state.lock_house_operation().await; - // Group state participates in thermostat arbitration. Exclude an already-running cycle - // while changing group control/profile state so no cycle can act on a stale group snapshot. - // Lock order stays house -> cycle -> group -> zone -> device. let _cycle_guard = state.lock_zone_control_cycle().await; let _group_guard = state.lock_group_operation(group_id).await; let mut group = state.db.get_group(group_id)? @@ -36,12 +160,9 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl 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); - } + for zone_id in &locked_zone_ids { _zone_guards.push(state.lock_zone_operation(zone_id).await); } + let schedules = state.db.list_schedules()?; - 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(); let resulting_control_enabled = patch.power.unwrap_or(group.power_enabled); if climate_change && !resulting_control_enabled { if source == "automation.group" { @@ -53,38 +174,27 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl "zones": [], "devices": state.db.list_devices()?, "failed": [], - "master_power_enabled": true, "suppressed": true, })); } return Err(AppError::BadRequest("enable group control before changing group mode, preset or setpoint".into())); } - if let Some(power) = patch.power { - group.power_enabled = power; - } + + if let Some(power) = patch.power { group.power_enabled = power; } group.updated_at = Utc::now(); state.db.save_group(&group)?; state.broadcast("group.updated", serde_json::to_value(&group)?); - // Group power is a scoped bulk power action. OFF powers member thermostats down but - // releases group ownership: members are represented as individually OFF, not as - // "blocked by a disabled group". ON clears that scoped OFF and lets group arbitration - // take ownership again. The whole-house master remains independent. let manual_group_control = source != "automation.group"; - // A manual Group OFF is a temporary bulk pause. All members receive the same - // 15-minute hand-back deadline, so their thermostat automation resumes together. - // Automation-driven group OFF remains persistent until another automation/user action. let group_handback_at = if manual_group_control && patch.power == Some(false) { Some(group.updated_at.clone() + chrono::Duration::minutes(LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES)) } else { None }; let explicit_group_power = patch.power.is_some() && manual_group_control; - // Applying a manual mode/profile/custom target to an already-enabled group is itself an - // explicit scoped takeover. It must wake members that were left locally OFF by a previous - // group/whole-house OFF; otherwise the UI says "group control" while every unit stays OFF. let manual_group_activation = manual_group_control && group.power_enabled && climate_change; let explicit_group_takeover = explicit_group_power || manual_group_activation; + let requested_member_power = patch.power.or(manual_group_activation.then_some(true)); let mut zones = Vec::new(); let mut failed = Vec::new(); @@ -93,15 +203,9 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl 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 patch.power.is_some() || climate_change { - rearm_compressor_queue(&mut zone); - } + if patch.power.is_some() || climate_change { rearm_compressor_queue(&mut zone); } let mut temporary_owns_zone = temporary_quick_thermostat_is_active(&zone, Utc::now()); - // A user/HA group power click is an explicit scoped takeover. End any older direct - // or temporary ownership so all members react consistently to the bulk command. - // Scheduled automations deliberately do not do this: manual/local ownership keeps - // its higher priority and the automation can only affect currently free members. if explicit_group_takeover { if zone.temporary_quick_thermostat.is_some() { if temporary_owns_zone { @@ -111,158 +215,47 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl } temporary_owns_zone = false; } - if zone.device_manual_override { - reset_device_manual_override(&mut zone); - } + if zone.device_manual_override { reset_device_manual_override(&mut zone); } } if temporary_owns_zone { - if let Some(session) = zone.temporary_quick_thermostat.as_mut() { - if let Some(mode) = patch.mode.as_deref() { session.deferred_mode = Some(mode.to_string()); } - if let Some(preset) = patch.preset.as_deref() { - session.deferred_preset = Some(preset.to_string()); - if preset != "custom" { session.deferred_setpoint = None; } - } - if let Some(setpoint) = custom_setpoint { session.deferred_setpoint = Some(setpoint); } - } + defer_group_climate_change(&mut zone, &patch, custom_setpoint); if climate_change { state.log("info", "group.control_deferred_by_temporary_thermostat", &format!("Group climate change deferred for {} while Temporary Quick Thermostat owns the zone", zone.name), json!({ "zone_id": zone.id, "group_id": group.id, "source": source })); } } else { - if let Some(mode) = patch.mode.as_deref() { - match mode { - "house" | "auto" => zone.inherit_house_mode = true, - "cool" | "heat" => { - zone.inherit_house_mode = false; - zone.mode = mode.to_string(); - } - _ => {} - } - } - if let Some(preset) = patch.preset.as_deref() { - if preset == "auto" { - zone.manual_preset = None; - zone.manual_setpoint = None; - zone.manual_override_until = None; - } else { - zone.manual_preset = Some(preset.to_string()); - if preset != "custom" { zone.manual_setpoint = None; } - zone.manual_override_until = if manual_group_control { - None - } else { - 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 = if manual_group_control { - None - } else { - next_schedule_boundary_utc(&zone.id, &schedules, Local::now()) - }; - } - } - // Group power OFF is represented on every member as a local thermostat OFF so the - // physical unit is stopped immediately and group ownership is released. A manual Group - // OFF gets one synchronized 15-minute hand-back deadline for all members; automation - // Group OFF is persistent. Power ON clears the scoped OFF immediately. - let mut force_power_off_after_save = false; - let mut applied_group_power: Option = None; - let requested_member_power = patch.power.or(manual_group_activation.then_some(true)); - if let Some(power) = requested_member_power { - let automatic_power_blocked = source == "automation.group" - && (zone.device_manual_override || zone.local_thermostat_power.is_some() || temporary_owns_zone); - if automatic_power_blocked { - failed.push(json!({ - "scope": "ownership", - "zone_id": zone.id, - "device_id": zone.device_id, - "error": "manual/local thermostat ownership has priority over group automation", - })); - } else if power { - zone.local_thermostat_power = None; - zone.local_thermostat_resume_at = None; - zone.local_thermostat_restore_zone_enabled = None; - applied_group_power = Some(true); - } else { - zone.local_thermostat_power = Some(false); - zone.local_thermostat_resume_at = group_handback_at.clone(); - zone.local_thermostat_restore_zone_enabled = None; - zone.demand = false; - zone.demand_since = None; - zone.effective_mode = "off".into(); - zone.device_setpoint = None; - force_power_off_after_save = forced_off_devices.insert(zone.device_id.clone()); - applied_group_power = Some(false); - } + apply_group_climate_change(&mut zone, &patch, custom_setpoint, manual_group_control, &schedules); } - if !temporary_owns_zone && !zone.device_manual_override { - if applied_group_power == Some(false) { - zone.control_owner = "local_thermostat".into(); - zone.control_source = "local_thermostat".into(); - zone.control_since = Some(Utc::now()); - zone.control_resume_at = group_handback_at.clone(); - zone.control_reason = if group_handback_at.is_some() { - format!("Group {} powered the thermostat off; automation resumes after {} minutes", group.name, LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES) - } else { - format!("Group {} powered the thermostat off; group ownership released", group.name) - }; - } else if group.power_enabled && zone.local_thermostat_power.is_none() { - zone.control_owner = "automation".into(); - zone.control_source = format!("group:{}", group.id); - zone.control_since = Some(Utc::now()); - zone.control_resume_at = zone.manual_override_until; - zone.control_reason = format!("Controlled by group {}", group.name); - } else if !group.power_enabled && zone.control_source == format!("group:{}", group.id) { - zone.control_owner = "automation".into(); - zone.control_source = "automation".into(); - zone.control_since = Some(Utc::now()); - zone.control_resume_at = zone.manual_override_until; - zone.control_reason = format!("Group {} ownership released; zone automation resumed", group.name); - } - } + let (applied_group_power, should_force_off) = match apply_group_member_power( + &mut zone, requested_member_power, source, temporary_owns_zone, group_handback_at.clone(), + ) { + Ok(result) => result, + Err(error) => { failed.push(error); (None, false) } + }; + update_group_member_ownership(&mut zone, &group, temporary_owns_zone, applied_group_power, group_handback_at); + zone.revision = zone.revision.saturating_add(1); zone.updated_at = Utc::now(); state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); - if force_power_off_after_save { + if should_force_off && forced_off_devices.insert(zone.device_id.clone()) { if let Err(err) = force_power_off_device_locked(state, &zone.device_id).await { state.log("error", "group.power_off_error", &err.to_string(), json!({ - "group_id": group.id, - "zone_id": zone.id, - "device_id": zone.device_id, - "source": source, + "group_id": group.id, "zone_id": zone.id, "device_id": zone.device_id, "source": source, })); failed.push(json!({ - "scope": "device", - "zone_id": zone.id, - "device_id": zone.device_id, - "error": err.to_string(), + "scope": "device", "zone_id": zone.id, "device_id": zone.device_id, "error": err.to_string(), })); } } zones.push(zone); } - let master_power_enabled = true; - let control_toggled_on = patch.power == Some(true); - let control_enabled = group.power_enabled; - - // OFF is already a forced, per-member physical transition performed under zone -> device - // locks above. ON/profile/setpoint changes use the thermostat arbiter so target, hysteresis, - // compressor protection and mode-change safety are applied consistently before returning. - let run_immediately = control_toggled_on || (control_enabled && climate_change); + let run_immediately = patch.power == Some(true) || (group.power_enabled && climate_change); let zones = if run_immediately { - // Do not just wake the background loop: a group ON/profile/setpoint action is expected - // to arbitrate every member before the HTTP request completes. Release member/domain - // locks first, then run one globally serialized thermostat cycle. drop(_zone_guards); drop(_group_guard); drop(_cycle_guard); @@ -279,16 +272,13 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl }; 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, "setpoint": custom_setpoint, - "zones": zones.len(), "failed": failed.len(), "master_power_enabled": master_power_enabled, + "group_id": group.id, "power_enabled": group.power_enabled, "mode": patch.mode, "preset": patch.preset, + "setpoint": custom_setpoint, "zones": zones.len(), "failed": failed.len(), })); Ok(json!({ "group": group, "zones": zones, "devices": state.db.list_devices()?, "failed": failed, - "master_power_enabled": master_power_enabled, })) } - - diff --git a/src/engine/ownership.rs b/src/engine/ownership.rs index fb36e37..40a4459 100644 --- a/src/engine/ownership.rs +++ b/src/engine/ownership.rs @@ -1,4 +1,4 @@ -pub fn refresh_control_ownership(zone: &mut Zone, _house_power_enabled: bool) { +pub fn refresh_control_ownership(zone: &mut Zone) { let now = Utc::now(); let (owner, source, resume_at, reason) = if zone.device_manual_override { let source = match zone.control_source.as_str() { diff --git a/src/engine/temporary_thermostat.rs b/src/engine/temporary_thermostat.rs index 86e8bb9..a75e451 100644 --- a/src/engine/temporary_thermostat.rs +++ b/src/engine/temporary_thermostat.rs @@ -164,7 +164,6 @@ async fn activate_due_temporary_quick_thermostats( zones: &mut [Zone], schedules: &[Schedule], house_mode: &str, - house_power_enabled: bool, ) -> Result<(), AppError> { let now = Utc::now(); for zone in zones.iter_mut() { @@ -184,10 +183,6 @@ async fn activate_due_temporary_quick_thermostats( continue; } if started_at > now { continue; } - if !house_power_enabled { - set_temporary_wait_state(state, zone, "waiting_master", now)?; - continue; - } if zone.device_manual_override { set_temporary_wait_state(state, zone, "paused_manual", now)?; continue; diff --git a/src/engine/tests.rs b/src/engine/tests.rs index f114020..6ba5e0e 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -552,7 +552,7 @@ mod tests { let mut zone = test_zone("device"); zone.local_thermostat_power = Some(true); zone.control_source = "web_thermostat".into(); - refresh_control_ownership(&mut zone, false); + refresh_control_ownership(&mut zone); assert_eq!(zone.control_owner, "local_thermostat"); assert_ne!(zone.control_source, "global"); } @@ -760,7 +760,7 @@ mod tests { assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now())); zone.control_source = "automation.device".into(); - refresh_control_ownership(&mut zone, true); + refresh_control_ownership(&mut zone); assert_eq!(zone.control_owner, "automation"); assert_eq!(zone.control_source, "automation.device"); assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now())); @@ -797,7 +797,7 @@ mod tests { set_local_thermostat_power(&mut zone, false, Utc::now()); assert!(reset_local_thermostat_override(&mut zone)); - refresh_control_ownership(&mut zone, true); + refresh_control_ownership(&mut zone); refresh_zone_runtime_target(&mut zone, &[], "cool"); assert!(zone.local_thermostat_power.is_none()); @@ -910,7 +910,7 @@ mod tests { let mut zone = test_zone("device"); zone.local_thermostat_power = Some(true); zone.control_source = "web_thermostat".into(); - refresh_control_ownership(&mut zone, true); + refresh_control_ownership(&mut zone); let owner = zone.control_owner.clone(); let source = zone.control_source.clone(); let until = Utc::now() + chrono::Duration::seconds(180); @@ -985,7 +985,7 @@ mod tests { assert!(set_house_bulk_thermostat_power(&mut zone, true)); assert!(zone.local_thermostat_power.is_none()); assert_eq!(zone.control_source, "house_power"); - refresh_control_ownership(&mut zone, true); + refresh_control_ownership(&mut zone); assert_eq!(zone.control_owner, "automation"); assert_eq!(zone.control_source, "house_power"); assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now())); diff --git a/src/engine/zone_control.rs b/src/engine/zone_control.rs index e98012b..b27bb2e 100644 --- a/src/engine/zone_control.rs +++ b/src/engine/zone_control.rs @@ -30,77 +30,292 @@ pub(crate) fn rearm_compressor_queue(zone: &mut Zone) { clear_compressor_pending(zone, true); } -async fn control_zones(state: &AppState) -> Result<()> { - let _cycle_guard = state.lock_zone_control_cycle().await; - let schedules = state.db.list_schedules()?; - let settings = state.settings.read().await.clone(); - let mut zone_snapshot = state.db.list_zones()?; - // Local/temporary thermostat ownership is independent from the legacy whole-house master gate - // commands. Expire/activate sessions on their own deadlines. - expire_local_thermostat_overrides(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?; - let temporary_restored_disabled = expire_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?; - activate_due_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode, true).await?; - // Outdoor temperature is deliberately optional. Prefer the configured Home - // Assistant entity, but keep the dashboard/assist useful by falling back to the - // outdoor sensors reported by GREE units when HA is temporarily unavailable. - let device_snapshot = state.db.list_devices()?; - let configured_outdoor = settings.home_assistant.outdoor_entity_id.trim(); - let resolved_outdoor = if configured_outdoor.is_empty() { +async fn resolve_cycle_outdoor_temperature( + state: &AppState, + settings: &RuntimeSettings, + devices: &[Device], +) -> Option { + let configured = settings.home_assistant.outdoor_entity_id.trim(); + let resolved = if configured.is_empty() { None } else { - home_assistant::resolve_entity_id(&settings.home_assistant, Some(configured_outdoor)) + home_assistant::resolve_entity_id(&settings.home_assistant, Some(configured)) }; - let ha_outdoor_temperature = if let Some(entity_id) = resolved_outdoor.as_deref() { - match home_assistant::read_temperature(&state.http, &settings.home_assistant, Some(entity_id), Some(settings.home_assistant.sensor_stale_after_seconds)).await { + let from_home_assistant = if let Some(entity_id) = resolved.as_deref() { + match home_assistant::read_temperature( + &state.http, + &settings.home_assistant, + Some(entity_id), + Some(settings.home_assistant.sensor_stale_after_seconds), + ).await { Ok(value) => { - record_ha_history( - state, - entity_id, - None, - "outdoor", - value, - settings.poll_interval_seconds, - ); + record_ha_history(state, entity_id, None, "outdoor", value, settings.poll_interval_seconds); Some(value) } Err(err) => { - tracing::debug!(configured_entity=%configured_outdoor, resolved_entity=%entity_id, error=?err, "outdoor Home Assistant sensor unavailable; trying GREE fallback"); + tracing::debug!(configured_entity=%configured, resolved_entity=%entity_id, error=?err, "outdoor Home Assistant sensor unavailable; trying GREE fallback"); None } } } else { None }; - let outdoor_temperature = ha_outdoor_temperature.or_else(|| gree_outdoor_temperature(&device_snapshot)); - { - let mut current = state.outdoor_temperature.write().await; - if *current != outdoor_temperature { - *current = outdoor_temperature; - state.broadcast("outdoor.updated", json!({"temperature": outdoor_temperature})); - } + let temperature = from_home_assistant.or_else(|| gree_outdoor_temperature(devices)); + let mut current = state.outdoor_temperature.write().await; + if *current != temperature { + *current = temperature; + state.broadcast("outdoor.updated", json!({"temperature": temperature})); } - let outdoor_assist_temperature = if settings.outdoor_assist_enabled { outdoor_temperature } else { None }; - let night_active = night_mode_active(&settings.night_mode, Local::now().time()); + temperature +} - // Read all per-zone Home Assistant sensors concurrently. A down HA instance should cost - // one request timeout per cycle, not one timeout multiplied by the number of zones. - let room_sensor_reads = futures_util::future::join_all(zone_snapshot.iter().filter_map(|zone| { +async fn read_cycle_room_sensors( + state: &AppState, + settings: &RuntimeSettings, + zones: &[Zone], +) -> HashMap, Result)> { + futures_util::future::join_all(zones.iter().filter_map(|zone| { if !matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") { return None; } let zone_id = zone.id.clone(); let resolved_entity = home_assistant::resolve_entity_id(&settings.home_assistant, zone.ha_entity_id.as_deref()); let http = &state.http; let ha_settings = &settings.home_assistant; - let stale_after_seconds = effective_sensor_stale_after_seconds(zone.sensor_stale_after_seconds, ha_settings.sensor_stale_after_seconds); + let stale_after_seconds = effective_sensor_stale_after_seconds( + zone.sensor_stale_after_seconds, + ha_settings.sensor_stale_after_seconds, + ); Some(async move { let result = home_assistant::read_temperature(http, ha_settings, resolved_entity.as_deref(), Some(stale_after_seconds)).await .map_err(|err| err.to_string()); (zone_id, resolved_entity, result) }) - })).await; - let mut room_sensor_results: HashMap, Result)> = room_sensor_reads.into_iter() - .map(|(zone_id, entity_id, result)| (zone_id, (entity_id, result))) - .collect(); + })) + .await + .into_iter() + .map(|(zone_id, entity_id, result)| (zone_id, (entity_id, result))) + .collect() +} + +fn refresh_zone_temperature( + state: &AppState, + settings: &RuntimeSettings, + zone: &mut Zone, + device: &Device, + room_sensor_results: &mut HashMap, Result)>, +) -> (String, bool) { + let previous_source = zone.control_temperature_source.clone(); + let device_temperature = if device.enabled && device.online && device.communication_failures == 0 { + device.current_temperature + } else { + None + }; + let external_temperature = if matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") { + match room_sensor_results.remove(&zone.id) { + Some((resolved_entity, Ok(value))) => { + if let Some(entity_id) = resolved_entity.as_deref() { + record_ha_history(state, entity_id, Some(&zone.id), "room", value, settings.poll_interval_seconds); + } + Some(value) + } + Some((resolved_entity, Err(err))) => { + if !matches!(previous_source.as_str(), "device_fallback" | "device_discrepancy_fallback") { + let kind = if err.contains("Home Assistant sensor is stale:") { "ha.sensor_stale" } else { "ha.sensor_error" }; + state.log("warn", kind, &err, json!({ + "zone_id": zone.id, + "configured_entity_id": zone.ha_entity_id.as_deref(), + "resolved_entity_id": resolved_entity, + })); + } + None + } + None => None, + } + } else { + None + }; + let (temperature, source, discrepancy) = select_zone_temperature(zone, device_temperature, external_temperature); + zone.device_temperature = device_temperature; + zone.external_temperature = external_temperature; + zone.current_temperature = temperature; + zone.control_temperature_source = source; + zone.updated_at = Utc::now(); + (previous_source, discrepancy) +} + +fn persist_zone_cycle_with_history( + state: &AppState, + zone: &Zone, + cycle_started_at: DateTime, + outdoor_temperature: Option, + poll_interval_seconds: u64, +) -> Result { + record_zone_history(state, zone, outdoor_temperature, poll_interval_seconds); + let persisted = persist_zone_cycle(state, zone, cycle_started_at)?; + state.broadcast("zone.updated", serde_json::to_value(&persisted)?); + Ok(persisted) +} + +async fn handle_zone_pre_control_state( + state: &AppState, + settings: &RuntimeSettings, + schedules: &[Schedule], + temporary_restored_disabled: &[String], + zone: &mut Zone, + device: &Device, + effective_mode: &str, + cycle_started_at: DateTime, + outdoor_temperature: Option, +) -> Result { + // A queued whole-house ON is a delayed bulk physical action, not thermostat ownership. + if zone.compressor_pending_action.as_deref() == Some("global_power_on") { + let now = Utc::now(); + if device.power { + clear_compressor_pending(zone, true); + zone.updated_at = now; + persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?; + return Ok(true); + } + let due = !settings.compressor_protection_enabled + || zone.compressor_pending_until.as_ref().map(|until| until <= &now).unwrap_or(true); + if due { + let _device_guard = state.lock_device_operation(&zone.device_id).await; + match send_command_locked(state, &zone.device_id, DeviceCommand { power: Some(true), ..Default::default() }).await { + Ok(updated_device) => { + if !device.power && updated_device.power { zone.last_power_change_at = Some(Utc::now()); } + clear_compressor_pending(zone, true); + zone.last_action_at = Some(Utc::now()); + state.log("info", "house.power_one_shot_executed", &format!("Executed queued global ON for {}", zone.name), json!({ + "zone_id": zone.id, "device_id": zone.device_id + })); + } + Err(err) => { + zone.compressor_pending_until = Some(Utc::now() + chrono::Duration::seconds(10)); + zone.lockout_until = zone.compressor_pending_until.clone(); + zone.lockout_reason = Some("global_start_retry".into()); + state.log("error", "house.power_one_shot_error", &err.to_string(), json!({ + "zone_id": zone.id, "device_id": zone.device_id + })); + } + } + } + zone.updated_at = Utc::now(); + persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?; + return Ok(true); + } + + if !zone.enabled { + if temporary_restored_disabled.iter().any(|zone_id| zone_id == &zone.id) { + ensure_device_off_after_temporary_disabled_restore(state, zone, device).await; + } + clear_compressor_pending(zone, true); + zone.demand = false; + zone.demand_since = None; + persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?; + return Ok(true); + } + + if !device.enabled { + clear_compressor_pending(zone, true); + zone.demand = false; + zone.demand_since = None; + zone.device_setpoint = None; + persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?; + return Ok(true); + } + + if zone.device_manual_override { + clear_compressor_pending(zone, true); + let temporary_active = temporary_quick_thermostat_is_active(zone, zone.updated_at.clone()); + let pause_started_at = zone.updated_at.clone(); + if temporary_active { + if let Some(session) = zone.temporary_quick_thermostat.as_mut() { + if session.paused_at.is_none() { session.paused_at = Some(pause_started_at); } + session.state = "paused_manual".into(); + session.condition_started_at = None; + session.condition_last_observed_at = None; + } + } + let target_mode = if effective_mode == "off" { zone.mode.as_str() } else { effective_mode }; + let active_schedule = active_schedule_for_zone(zone, schedules, Local::now()); + let (preset, target) = resolve_zone_target(zone, active_schedule, target_mode); + zone.active_preset = preset; + zone.effective_setpoint = Some(target); + zone.effective_mode = if device.power { device.mode.clone() } else { "off".into() }; + zone.device_setpoint = if device.power { Some(device.target_temperature) } else { None }; + zone.demand = false; + zone.demand_since = None; + zone.target_alerted_at = None; + persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?; + return Ok(true); + } + + let condition_sample_at = match zone.control_temperature_source.as_str() { + "home_assistant" | "combined" => Some(zone.updated_at.clone()), + _ => device.last_seen.clone(), + }; + let max_condition_gap_seconds = settings.poll_interval_seconds + .max(settings.zone_interval_seconds) + .saturating_mul(2) + .saturating_add(5); + let condition_now = zone.updated_at.clone(); + if let Some(reason) = evaluate_temporary_quick_thermostat_condition( + zone, + condition_now, + condition_sample_at, + max_condition_gap_seconds, + ) { + let finish_kind = zone.temporary_quick_thermostat.as_ref().map(|item| item.finish_kind.clone()).unwrap_or_default(); + finish_temporary_quick_thermostat(zone, schedules, &settings.house_mode); + let persisted = persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?; + ensure_device_off_after_temporary_disabled_restore(state, &persisted, device).await; + state.log("info", "zone.temporary_quick_thermostat_finished", &format!("Temporary Quick Thermostat finished for {}", zone.name), json!({ + "zone_id": zone.id, "device_id": zone.device_id, "finish_kind": finish_kind, "reason": reason + })); + state.wake_zone_control(); + return Ok(true); + } + + if zone.local_thermostat_power == Some(false) { + zone.effective_mode = "off".into(); + zone.demand = false; + zone.demand_since = None; + zone.device_setpoint = None; + if device.online && device.communication_failures == 0 && device.power { + let _device_guard = state.lock_device_operation(&zone.device_id).await; + let latest = state.db.get_zone(&zone.id)?; + if latest.as_ref().map(|item| item.local_thermostat_power == Some(false) && !item.device_manual_override).unwrap_or(false) { + if let Err(err) = send_command_locked( + state, + &zone.device_id, + DeviceCommand { power: Some(false), ..Default::default() }, + ).await { + state.log("error", "zone.local_power_error", &err.to_string(), json!({"zone_id": zone.id, "device_id": zone.device_id})); + } + } + } + persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?; + return Ok(true); + } + + Ok(false) +} + +async fn control_zones(state: &AppState) -> Result<()> { + let _cycle_guard = state.lock_zone_control_cycle().await; + let schedules = state.db.list_schedules()?; + let settings = state.settings.read().await.clone(); + let mut zone_snapshot = state.db.list_zones()?; + // Local/temporary thermostat ownership has its own deadlines. Expire and activate sessions independently. + expire_local_thermostat_overrides(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?; + let temporary_restored_disabled = expire_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?; + activate_due_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?; + + let device_snapshot = state.db.list_devices()?; + let outdoor_temperature = resolve_cycle_outdoor_temperature(state, &settings, &device_snapshot).await; + let outdoor_assist_temperature = if settings.outdoor_assist_enabled { outdoor_temperature } else { None }; + let night_active = night_mode_active(&settings.night_mode, Local::now().time()); + let mut room_sensor_results = read_cycle_room_sensors(state, &settings, &zone_snapshot).await; for zone_snapshot_item in zone_snapshot { // Every thermostat decision participates in the same zone -> device ordering as @@ -108,7 +323,7 @@ async fn control_zones(state: &AppState) -> Result<()> { // interactive change cannot be evaluated from a stale snapshot. let _zone_guard = state.lock_zone_operation(&zone_snapshot_item.id).await; let Some(mut zone) = state.db.get_zone(&zone_snapshot_item.id)? else { continue; }; - let cycle_started_at = zone.updated_at; + let cycle_started_at = zone.updated_at.clone(); if zone.manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) { zone.manual_preset = None; zone.manual_setpoint = None; @@ -119,8 +334,7 @@ async fn control_zones(state: &AppState) -> Result<()> { zone.control_reason = "Group override expired at schedule boundary".into(); } } - // v0.8.20 makes direct/manual takeover persistent. Normalize any legacy persisted - // boundary from older releases instead of silently returning ownership to schedules. + // Direct/manual takeover stays active until the user explicitly resumes automation. if zone.device_manual_override && zone.device_manual_override_until.is_some() { zone.device_manual_override_until = None; zone.control_resume_at = None; @@ -142,220 +356,24 @@ async fn control_zones(state: &AppState) -> Result<()> { // persistent gate: later thermostat/group/manual intent may act independently. let effective_mode_owned = effective_zone_mode(&zone, &settings.house_mode); zone.effective_mode = effective_mode_owned.clone(); - refresh_control_ownership(&mut zone, true); + refresh_control_ownership(&mut zone); let effective_mode = effective_mode_owned.as_str(); - let previous_source = zone.control_temperature_source.clone(); - // Never feed the thermostat a cached GREE temperature after any communication - // failure. External HA sensors may still keep a zone operational when configured. - let device_temperature = if device.enabled && device.online && device.communication_failures == 0 { - device.current_temperature - } else { - None - }; - let external_temperature = if matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") { - match room_sensor_results.remove(&zone.id) { - Some((resolved_entity, Ok(value))) => { - if let Some(entity_id) = resolved_entity.as_deref() { - record_ha_history(state, entity_id, Some(&zone.id), "room", value, settings.poll_interval_seconds); - } - Some(value) - } - Some((resolved_entity, Err(err))) => { - if !matches!(previous_source.as_str(), "device_fallback" | "device_discrepancy_fallback") { - let notification_kind = if err.contains("Home Assistant sensor is stale:") { - "ha.sensor_stale" - } else { - "ha.sensor_error" - }; - state.log("warn", notification_kind, &err, json!({ - "zone_id": zone.id, - "configured_entity_id": zone.ha_entity_id.as_deref(), - "resolved_entity_id": resolved_entity, - })); - } - None - } - None => None, - } - } else { - None - }; + let (previous_source, discrepancy) = refresh_zone_temperature( + state, &settings, &mut zone, &device, &mut room_sensor_results, + ); - let (temperature, control_source, discrepancy) = select_zone_temperature(&zone, device_temperature, external_temperature); - zone.device_temperature = device_temperature; - zone.external_temperature = external_temperature; - zone.current_temperature = temperature; - zone.control_temperature_source = control_source; - zone.updated_at = Utc::now(); - - // A queued whole-house ON is a delayed bulk physical action, not thermostat - // ownership. It must survive local/group/manual state while compressor protection is - // active, then execute once and hand control straight back to the existing owner. - if zone.compressor_pending_action.as_deref() == Some("global_power_on") { - let now = Utc::now(); - if device.power { - clear_compressor_pending(&mut zone, true); - zone.updated_at = now; - let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; - state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); - continue; - } - let due = !settings.compressor_protection_enabled - || zone.compressor_pending_until.map(|until| until <= now).unwrap_or(true); - if due { - let _device_guard = state.lock_device_operation(&zone.device_id).await; - match send_command_locked(state, &zone.device_id, DeviceCommand { power: Some(true), ..Default::default() }).await { - Ok(updated_device) => { - if !device.power && updated_device.power { zone.last_power_change_at = Some(Utc::now()); } - clear_compressor_pending(&mut zone, true); - zone.last_action_at = Some(Utc::now()); - state.log("info", "house.power_one_shot_executed", &format!("Executed queued global ON for {}", zone.name), json!({ - "zone_id": zone.id, "device_id": zone.device_id - })); - } - Err(err) => { - // Keep the user-visible task and retry on a bounded deadline instead of - // spinning immediately or silently dropping the requested global start. - zone.compressor_pending_until = Some(Utc::now() + chrono::Duration::seconds(10)); - zone.lockout_until = zone.compressor_pending_until; - zone.lockout_reason = Some("global_start_retry".into()); - state.log("error", "house.power_one_shot_error", &err.to_string(), json!({ - "zone_id": zone.id, "device_id": zone.device_id - })); - } - } - } - zone.updated_at = Utc::now(); - record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); - let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; - state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); - continue; - } - - // A disabled thermostat zone is completely outside normal controller ownership. - // Keep its sensors fresh, but do not let group state, schedules or thermostat - // modulation touch the unit. Manual control from the technical Devices view may - // therefore remain active until the zone is explicitly enabled again. - if !zone.enabled { - if temporary_restored_disabled.iter().any(|zone_id| zone_id == &zone.id) { - ensure_device_off_after_temporary_disabled_restore(state, &zone, &device).await; - } - clear_compressor_pending(&mut zone, true); - zone.demand = false; - zone.demand_since = None; - record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); - let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; - state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); - continue; - } - - // A technically disabled device is outside thermostat ownership. Do not create - // repeated command errors while keeping any available external sensor data visible. - if !device.enabled { - clear_compressor_pending(&mut zone, true); - zone.demand = false; - zone.demand_since = None; - zone.device_setpoint = None; - record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); - let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; - state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); - continue; - } - - // A physical/manual takeover has higher priority than thermostat, schedule, group and - // automation control. Continue sensor/history updates, but reflect the unit's real state - // instead of sending corrective frames that would fight the person holding the remote. - if zone.device_manual_override { - // Direct/manual ownership and the thermostat compressor queue are mutually - // exclusive. Clean any stale persisted task before remaining passive. - clear_compressor_pending(&mut zone, true); - // Manual/remote takeover pauses commands, but it must not erase the thermostat's - // selected profile/target. Keep the intended target visible and report the physical - // unit target separately through device_setpoint. This makes Resume/Profile actions - // deterministic and avoids a standby device target (for example 25 C) masquerading - // as the zone's Sleep/Comfort target. - let temporary_active = temporary_quick_thermostat_is_active(&zone, zone.updated_at.clone()); - let pause_started_at = zone.updated_at; - if temporary_active { - if let Some(session) = zone.temporary_quick_thermostat.as_mut() { - if session.paused_at.is_none() { session.paused_at = Some(pause_started_at); } - session.state = "paused_manual".into(); - session.condition_started_at = None; - session.condition_last_observed_at = None; - } - } - let target_mode = if effective_mode == "off" { zone.mode.as_str() } else { effective_mode }; - let active_schedule = active_schedule_for_zone(&zone, &schedules, Local::now()); - let (preset, target) = resolve_zone_target(&zone, active_schedule, target_mode); - zone.active_preset = preset; - zone.effective_setpoint = Some(target); - // Keep effective_mode's existing meaning during takeover: it reflects the physical - // unit, while effective_setpoint above remains the thermostat intent. - zone.effective_mode = if device.power { device.mode.clone() } else { "off".into() }; - zone.device_setpoint = if device.power { Some(device.target_temperature) } else { None }; - zone.demand = false; - zone.demand_since = None; - zone.target_alerted_at = None; - record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); - let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; - state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); - continue; - } - - // Temperature completion belongs to the temporary thermostat only while it truly owns - // the zone. A manual/device takeover above therefore pauses the hold instead of silently - // consuming it. GREE samples use last_seen; HA/combined samples were freshly read in this - // control cycle. A long gap resets continuous-hold evidence after restart/stale sensors. - let condition_sample_at = match zone.control_temperature_source.as_str() { - "home_assistant" | "combined" => Some(zone.updated_at.clone()), - _ => device.last_seen.clone(), - }; - let max_condition_gap_seconds = settings.poll_interval_seconds - .max(settings.zone_interval_seconds) - .saturating_mul(2) - .saturating_add(5); - let condition_now = zone.updated_at.clone(); - if let Some(reason) = evaluate_temporary_quick_thermostat_condition( + if handle_zone_pre_control_state( + state, + &settings, + &schedules, + &temporary_restored_disabled, &mut zone, - condition_now, - condition_sample_at, - max_condition_gap_seconds, - ) { - let finish_kind = zone.temporary_quick_thermostat.as_ref().map(|item| item.finish_kind.clone()).unwrap_or_default(); - finish_temporary_quick_thermostat(&mut zone, &schedules, &settings.house_mode); - record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); - let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; - state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); - ensure_device_off_after_temporary_disabled_restore(state, &persisted_zone, &device).await; - state.log("info", "zone.temporary_quick_thermostat_finished", &format!("Temporary Quick Thermostat finished for {}", zone.name), json!({ - "zone_id": zone.id, "device_id": zone.device_id, "finish_kind": finish_kind, "reason": reason - })); - state.wake_zone_control(); - continue; - } - - if zone.local_thermostat_power == Some(false) { - zone.effective_mode = "off".into(); - zone.demand = false; - zone.demand_since = None; - zone.device_setpoint = None; - if device.online && device.communication_failures == 0 && device.power { - let _device_guard = state.lock_device_operation(&zone.device_id).await; - let latest = state.db.get_zone(&zone.id)?; - if latest.as_ref().map(|item| item.local_thermostat_power == Some(false) && !item.device_manual_override).unwrap_or(false) { - if let Err(err) = send_command_locked( - state, - &zone.device_id, - DeviceCommand { power: Some(false), ..Default::default() }, - ).await { - state.log("error", "zone.local_power_error", &err.to_string(), json!({"zone_id": zone.id, "device_id": zone.device_id})); - } - } - } - record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); - let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; - state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); + &device, + effective_mode, + cycle_started_at.clone(), + outdoor_temperature, + ).await? { continue; } diff --git a/src/main.rs b/src/main.rs index 5f5f443..2e57cbf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,10 +34,6 @@ async fn main() -> Result<()> { runtime_settings.discovery_broadcast = config.discovery_broadcast.clone(); } config.apply_runtime_env_overrides(&mut runtime_settings); - // v0.9.4: whole-house ON/OFF is a bulk thermostat action, not a persistent master gate. - // Normalize databases upgraded from older releases so a historical OFF cannot suppress - // thermostats, groups or automations after restart. - runtime_settings.house_power_enabled = true; db.save_runtime_settings(&runtime_settings)?; if config.simulate && config.auto_seed && db.count_devices()? == 0 { diff --git a/src/models.rs b/src/models.rs index d2765f7..bac7156 100644 --- a/src/models.rs +++ b/src/models.rs @@ -12,5 +12,6 @@ include!("models/flow.rs"); include!("models/automation.rs"); include!("models/history.rs"); include!("models/integrations.rs"); +include!("models/settings_api.rs"); include!("models/control_plan.rs"); include!("models/runtime.rs"); diff --git a/src/models/runtime.rs b/src/models/runtime.rs index 396ebeb..acd165e 100644 --- a/src/models/runtime.rs +++ b/src/models/runtime.rs @@ -9,10 +9,6 @@ pub struct RuntimeSettings { /// Global seasonal mode. Zones follow this by default. Values: cool/heat/off; off pauses house-level thermostat control. #[serde(default = "default_house_mode")] pub house_mode: String, - /// Legacy compatibility flag. Since v0.9.4 global ON/OFF is a bulk thermostat action, not an - /// automation gate. The controller normalizes this field to true on load/import. - #[serde(default = "default_true")] - pub house_power_enabled: bool, /// `setpoint` keeps units powered and modulates compressor demand by changing target temperature. #[serde(default = "default_control_strategy")] pub control_strategy: String, diff --git a/src/models/settings_api.rs b/src/models/settings_api.rs new file mode 100644 index 0000000..33b053b --- /dev/null +++ b/src/models/settings_api.rs @@ -0,0 +1,113 @@ +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApplicationSettings { + pub simulator_enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GreeSettings { + pub controller_id: String, + pub poll_interval_seconds: u64, + pub zone_interval_seconds: u64, + pub discovery_timeout_ms: u64, + pub discovery_broadcast: String, + pub suppress_device_beep: bool, + pub compressor_protection_enabled: bool, + pub compressor_protection_seconds: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HistorySettings { + pub retention_days: u32, + pub compaction_enabled: bool, + pub event_retention_days: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InfluxDbSettingsUpdate { + pub enabled: bool, + pub version: String, + pub url: String, + pub database: String, + pub username: String, + #[serde(default)] + pub password: Option, + pub org: String, + pub bucket: String, + #[serde(default)] + pub token: Option, + pub history_threshold_days: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InfluxDbSettingsView { + pub enabled: bool, + pub version: String, + pub url: String, + pub database: String, + pub username: String, + pub password_configured: bool, + pub org: String, + pub bucket: String, + pub token_configured: bool, + pub history_threshold_days: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotificationSettingsUpdate { + pub enabled: bool, + pub mode: String, + pub provider: String, + #[serde(default)] + pub pushover_app_token: Option, + #[serde(default)] + pub pushover_user_key: Option, + #[serde(default)] + pub slack_webhook_url: Option, + #[serde(default)] + pub discord_webhook_url: Option, + pub cooldown_seconds: u64, + pub communication_failure_threshold: u32, + pub target_timeout_minutes: u32, + pub alert_types: NotificationAlertTypes, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotificationSettingsView { + pub enabled: bool, + pub mode: String, + pub provider: String, + pub pushover_configured: bool, + pub slack_configured: bool, + pub discord_configured: bool, + pub cooldown_seconds: u64, + pub communication_failure_threshold: u32, + pub target_timeout_minutes: u32, + pub alert_types: NotificationAlertTypes, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HomeAssistantSettingsUpdate { + pub url: String, + #[serde(default)] + pub token: Option, + pub default_entity_id: String, + pub outdoor_entity_id: String, + pub sensor_stale_after_seconds: u64, + pub allow_invalid_tls: bool, + pub sensor_aliases: BTreeMap, + pub flow_inputs: Vec, + pub outdoor_assist_enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HomeAssistantSettingsView { + pub url: String, + pub token_configured: bool, + pub default_entity_id: String, + pub outdoor_entity_id: String, + pub sensor_stale_after_seconds: u64, + pub allow_invalid_tls: bool, + pub sensor_aliases: BTreeMap, + pub flow_inputs: Vec, + pub outdoor_assist_enabled: bool, +} diff --git a/web/js/bootstrap.js b/web/js/bootstrap.js index ead6983..3801b30 100644 --- a/web/js/bootstrap.js +++ b/web/js/bootstrap.js @@ -2,7 +2,7 @@ async function loadBootstrap(showMessage = false) { if (app.loading) return; app.loading = true; try { - const data = await api('/api/bootstrap'); + const [data, sections] = await Promise.all([api('/api/bootstrap'), fetchSettingsSections()]); app.devices = data.devices || []; app.zones = data.zones || []; app.groups = data.groups || []; @@ -10,9 +10,9 @@ async function loadBootstrap(showMessage = false) { app.automations = data.automations || []; app.flows = data.flows || []; app.accessTokens = data.access_tokens || []; - app.settings = data.settings || null; - app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) }; - app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings?.home_assistant?.flow_inputs || [])); + app.settings = aggregateSettingsSections(sections, data.house?.mode || 'cool'); + app.sensorAliases = { ...(app.settings.home_assistant?.sensor_aliases || {}) }; + app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings.home_assistant?.flow_inputs || [])); app.system = data.system || {}; app.systemSnapshotAt = Date.now(); app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : null; diff --git a/web/js/entities.js b/web/js/entities.js index 1f1cd93..fd60f33 100644 --- a/web/js/entities.js +++ b/web/js/entities.js @@ -469,7 +469,6 @@ async function sendGroupControl(id, patch) { if (index >= 0) app.zones[index] = zone; else app.zones.push(zone); }); (result.devices || []).forEach(updateDevice); - if (typeof result.master_power_enabled === 'boolean' && app.settings) app.settings.house_power_enabled = result.master_power_enabled; if (patch.preset === 'custom' && patch.setpoint != null) delete app.groupCustomDrafts[id]; renderAll(); scheduleControlPlanLoad(); const failed = Array.isArray(result.failed) ? result.failed.length : 0; diff --git a/web/js/realtime.js b/web/js/realtime.js index 79187a6..65e88fd 100644 --- a/web/js/realtime.js +++ b/web/js/realtime.js @@ -10,6 +10,52 @@ async function loadLogs() { } catch (error) { toast(error.message, true); } } +async function handleWebSocketMessage(event) { + try { + const message = JSON.parse(event.data); + if (message.event === 'bootstrap') { + const data = message.data; app.devices = data.devices || []; app.zones = data.zones || []; app.groups = data.groups || []; app.schedules = data.schedules || []; app.automations = data.automations || []; + app.flows = data.flows || []; await loadSettingsSections(data.house?.mode || app.settings?.house_mode || 'cool'); app.system = data.system || app.system; app.systemSnapshotAt = Date.now(); app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : app.outdoorTemperature; renderAll(); scheduleControlPlanLoad(); if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return; + } + const data = message.data || {}; + if (['device.updated', 'device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); } + else if (message.event === 'device.deleted') { app.devices = app.devices.filter(v => v.id !== data.id); renderAll(); scheduleControlPlanLoad(); } + else if (message.event === 'devices.discovered') { (data.devices || []).forEach(updateDevice); renderAll(); scheduleControlPlanLoad(); } + else if (message.event === 'zone.updated') { const i = app.zones.findIndex(v => v.id === data.id); if (i >= 0) app.zones[i] = data; else app.zones.push(data); renderSummary(); renderHouseClimate(); renderZones(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); } + else if (message.event === 'zone.deleted') { app.zones = app.zones.filter(v => v.id !== data.id); renderAll(); scheduleControlPlanLoad(); } + else if (['group.updated', 'group.created'].includes(message.event)) { const i = app.groups.findIndex(v => v.id === data.id); if (i >= 0) app.groups[i] = data; else app.groups.push(data); renderGroups(); fillSelects(); scheduleControlPlanLoad(); } + else if (message.event === 'group.deleted') { app.groups = app.groups.filter(v => v.id !== data.id); renderGroups(); fillSelects(); scheduleControlPlanLoad(); } + else if (['flow.updated', 'flow.created'].includes(message.event)) { const i = app.flows.findIndex(v => v.id === data.id); if (i >= 0) app.flows[i] = data; else app.flows.push(data); renderFlows(); scheduleControlPlanLoad(); } + else if (message.event === 'flow.deleted') { app.flows = app.flows.filter(v => v.id !== data.id); renderFlows(); scheduleControlPlanLoad(); } + else if (message.event === 'settings.application.updated') { applySettingsSection('application', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); renderSimulationModeBanner(); renderSystemInfo(); } + else if (message.event === 'settings.gree.updated') { applySettingsSection('gree', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); scheduleControlPlanLoad(); } + else if (message.event === 'settings.history.updated') { applySettingsSection('history', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); renderLogRetention(); } + else if (message.event === 'settings.influxdb.updated') { applySettingsSection('influxdb', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); } + else if (message.event === 'settings.notifications.updated') { applySettingsSection('notifications', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); } + else if (message.event === 'settings.debug.updated') { applySettingsSection('debug', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); renderDebugOverlay(); if (data.overlay_enabled) loadDebugBacklog(); } + else if (message.event === 'settings.night.updated') { applySettingsSection('night', data); if (!isFormDirty($('#nightModeForm'))) renderNightSettings(); renderHouseClimate(); scheduleControlPlanLoad(); } + else if (message.event === 'settings.home_assistant.updated') { + applySettingsSection('homeAssistant', data); + if (!isFormDirty($('#homeAssistantForm'))) { app.sensorAliases = { ...(data.sensor_aliases || {}) }; app.flowSharedInputs = JSON.parse(JSON.stringify(data.flow_inputs || [])); renderHomeAssistantSettings(); } + renderHouseClimate(); if (app.flowDraft) renderFlowEditor(); scheduleControlPlanLoad(); + } + else if (message.event === 'house.mode_changed') { app.settings = app.settings || {}; app.settings.house_mode = data.mode || 'cool'; renderHouseClimate(); renderGroups(); scheduleControlPlanLoad(); } + else if (message.event === 'outdoor.updated') { app.outdoorTemperature = Number.isFinite(Number(data.temperature)) ? Number(data.temperature) : null; renderHouseClimate(); scheduleControlPlanLoad(); } + else if (message.event === 'gree.frame_received') { + app.system = app.system || {}; + app.system.gree_received_frames = Number(data.total || 0); + app.system.gree_received_frames_by_device = { ...(app.system.gree_received_frames_by_device || {}) }; + if (data.device_id) app.system.gree_received_frames_by_device[data.device_id] = Number(data.device_count || 0); + renderGreeFrameStats(); + renderSystemInfo(); + } + else if (message.event === 'gree.frame') { if (app.settings?.debug?.overlay_enabled) debugLine('GREE', `${data.direction || '?'} ${data.protocol_version || ''}`, data.device_name || data.device_id || data.target || '', message.timestamp, data.payload); } + else if (message.event === 'api.request') { if (app.settings?.debug?.overlay_enabled) debugLine('HTTP', `${data.method || '?'} ${data.status || ''}`, `${data.path || ''} · ${data.duration_ms ?? '?'} ms`, message.timestamp); } + else if (message.event === 'log.created') { if (app.settings?.debug?.overlay_enabled) debugLine('API', data.kind || data.level || 'log', data.message || '', message.timestamp, data.metadata); if (app.currentView === 'logs') loadLogs(); } + else if (message.event.startsWith('schedule.') || message.event.startsWith('automation.') || message.event.startsWith('flow.')) scheduleControlPlanLoad(); + } catch (_) { } +} + function connectWebSocket() { if (app.ws && [WebSocket.OPEN, WebSocket.CONNECTING].includes(app.ws.readyState)) return; clearTimeout(app.wsTimer); @@ -19,47 +65,8 @@ function connectWebSocket() { ws.onopen = () => updateConnectionIndicator('connected'); ws.onclose = () => { updateConnectionIndicator('disconnected'); app.wsTimer = setTimeout(connectWebSocket, 3000); }; ws.onerror = () => updateConnectionIndicator('connectionError'); + let messageQueue = Promise.resolve(); ws.onmessage = event => { - try { - const message = JSON.parse(event.data); - if (message.event === 'bootstrap') { - const data = message.data; app.devices = data.devices || []; app.zones = data.zones || []; app.groups = data.groups || []; app.schedules = data.schedules || []; app.automations = data.automations || []; - app.flows = data.flows || []; app.settings = data.settings || app.settings; app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) }; app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings?.home_assistant?.flow_inputs || [])); app.system = data.system || app.system; app.systemSnapshotAt = Date.now(); app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : app.outdoorTemperature; renderAll(); scheduleControlPlanLoad(); if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return; - } - const data = message.data || {}; - if (['device.updated', 'device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); } - else if (message.event === 'device.deleted') { app.devices = app.devices.filter(v => v.id !== data.id); renderAll(); scheduleControlPlanLoad(); } - else if (message.event === 'devices.discovered') { (data.devices || []).forEach(updateDevice); renderAll(); scheduleControlPlanLoad(); } - else if (message.event === 'zone.updated') { const i = app.zones.findIndex(v => v.id === data.id); if (i >= 0) app.zones[i] = data; else app.zones.push(data); renderSummary(); renderHouseClimate(); renderZones(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); } - else if (message.event === 'zone.deleted') { app.zones = app.zones.filter(v => v.id !== data.id); renderAll(); scheduleControlPlanLoad(); } - else if (['group.updated', 'group.created'].includes(message.event)) { const i = app.groups.findIndex(v => v.id === data.id); if (i >= 0) app.groups[i] = data; else app.groups.push(data); renderGroups(); fillSelects(); scheduleControlPlanLoad(); } - else if (message.event === 'group.deleted') { app.groups = app.groups.filter(v => v.id !== data.id); renderGroups(); fillSelects(); scheduleControlPlanLoad(); } - else if (['flow.updated', 'flow.created'].includes(message.event)) { const i = app.flows.findIndex(v => v.id === data.id); if (i >= 0) app.flows[i] = data; else app.flows.push(data); renderFlows(); scheduleControlPlanLoad(); } - else if (message.event === 'flow.deleted') { app.flows = app.flows.filter(v => v.id !== data.id); renderFlows(); scheduleControlPlanLoad(); } - else if (message.event === 'settings.updated') { - const settingsDirty = isFormDirty($('#settingsForm')), nightDirty = isFormDirty($('#nightModeForm')), haDirty = isFormDirty($('#homeAssistantForm')); - app.settings = data; - if (!haDirty) { app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) }; app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings?.home_assistant?.flow_inputs || [])); } - if (!settingsDirty) renderSettings(); - if (!nightDirty) renderNightSettings(); - if (!haDirty) renderHomeAssistantSettings(); - renderHouseClimate(); renderSimulationModeBanner(); renderSystemInfo(); renderDebugOverlay(); if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); scheduleControlPlanLoad(); - } - else if (message.event === 'house.power_changed') { renderHouseClimate(); renderGroups(); scheduleControlPlanLoad(); } - else if (message.event === 'debug.settings') { app.settings = app.settings || {}; app.settings.debug = data; if (!isFormDirty($('#settingsForm'))) renderSettings(); renderDebugOverlay(); if (data.overlay_enabled) loadDebugBacklog(); } - else if (message.event === 'outdoor.updated') { app.outdoorTemperature = Number.isFinite(Number(data.temperature)) ? Number(data.temperature) : null; renderHouseClimate(); scheduleControlPlanLoad(); } - else if (message.event === 'gree.frame_received') { - app.system = app.system || {}; - app.system.gree_received_frames = Number(data.total || 0); - app.system.gree_received_frames_by_device = { ...(app.system.gree_received_frames_by_device || {}) }; - if (data.device_id) app.system.gree_received_frames_by_device[data.device_id] = Number(data.device_count || 0); - renderGreeFrameStats(); - renderSystemInfo(); - } - else if (message.event === 'gree.frame') { if (app.settings?.debug?.overlay_enabled) debugLine('GREE', `${data.direction || '?'} ${data.protocol_version || ''}`, data.device_name || data.device_id || data.target || '', message.timestamp, data.payload); } - else if (message.event === 'api.request') { if (app.settings?.debug?.overlay_enabled) debugLine('HTTP', `${data.method || '?'} ${data.status || ''}`, `${data.path || ''} · ${data.duration_ms ?? '?'} ms`, message.timestamp); } - else if (message.event === 'log.created') { if (app.settings?.debug?.overlay_enabled) debugLine('API', data.kind || data.level || 'log', data.message || '', message.timestamp, data.metadata); if (app.currentView === 'logs') loadLogs(); } - else if (message.event.startsWith('schedule.') || message.event.startsWith('automation.') || message.event.startsWith('flow.')) scheduleControlPlanLoad(); - } catch (_) { } + messageQueue = messageQueue.then(() => handleWebSocketMessage(event)).catch(() => {}); }; } diff --git a/web/js/settings.js b/web/js/settings.js index 4c25d30..05200fc 100644 --- a/web/js/settings.js +++ b/web/js/settings.js @@ -1,103 +1,127 @@ +const SETTINGS_ENDPOINTS = { + application: '/api/settings/application', + gree: '/api/settings/gree', + history: '/api/settings/history', + influxdb: '/api/settings/influxdb', + notifications: '/api/settings/notifications', + night: '/api/settings/night', + homeAssistant: '/api/settings/home-assistant', + debug: '/api/settings/debug', +}; -function currentSettingsBody() { - const settings = app.settings || {}; - const influx = settings.influxdb || {}; - const ha = settings.home_assistant || {}; +async function fetchSettingsSections() { + const [application, gree, history, influxdb, notifications, night, homeAssistant, debug] = await Promise.all([ + api(SETTINGS_ENDPOINTS.application), + api(SETTINGS_ENDPOINTS.gree), + api(SETTINGS_ENDPOINTS.history), + api(SETTINGS_ENDPOINTS.influxdb), + api(SETTINGS_ENDPOINTS.notifications), + api(SETTINGS_ENDPOINTS.night), + api(SETTINGS_ENDPOINTS.homeAssistant), + api(SETTINGS_ENDPOINTS.debug), + ]); + return { application, gree, history, influxdb, notifications, night, homeAssistant, debug }; +} + +function aggregateSettingsSections(sections, houseMode = app.settings?.house_mode || 'cool') { return { - controller_id: settings.controller_id || 'gree-controller', - simulator_enabled: !!settings.simulator_enabled, - poll_interval_seconds: Number(settings.poll_interval_seconds || 15), - zone_interval_seconds: Number(settings.zone_interval_seconds || 5), - discovery_timeout_ms: Number(settings.discovery_timeout_ms || 3000), - discovery_broadcast: settings.discovery_broadcast || '255.255.255.255:7000', - house_mode: settings.house_mode || 'cool', - house_power_enabled: settings.house_power_enabled !== false, - control_strategy: 'setpoint', - outdoor_assist_enabled: !!settings.outdoor_assist_enabled, - history_retention_days: Number(settings.history_retention_days || 30), - history_compaction_enabled: settings.history_compaction_enabled !== false, - event_log_retention_days: Number(settings.event_log_retention_days || 30), - suppress_device_beep: !!settings.suppress_device_beep, - compressor_protection_enabled: settings.compressor_protection_enabled !== false, - compressor_protection_seconds: Number(settings.compressor_protection_seconds || 180), - notifications: { - enabled: !!settings.notifications?.enabled, mode: settings.notifications?.mode || 'problems', provider: settings.notifications?.provider || 'pushover', - pushover_app_token: '', pushover_user_key: '', slack_webhook_url: '', discord_webhook_url: '', - cooldown_seconds: Number(settings.notifications?.cooldown_seconds || 300), communication_failure_threshold: Number(settings.notifications?.communication_failure_threshold || 3), target_timeout_minutes: Number(settings.notifications?.target_timeout_minutes || 60), - alert_types: { - stale_sensor: settings.notifications?.alert_types?.stale_sensor !== false, - sensor_errors: settings.notifications?.alert_types?.sensor_errors !== false, - communication: settings.notifications?.alert_types?.communication !== false, - target_timeout: settings.notifications?.alert_types?.target_timeout !== false, - automation: settings.notifications?.alert_types?.automation !== false, - control_errors: settings.notifications?.alert_types?.control_errors !== false, - important_events: settings.notifications?.alert_types?.important_events !== false, - other: settings.notifications?.alert_types?.other !== false, - }, - }, - night_mode: { - enabled: !!settings.night_mode?.enabled, - start_time: settings.night_mode?.start_time || '22:00', - end_time: settings.night_mode?.end_time || '06:00', - max_fan_speed: Number(settings.night_mode?.max_fan_speed || 1), - force_quiet: settings.night_mode?.force_quiet !== false, - use_native_sleep: settings.night_mode?.use_native_sleep !== false, - }, - influxdb: { - enabled: !!influx.enabled, - version: String(influx.version || '2'), - url: influx.url || '', - database: influx.database || 'gree_controller', - username: influx.username || '', - password: '', - org: influx.org || '', - bucket: influx.bucket || 'gree_controller', - token: '', - history_threshold_days: Number(influx.history_threshold_days || 30), - }, - debug: { - overlay_enabled: !!settings.debug?.overlay_enabled, - gree_frames: !!settings.debug?.gree_frames, - }, - home_assistant: { - url: ha.url || '', - token: '', - default_entity_id: ha.default_entity_id || '', - outdoor_entity_id: ha.outdoor_entity_id || '', - sensor_stale_after_seconds: Number(ha.sensor_stale_after_seconds || 300), - allow_invalid_tls: !!ha.allow_invalid_tls, - sensor_aliases: { ...(ha.sensor_aliases || {}) }, - flow_inputs: JSON.parse(JSON.stringify(ha.flow_inputs || [])), - }, + simulator_enabled: !!sections.application.simulator_enabled, + controller_id: sections.gree.controller_id, + poll_interval_seconds: Number(sections.gree.poll_interval_seconds), + zone_interval_seconds: Number(sections.gree.zone_interval_seconds), + discovery_timeout_ms: Number(sections.gree.discovery_timeout_ms), + discovery_broadcast: sections.gree.discovery_broadcast, + suppress_device_beep: !!sections.gree.suppress_device_beep, + compressor_protection_enabled: sections.gree.compressor_protection_enabled !== false, + compressor_protection_seconds: Number(sections.gree.compressor_protection_seconds), + history_retention_days: Number(sections.history.retention_days), + history_compaction_enabled: sections.history.compaction_enabled !== false, + event_log_retention_days: Number(sections.history.event_retention_days), + influxdb: sections.influxdb, + notifications: sections.notifications, + night_mode: sections.night, + home_assistant: sections.homeAssistant, + outdoor_assist_enabled: !!sections.homeAssistant.outdoor_assist_enabled, + debug: sections.debug, + house_mode: houseMode || 'cool', }; } -function settingsBodyFromForm(form) { +async function loadSettingsSections(houseMode = app.settings?.house_mode || 'cool') { + const sections = await fetchSettingsSections(); + app.settings = aggregateSettingsSections(sections, houseMode); + app.sensorAliases = { ...(app.settings.home_assistant?.sensor_aliases || {}) }; + app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings.home_assistant?.flow_inputs || [])); + return app.settings; +} + +function applySettingsSection(section, data) { + app.settings = app.settings || {}; + if (section === 'application') app.settings.simulator_enabled = !!data.simulator_enabled; + else if (section === 'gree') Object.assign(app.settings, data); + else if (section === 'history') { + app.settings.history_retention_days = Number(data.retention_days); + app.settings.history_compaction_enabled = data.compaction_enabled !== false; + app.settings.event_log_retention_days = Number(data.event_retention_days); + } else if (section === 'influxdb') app.settings.influxdb = data; + else if (section === 'notifications') app.settings.notifications = data; + else if (section === 'night') app.settings.night_mode = data; + else if (section === 'homeAssistant') { + app.settings.home_assistant = data; + app.settings.outdoor_assist_enabled = !!data.outdoor_assist_enabled; + } else if (section === 'debug') app.settings.debug = data; +} + +function applicationSettingsBodyFromForm(form) { + return { simulator_enabled: form.simulator_enabled.checked }; +} + +function greeSettingsBodyFromForm(form) { const raw = Object.fromEntries(new FormData(form)); - const body = currentSettingsBody(); - body.controller_id = raw.controller_id; - body.simulator_enabled = form.simulator_enabled.checked; - body.poll_interval_seconds = Number(raw.poll_interval_seconds); - body.zone_interval_seconds = Number(raw.zone_interval_seconds); - body.discovery_timeout_ms = Number(raw.discovery_timeout_ms); - body.discovery_broadcast = raw.discovery_broadcast; - body.history_retention_days = Number(raw.history_retention_days); - body.history_compaction_enabled = form.history_compaction_enabled.checked; - body.event_log_retention_days = Number(raw.event_log_retention_days); - body.suppress_device_beep = form.suppress_device_beep.checked; - body.compressor_protection_enabled = form.compressor_protection_enabled.checked; - body.compressor_protection_seconds = Math.max(30, Math.min(1800, Math.round(Number(raw.compressor_protection_minutes || 3) * 60))); - body.influxdb = { - enabled: form.influx_enabled.checked, version: raw.influx_version, url: raw.influx_url, - database: raw.influx_database, username: raw.influx_username, password: raw.influx_password, - org: raw.influx_org, bucket: raw.influx_bucket, token: raw.influx_token, + return { + controller_id: raw.controller_id, + poll_interval_seconds: Number(raw.poll_interval_seconds), + zone_interval_seconds: Number(raw.zone_interval_seconds), + discovery_timeout_ms: Number(raw.discovery_timeout_ms), + discovery_broadcast: raw.discovery_broadcast, + suppress_device_beep: form.suppress_device_beep.checked, + compressor_protection_enabled: form.compressor_protection_enabled.checked, + compressor_protection_seconds: Math.max(30, Math.min(1800, Math.round(Number(raw.compressor_protection_minutes || 3) * 60))), + }; +} + +function historySettingsBodyFromForm(form, eventRetentionDays = null) { + const raw = Object.fromEntries(new FormData(form)); + return { + retention_days: Number(raw.history_retention_days), + compaction_enabled: form.history_compaction_enabled.checked, + event_retention_days: eventRetentionDays == null ? Number(raw.event_log_retention_days) : Number(eventRetentionDays), + }; +} + +function influxDbSettingsBodyFromForm(form) { + const raw = Object.fromEntries(new FormData(form)); + const body = { + enabled: form.influx_enabled.checked, + version: raw.influx_version, + url: raw.influx_url, + database: raw.influx_database, + username: raw.influx_username, + org: raw.influx_org, + bucket: raw.influx_bucket, history_threshold_days: Number(raw.influx_threshold_days), }; - body.debug = { overlay_enabled: form.debug_overlay_enabled.checked, gree_frames: form.debug_gree_frames.checked }; - body.notifications = { - enabled: form.notifications_enabled.checked, mode: raw.notifications_mode, provider: raw.notifications_provider, - pushover_app_token: raw.pushover_app_token || '', pushover_user_key: raw.pushover_user_key || '', - slack_webhook_url: raw.slack_webhook_url || '', discord_webhook_url: raw.discord_webhook_url || '', + if (raw.influx_password) body.password = raw.influx_password; + if (raw.influx_token) body.token = raw.influx_token; + return body; +} + +function notificationSettingsBodyFromForm(form) { + const raw = Object.fromEntries(new FormData(form)); + const body = { + enabled: form.notifications_enabled.checked, + mode: raw.notifications_mode, + provider: raw.notifications_provider, cooldown_seconds: Number(raw.notification_cooldown_seconds || 300), communication_failure_threshold: Number(raw.notification_failure_threshold || 3), target_timeout_minutes: Number(raw.notification_target_timeout || 60), @@ -112,13 +136,20 @@ function settingsBodyFromForm(form) { other: form.notification_alert_other.checked, }, }; + if (raw.pushover_app_token) body.pushover_app_token = raw.pushover_app_token; + if (raw.pushover_user_key) body.pushover_user_key = raw.pushover_user_key; + if (raw.slack_webhook_url) body.slack_webhook_url = raw.slack_webhook_url; + if (raw.discord_webhook_url) body.discord_webhook_url = raw.discord_webhook_url; return body; } +function debugSettingsBodyFromForm(form) { + return { overlay_enabled: form.debug_overlay_enabled.checked, gree_frames: form.debug_gree_frames.checked }; +} + function nightSettingsBodyFromForm(form) { const raw = Object.fromEntries(new FormData(form)); - const body = currentSettingsBody(); - body.night_mode = { + return { enabled: form.night_mode_enabled.checked, start_time: raw.night_mode_start, end_time: raw.night_mode_end, @@ -126,30 +157,25 @@ function nightSettingsBodyFromForm(form) { force_quiet: form.night_mode_force_quiet.checked, use_native_sleep: form.night_mode_native_sleep.checked, }; - return body; } function homeAssistantSettingsBodyFromForm(form) { const raw = Object.fromEntries(new FormData(form)); - const body = currentSettingsBody(); - body.outdoor_assist_enabled = form.outdoor_assist_enabled.checked; - body.home_assistant = { + const body = { url: raw.ha_url, - token: raw.ha_token, default_entity_id: raw.ha_entity_id, outdoor_entity_id: raw.ha_outdoor_entity_id, sensor_stale_after_seconds: Math.max(60, Math.min(86400, Math.round(Number(raw.ha_sensor_stale_after_minutes || 5) * 60))), allow_invalid_tls: form.ha_allow_invalid_tls.checked, sensor_aliases: { ...(app.sensorAliases || {}) }, flow_inputs: JSON.parse(JSON.stringify(app.flowSharedInputs || [])), + outdoor_assist_enabled: form.outdoor_assist_enabled.checked, }; + if (raw.ha_token) body.token = raw.ha_token; return body; } -async function saveRuntimeSettings(body, notify = true) { - app.settings = await api('/api/settings', { method: 'PUT', body }); - app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) }; - app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings?.home_assistant?.flow_inputs || [])); +function refreshSettingsUi() { renderSettings(); renderNightSettings(); renderHomeAssistantSettings(); @@ -160,23 +186,56 @@ async function saveRuntimeSettings(body, notify = true) { if (app.flowDraft) renderFlowEditor(); scheduleControlPlanLoad(); if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); +} + +async function saveMainSettings(form, notify = true) { + const [application, gree, history, influxdb, notifications, debug] = await Promise.all([ + api(SETTINGS_ENDPOINTS.application, { method: 'PUT', body: applicationSettingsBodyFromForm(form) }), + api(SETTINGS_ENDPOINTS.gree, { method: 'PUT', body: greeSettingsBodyFromForm(form) }), + api(SETTINGS_ENDPOINTS.history, { method: 'PUT', body: historySettingsBodyFromForm(form) }), + api(SETTINGS_ENDPOINTS.influxdb, { method: 'PUT', body: influxDbSettingsBodyFromForm(form) }), + api(SETTINGS_ENDPOINTS.notifications, { method: 'PUT', body: notificationSettingsBodyFromForm(form) }), + api(SETTINGS_ENDPOINTS.debug, { method: 'PUT', body: debugSettingsBodyFromForm(form) }), + ]); + applySettingsSection('application', application); + applySettingsSection('gree', gree); + applySettingsSection('history', history); + applySettingsSection('influxdb', influxdb); + applySettingsSection('notifications', notifications); + applySettingsSection('debug', debug); + refreshSettingsUi(); + if (notify) toast(tr('common.saved')); +} + +async function saveNightSettings(form, notify = true) { + const data = await api(SETTINGS_ENDPOINTS.night, { method: 'PUT', body: nightSettingsBodyFromForm(form) }); + applySettingsSection('night', data); + refreshSettingsUi(); + if (notify) toast(tr('common.saved')); +} + +async function saveHomeAssistantSettings(form, notify = true) { + const data = await api(SETTINGS_ENDPOINTS.homeAssistant, { method: 'PUT', body: homeAssistantSettingsBodyFromForm(form) }); + applySettingsSection('homeAssistant', data); + app.sensorAliases = { ...(data.sensor_aliases || {}) }; + app.flowSharedInputs = JSON.parse(JSON.stringify(data.flow_inputs || [])); + refreshSettingsUi(); if (notify) toast(tr('common.saved')); - return app.settings; } $('#settingsForm').addEventListener('submit', async event => { event.preventDefault(); const form = event.currentTarget; - await runFormTask(form, () => saveRuntimeSettings(settingsBodyFromForm(form), true)); + await runFormTask(form, () => saveMainSettings(form, true)); }); $('#nightModeForm')?.addEventListener('submit', async event => { event.preventDefault(); const form = event.currentTarget; - await runFormTask(form, () => saveRuntimeSettings(nightSettingsBodyFromForm(form), true)); + await runFormTask(form, () => saveNightSettings(form, true)); }); $('#homeAssistantForm')?.addEventListener('submit', async event => { event.preventDefault(); const form = event.currentTarget; - await runFormTask(form, () => saveRuntimeSettings(homeAssistantSettingsBodyFromForm(form), true)); + await runFormTask(form, () => saveHomeAssistantSettings(form, true)); }); $('#addSensorAlias')?.addEventListener('click', () => { @@ -206,10 +265,11 @@ document.addEventListener('click', event => { $('#saveLogRetention')?.addEventListener('click', async () => { const days = Number($('#logRetentionDays')?.value || 30); try { - const result = await api('/api/events/retention', { method: 'PUT', body: { days } }); - app.settings.event_log_retention_days = result.days; - $('#settingsForm').event_log_retention_days.value = result.days; - renderLogRetention(); await loadLogs(); toast(tr('logs.retentionSaved', { days: result.days })); + const form = $('#settingsForm'); + const result = await api(SETTINGS_ENDPOINTS.history, { method: 'PUT', body: historySettingsBodyFromForm(form, days) }); + applySettingsSection('history', result); + form.event_log_retention_days.value = result.event_retention_days; + renderLogRetention(); await loadLogs(); toast(tr('logs.retentionSaved', { days: result.event_retention_days })); } catch (error) { toast(error.message, true); } }); @@ -246,7 +306,7 @@ $('#haTest').addEventListener('click', async event => { const idle = button.textContent; button.textContent = tr('settings.testingHa'); try { - await saveRuntimeSettings(homeAssistantSettingsBodyFromForm(form), false); + await saveHomeAssistantSettings(form, false); const entity = form.ha_entity_id.value || form.ha_outdoor_entity_id.value || null; const result = await api('/api/integrations/home-assistant/test', { method: 'POST', body: { entity_id: entity } }); markFormClean(form); @@ -257,11 +317,11 @@ $('#haTest').addEventListener('click', async event => { $('#exportSettings').addEventListener('click', async () => { try { - const data = await api('/api/settings/export'); + const data = await api('/api/configuration/export'); const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); - link.download = `gree-controller-settings-${new Date().toISOString().slice(0, 10)}.json`; + link.download = `gree-controller-configuration-${new Date().toISOString().slice(0, 10)}.json`; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(link.href); toast(tr('toast.exported')); } catch (error) { toast(error.message, true); } @@ -273,7 +333,7 @@ $('#importSettingsFile').addEventListener('change', async event => { try { if (!confirm(tr('settings.importConfirm'))) return; const body = JSON.parse(await file.text()); - await api('/api/settings/import', { method: 'POST', body }); + await api('/api/configuration/import', { method: 'POST', body }); app.debugBacklogLoaded = false; await loadBootstrap(); toast(tr('toast.imported')); diff --git a/web/styles.css b/web/styles.css index 7e0da6d..346d069 100644 --- a/web/styles.css +++ b/web/styles.css @@ -6731,7 +6731,7 @@ textarea:focus { border-color:var(--accent); outline:2px solid color-mix(in srgb .flow-generated-card .card-footer > small { color:var(--accent); } -/* 0.11.6 production layout guards and in-app chart preview. */ +/* 0.12.0 production layout guards and in-app chart preview. */ html,body { max-width:100%; overflow-x:clip; } main,.view { min-width:0; max-width:100%; } .flow-info-panel,.flow-info-panel > div { min-width:0; } @@ -7113,7 +7113,7 @@ body.chart-fullscreen-open::before { content:""; position:fixed; inset:0; z-inde } -/* 0.11.6 diagnostics, manual-control safety and technical connection checks. */ +/* 0.12.0 diagnostics, manual-control safety and technical connection checks. */ .manual-device-problem { display: grid; gap: 4px;