From 615b2836b77b49f2ace2d90e7aa68eb5c8949ce0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Fri, 28 Aug 2026 12:56:36 +0200 Subject: [PATCH] v0.8.6 --- BUILD_REPORT.md | 53 +- Cargo.lock | 2 +- Cargo.toml | 2 +- FILE_MANIFEST.sha256 | 22 +- README.md | 14 +- .../gree_controller/manifest.json | 2 +- lang/en.json | 8 +- lang/pl.json | 8 +- src/api.rs | 532 ++++++++--- src/engine.rs | 840 +++++++++++++++--- src/models.rs | 54 +- web/app.js | 73 +- 12 files changed, 1279 insertions(+), 331 deletions(-) diff --git a/BUILD_REPORT.md b/BUILD_REPORT.md index ca9bc54..d368406 100644 --- a/BUILD_REPORT.md +++ b/BUILD_REPORT.md @@ -1,29 +1,40 @@ -# GREE Controller v0.8.5 - build and validation report +# GREE Controller v0.8.6 - audit remediation report -## 0.8.5 Temporary temperature-condition activation fix +Source baseline: GREE Controller v0.8.5 audited on 2026-08-28. -Source baseline: v0.8.4. +## Implemented remediation -### Fix +This build addresses the ownership, safety, lifecycle and scheduling findings from the v0.8.5 control-logic audit, including the critical K1-K9 findings and high-priority H1-H14 findings. -- Corrected Temporary Quick Thermostat temperature-condition activation detection. -- `activated_at` now correctly marks a session as active for `temperature_reached` and `temperature_stable` evaluation. -- Existing `local_thermostat_power == true` remains a compatibility/recovery marker for sessions persisted by earlier releases. -- Future delayed sessions still cannot evaluate or complete their temperature condition before `started_at`. -- The change directly addresses the v0.8.4 updater failures: - - `temporary_stable_condition_requires_continuous_hold_time` - - `temporary_stable_condition_resets_when_temperature_leaves_range` +Main changes: -### Validation executed in this environment +- separated ordinary Quick Thermostat handback state from Temporary Quick Thermostat lifecycle; +- made `activated_at` authoritative for fresh Temporary sessions, with legacy fallback only for generation 0 data; +- added explicit Temporary lifecycle states and preserved/restored the underlying Quick/manual state; +- delayed capture of `restore_zone_enabled` until actual takeover for delayed/at sessions; +- manual/direct device takeover now outranks Temporary completion and pauses condition/runtime accounting; +- active Temporary sessions freeze their active mode/target and defer house/group mode/preset changes until handback; +- whole-house/group ON no longer sends a bare `power=true` when there is no valid effective Heat/Cool decision; +- generic Settings cannot mutate house master/mode outside the House transition APIs; +- configuration export/import strips ephemeral runtime ownership, safely stops detached devices, reconciles gates and repolls devices; +- direct climate automations for thermostat-managed devices now modify durable zone state instead of competing one-shot frames; +- schedule boundaries and time automations participate in the common next-deadline wakeup; +- continuous temperature hold requires fresh observations and is reset across controller restarts/manual takeover; +- disabling a device or zone now follows the safe shutdown/cleanup path; +- active delayed/at Temporary sessions can be edited without accidentally rescheduling their historical start; +- Temporary schedule-boundary deadlines are refreshed after schedule edits; +- conflicting same-cycle automations are resolved deterministically, and blocked automations do not consume cooldown; +- UI exposes scheduled/waiting-master/paused-manual/active Temporary states and uses the same fresh-session activation rule as the backend. -- JavaScript syntax checks for `web/app.js`, `web/theme-init.js`, `web/sw.js` -- JSON parsing for project JSON files -- Python syntax compilation for Python sources -- shell syntax checks for `scripts/*.sh` -- version consistency checks -- regenerated and verified `FILE_MANIFEST.sha256` -- final ZIP integrity check +## Validation executed in this environment -### Rust toolchain note +- JavaScript syntax checks for web application files; +- JSON parsing for language and Home Assistant manifest files; +- Python syntax compilation for Home Assistant integration files; +- shell syntax checks for `scripts/*.sh`; +- regenerated and verified `FILE_MANIFEST.sha256`; +- final ZIP integrity check. -This environment does not contain `cargo`, `rustc` or `rustfmt`, so Rust tests cannot be executed here. The supplied deployment log confirms v0.8.4 compiled and 46/48 tests passed; this hotfix changes the activation predicate responsible for the two failures. `scripts/update.sh` will run the full Rust test suite before replacing the service. +## Rust toolchain note + +This environment does not contain `cargo`, `rustc`, `rustfmt` or `rust-analyzer`, so the Rust build and Rust test suite could not be executed here. The Rust changes were additionally checked for balanced delimiters and consistency of the modified model initializers, but a real `cargo test` remains required before production deployment. diff --git a/Cargo.lock b/Cargo.lock index 7dc7a2d..bec7134 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.8.5" +version = "0.8.6" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index 8450463..4239fd8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.8.5" +version = "0.8.6" 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 949b1e3..bb65ae4 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,10 +1,10 @@ d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -04ab15fbace991a367ab08543d17cd7c8f3aed06465e905f179ac6ca47b58359 ./BUILD_REPORT.md -bd7d9b714ad9096d7ecde66df3b97ae9a73c4caddcbbbd338e4fd3af5c09dd03 ./Cargo.lock -310dd2cd0f8be29268d31c4b969ef01e956265614df1c899f3291ed639452bc7 ./Cargo.toml +1db386d955fe4d4322738d467a3c3c812e762e56297a2a08c17612d063555b95 ./BUILD_REPORT.md +68101e8986367a641aa21fcb24d0c8c80dee1161636c7f4c833f0e918a6f3951 ./Cargo.lock +3ce722a5e1989ba030288191913fa79ae62faf5353312a37dda8d12a00300343 ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -ff399e8c70e10e8622ad08beba55c253fa5eb9627719aa2023147a91139b74b4 ./README.md +dc05ed0028f6d276ba07acdf2fc8598fd0862eb4fbc433ad6fd14a1d15338f74 ./README.md 91c826e2f8c974bdb7b15e01e9b20487f06ff887247ecd9600f1263c11a61b96 ./build.rs d458a3b1ed381c885d7c4b7e38f306f3e66838d27480551436f1e340995ea6e2 ./docs/API.md 234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md @@ -24,7 +24,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 -f3b837e006c0696928fa0140b42e0d69d44bbe4083460db71a62b4629f42ba84 ./home-assistant/custom_components/gree_controller/manifest.json +ebcbec00f0c26f2c47e0c1cb3f6fcd68711a509c6cb7e8e95650e08ad8dc1254 ./home-assistant/custom_components/gree_controller/manifest.json 38d659d0273c0d1428679e44574a2666c605d1b3796d323b626baa40560788cf ./home-assistant/custom_components/gree_controller/number.py 39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py 1cae12876eb81085910907cf1fcf340a18dcba4f8c967ef222c9496325057849 ./home-assistant/custom_components/gree_controller/sensor.py @@ -33,8 +33,8 @@ f3b837e006c0696928fa0140b42e0d69d44bbe4083460db71a62b4629f42ba84 ./home-assista 13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json 4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md -ca87682179c70391e85348482a9ad9acc7f4b0f86063b957da6dda03849a3993 ./lang/en.json -2d8c3a6fcebf62a6b551a109c05e8a91fe7a1fd099d24eb990d9dbc245b869df ./lang/pl.json +cf7819fb7572ed0b082c52f9cb5a9300abbc54ae499742e543d311de052386b1 ./lang/en.json +8a54ee55314fab8aaea867de4352dfac9c9b2022402d911c7804cf4b62a1a181 ./lang/pl.json 028e1f16e9fbaed57cadb88eff04e65b4bd67722c50b4d6b1fb525f5a2f39abf ./make_zip.py bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh @@ -47,15 +47,15 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh -e1153953e3217380ed4f4559e85236d3e26d6dd6168683a721f053252426c454 ./src/api.rs +842e5a237e9d443457968d5e570946cde8ea5aab62a36a45bff2b07679b70407 ./src/api.rs 9040e8cb6647c76a875148b7591abcccfe4e2d4708ef462a1e5d25dd4ccac911 ./src/config.rs 5dfda2f4dc540c502885b0cd7017dc77768684588acf528f01d1fd88f1af4aec ./src/db.rs -da12b76d1de2f6394829720805c0a82f88cbda05d7e32e55915d18a648b6685f ./src/engine.rs +66b264aeac7865b17faa60ca6b3673e97b31f3e6f43fb6dc048eb527f383da91 ./src/engine.rs 4b271b6fc365b1078c01d6178eb563841b2ecaed5d8639196f58e1312d2236fe ./src/error.rs c6ff66da9ad08506f839ec56a869ead3abc824b5311e0ea9244517d11f7f4207 ./src/home_assistant.rs 190b0a33431539676e5dd7796698077f16c179d42eae4501ca96a91bf797cbf8 ./src/influxdb.rs c67212da6a2bd5c2933ece31586ca4048b484fda3105f23a6e06e881b079d129 ./src/main.rs -8dd681a96aa4f962b7f7dde80bed262e228fa93806e94f635eee8c3840f6c8be ./src/models.rs +ae5405f581832e8a26ae6e751bf570d0c471ffc86f372b8f492ff2825f103d6b ./src/models.rs ea4bd7f20759101c2525600a1f342d2fc0946e2f0b3208b03cc5a415b6947c10 ./src/notifications.rs 7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs bc03d88e5476386747ff5e32f1788cdc1fcd4bef238ab66df2dada282c348c8f ./src/protocol/gree.rs @@ -63,7 +63,7 @@ a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol 6a1c0cab3eab80ecd254c5e486b1fba068523edb8d699c64054c17f67f4a31d0 ./src/queries.rs 2d69811db832c90ce06035ee29665205a04e4c514a21ecae9623991a9b14d825 ./src/state.rs b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service -18de577ee8a978ba691f3ce1ada5aa58f0229fc9b1e5015cbc5726c284bfa85d ./web/app.js +eaef425b5e587e266e315d62c4e178af8b9b9ce16327181216627672a924d621 ./web/app.js e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg ee64c273b334c2d0f9747408b6142a8d506021cbf2107178c2213ca4e4a17c8c ./web/index.html fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest diff --git a/README.md b/README.md index 9c745e8..5640e36 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Standalone local GREE air-conditioner controller written in Rust. It runs on a regular Linux host or an LXC container and provides a mobile-first web interface without depending on the vendor cloud. -Current version: **0.8.5**. +Current version: **0.8.6**. ## Highlights @@ -47,7 +47,7 @@ See [`BUILD_REPORT.md`](BUILD_REPORT.md) for package validation details and [`do On Debian, Ubuntu or an LXC container: ```bash -unzip gree-controller-v0.8.5.zip +unzip gree-controller-v0.8.6.zip cd gree-controller chmod +x scripts/*.sh ./scripts/dev.sh @@ -480,6 +480,16 @@ Zones now use setpoint modulation, global seasonal house mode, cooling/heating C +## 0.8.6 Control-logic audit remediation + +- Separates ordinary Quick Thermostat handback state from Temporary Quick Thermostat lifecycle and makes fresh-session activation depend on `activated_at`. +- Hardens ownership priority so direct/manual takeover outranks temporary completion and pauses temporary hold/runtime accounting. +- Freezes active Temporary mode/target while deferring house/group climate changes until handback. +- Prevents unmanaged whole-house/group power-on, closes generic Settings/import bypasses, and safely stops devices before disable/detach. +- Makes thermostat-managed device automations durable zone-state changes instead of competing one-shot device frames. +- Adds schedule/time-automation deadlines to the common wakeup path and refreshes temporary schedule-boundary deadlines after schedule edits. +- Aligns Web UI/API lifecycle handling for active delayed/at sessions and exposes scheduled/waiting-master/paused-manual/active states. + ## 0.8.5 Temporary temperature-condition activation fix - Fixed `temperature_stable` / `temperature_reached` evaluation for sessions already marked active through `activated_at`. diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index cf96c0a..c66997f 100644 --- a/home-assistant/custom_components/gree_controller/manifest.json +++ b/home-assistant/custom_components/gree_controller/manifest.json @@ -1,7 +1,7 @@ { "domain": "gree_controller", "name": "GREE Controller", - "version": "0.8.5", + "version": "0.8.6", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/lang/en.json b/lang/en.json index 1954571..0d6aa2e 100644 --- a/lang/en.json +++ b/lang/en.json @@ -544,10 +544,10 @@ "house.noControl": "Do not control", "house.powerAllEyebrow": "Global power", "house.powerAllTitle": "All air conditioners", - "house.powerAllHint": "Master power for thermostat zones. “Turn all off” also turns off every group and unit. “Turn all on” starts enabled zones only; disabled zones remain disabled. Choosing whole-house Cooling/Heating or a profile automatically enables master power.", + "house.powerAllHint": "Whole-house automation control. “Turn all off” performs a one-shot OFF for groups and units and stops automation; later remote/Devices control is still allowed. “Turn all on” resumes automation but never starts a unit without an effective Heating/Cooling mode.", "house.powerOnAll": "Turn all on", "house.powerOffAll": "Turn all off", - "house.powerOnDone": "All enabled zones turned on", + "house.powerOnDone": "Whole-house automation resumed", "house.powerOffDone": "All air conditioners turned off", "house.powerPartial": "Could not change power on {count} devices", "preset.manual": "Manual", @@ -567,7 +567,7 @@ "groups.followHouse": "Global mode", "groups.mode": "Mode", "groups.profile": "Profile", - "groups.masterOff": "Global power is off. Pressing ON for this group resumes the master and starts eligible units in this group.", + "groups.masterOff": "Whole-house automation is off. Pressing ON for this group resumes automation; units start only when an effective Heating/Cooling mode exists.", "groups.memberCount": "{count} zones", "groups.emptyTitle": "No groups yet", "groups.emptyText": "Create a group to control selected air conditioners together.", @@ -668,6 +668,8 @@ "zones.temporaryTimeStatus": "Until automatic return to automation", "zones.temporaryScheduledStatus": "Scheduled start: {time}. Normal automation continues unchanged until then.", "zones.temporaryStartingStatus": "Starting Quick Thermostat…", + "zones.temporaryWaitingMaster": "The session is waiting for MASTER to be enabled and is not consuming active runtime.", + "zones.temporaryPausedManual": "The session is paused by manual/remote control. Active runtime and condition hold time are not advancing.", "zones.temporaryStopped": "Temporary mode finished — automation resumed control", "zones.temporaryStarted": "Temporary Quick Thermostat started", "zones.temporaryScheduledToast": "Temporary Quick Thermostat has been scheduled", diff --git a/lang/pl.json b/lang/pl.json index 6cd0093..ecbfc37 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -544,10 +544,10 @@ "house.noControl": "Nie steruj", "house.powerAllEyebrow": "Zasilanie globalne", "house.powerAllTitle": "Wszystkie klimatyzatory", - "house.powerAllHint": "Nadrzędne zasilanie stref. „Wyłącz wszystkie” wyłącza też wszystkie grupy i jednostki. „Włącz wszystkie” uruchamia tylko włączone strefy — strefy wyłączone pozostają wyłączone. Globalny wybór Chłodzenia/Grzania albo profilu automatycznie włącza zasilanie globalne.", + "house.powerAllHint": "Sterowanie automatyki domu. „Wyłącz wszystkie” jednorazowo wyłącza grupy i jednostki oraz zatrzymuje automatykę; późniejsze sterowanie pilotem/Devices jest nadal dozwolone. „Włącz wszystkie” wznawia automatykę, ale nie uruchamia jednostek bez efektywnego trybu Grzanie/Chłodzenie.", "house.powerOnAll": "Włącz wszystkie", "house.powerOffAll": "Wyłącz wszystkie", - "house.powerOnDone": "Włączono wszystkie aktywne strefy", + "house.powerOnDone": "Wznowiono automatykę domu", "house.powerOffDone": "Wyłączono wszystkie klimatyzatory", "house.powerPartial": "Nie udało się zmienić zasilania {count} urządzeń", "preset.manual": "Ręcznie", @@ -567,7 +567,7 @@ "groups.followHouse": "Tryb globalny", "groups.mode": "Tryb", "groups.profile": "Profil", - "groups.masterOff": "Globalne zasilanie jest wyłączone. Naciśnięcie WŁ. dla tej grupy wznowi master i uruchomi dozwolone jednostki tej grupy.", + "groups.masterOff": "Automatyka domu jest wyłączona. Naciśnięcie WŁ. dla tej grupy wznowi automatykę; jednostki ruszą tylko przy prawidłowym trybie Grzanie/Chłodzenie.", "groups.memberCount": "Strefy: {count}", "groups.emptyTitle": "Brak grup", "groups.emptyText": "Utwórz grupę, aby wspólnie sterować wybranymi klimatyzatorami.", @@ -668,6 +668,8 @@ "zones.temporaryTimeStatus": "Do automatycznego powrotu do automatyki", "zones.temporaryScheduledStatus": "Zaplanowany start: {time}. Do tego czasu normalna automatyka działa bez zmian.", "zones.temporaryStartingStatus": "Uruchamianie szybkiego termostatu…", + "zones.temporaryWaitingMaster": "Sesja czeka na włączenie MASTER — nie zużywa czasu aktywnej pracy.", + "zones.temporaryPausedManual": "Sesja wstrzymana przez sterowanie ręczne/pilot. Czas aktywnej pracy i licznik warunku nie biegną.", "zones.temporaryStopped": "Tryb czasowy zakończony — automatyka przejęła sterowanie", "zones.temporaryStarted": "Tryb czasowy szybkiego termostatu uruchomiony", "zones.temporaryScheduledToast": "Tryb czasowy szybkiego termostatu został zaplanowany", diff --git a/src/api.rs b/src/api.rs index 1319dfa..be22b6b 100644 --- a/src/api.rs +++ b/src/api.rs @@ -363,6 +363,9 @@ async fn get_device(State(state): State, Path(id): Path) -> Re } async fn patch_device(State(state): State, Path(id): Path, Json(patch): Json) -> Result, AppError> { + if patch.enabled == Some(false) { + engine::disable_device_safely(&state, &id).await?; + } let _device_guard = state.lock_device_operation(&id).await; let mut device = state.db.get_device(&id)?.ok_or_else(|| AppError::NotFound(format!("device {id}")))?; if let Some(v) = patch.name { if !v.trim().is_empty() { device.name = v.trim().to_string(); } } @@ -540,7 +543,7 @@ impl ZoneInput { sensor_source: self.sensor_source, ha_entity_id: self.ha_entity_id.filter(|v| !v.trim().is_empty()), external_sensor_weight: self.external_sensor_weight, max_sensor_difference: self.max_sensor_difference, sensor_stale_after_seconds: self.sensor_stale_after_seconds, device_temperature: None, external_temperature: None, current_temperature: None, control_temperature_source: "device".into(), - active_preset: "comfort".into(), manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None, local_thermostat_resume_at: None, temporary_quick_thermostat: None, + active_preset: "comfort".into(), manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None, local_thermostat_resume_at: None, local_thermostat_restore_zone_enabled: None, temporary_quick_thermostat: None, device_manual_override: false, device_manual_override_since: None, device_manual_override_until: None, device_manual_override_fields: Vec::new(), device_manual_override_baseline: None, revision: 1, control_owner: "automation".into(), control_source: "automation".into(), control_since: Some(Utc::now()), control_resume_at: None, control_reason: "zone created".into(), last_power_change_at: None, last_mode_change_at: None, lockout_until: None, lockout_reason: None, @@ -611,6 +614,7 @@ async fn update_zone(State(state): State, Path(id): Path, Json zone.manual_override_until = existing.manual_override_until; zone.local_thermostat_power = existing.local_thermostat_power; zone.local_thermostat_resume_at = existing.local_thermostat_resume_at; + zone.local_thermostat_restore_zone_enabled = existing.local_thermostat_restore_zone_enabled; zone.temporary_quick_thermostat = existing.temporary_quick_thermostat; zone.device_manual_override = existing.device_manual_override; zone.device_manual_override_since = existing.device_manual_override_since; @@ -643,6 +647,18 @@ async fn update_zone(State(state): State, Path(id): Path, Json let settings = state.settings.read().await.clone(); canonicalize_zone_ha_entity(&mut zone, &settings); let power_off_device = !device_changed && existing.enabled && !zone.enabled; + if power_off_device { + // Full configuration PUT and quick-control disable use the same ownership cleanup. + // No local/temporary/manual takeover survives a disabled thermostat zone (H6). + if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { + engine::finish_temporary_quick_thermostat(&mut zone, &state.db.list_schedules()?, &settings.house_mode); + } else { + zone.temporary_quick_thermostat = None; + } + engine::reset_local_thermostat_override(&mut zone); + engine::reset_device_manual_override(&mut zone); + zone.enabled = false; + } state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); drop(_device_guard); @@ -668,11 +684,15 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl let resume_device_takeover = patch.clear_device_manual_override.unwrap_or(false); let resume_local_thermostat = patch.clear_local_thermostat_override.unwrap_or(false); let stop_temporary_quick_thermostat = patch.clear_temporary_quick_thermostat.unwrap_or(false); - // Any thermostat action takes ownership back from a physical/pilot takeover. Local power - // is a thermostat state of its own and must never be recorded as device manual control. + if patch.temporary_quick_thermostat.is_some() && (patch.power.is_some() || stop_temporary_quick_thermostat || resume_local_thermostat) { + return Err(AppError::BadRequest("temporary thermostat cannot be combined with local power/clear operations in one request".into())); + } + // Direct/manual device takeover is higher priority than a temporary thermostat. Creating + // or editing a temporary session therefore never clears an active pilot/Devices takeover; + // the session waits/pauses instead. Other explicit thermostat actions still resume control. let resume_device_automation = resume_device_takeover || patch.power.is_some() || patch.setpoint.is_some() || patch.mode.is_some() - || patch.preset.is_some() || patch.enabled.is_some() || patch.temporary_quick_thermostat.is_some(); + || patch.preset.is_some() || patch.enabled.is_some(); if resume_device_automation || resume_local_thermostat || stop_temporary_quick_thermostat || patch.temporary_quick_thermostat.is_some() { zone.control_source = if source.contains("home_assistant") { "home_assistant_thermostat".into() } else { "web_thermostat".into() }; } @@ -681,8 +701,8 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl engine::reset_local_thermostat_override(&mut zone); } if stop_temporary_quick_thermostat && zone.temporary_quick_thermostat.is_some() { - if zone.local_thermostat_power == Some(true) { - engine::reset_local_thermostat_override(&mut zone); + if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { + engine::finish_temporary_quick_thermostat(&mut zone, &schedules, &state.settings.read().await.house_mode); } else { // Cancelling a delayed session before it starts must not erase unrelated // quick preset/setpoint state that automation may be using in the meantime. @@ -691,27 +711,45 @@ 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 start_kind = request.start_kind.as_str(); - if !matches!(start_kind, "now" | "delay" | "at") { + 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())); } - let started_at = match 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())); + + // 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) } - 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!(), + "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) }; + + if !editing_active && start_kind == "now" && !runtime.house_power_enabled { + return Err(AppError::BadRequest("temporary thermostat cannot start while whole-house automation is off; enable house power or schedule it for later".into())); + } + 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())); @@ -722,88 +760,171 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl 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 = request.tolerance_c.unwrap_or(0.3); - if !(0.1..=3.0).contains(&tolerance) { + let min_stable_tolerance = (zone.hysteresis.max(0.1) / 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 expires_at = match 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(started_at.clone() + ChronoDuration::minutes(minutes as i64)) - } - "until" => { - let until = request.until.clone().ok_or_else(|| AppError::BadRequest("temporary thermostat end time is required".into()))?; - if until <= started_at { return Err(AppError::BadRequest("temporary thermostat end time must be after its start time".into())); } - if until > started_at.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" => Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, started_at.with_timezone(&chrono::Local)) - .ok_or_else(|| AppError::BadRequest("this zone has no future schedule transition".into()))?), - _ => None, - }; - + 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_expires_at = if is_temperature_condition { + 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(started_at.clone() + ChronoDuration::minutes(minutes as i64)) + Ok(minutes.saturating_mul(60)) }).transpose()? } else { None }; - // Replacing/editing an active temporary session must keep the state that existed - // before the very first temporary takeover. Otherwise editing a session that was - // started from a disabled zone would incorrectly restore automation as enabled. - let restore_zone_enabled = zone.temporary_quick_thermostat.as_ref() - .and_then(|session| session.restore_zone_enabled) - .unwrap_or(zone.enabled); - let activate_now = start_kind == "now"; - if !activate_now && zone.temporary_quick_thermostat.is_some() { - if zone.local_thermostat_power == Some(true) { - engine::reset_local_thermostat_override(&mut zone); - } else { - zone.temporary_quick_thermostat = 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 immediate_activation = !editing_active && start_kind == "now" && runtime.house_power_enabled && !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(), + )); } } - if activate_now { - engine::set_local_thermostat_power(&mut zone, true, now.clone()); + let state_value = if editing_active { + if zone.device_manual_override { "paused_manual" } else { "active" } + } else if start_kind == "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.setpoint = target; + 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: start_kind.into(), + start_kind, finish_kind: finish_kind.into(), started_at, - activated_at: activate_now.then_some(now), - restore_zone_enabled: Some(restore_zone_enabled), - expires_at, - // Keep the requested thermostat target even for delayed time-based sessions; - // it is applied only when ownership actually starts. + activated_at: if immediate_activation { Some(now.clone()) } else { activated_at.clone() }, + state: state_value.into(), + generation: existing_session.as_ref().map(|session| session.generation.saturating_add(1)).unwrap_or(1), + 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, - safety_expires_at, + condition_last_observed_at: None, + paused_at: if zone.device_manual_override && (editing_active || start_kind == "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()), + 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 }, }); } if let Some(power) = patch.power { - zone.temporary_quick_thermostat = None; + // The neighbouring quick-power control and the explicit Stop button must use the + // same temporary-session cleanup/restore semantics before local ownership changes. + if zone.temporary_quick_thermostat.is_some() { + if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { + engine::finish_temporary_quick_thermostat(&mut zone, &schedules, &state.settings.read().await.house_mode); + } else { + zone.temporary_quick_thermostat = None; + } + } engine::set_local_thermostat_power(&mut zone, power, Utc::now()); if power { zone.enabled = true; } // A manually started local thermostat keeps an already selected target/profile until @@ -821,9 +942,12 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl zone.effective_setpoint = Some(value); if zone.local_thermostat_power == Some(true) { if let Some(session) = zone.temporary_quick_thermostat.as_mut() { + // +/- always edits the live temporary target, including duration/until + // sessions, so the modal and regulator cannot diverge (M14). + session.temperature_target = Some(value); if matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable") { - session.temperature_target = Some(value); session.condition_started_at = None; + session.condition_last_observed_at = None; } } } @@ -834,42 +958,72 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl }; } if let Some(value) = patch.mode.as_deref() { - match value { - "house" | "auto" => zone.inherit_house_mode = true, - "cool" | "heat" => { - zone.inherit_house_mode = false; - zone.mode = value.to_string(); + if !matches!(value, "house" | "auto" | "cool" | "heat") { + return Err(AppError::BadRequest("zone mode must be house, cool or heat".into())); + } + if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { + if let Some(session) = zone.temporary_quick_thermostat.as_mut() { + session.deferred_mode = Some(value.to_string()); + } + } else { + match value { + "house" | "auto" => zone.inherit_house_mode = true, + "cool" | "heat" => { + zone.inherit_house_mode = false; + zone.mode = value.to_string(); + } + _ => unreachable!(), } - _ => return Err(AppError::BadRequest("zone mode must be house, cool or heat".into())), } } if let Some(value) = patch.preset.as_deref() { - match value { - "auto" => { - zone.manual_preset = None; - zone.manual_setpoint = None; - zone.manual_override_until = None; + if !matches!(value, "auto" | "comfort" | "sleep" | "away" | "custom") { + return Err(AppError::BadRequest("unsupported zone preset".into())); + } + if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { + if let Some(session) = zone.temporary_quick_thermostat.as_mut() { + session.deferred_preset = Some(value.to_string()); } - "comfort" | "sleep" | "away" | "custom" => { - zone.manual_preset = Some(value.to_string()); - zone.manual_setpoint = None; - zone.manual_override_until = if zone.local_thermostat_power == Some(true) { - None - } else { - engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()) - }; + } else { + match value { + "auto" => { + zone.manual_preset = None; + zone.manual_setpoint = None; + zone.manual_override_until = None; + } + "comfort" | "sleep" | "away" | "custom" => { + zone.manual_preset = Some(value.to_string()); + zone.manual_setpoint = None; + zone.manual_override_until = if zone.local_thermostat_power == Some(true) { + None + } else { + engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()) + }; + } + _ => unreachable!(), } - _ => return Err(AppError::BadRequest("unsupported zone preset".into())), } } if patch.clear_override.unwrap_or(false) { - zone.manual_preset = None; - zone.manual_setpoint = None; - zone.manual_override_until = None; + if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { + if let Some(session) = zone.temporary_quick_thermostat.as_mut() { + session.deferred_preset = Some("auto".into()); + } + } else { + zone.manual_preset = None; + zone.manual_setpoint = None; + zone.manual_override_until = None; + } } if let Some(value) = patch.enabled { if !value { + if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { + engine::finish_temporary_quick_thermostat(&mut zone, &schedules, &state.settings.read().await.house_mode); + } else { + zone.temporary_quick_thermostat = None; + } engine::reset_local_thermostat_override(&mut zone); + engine::reset_device_manual_override(&mut zone); zone.enabled = false; } else { zone.enabled = true; @@ -882,17 +1036,6 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl && state.db.list_groups()?.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id)); engine::refresh_control_ownership(&mut zone, runtime.house_power_enabled, blocked_by_group); engine::refresh_zone_runtime_target(&mut zone, &schedules, &house_mode); - if (patch.preset.is_some() || patch.clear_override.unwrap_or(false)) - && zone.local_thermostat_power == Some(true) - && zone.temporary_quick_thermostat.is_some() { - let current_target = zone.effective_setpoint; - if let Some(session) = zone.temporary_quick_thermostat.as_mut() { - if matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable") { - session.temperature_target = current_target; - session.condition_started_at = None; - } - } - } zone.revision = zone.revision.saturating_add(1); zone.updated_at = Utc::now(); state.db.save_zone(&zone)?; @@ -1016,6 +1159,7 @@ async fn create_group(State(state): State, Json(input): Json, Path(id): Path, Jso }; state.db.save_group(&group)?; state.broadcast("group.updated", serde_json::to_value(&group)?); + state.wake_zone_control(); Ok(Json(group)) } @@ -1041,6 +1186,7 @@ async fn delete_group(State(state): State, Path(id): Path) -> } if !state.db.delete_group(&id)? { return Err(AppError::NotFound(format!("group {id}"))); } state.broadcast("group.deleted", json!({"id": id})); + state.wake_zone_control(); Ok(StatusCode::NO_CONTENT) } @@ -1224,7 +1370,13 @@ fn clear_all_local_thermostat_overrides(state: &AppState) -> Result, Json(input): Json, Json(input): Json, Json(input): Json, Json(input): Json = Vec::new(); state.wake_zone_control(); let devices = state.db.list_devices()?; state.log("info", "house.preset", &format!("House preset set to {}", input.preset), json!({ @@ -1520,17 +1679,30 @@ fn validate_schedule_conflicts(state: &AppState, item: &Schedule, exclude_id: Op fn refresh_zone_override_boundary(state: &AppState, zone_id: &str) -> Result<(), AppError> { let Some(mut zone) = state.db.get_zone(zone_id)? else { return Ok(()); }; - if zone.manual_preset.is_none() && zone.manual_setpoint.is_none() && !zone.device_manual_override { return Ok(()); } + let has_temporary_schedule_boundary = zone.temporary_quick_thermostat.as_ref() + .map(|session| session.finish_kind == "schedule_boundary") + .unwrap_or(false); + if zone.manual_preset.is_none() && zone.manual_setpoint.is_none() && !zone.device_manual_override && !has_temporary_schedule_boundary { return Ok(()); } let schedules = state.db.list_schedules()?; let boundary = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()); // An active Temporary Quick Thermostat explicitly owns its target until its own finish // rule. Editing/applying schedules must not arm the generic quick-setpoint boundary and // accidentally clear that target at the next schedule transition. - let temporary_owns_zone = zone.local_thermostat_power == Some(true) && zone.temporary_quick_thermostat.is_some(); + let temporary_owns_zone = engine::temporary_quick_thermostat_is_active(&zone, Utc::now()); if (zone.manual_preset.is_some() || zone.manual_setpoint.is_some()) && !temporary_owns_zone { zone.manual_override_until = boundary; } if zone.device_manual_override { zone.device_manual_override_until = boundary; zone.control_resume_at = boundary; } + if has_temporary_schedule_boundary { + let reference = zone.temporary_quick_thermostat.as_ref() + .filter(|session| session.activated_at.is_none()) + .map(|session| session.started_at.with_timezone(&chrono::Local)) + .unwrap_or_else(chrono::Local::now); + let refreshed = engine::next_schedule_boundary_utc(&zone.id, &schedules, reference).or(Some(Utc::now())); + if let Some(session) = zone.temporary_quick_thermostat.as_mut() { + session.expires_at = refreshed; + } + } zone.revision = zone.revision.saturating_add(1); zone.updated_at = Utc::now(); state.db.save_zone(&zone)?; @@ -1550,6 +1722,7 @@ async fn create_schedule(State(state): State, Json(input): Json, Path(id): Path, Json(input): Json) -> Result, AppError> { @@ -1563,6 +1736,7 @@ async fn update_schedule(State(state): State, Path(id): Path, refresh_zone_override_boundary(&state, &old_zone_id)?; if item.zone_id != old_zone_id { refresh_zone_override_boundary(&state, &item.zone_id)?; } state.broadcast("schedule.updated", serde_json::to_value(&item)?); + state.wake_zone_control(); Ok(Json(item)) } async fn delete_schedule(State(state): State, Path(id): Path) -> Result { @@ -1570,6 +1744,7 @@ async fn delete_schedule(State(state): State, Path(id): Path) if !state.db.delete_schedule(&id)? { return Err(AppError::NotFound(format!("schedule {id}"))); } refresh_zone_override_boundary(&state, &existing.zone_id)?; state.broadcast("schedule.deleted", json!({"id": id})); + state.wake_zone_control(); Ok(StatusCode::NO_CONTENT) } @@ -2040,6 +2215,11 @@ async fn get_settings(State(state): State) -> Json { async fn update_settings(State(state): State, Json(mut input): Json) -> Result, AppError> { let old = state.settings.read().await.clone(); + if input.house_power_enabled != old.house_power_enabled || input.house_mode != old.house_mode { + return Err(AppError::BadRequest( + "house_power_enabled and house_mode must be changed through the House Control API".into(), + )); + } 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); @@ -2138,7 +2318,11 @@ fn validate_night_mode(settings: &mut RuntimeSettings) -> Result<(), AppError> { async fn export_settings(State(state): State) -> Result, AppError> { let settings = state.settings.read().await.clone(); - Ok(Json(state.db.export_configuration(settings)?)) + 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> { @@ -2262,6 +2446,74 @@ fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), App Ok(()) } +fn sanitize_configuration_runtime(export: &mut ConfigurationExport) { + let now = Utc::now(); + 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.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); @@ -2271,9 +2523,57 @@ async fn import_settings(State(state): State, Json(mut export): Json = export.zones.iter() + .map(|zone| (zone.id.clone(), zone.device_id.clone())) + .collect(); + let mut detach_devices = std::collections::HashSet::new(); + for current in state.db.list_zones()? { + if imported_zone_map.get(¤t.id).map(String::as_str) != Some(current.device_id.as_str()) { + detach_devices.insert(current.device_id); + } + } + for device_id in detach_devices { + ensure_device_stopped_for_detach(&state, &device_id, "configuration.import").await?; + } + + // Configuration import never restores ephemeral owners/timers or cached physical state. + // Imported devices are reconciled from a fresh poll and current house/group/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 disabled_group_zones: std::collections::HashSet = export.groups.iter() + .filter(|group| !group.power_enabled) + .flat_map(|group| group.zone_ids.iter().cloned()) + .collect(); + 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() }; + export.settings.house_power_enabled + && zone.enabled + && effective_mode != "off" + && !disabled_group_zones.contains(&zone.id) + }) + .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(&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(&state).await?; + state.initial_device_sync_complete.store(true, Ordering::Release); + 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}))) diff --git a/src/engine.rs b/src/engine.rs index 19f24fc..d05614a 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -11,7 +11,26 @@ use crate::{ state::{AppState, PendingControllerCommand}, }; +fn reset_temporary_condition_observations_after_restart(state: &AppState) -> Result { + let mut changed = 0usize; + for mut zone in state.db.list_zones()? { + let Some(session) = zone.temporary_quick_thermostat.as_mut() else { continue; }; + if session.condition_started_at.is_none() && session.condition_last_observed_at.is_none() { continue; } + session.condition_started_at = None; + session.condition_last_observed_at = None; + zone.updated_at = Utc::now(); + state.db.save_zone(&zone)?; + changed += 1; + } + Ok(changed) +} + pub fn start(state: AppState) { + // A continuous temperature hold cannot span controller downtime. Preserve the session + // itself, but require fresh observations after every process restart (H11). + if let Err(err) = reset_temporary_condition_observations_after_restart(&state) { + tracing::warn!(error=?err, "cannot reset temporary thermostat observation continuity after restart"); + } let poll_state = state.clone(); tokio::spawn(async move { sleep(Duration::from_millis(500)).await; @@ -328,7 +347,7 @@ async fn poll_one_locked(state: &AppState, device_id: &str) -> Result Result<()> { +pub(crate) async fn poll_all(state: &AppState) -> Result<()> { let device_ids: Vec = state.db.list_devices()?.into_iter() .filter(|device| device.enabled) .map(|device| device.id) @@ -597,13 +616,20 @@ pub const LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES: i64 = 15; /// deadline in one backend-owned place. Every fresh OFF action receives a fresh /// deadline; ON cancels any pending hand-back. pub fn set_local_thermostat_power(zone: &mut Zone, power: bool, now: DateTime) -> bool { + let previous_restore = zone.local_thermostat_restore_zone_enabled; + // Ordinary Quick Thermostat has its own restore state. A temporary session never uses + // this field, so its lifecycle cannot be erased by the 15-minute local hand-back. + if power && zone.local_thermostat_power != Some(true) && zone.local_thermostat_restore_zone_enabled.is_none() { + zone.local_thermostat_restore_zone_enabled = Some(zone.enabled); + } let resume_at = if power { None } else { Some(now + chrono::Duration::minutes(LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES)) }; let changed = zone.local_thermostat_power != Some(power) - || zone.local_thermostat_resume_at != resume_at; + || zone.local_thermostat_resume_at != resume_at + || zone.local_thermostat_restore_zone_enabled != previous_restore; zone.local_thermostat_power = Some(power); zone.local_thermostat_resume_at = resume_at; changed @@ -621,17 +647,40 @@ fn local_thermostat_handback_is_active(zone: &Zone) -> bool { zone.local_thermostat_power == Some(false) && !zone.device_manual_override } +/// Clear only the ordinary local Quick Thermostat. Temporary Quick Thermostat state is +/// deliberately untouched; the two ownership mechanisms have independent cleanup paths. pub fn reset_local_thermostat_override(zone: &mut Zone) -> bool { - let restore_zone_enabled = zone.temporary_quick_thermostat.as_ref().and_then(|session| session.restore_zone_enabled); + let temporary_active = zone.temporary_quick_thermostat.as_ref() + .map(|session| session.activated_at.is_some() + || (session.generation == 0 && session.start_kind == "now" && zone.local_thermostat_power == Some(true))) + .unwrap_or(false); + if temporary_active { + let Some(session) = zone.temporary_quick_thermostat.as_mut() else { return false; }; + let changed = session.restore_local_thermostat_power.is_some() + || session.restore_local_thermostat_resume_at.is_some() + || session.restore_local_thermostat_zone_enabled.is_some() + || session.restore_manual_preset.is_some() + || session.restore_manual_setpoint.is_some() + || session.restore_manual_override_until.is_some(); + session.restore_local_thermostat_power = None; + session.restore_local_thermostat_resume_at = None; + session.restore_local_thermostat_zone_enabled = None; + session.restore_manual_preset = None; + session.restore_manual_setpoint = None; + session.restore_manual_override_until = None; + return changed; + } + + let restore_zone_enabled = zone.local_thermostat_restore_zone_enabled; let changed = zone.local_thermostat_power.is_some() || zone.local_thermostat_resume_at.is_some() - || zone.temporary_quick_thermostat.is_some() + || zone.local_thermostat_restore_zone_enabled.is_some() || zone.manual_preset.is_some() || zone.manual_setpoint.is_some() || zone.manual_override_until.is_some(); zone.local_thermostat_power = None; zone.local_thermostat_resume_at = None; - zone.temporary_quick_thermostat = None; + zone.local_thermostat_restore_zone_enabled = None; zone.manual_preset = None; zone.manual_setpoint = None; zone.manual_override_until = None; @@ -641,8 +690,20 @@ pub fn reset_local_thermostat_override(zone: &mut Zone) -> bool { changed } +pub fn temporary_quick_thermostat_is_active(zone: &Zone, now: DateTime) -> bool { + let Some(session) = zone.temporary_quick_thermostat.as_ref() else { return false; }; + if session.activated_at.as_ref().map(|at| at <= &now).unwrap_or(false) { return true; } + // Legacy recovery only for old immediate sessions persisted before activated_at existed. + // Fresh delay/at sessions must never inherit activity from an unrelated local Quick ON. + session.generation == 0 + && session.start_kind == "now" + && session.started_at <= now + && zone.local_thermostat_power == Some(true) +} + fn temporary_quick_thermostat_hard_deadline(session: &TemporaryQuickThermostat) -> Option> { - match (session.expires_at.clone(), session.safety_expires_at.clone()) { + if session.state == "paused_manual" { return None; } + match (session.expires_at, session.safety_expires_at) { (Some(a), Some(b)) => Some(a.min(b)), (Some(a), None) => Some(a), (None, Some(b)) => Some(b), @@ -653,7 +714,7 @@ fn temporary_quick_thermostat_hard_deadline(session: &TemporaryQuickThermostat) fn temporary_quick_thermostat_next_deadline(session: &TemporaryQuickThermostat) -> Option> { let hard = temporary_quick_thermostat_hard_deadline(session); let hold = if session.finish_kind == "temperature_stable" && session.hold_seconds > 0 { - session.condition_started_at.clone().map(|started| started + chrono::Duration::seconds(session.hold_seconds as i64)) + session.condition_started_at.map(|started| started + chrono::Duration::seconds(session.hold_seconds as i64)) } else { None }; @@ -667,29 +728,85 @@ fn temporary_quick_thermostat_next_deadline(session: &TemporaryQuickThermostat) fn temporary_quick_thermostat_wakeup_at(zone: &Zone, now: DateTime) -> Option> { let session = zone.temporary_quick_thermostat.as_ref()?; - let pending = session.activated_at.is_none() && zone.local_thermostat_power != Some(true) && session.started_at > now; - if pending { Some(session.started_at.clone()) } else { temporary_quick_thermostat_next_deadline(session) } + if temporary_quick_thermostat_is_active(zone, now) { + return temporary_quick_thermostat_next_deadline(session); + } + // Once a due session is waiting for master/manual ownership, normal wakeups or an + // explicit state-change notification will retry it. Returning a past start would spin. + (session.started_at > now).then_some(session.started_at) } -fn expire_temporary_quick_thermostats(state: &AppState, zones: &mut [Zone], schedules: &[Schedule], house_mode: &str) -> Result, AppError> { +/// Finish an active temporary session and apply climate changes that were deferred while +/// it owned the zone. Pending-session cancellation should simply remove the session instead. +pub fn finish_temporary_quick_thermostat(zone: &mut Zone, schedules: &[Schedule], house_mode: &str) -> bool { + let now = Utc::now(); + let was_active = temporary_quick_thermostat_is_active(zone, now); + let Some(session) = zone.temporary_quick_thermostat.take() else { return false; }; + if !was_active { return false; } + + zone.local_thermostat_power = session.restore_local_thermostat_power; + zone.local_thermostat_resume_at = session.restore_local_thermostat_resume_at; + zone.local_thermostat_restore_zone_enabled = session.restore_local_thermostat_zone_enabled; + zone.manual_preset = session.restore_manual_preset; + zone.manual_setpoint = session.restore_manual_setpoint; + zone.manual_override_until = session.restore_manual_override_until; + if let Some(enabled) = session.restore_zone_enabled { + zone.enabled = enabled; + } + + if let Some(mode) = session.deferred_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) = session.deferred_preset.as_deref() { + if preset == "auto" { + zone.manual_preset = None; + zone.manual_setpoint = None; + zone.manual_override_until = None; + } else if matches!(preset, "comfort" | "sleep" | "away") { + zone.manual_preset = Some(preset.to_string()); + zone.manual_override_until = next_schedule_boundary_utc(&zone.id, schedules, Local::now()); + } + } + refresh_zone_runtime_target(zone, schedules, house_mode); + true +} + +async fn expire_temporary_quick_thermostats(state: &AppState, zones: &mut [Zone], schedules: &[Schedule], house_mode: &str) -> Result, AppError> { let now = Utc::now(); let mut restored_disabled_zones = Vec::new(); for zone in zones.iter_mut() { - let Some(session) = zone.temporary_quick_thermostat.as_ref() else { continue; }; - let Some(deadline) = temporary_quick_thermostat_hard_deadline(session) else { continue; }; + let zone_id = zone.id.clone(); + let _zone_guard = state.lock_zone_operation(&zone_id).await; + let Some(latest) = state.db.get_zone(&zone_id)? else { continue; }; + *zone = latest; + let active_under_manual = zone.device_manual_override + && zone.temporary_quick_thermostat.as_ref().and_then(|session| session.activated_at).is_some(); + if active_under_manual { + set_temporary_wait_state(state, zone, "paused_manual", now)?; + continue; + } + let Some((deadline, finish_kind, restore_zone_enabled)) = zone.temporary_quick_thermostat.as_ref() + .and_then(|session| temporary_quick_thermostat_hard_deadline(session) + .map(|deadline| (deadline, session.finish_kind.clone(), session.restore_zone_enabled))) + else { continue; }; if deadline > now { continue; } - let finish_kind = session.finish_kind.clone(); - let was_activated = session.activated_at.is_some() || zone.local_thermostat_power == Some(true); - let restores_disabled = was_activated && session.restore_zone_enabled == Some(false); + let was_activated = temporary_quick_thermostat_is_active(zone, now); + let restores_disabled = was_activated && restore_zone_enabled == Some(false); if was_activated { - reset_local_thermostat_override(zone); - refresh_zone_runtime_target(zone, schedules, house_mode); + finish_temporary_quick_thermostat(zone, schedules, house_mode); } else { - // A delayed session that expired before it ever acquired ownership must not - // clear unrelated manual/schedule state that was active while it was waiting. + // A delayed session that expires before it acquires ownership must not clear + // unrelated local/manual/schedule state that was active while it was waiting. zone.temporary_quick_thermostat = None; } - zone.updated_at = now.clone(); + zone.updated_at = now; state.db.save_zone(zone)?; state.broadcast("zone.updated", serde_json::to_value(&*zone)?); state.log("info", "zone.temporary_quick_thermostat_finished", &format!("Temporary Quick Thermostat finished for {}", zone.name), json!({ @@ -701,31 +818,121 @@ fn expire_temporary_quick_thermostats(state: &AppState, zones: &mut [Zone], sche Ok(restored_disabled_zones) } -fn activate_due_temporary_quick_thermostats(state: &AppState, zones: &mut [Zone]) -> Result<(), AppError> { +fn set_temporary_wait_state(state: &AppState, zone: &mut Zone, value: &str, now: DateTime) -> Result<(), AppError> { + let Some(session) = zone.temporary_quick_thermostat.as_mut() else { return Ok(()); }; + let mut changed = false; + if session.state != value { + session.state = value.to_string(); + changed = true; + } + if value == "paused_manual" && session.paused_at.is_none() { + session.paused_at = Some(now); + changed = true; + } + if !changed { return Ok(()); } + session.condition_started_at = None; + session.condition_last_observed_at = None; + zone.updated_at = now; + state.db.save_zone(zone)?; + state.broadcast("zone.updated", serde_json::to_value(&*zone)?); + Ok(()) +} + +async fn activate_due_temporary_quick_thermostats( + state: &AppState, + zones: &mut [Zone], + schedules: &[Schedule], + house_mode: &str, + house_power_enabled: bool, +) -> Result<(), AppError> { let now = Utc::now(); for zone in zones.iter_mut() { - let Some(session) = zone.temporary_quick_thermostat.as_ref() else { continue; }; - let already_active = session.activated_at.is_some() || zone.local_thermostat_power == Some(true); - if already_active || session.started_at > now { continue; } - if temporary_quick_thermostat_hard_deadline(session).map(|deadline| deadline <= now).unwrap_or(false) { + let zone_id = zone.id.clone(); + let _zone_guard = state.lock_zone_operation(&zone_id).await; + let Some(latest) = state.db.get_zone(&zone_id)? else { continue; }; + *zone = latest; + let Some((session_state, started_at)) = zone.temporary_quick_thermostat.as_ref() + .map(|session| (session.state.clone(), session.started_at)) + else { continue; }; + if temporary_quick_thermostat_is_active(zone, now) { + if session_state != "active" && !zone.device_manual_override { + set_temporary_wait_state(state, zone, "active", now)?; + } + 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; } - let target = session.temperature_target + let (temperature_target, duration_seconds, safety_duration_seconds, finish_kind) = { + let session = zone.temporary_quick_thermostat.as_ref().expect("temporary session checked above"); + (session.temperature_target, session.duration_seconds, session.safety_duration_seconds, session.finish_kind.clone()) + }; + let target = temperature_target .or(zone.manual_setpoint) .or(zone.effective_setpoint) .unwrap_or(zone.setpoint); - set_local_thermostat_power(zone, true, now.clone()); + let restore_enabled = zone.enabled; + let restore_local_power = zone.local_thermostat_power; + let restore_local_resume_at = zone.local_thermostat_resume_at; + let restore_local_zone_enabled = zone.local_thermostat_restore_zone_enabled; + let restore_manual_preset = zone.manual_preset.clone(); + let restore_manual_setpoint = zone.manual_setpoint; + let restore_manual_override_until = zone.manual_override_until; + let configured_mode = if zone.inherit_house_mode { house_mode } else { zone.mode.as_str() }; + let active_mode = if configured_mode == "off" { zone.mode.clone() } else { configured_mode.to_string() }; + let schedule_boundary = if finish_kind == "schedule_boundary" { + next_schedule_boundary_utc(&zone.id, schedules, Local::now()) + } else { None }; + if finish_kind == "schedule_boundary" && schedule_boundary.is_none() { + zone.temporary_quick_thermostat = None; + zone.updated_at = now; + state.db.save_zone(zone)?; + state.broadcast("zone.updated", serde_json::to_value(&*zone)?); + state.log("warn", "zone.temporary_quick_thermostat_cancelled", &format!("Temporary Quick Thermostat cancelled for {} because no future schedule boundary exists", zone.name), json!({ + "zone_id": zone.id, "device_id": zone.device_id + })); + continue; + } + + // Temporary ownership is independent from the ordinary local hand-back state. + zone.local_thermostat_power = Some(true); + zone.local_thermostat_resume_at = None; + zone.local_thermostat_restore_zone_enabled = None; zone.enabled = true; - zone.setpoint = target; zone.manual_setpoint = Some(target); zone.effective_setpoint = Some(target); zone.manual_override_until = None; if let Some(session) = zone.temporary_quick_thermostat.as_mut() { - session.activated_at = Some(now.clone()); + session.activated_at = Some(now); + session.state = "active".into(); + session.active_mode = Some(active_mode); + session.restore_zone_enabled = Some(restore_enabled); + session.restore_local_thermostat_power = restore_local_power; + session.restore_local_thermostat_resume_at = restore_local_resume_at; + session.restore_local_thermostat_zone_enabled = restore_local_zone_enabled; + session.restore_manual_preset = restore_manual_preset; + session.restore_manual_setpoint = restore_manual_setpoint; + session.restore_manual_override_until = restore_manual_override_until; session.condition_started_at = None; + session.condition_last_observed_at = None; + session.paused_at = None; + if session.finish_kind == "duration" { + session.expires_at = duration_seconds.map(|seconds| now + chrono::Duration::seconds(seconds as i64)); + } else if session.finish_kind == "schedule_boundary" { + session.expires_at = schedule_boundary; + } + if matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable") { + session.safety_expires_at = safety_duration_seconds.map(|seconds| now + chrono::Duration::seconds(seconds as i64)); + } } - zone.updated_at = now.clone(); + zone.updated_at = now; state.db.save_zone(zone)?; state.broadcast("zone.updated", serde_json::to_value(&*zone)?); state.log("info", "zone.temporary_quick_thermostat_started", &format!("Temporary Quick Thermostat started for {}", zone.name), json!({ @@ -736,10 +943,13 @@ fn activate_due_temporary_quick_thermostats(state: &AppState, zones: &mut [Zone] } async fn ensure_device_off_after_temporary_disabled_restore(state: &AppState, zone: &Zone, device: &Device) { - if zone.enabled || !device.enabled || !device.online || device.communication_failures > 0 || !device.power { return; } + if zone.enabled || zone.device_manual_override || !device.enabled || !device.online || device.communication_failures > 0 || !device.power { return; } let _device_guard = state.lock_device_operation(&zone.device_id).await; let should_stop = state.db.get_zone(&zone.id).ok().flatten() - .map(|latest| !latest.enabled && latest.temporary_quick_thermostat.is_none() && latest.local_thermostat_power.is_none()) + .map(|latest| !latest.enabled + && !latest.device_manual_override + && latest.temporary_quick_thermostat.is_none() + && latest.local_thermostat_power.is_none()) .unwrap_or(false); if !should_stop { return; } if let Err(err) = send_command_locked(state, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }).await { @@ -759,24 +969,38 @@ fn temporary_temperature_condition_met(zone: &Zone, session: &TemporaryQuickTher } } -/// Update a temperature-based temporary session from the freshly selected room sensor. -/// Returns a completion reason when ownership should be handed back immediately. -fn evaluate_temporary_quick_thermostat_condition(zone: &mut Zone, now: DateTime) -> Option { - // `activated_at` is the primary marker that a scheduled Temporary Quick Thermostat - // actually started. `local_thermostat_power == Some(true)` is retained as a legacy/ - // recovery marker for sessions persisted before `activated_at` existed. A future - // scheduled session must never start or finish a temperature condition early. - let session_active = zone.temporary_quick_thermostat.as_ref().map(|session| { - session.activated_at.as_ref().map(|at| at <= &now).unwrap_or(false) - || (zone.local_thermostat_power == Some(true) && session.started_at <= now) - }).unwrap_or(false); - if !session_active { return None; } +/// Update a temperature-based temporary session only from a fresh sensor observation. +/// Cached samples and long controller gaps cannot count as continuous hold time. +fn evaluate_temporary_quick_thermostat_condition( + zone: &mut Zone, + now: DateTime, + sample_at: Option>, + max_gap_seconds: u64, +) -> Option { + if !temporary_quick_thermostat_is_active(zone, now) || zone.device_manual_override { return None; } + let is_condition = zone.temporary_quick_thermostat.as_ref() + .map(|session| matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable")) + .unwrap_or(false); + if !is_condition { return None; } + let Some(sample_at) = sample_at else { + if let Some(session) = zone.temporary_quick_thermostat.as_mut() { + session.condition_started_at = None; + session.condition_last_observed_at = None; + } + return None; + }; + let last_observed = zone.temporary_quick_thermostat.as_ref().and_then(|session| session.condition_last_observed_at); + if last_observed.map(|last| sample_at <= last).unwrap_or(false) { return None; } + let gap_broken = last_observed + .map(|last| sample_at.signed_duration_since(last).num_seconds() > max_gap_seconds.max(1) as i64) + .unwrap_or(false); let met = zone.temporary_quick_thermostat.as_ref() - .filter(|session| matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable")) .map(|session| temporary_temperature_condition_met(zone, session))?; let session = zone.temporary_quick_thermostat.as_mut()?; + session.condition_last_observed_at = Some(sample_at); + if gap_broken { session.condition_started_at = None; } match session.finish_kind.as_str() { "temperature_reached" => { if met { return Some("temperature_reached".into()); } @@ -787,8 +1011,8 @@ fn evaluate_temporary_quick_thermostat_condition(zone: &mut Zone, now: DateTime< session.condition_started_at = None; return None; } - let started = session.condition_started_at.get_or_insert(now.clone()).clone(); - if session.hold_seconds == 0 || now.signed_duration_since(started).num_seconds() >= session.hold_seconds as i64 { + let started = session.condition_started_at.get_or_insert(sample_at); + if session.hold_seconds == 0 || sample_at.signed_duration_since(*started).num_seconds() >= session.hold_seconds as i64 { return Some("temperature_stable".into()); } } @@ -797,9 +1021,13 @@ fn evaluate_temporary_quick_thermostat_condition(zone: &mut Zone, now: DateTime< None } -fn expire_local_thermostat_overrides(state: &AppState, zones: &mut [Zone], schedules: &[Schedule], house_mode: &str) -> Result<(), AppError> { +async fn expire_local_thermostat_overrides(state: &AppState, zones: &mut [Zone], schedules: &[Schedule], house_mode: &str) -> Result<(), AppError> { let now = Utc::now(); for zone in zones.iter_mut() { + let zone_id = zone.id.clone(); + let _zone_guard = state.lock_zone_operation(&zone_id).await; + let Some(latest) = state.db.get_zone(&zone_id)? else { continue; }; + *zone = latest; // A direct device/pilot takeover has higher priority than the local-OFF hand-back. // Do not let the old timer expire underneath someone who is actively controlling // the unit. When that takeover ends and the device returns to OFF, the deadline is @@ -831,19 +1059,40 @@ fn expire_local_thermostat_overrides(state: &AppState, zones: &mut [Zone], sched Ok(()) } +fn next_time_automation_utc(item: &Automation, now: DateTime) -> Option> { + if !item.enabled || item.trigger_kind != "time" { return None; } + let expected = NaiveTime::parse_from_str(item.at_time.as_deref()?, "%H:%M").ok()?; + let minute_floor = now.with_second(0)?.with_nanosecond(0)?; + for offset in 0..=(24 * 60) { + let candidate = minute_floor + chrono::Duration::minutes(offset); + if candidate <= now { continue; } + if candidate.hour() == expected.hour() && candidate.minute() == expected.minute() { + return Some(candidate.with_timezone(&Utc)); + } + } + None +} + fn next_zone_control_deadline_delay(state: &AppState) -> Result, AppError> { let now = Utc::now(); - Ok(state.db.list_zones()?.into_iter() - .filter_map(|zone| { - let local = if local_thermostat_handback_is_active(&zone) { zone.local_thermostat_resume_at.clone() } else { None }; - let temporary = temporary_quick_thermostat_wakeup_at(&zone, now.clone()); - match (local, temporary) { - (Some(a), Some(b)) => Some(a.min(b)), - (Some(a), None) => Some(a), - (None, Some(b)) => Some(b), - (None, None) => None, - } - }) + let local_now = now.with_timezone(&Local); + let zones = state.db.list_zones()?; + let schedules = state.db.list_schedules()?; + let automations = state.db.list_automations()?; + let mut deadlines: Vec> = Vec::new(); + + for zone in &zones { + if local_thermostat_handback_is_active(zone) { + if let Some(at) = zone.local_thermostat_resume_at.clone() { deadlines.push(at); } + } + if let Some(at) = temporary_quick_thermostat_wakeup_at(zone, now.clone()) { deadlines.push(at); } + if let Some(at) = next_schedule_boundary_utc(&zone.id, &schedules, local_now.clone()) { deadlines.push(at); } + } + for automation in &automations { + if let Some(at) = next_time_automation_utc(automation, local_now.clone()) { deadlines.push(at); } + } + + Ok(deadlines.into_iter() .map(|at| (at - now.clone()).to_std().unwrap_or(Duration::ZERO)) .min()) } @@ -897,6 +1146,7 @@ fn normalized_direct_source(source: &str) -> &'static str { } pub fn reset_device_manual_override(zone: &mut Zone) -> bool { + let now = Utc::now(); let changed = zone.device_manual_override || zone.device_manual_override_since.is_some() || zone.device_manual_override_until.is_some() @@ -907,10 +1157,37 @@ pub fn reset_device_manual_override(zone: &mut Zone) -> bool { zone.device_manual_override_until = None; zone.device_manual_override_fields.clear(); zone.device_manual_override_baseline = None; + if let Some(session) = zone.temporary_quick_thermostat.as_mut() { + let pause = session.paused_at.take() + .map(|paused_at| now.signed_duration_since(paused_at)) + .filter(|pause| *pause > chrono::Duration::zero()); + if session.activated_at.is_some() { + if let Some(pause) = pause { + if matches!(session.finish_kind.as_str(), "duration" | "until") { + session.expires_at = session.expires_at.map(|at| at + pause); + } + if matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable") { + session.safety_expires_at = session.safety_expires_at.map(|at| at + pause); + } + } + session.state = "active".into(); + } else { + // A due session blocked by manual ownership has not started its work clock. + // Preserve the requested remaining `until` window by excluding manual wait time. + if session.finish_kind == "until" { + if let Some(pause) = pause { + session.expires_at = session.expires_at.map(|at| at + pause); + } + } + session.state = "scheduled".into(); + } + session.condition_started_at = None; + session.condition_last_observed_at = None; + } if zone.control_owner == "direct_manual" { zone.control_owner = "automation".into(); zone.control_source = "automation".into(); - zone.control_since = Some(Utc::now()); + zone.control_since = Some(now); zone.control_resume_at = None; zone.control_reason = "Manual takeover cleared; automation may resume".into(); } @@ -952,6 +1229,7 @@ fn persist_manual_override_clear(state: &AppState, zone: &mut Zone, source: &str "local_thermostat_resume_rearmed": local_resume_rearmed, "local_thermostat_resume_at": zone.local_thermostat_resume_at, })); + state.wake_zone_control(); Ok(true) } @@ -964,6 +1242,16 @@ fn set_device_manual_override(state: &AppState, zone: &mut Zone, fields: Vec Result send_command_locked_forced(state, device_id, DeviceCommand { power: Some(false), ..Default::default() }).await } +/// Technical device disable is a safety transition, not just a database flag. The unit is +/// explicitly powered off while it is still commandable, then removed from controller polling. +pub async fn disable_device_safely(state: &AppState, device_id: &str) -> Result { + let _device_guard = state.lock_device_operation(device_id).await; + let mut device = state.db.get_device(device_id)? + .ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?; + if !device.enabled { return Ok(device); } + device = send_command_locked_forced( + state, + device_id, + DeviceCommand { power: Some(false), ..Default::default() }, + ).await?; + device.enabled = false; + device.updated_at = Utc::now(); + state.db.save_device(&device)?; + state.broadcast("device.updated", serde_json::to_value(&device)?); + state.wake_zone_control(); + Ok(device) +} + pub fn clear_all_device_manual_overrides(state: &AppState, source: &str) -> Result { let mut cleared = 0usize; for mut zone in state.db.list_zones()? { @@ -1099,11 +1407,9 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl let mut group = state.db.get_group(group_id)? .ok_or_else(|| AppError::NotFound(format!("group {group_id}")))?; let schedules = state.db.list_schedules()?; - let activates_group = patch.mode.is_some() || patch.preset.is_some(); + let climate_change = patch.mode.is_some() || patch.preset.is_some(); if let Some(power) = patch.power { group.power_enabled = power; - } else if activates_group { - group.power_enabled = true; } group.updated_at = Utc::now(); state.db.save_group(&group)?; @@ -1130,33 +1436,40 @@ 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; }; - let temporary_owns_zone = zone.local_thermostat_power == Some(true) && zone.temporary_quick_thermostat.is_some(); - if let Some(mode) = patch.mode.as_deref().filter(|_| !temporary_owns_zone) { - match mode { - "house" | "auto" => zone.inherit_house_mode = true, - "cool" | "heat" => { - zone.inherit_house_mode = false; - zone.mode = mode.to_string(); + let temporary_owns_zone = temporary_quick_thermostat_is_active(&zone, Utc::now()); + 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 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().filter(|_| !temporary_owns_zone) { - if preset == "auto" { - zone.manual_preset = None; - zone.manual_setpoint = None; - zone.manual_override_until = None; - } else { - zone.manual_preset = Some(preset.to_string()); - zone.manual_setpoint = None; - zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &schedules, Local::now()); + 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()); + zone.manual_setpoint = None; + zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &schedules, Local::now()); + } } } - if temporary_owns_zone && (patch.mode.is_some() || patch.preset.is_some()) { - 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 - })); - } zone.revision = zone.revision.saturating_add(1); zone.updated_at = Utc::now(); state.db.save_zone(&zone)?; @@ -1166,25 +1479,25 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl let runtime = state.settings.read().await.clone(); let master_power_enabled = runtime.house_power_enabled; - let should_command_power = patch.power.is_some() || activates_group; + let should_command_power = patch.power.is_some(); let desired_power = group.power_enabled; // A zone may intentionally belong to more than one group. Power-off is authoritative: // turning one group on must never briefly wake a member that is still blocked by another group. let mut failed = Vec::new(); - if should_command_power && (!desired_power || master_power_enabled) { + if should_command_power && !desired_power { let mut seen = std::collections::HashSet::new(); for zone in &zones { if !seen.insert(zone.device_id.clone()) { continue; } - // Re-check manual takeover and all group gates only after acquiring the - // per-device lock, so a pilot event detected by polling cannot be overwritten. + // Group OFF is an immediate safety transition. Group ON never emits a bare + // power=true frame; the thermostat arbiter starts the unit with mode/target. let Some(device) = state.db.get_device(&zone.device_id)? else { continue; }; if !device.enabled { continue; } - match send_group_power_if_current(state, &group.id, &zone.id, &device.id, desired_power).await { + match send_group_power_if_current(state, &group.id, &zone.id, &device.id, false).await { Ok(_) => {} Err(err) => { state.log("error", "group.power_error", &err.to_string(), json!({ "group_id": group.id, "device_id": device.id, "device_name": device.name, - "power": desired_power, "source": source, + "power": false, "source": source, })); failed.push(json!({"device_id": device.id, "device_name": device.name, "error": err.to_string()})); } @@ -1192,7 +1505,7 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl } } - if desired_power && should_command_power { + if desired_power && (should_command_power || climate_change) { state.wake_zone_control(); } state.log("info", source, &format!("Updated group {}", group.name), json!({ @@ -1323,6 +1636,93 @@ async fn send_automatic_device_command_if_owned( send_command_locked(state, device_id, command).await.map(Some) } +async fn apply_automatic_device_action( + state: &AppState, + device_id: &str, + command: DeviceCommand, +) -> Result, AppError> { + let zones = state.db.list_zones()?; + let Some(zone_id) = zones.iter().find(|zone| zone.device_id == device_id).map(|zone| zone.id.clone()) else { + return send_automatic_device_command_if_owned(state, device_id, command).await; + }; + + let _zone_guard = state.lock_zone_operation(&zone_id).await; + let mut zone = state.db.get_zone(&zone_id)?.ok_or_else(|| AppError::NotFound(format!("zone {zone_id}")))?; + let settings = state.settings.read().await.clone(); + if !settings.house_power_enabled || zone.device_manual_override || zone.local_thermostat_power.is_some() { + return Ok(None); + } + let groups = state.db.list_groups()?; + if groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|member| member == &zone.id)) { + return Ok(None); + } + // A power-on automation is an explicit domain transition and may re-enable a zone that + // a previous power automation disabled. Other actions still respect a disabled zone gate. + if !zone.enabled && command.power != Some(true) { return Ok(None); } + + let mut domain_changed = false; + if let Some(power) = command.power { + zone.enabled = power; + domain_changed = true; + } + if let Some(mode) = command.mode.as_deref() { + match mode { + "heat" | "cool" => { + zone.mode = mode.to_string(); + zone.inherit_house_mode = false; + domain_changed = true; + } + "auto" => { + zone.inherit_house_mode = true; + domain_changed = true; + } + _ => return Err(AppError::BadRequest( + "device automation for a thermostat-managed unit supports only heat, cool or auto mode".into(), + )), + } + } + if let Some(target) = command.target_temperature { + zone.manual_setpoint = Some((target.clamp(8.0, 30.0) * 2.0).round() / 2.0); + zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &state.db.list_schedules()?, Local::now()); + domain_changed = true; + } + + if domain_changed { + zone.revision = zone.revision.saturating_add(1); + zone.updated_at = Utc::now(); + zone.control_source = "automation.device".into(); + state.db.save_zone(&zone)?; + state.broadcast("zone.updated", serde_json::to_value(&zone)?); + state.wake_zone_control(); + } + + if command.power == Some(false) { + return force_power_off_device(state, device_id).await.map(Some); + } + + // Climate fields above are durable zone state. Only non-climate device capabilities remain + // a one-shot command; the thermostat can no longer undo power/mode/target next cycle. + let residual = DeviceCommand { + power: None, + mode: None, + target_temperature: None, + fan_speed: command.fan_speed, + swing_vertical: command.swing_vertical, + swing_horizontal: command.swing_horizontal, + quiet: command.quiet, + turbo: command.turbo, + light: command.light, + air: command.air, + xfan: command.xfan, + health: command.health, + sleep: command.sleep, + }; + if residual.is_empty() { + return state.db.get_device(device_id)?.map(Some).ok_or_else(|| AppError::NotFound(format!("device {device_id}"))); + } + send_automatic_device_command_if_owned(state, device_id, residual).await +} + async fn control_zones(state: &AppState) -> Result<()> { let schedules = state.db.list_schedules()?; let groups = state.db.list_groups()?; @@ -1331,9 +1731,9 @@ async fn control_zones(state: &AppState) -> Result<()> { // Local quick-thermostat OFF is intentionally temporary. Expire the ownership marker // before the house-power early return so the hand-back still happens while the master // is off; no physical state is restored here, only automation ownership. - expire_local_thermostat_overrides(state, &mut zone_snapshot, &schedules, &settings.house_mode)?; - let temporary_restored_disabled = expire_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode)?; - activate_due_temporary_quick_thermostats(state, &mut zone_snapshot)?; + 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, settings.house_power_enabled).await?; // Outdoor temperature is deliberately optional. Prefer the configured Home // Assistant entity, but keep the dashboard/assist useful by falling back to the @@ -1474,24 +1874,6 @@ async fn control_zones(state: &AppState) -> Result<()> { zone.control_temperature_source = control_source; zone.updated_at = Utc::now(); - let condition_now = zone.updated_at.clone(); - if let Some(reason) = evaluate_temporary_quick_thermostat_condition(&mut zone, condition_now) { - let finish_kind = zone.temporary_quick_thermostat.as_ref().map(|item| item.finish_kind.clone()).unwrap_or_default(); - reset_local_thermostat_override(&mut zone); - refresh_zone_runtime_target(&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)?); - if persisted_zone.temporary_quick_thermostat.is_none() { - 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; - } - // 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 @@ -1529,6 +1911,16 @@ async fn control_zones(state: &AppState) -> Result<()> { // 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); @@ -1547,6 +1939,38 @@ async fn control_zones(state: &AppState) -> Result<()> { 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( + &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; @@ -2132,6 +2556,11 @@ pub fn refresh_zone_runtime_target(zone: &mut Zone, schedules: &[Schedule], hous } fn effective_zone_mode(zone: &Zone, house_mode: &str) -> String { + if temporary_quick_thermostat_is_active(zone, Utc::now()) { + if let Some(mode) = zone.temporary_quick_thermostat.as_ref().and_then(|session| session.active_mode.as_deref()) { + if matches!(mode, "cool" | "heat") { return mode.to_string(); } + } + } let configured = if zone.inherit_house_mode { house_mode } else { zone.mode.as_str() }; if zone.local_thermostat_power == Some(true) && configured == "off" { zone.mode.clone() @@ -2257,11 +2686,7 @@ pub async fn build_control_plan(state: &AppState) -> Result Result<()> { let devices = state.db.list_devices()?; let zones = state.db.list_zones()?; let groups = state.db.list_groups()?; - for mut item in state.db.list_automations()? { + let mut automations = state.db.list_automations()?; + // Stable arbitration for same-cycle conflicts: the oldest configured rule wins, then ID. + // This avoids database row order deciding the physical outcome (M2). + automations.sort_by(|a, b| a.created_at.cmp(&b.created_at).then_with(|| a.id.cmp(&b.id))); + let mut claimed_devices = std::collections::HashSet::::new(); + + for mut item in automations { if !item.enabled || !automation_ready(&item) { continue; } let should_fire = match item.trigger_kind.as_str() { "temperature_above" => find_temperature(&devices, item.trigger_device_id.as_deref()) @@ -2500,30 +2931,22 @@ async fn run_automations(state: &AppState) -> Result<()> { _ => false, }; if !should_fire { continue; } - if item.action_group_id.is_none() && device_blocked_by_disabled_zone(&item.action_device_id, &zones) { - // Disabled thermostat zones are outside normal automation. The underlying - // unit can still be operated manually from the technical Devices view. - item.last_fired_at = Some(Utc::now()); - item.updated_at = Utc::now(); - state.db.save_automation(&item)?; + if item.action_group_id.is_none() + && device_blocked_by_disabled_zone(&item.action_device_id, &zones) + && item.action.power != Some(true) + { state.log("info", "automation.blocked_by_zone", &format!("Automation {} suppressed by disabled zone", item.name), json!({ "automation_id": item.id, "device_id": item.action_device_id })); continue; } if item.action_group_id.is_none() && device_blocked_by_manual_override(&item.action_device_id, &zones) { - item.last_fired_at = Some(Utc::now()); - item.updated_at = Utc::now(); - state.db.save_automation(&item)?; state.log("info", "automation.blocked_by_manual_override", &format!("Automation {} suppressed by manual device control", item.name), json!({ "automation_id": item.id, "device_id": item.action_device_id })); continue; } if item.action_group_id.is_none() && device_blocked_by_local_thermostat(&item.action_device_id, &zones) { - item.last_fired_at = Some(Utc::now()); - item.updated_at = Utc::now(); - state.db.save_automation(&item)?; state.log("info", "automation.blocked_by_local_thermostat", &format!("Automation {} suppressed by local thermostat ownership", item.name), json!({ "automation_id": item.id, "device_id": item.action_device_id })); @@ -2532,35 +2955,53 @@ async fn run_automations(state: &AppState) -> Result<()> { if item.action_group_id.is_none() && device_blocked_by_disabled_group(&item.action_device_id, &zones, &groups) { // Group power-off is authoritative for normal controller-owned zones. A manual // takeover is filtered above and therefore remains higher priority than the group. - item.last_fired_at = Some(Utc::now()); - item.updated_at = Utc::now(); - state.db.save_automation(&item)?; state.log("info", "automation.blocked_by_group", &format!("Automation {} suppressed by disabled group", item.name), json!({ "automation_id": item.id, "device_id": item.action_device_id })); continue; } - let result = if let Some(group_id) = item.action_group_id.as_deref() { + + let target_devices: Vec = if let Some(group_id) = item.action_group_id.as_deref() { + groups.iter().find(|group| group.id == group_id) + .map(|group| group.zone_ids.iter() + .filter_map(|zone_id| zones.iter().find(|zone| &zone.id == zone_id).map(|zone| zone.device_id.clone())) + .collect()) + .unwrap_or_default() + } else { + vec![item.action_device_id.clone()] + }; + if target_devices.iter().any(|device_id| claimed_devices.contains(device_id)) { + state.log("warn", "automation.conflict", &format!("Automation {} skipped because an older due automation already claimed the same target", item.name), json!({ + "automation_id": item.id, + "group_id": item.action_group_id, + "device_id": item.action_device_id, + "target_devices": target_devices, + })); + continue; + } + + let result: Result = if let Some(group_id) = item.action_group_id.as_deref() { let group_mode = item.action.mode.as_deref().map(|mode| if mode == "auto" { "house".to_string() } else { mode.to_string() }); control_group(state, group_id, GroupControlPatch { power: item.action.power, mode: group_mode, preset: item.action_preset.clone(), - }, "automation.group").await.map(|_| ()) + }, "automation.group").await.map(|_| true) } else { - match send_automatic_device_command_if_owned(state, &item.action_device_id, item.action.clone()).await { - Ok(Some(_)) => Ok(()), + match apply_automatic_device_action(state, &item.action_device_id, item.action.clone()).await { + Ok(Some(_)) => Ok(true), Ok(None) => { state.log("info", "automation.blocked_by_fresh_ownership", &format!("Automation {} was suppressed after ownership changed", item.name), json!({ "automation_id": item.id, "device_id": item.action_device_id })); - Ok(()) + Ok(false) } Err(err) => Err(err), } }; match result { - Ok(()) => { + Ok(true) => { + for device_id in target_devices { claimed_devices.insert(device_id); } item.last_fired_at = Some(Utc::now()); item.updated_at = Utc::now(); state.db.save_automation(&item)?; @@ -2568,6 +3009,10 @@ async fn run_automations(state: &AppState) -> Result<()> { "automation_id": item.id, "group_id": item.action_group_id, "device_id": item.action_device_id })); } + Ok(false) => { + // Ownership suppression is not an execution. Do not consume cooldown (M3), + // so a still-valid trigger may run as soon as the higher-priority owner leaves. + } Err(err) => { // A failed action is still an execution attempt. Apply the configured cooldown // so an offline/disabled target cannot be hammered on every automation cycle. @@ -2741,7 +3186,7 @@ mod tests { sensor_source: source.into(), ha_entity_id: Some("sensor.room_temperature".into()), external_sensor_weight: 0.4, max_sensor_difference: 3.0, sensor_stale_after_seconds: 300, device_temperature: None, external_temperature: None, current_temperature: None, control_temperature_source: "device".into(), active_preset: "comfort".into(), - manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None, local_thermostat_resume_at: None, temporary_quick_thermostat: None, + manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None, local_thermostat_resume_at: None, local_thermostat_restore_zone_enabled: None, temporary_quick_thermostat: None, device_manual_override: false, device_manual_override_since: None, device_manual_override_until: None, device_manual_override_fields: Vec::new(), device_manual_override_baseline: None, revision: 1, control_owner: "automation".into(), control_source: "automation".into(), control_since: Some(Utc::now()), control_resume_at: None, control_reason: "test".into(), last_power_change_at: None, last_mode_change_at: None, lockout_until: None, lockout_reason: None, effective_mode: "heat".into(), effective_setpoint: Some(21.0), device_setpoint: None, @@ -2827,17 +3272,63 @@ mod tests { finish_kind: "temperature_stable".into(), started_at: now.clone(), activated_at: Some(now), + state: "active".into(), + generation: 1, + active_mode: Some("heat".into()), restore_zone_enabled: Some(true), + restore_local_thermostat_power: None, + restore_local_thermostat_resume_at: None, + restore_local_thermostat_zone_enabled: None, + restore_manual_preset: None, + restore_manual_setpoint: None, + restore_manual_override_until: None, expires_at: None, + duration_seconds: None, + safety_duration_seconds: None, temperature_target: Some(23.0), temperature_operator: Some("within".into()), tolerance_c: 0.3, hold_seconds: 3600, condition_started_at: None, + condition_last_observed_at: None, + paused_at: None, + deferred_mode: None, + deferred_preset: None, safety_expires_at: None, } } + #[test] + fn scheduled_temporary_session_is_not_activated_by_unrelated_local_quick_on() { + let now = Utc::now(); + let mut zone = test_zone("device"); + zone.local_thermostat_power = Some(true); + let mut session = temporary_session(now + chrono::Duration::hours(1)); + session.start_kind = "delay".into(); + session.activated_at = None; + session.state = "scheduled".into(); + zone.temporary_quick_thermostat = Some(session); + + assert!(!temporary_quick_thermostat_is_active(&zone, now)); + assert_eq!(temporary_quick_thermostat_wakeup_at(&zone, now), Some(now + chrono::Duration::hours(1))); + } + + #[test] + fn local_handback_cleanup_does_not_remove_pending_temporary_session() { + let now = Utc::now(); + let mut zone = test_zone("device"); + set_local_thermostat_power(&mut zone, false, now); + let mut session = temporary_session(now + chrono::Duration::minutes(30)); + session.start_kind = "delay".into(); + session.activated_at = None; + session.state = "scheduled".into(); + zone.temporary_quick_thermostat = Some(session); + + reset_local_thermostat_override(&mut zone); + assert!(zone.temporary_quick_thermostat.is_some()); + assert!(zone.local_thermostat_power.is_none()); + } + #[test] fn temporary_quick_thermostat_restores_previous_zone_enabled_state() { let now = Utc::now(); @@ -2849,13 +3340,51 @@ mod tests { zone.temporary_quick_thermostat = Some(session); zone.manual_setpoint = Some(23.0); - assert!(reset_local_thermostat_override(&mut zone)); + assert!(finish_temporary_quick_thermostat(&mut zone, &[], "heat")); assert!(!zone.enabled); assert!(zone.local_thermostat_power.is_none()); assert!(zone.temporary_quick_thermostat.is_none()); assert!(zone.manual_setpoint.is_none()); } + #[test] + fn temporary_quick_thermostat_returns_to_underlying_local_quick_state() { + let now = Utc::now(); + let mut zone = test_zone("device"); + zone.enabled = true; + zone.local_thermostat_power = Some(true); + zone.local_thermostat_restore_zone_enabled = None; + zone.manual_setpoint = Some(24.0); + let mut session = temporary_session(now); + session.restore_zone_enabled = Some(true); + session.restore_local_thermostat_power = Some(true); + session.restore_local_thermostat_zone_enabled = Some(false); + session.restore_manual_setpoint = Some(22.0); + zone.temporary_quick_thermostat = Some(session); + zone.manual_setpoint = Some(23.0); + + assert!(finish_temporary_quick_thermostat(&mut zone, &[], "heat")); + assert!(zone.enabled); + assert_eq!(zone.local_thermostat_power, Some(true)); + assert_eq!(zone.local_thermostat_restore_zone_enabled, Some(false)); + assert_eq!(zone.manual_setpoint, Some(22.0)); + } + + #[test] + fn legacy_immediate_session_can_use_local_power_fallback_but_fresh_session_cannot() { + let now = Utc::now(); + let mut zone = test_zone("device"); + zone.local_thermostat_power = Some(true); + let mut session = temporary_session(now); + session.activated_at = None; + session.generation = 0; + zone.temporary_quick_thermostat = Some(session); + assert!(temporary_quick_thermostat_is_active(&zone, now)); + + zone.temporary_quick_thermostat.as_mut().unwrap().generation = 1; + assert!(!temporary_quick_thermostat_is_active(&zone, now)); + } + #[test] fn temporary_stable_condition_requires_continuous_hold_time() { let now = Utc::now(); @@ -2865,8 +3394,8 @@ mod tests { session.condition_started_at = Some(now.clone() - chrono::Duration::seconds(3599)); zone.temporary_quick_thermostat = Some(session); - assert!(evaluate_temporary_quick_thermostat_condition(&mut zone, now.clone()).is_none()); - assert_eq!(evaluate_temporary_quick_thermostat_condition(&mut zone, now + chrono::Duration::seconds(2)), Some("temperature_stable".into())); + assert!(evaluate_temporary_quick_thermostat_condition(&mut zone, now.clone(), Some(now.clone()), 10).is_none()); + assert_eq!(evaluate_temporary_quick_thermostat_condition(&mut zone, now + chrono::Duration::seconds(2), Some(now + chrono::Duration::seconds(2)), 10), Some("temperature_stable".into())); } #[test] @@ -2878,10 +3407,24 @@ mod tests { session.condition_started_at = Some(now.clone() - chrono::Duration::minutes(30)); zone.temporary_quick_thermostat = Some(session); - assert!(evaluate_temporary_quick_thermostat_condition(&mut zone, now).is_none()); + assert!(evaluate_temporary_quick_thermostat_condition(&mut zone, now, Some(now), 10).is_none()); assert!(zone.temporary_quick_thermostat.as_ref().unwrap().condition_started_at.is_none()); } + #[test] + fn temporary_stable_condition_resets_after_observation_gap() { + let now = Utc::now(); + let mut zone = test_zone("device"); + zone.current_temperature = Some(23.0); + let mut session = temporary_session(now); + session.condition_started_at = Some(now - chrono::Duration::hours(1)); + session.condition_last_observed_at = Some(now - chrono::Duration::minutes(10)); + zone.temporary_quick_thermostat = Some(session); + + assert!(evaluate_temporary_quick_thermostat_condition(&mut zone, now, Some(now), 30).is_none()); + assert_eq!(zone.temporary_quick_thermostat.as_ref().unwrap().condition_started_at, Some(now)); + } + #[test] fn delayed_temporary_session_does_not_block_automation_before_start() { let now = Utc::now(); @@ -2889,6 +3432,7 @@ mod tests { let mut session = temporary_session(now.clone() + chrono::Duration::hours(1)); session.start_kind = "delay".into(); session.activated_at = None; + session.state = "scheduled".into(); session.expires_at = Some(now.clone() + chrono::Duration::hours(3)); zone.temporary_quick_thermostat = Some(session); @@ -2906,7 +3450,7 @@ mod tests { session.activated_at = None; zone.temporary_quick_thermostat = Some(session); - assert!(evaluate_temporary_quick_thermostat_condition(&mut zone, now).is_none()); + assert!(evaluate_temporary_quick_thermostat_condition(&mut zone, now, Some(now), 10).is_none()); assert!(zone.temporary_quick_thermostat.as_ref().unwrap().condition_started_at.is_none()); } @@ -2922,7 +3466,7 @@ mod tests { zone.temporary_quick_thermostat = Some(session); assert_eq!( - evaluate_temporary_quick_thermostat_condition(&mut zone, now), + evaluate_temporary_quick_thermostat_condition(&mut zone, now, Some(now), 10), Some("temperature_stable".into()) ); } diff --git a/src/models.rs b/src/models.rs index 1c0bd7d..c74e14b 100644 --- a/src/models.rs +++ b/src/models.rs @@ -39,6 +39,7 @@ fn default_night_max_fan_speed() -> u8 { 1 } fn default_group_power_enabled() -> bool { true } fn default_temporary_tolerance() -> f64 { 0.3 } fn default_temporary_start_kind() -> String { "now".into() } +fn default_temporary_state() -> String { "scheduled".into() } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Device { @@ -284,13 +285,44 @@ pub struct TemporaryQuickThermostat { /// so a delayed session can survive restarts without being mistaken for an active one. #[serde(default)] pub activated_at: Option>, - /// Zone automation enabled-state from before the temporary session. A temporary + /// Explicit lifecycle state: scheduled | waiting_master | paused_manual | active. + #[serde(default = "default_temporary_state")] + pub state: String, + /// Fresh sessions use generation >= 1. Generation 0 identifies legacy payloads that + /// predate explicit activated_at/state semantics and may use the compatibility fallback. + #[serde(default)] + pub generation: u64, + /// Heat/cool mode captured when ownership really starts. It keeps a temporary session + /// independent from later whole-house mode changes until hand-back. + #[serde(default)] + pub active_mode: Option, + /// Zone automation enabled-state from immediately before the actual takeover. A delayed /// Quick Thermostat may run even when normal automation was disabled, then restore it. #[serde(default)] pub restore_zone_enabled: Option, + /// Ordinary local Quick Thermostat state hidden underneath this higher-priority session. + /// It is captured at actual takeover and restored on hand-back. + #[serde(default)] + pub restore_local_thermostat_power: Option, + #[serde(default)] + pub restore_local_thermostat_resume_at: Option>, + #[serde(default)] + pub restore_local_thermostat_zone_enabled: Option, + #[serde(default)] + pub restore_manual_preset: Option, + #[serde(default)] + pub restore_manual_setpoint: Option, + #[serde(default)] + pub restore_manual_override_until: Option>, /// Hard end for duration/until/schedule-boundary modes. #[serde(default)] pub expires_at: Option>, + /// Relative durations are retained so delayed/manual-waiting sessions start their clocks + /// when ownership actually begins rather than at the originally requested wall-clock time. + #[serde(default)] + pub duration_seconds: Option, + #[serde(default)] + pub safety_duration_seconds: Option, /// Temperature condition used by reached/stable modes. #[serde(default)] pub temperature_target: Option, @@ -302,9 +334,23 @@ pub struct TemporaryQuickThermostat { /// Continuous in-condition time required by temperature_stable. #[serde(default)] pub hold_seconds: u64, - /// Set only while the latest room samples continuously satisfy the condition. + /// Set only while fresh consecutive room samples continuously satisfy the condition. #[serde(default)] pub condition_started_at: Option>, + /// Timestamp of the last fresh sensor sample used by the condition evaluator. This + /// prevents cached samples and controller downtime from counting as continuous hold time. + #[serde(default)] + pub condition_last_observed_at: Option>, + /// Start of a higher-priority direct/manual pause. Active deadlines are shifted by this + /// pause when ownership returns so hidden manual time is never consumed by the session. + #[serde(default)] + pub paused_at: Option>, + /// Group/house climate changes received while this session owns the zone. They are + /// applied only after hand-back instead of partially overwriting the active session. + #[serde(default)] + pub deferred_mode: Option, + #[serde(default)] + pub deferred_preset: Option, /// Optional fail-safe for temperature-based modes. #[serde(default)] pub safety_expires_at: Option>, @@ -425,6 +471,10 @@ pub struct Zone { /// the current group/schedule state is evaluated again. #[serde(default)] pub local_thermostat_resume_at: Option>, + /// Zone automation enabled-state from before an ordinary local Quick Thermostat takeover. + /// Kept separate from the temporary-session restore state. + #[serde(default)] + pub local_thermostat_restore_zone_enabled: Option, /// Separate, user-defined temporary Quick Thermostat session. This is intentionally /// independent from local_thermostat_resume_at, which belongs to the local-OFF /// hand-back mechanism. diff --git a/web/app.js b/web/app.js index 886a3cc..bf54ebf 100644 --- a/web/app.js +++ b/web/app.js @@ -87,13 +87,24 @@ function temporarySessionStatus(zone) { const safetyRemaining = secondsUntil(session.safety_expires_at); const hardRemaining = secondsUntil(session.expires_at); const startRemaining = secondsUntil(session.started_at); - const pending = !session.activated_at && zone.local_thermostat_power !== true; + const legacyActive = !session.activated_at + && Number(session.generation ?? 0) === 0 + && (session.start_kind || 'now') === 'now' + && zone.local_thermostat_power === true + && (!session.started_at || new Date(session.started_at).getTime() <= Date.now()); + const pending = !session.activated_at && !legacyActive; const target = Number(session.temperature_target ?? zone.effective_setpoint ?? zone.setpoint); const targetText = Number.isFinite(target) ? target.toFixed(1) : '--'; const tolerance = Number(session.tolerance_c ?? 0.3).toFixed(1); const operatorKey = ({within:'zones.temporaryWithinShort',at_or_below:'zones.temporaryAtOrBelowShort',at_or_above:'zones.temporaryAtOrAboveShort'})[session.temperature_operator || 'within'] || 'zones.temporaryWithinShort'; const condition = tr(operatorKey, {target:targetText, tolerance}); + if (session.state === 'waiting_master') { + return {countdown:startRemaining != null ? formatExtendedCountdown(startRemaining) : '00:00', detail:tr('zones.temporaryWaitingMaster'), kind:session.finish_kind, pending:true}; + } + if (session.state === 'paused_manual') { + return {countdown:'—', detail:tr('zones.temporaryPausedManual'), kind:session.finish_kind, pending}; + } if (pending) { return { countdown:startRemaining != null && startRemaining > 0 ? formatExtendedCountdown(startRemaining) : '00:00', @@ -1437,6 +1448,12 @@ function populateTemporaryThermostat(id) { form.start_at.value = dateTimeLocalValue(new Date(Date.now() + 60 * 60 * 1000)); form.until.value = dateTimeLocalValue(new Date(Date.now() + 2 * 60 * 60 * 1000)); const session = zone.temporary_quick_thermostat; + const activeSession = !!session?.activated_at || (!!session + && Number(session.generation ?? 0) === 0 + && (session.start_kind || 'now') === 'now' + && zone.local_thermostat_power === true + && (!session.started_at || new Date(session.started_at).getTime() <= Date.now())); + form.dataset.activeSession = activeSession ? 'true' : 'false'; if (session) { form.start_kind.value = session.start_kind || (new Date(session.started_at).getTime() > Date.now() ? 'at' : 'now'); if (form.start_kind.value === 'delay' && session.started_at) { @@ -1448,24 +1465,32 @@ function populateTemporaryThermostat(id) { if (session.temperature_operator) form.temperature_operator.value = session.temperature_operator; form.tolerance_c.value = Number(session.tolerance_c ?? 0.3).toFixed(1); if (session.hold_seconds) form.hold_minutes.value = Math.max(1, Math.round(Number(session.hold_seconds) / 60)); - if (session.expires_at) { - form.until.value = dateTimeLocalValue(session.expires_at); - if (session.finish_kind === 'duration') { - const reference = (!session.activated_at && zone.local_thermostat_power !== true && session.started_at) - ? new Date(session.started_at).getTime() - : Date.now(); - form.duration_minutes.value = Math.max(1, Math.ceil((new Date(session.expires_at).getTime() - reference) / 60000)); + if (session.finish_kind === 'duration') { + if (session.duration_seconds) { + form.duration_minutes.value = Math.max(1, Math.round(Number(session.duration_seconds) / 60)); + } else if (session.expires_at) { + const base = new Date(session.activated_at || session.started_at || Date.now()).getTime(); + form.duration_minutes.value = Math.max(1, Math.round((new Date(session.expires_at).getTime() - base) / 60000)); } } - if (session.safety_expires_at) { - const reference = (!session.activated_at && zone.local_thermostat_power !== true && session.started_at) - ? new Date(session.started_at).getTime() - : Date.now(); - form.max_duration_minutes.value = Math.max(1, Math.ceil((new Date(session.safety_expires_at).getTime() - reference) / 60000)); + if (session.expires_at && session.finish_kind !== 'duration') form.until.value = dateTimeLocalValue(session.expires_at); + if (session.safety_duration_seconds) { + form.max_duration_minutes.value = Math.max(1, Math.round(Number(session.safety_duration_seconds) / 60)); + } else if (session.safety_expires_at) { + const base = new Date(session.activated_at || session.started_at || Date.now()).getTime(); + form.max_duration_minutes.value = Math.max(1, Math.round((new Date(session.safety_expires_at).getTime() - base) / 60000)); } else if (['temperature_reached','temperature_stable'].includes(session.finish_kind)) { form.max_duration_minutes.value = ''; } } + + // Once ownership has started, the historical start is informational. Editing changes only + // target/finish rules; rescheduling requires stopping the session and creating a new one. + form.start_kind.disabled = activeSession; + form.start_delay_minutes.disabled = activeSession; + form.start_at.disabled = activeSession; + $$('[data-temporary-start-delay]').forEach(button => { button.disabled = activeSession; }); + updateTemporaryThermostatFields(); const active = $('#temporaryThermostatActive'); active.hidden = !session; @@ -2172,9 +2197,13 @@ $('#temporaryThermostatStop')?.addEventListener('click', async event => { $('#temporaryThermostatForm')?.addEventListener('submit', async event => { event.preventDefault(); const form = event.currentTarget, raw = Object.fromEntries(new FormData(form)); - const request = {start_kind:raw.start_kind, finish_kind:raw.finish_kind, target_temperature:parseDecimal(raw.target_temperature)}; - if (raw.start_kind === 'delay') request.start_delay_minutes = Number(raw.start_delay_minutes); - if (raw.start_kind === 'at') { + const activeSession = form.dataset.activeSession === 'true'; + const zone = app.zones.find(item => item.id === raw.zone_id); + const existingSession = zone?.temporary_quick_thermostat; + const startKind = activeSession ? (existingSession?.start_kind || 'now') : raw.start_kind; + const request = {start_kind:startKind, finish_kind:raw.finish_kind, target_temperature:parseDecimal(raw.target_temperature)}; + if (!activeSession && startKind === 'delay') request.start_delay_minutes = Number(raw.start_delay_minutes); + if (!activeSession && startKind === 'at') { const startAt = new Date(raw.start_at); if (!Number.isFinite(startAt.getTime())) return toast(tr('zones.temporaryInvalidStartAt'), true); request.start_at = startAt.toISOString(); @@ -2183,9 +2212,9 @@ $('#temporaryThermostatForm')?.addEventListener('submit', async event => { if (raw.finish_kind === 'until') { const until = new Date(raw.until); if (!Number.isFinite(until.getTime())) return toast(tr('zones.temporaryInvalidUntil'), true); - const effectiveStart = raw.start_kind === 'delay' - ? new Date(Date.now() + Number(raw.start_delay_minutes) * 60000) - : raw.start_kind === 'at' ? new Date(raw.start_at) : new Date(); + const effectiveStart = activeSession ? new Date() + : startKind === 'delay' ? new Date(Date.now() + Number(raw.start_delay_minutes) * 60000) + : startKind === 'at' ? new Date(raw.start_at) : new Date(); if (Number.isFinite(effectiveStart.getTime()) && until <= effectiveStart) return toast(tr('zones.temporaryEndAfterStart'), true); request.until = until.toISOString(); } @@ -2198,9 +2227,9 @@ $('#temporaryThermostatForm')?.addEventListener('submit', async event => { } try { $('#temporaryThermostatSubmit').disabled = true; - const zone = await api(`/api/zones/${encodeURIComponent(raw.zone_id)}/control`, {method:'POST', body:{temporary_quick_thermostat:request}}); - const index = app.zones.findIndex(item => item.id === zone.id); if (index >= 0) app.zones[index] = zone; - form.closest('dialog').close(); renderAll(); scheduleControlPlanLoad(); toast(tr(raw.start_kind === 'now' ? 'zones.temporaryStarted' : 'zones.temporaryScheduledToast')); + const updatedZone = await api(`/api/zones/${encodeURIComponent(raw.zone_id)}/control`, {method:'POST', body:{temporary_quick_thermostat:request}}); + const index = app.zones.findIndex(item => item.id === updatedZone.id); if (index >= 0) app.zones[index] = updatedZone; + form.closest('dialog').close(); renderAll(); scheduleControlPlanLoad(); toast(tr(activeSession || startKind === 'now' ? 'zones.temporaryStarted' : 'zones.temporaryScheduledToast')); } catch (error) { toast(error.message, true); } finally { $('#temporaryThermostatSubmit').disabled = false; } });