This commit is contained in:
Mateusz Gruszczyński
2026-08-28 12:56:36 +02:00
parent 556a031358
commit 615b2836b7
12 changed files with 1279 additions and 331 deletions
+32 -21
View File
@@ -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.
Generated
+1 -1
View File
@@ -633,7 +633,7 @@ dependencies = [
[[package]]
name = "gree-controller"
version = "0.8.5"
version = "0.8.6"
dependencies = [
"aes",
"aes-gcm",
+1 -1
View File
@@ -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"
+11 -11
View File
@@ -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
+12 -2
View File
@@ -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`.
@@ -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",
+5 -3
View File
@@ -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",
+5 -3
View File
@@ -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",
+416 -116
View File
@@ -363,6 +363,9 @@ async fn get_device(State(state): State<AppState>, Path(id): Path<String>) -> Re
}
async fn patch_device(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<DevicePatch>) -> Result<Json<Device>, AppError> {
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<AppState>, Path(id): Path<String>, 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<AppState>, Path(id): Path<String>, 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<AppState>, Json(input): Json<GroupInpu
};
state.db.save_group(&group)?;
state.broadcast("group.created", serde_json::to_value(&group)?);
state.wake_zone_control();
Ok((StatusCode::CREATED, Json(group)))
}
@@ -1032,6 +1176,7 @@ async fn update_group(State(state): State<AppState>, Path(id): Path<String>, 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<AppState>, Path(id): Path<String>) ->
}
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<usize, AppEr
let mut cleared = 0;
for mut zone in state.db.list_zones()? {
if zone.local_thermostat_power.is_none() && zone.local_thermostat_resume_at.is_none() && zone.temporary_quick_thermostat.is_none() { continue; }
let temporary_was_active = engine::temporary_quick_thermostat_is_active(&zone, Utc::now());
let temporary_restore = zone.temporary_quick_thermostat.as_ref().and_then(|session| session.restore_zone_enabled);
zone.temporary_quick_thermostat = None;
engine::reset_local_thermostat_override(&mut zone);
if temporary_was_active {
if let Some(enabled) = temporary_restore { zone.enabled = enabled; }
}
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
@@ -1292,13 +1444,9 @@ async fn update_house_control(State(state): State<AppState>, Json(input): Json<H
state.broadcast("settings.updated", payload.clone());
if activate_all {
set_all_groups_power(&state, true)?;
let failed = command_all_enabled_devices_power(&state, true, "house_mode").await?;
if !failed.is_empty() {
state.log("warn", "house.mode_power_partial", "House mode enabled master power, but some devices could not be powered on", json!({
"mode": mode,
"failed": failed.len(),
}));
}
// Never send a bare power=true frame. Wake the thermostat arbiter so every unit
// starts only with a valid effective Heat/Cool mode and compressor lockout policy.
state.wake_zone_control();
}
state.log("info", "house.mode", &format!("House mode set to {}", mode), json!({"mode": mode, "master_power_enabled": activate_all}));
Ok(Json(payload))
@@ -1328,14 +1476,19 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
engine::clear_all_device_manual_overrides(&state, "house_power_off")?;
clear_all_local_thermostat_overrides(&state)?;
}
let failed = command_all_enabled_devices_power(&state, input.power, "house_power").await?;
let failed = if input.power {
state.wake_zone_control();
Vec::new()
} else {
command_all_enabled_devices_power(&state, false, "house_power").await?
};
let devices = state.db.list_devices()?;
let groups = state.db.list_groups()?;
let settings = state.settings.read().await;
let settings_payload = public_settings(&settings);
drop(settings);
state.log("info", "house.power_all", if input.power { "Whole-house power enabled; all enabled thermostat zones powered on" } else { "Whole-house power disabled; all groups and enabled devices powered off" }, json!({
state.log("info", "house.power_all", if input.power { "Whole-house automation enabled; thermostat arbiter resumed" } else { "Whole-house power disabled; all groups and enabled devices powered off" }, json!({
"power": input.power,
"failed": failed.len(),
}));
@@ -1370,7 +1523,11 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
let schedules = state.db.list_schedules()?;
let mut zones = state.db.list_zones()?;
for zone in &mut zones {
if input.preset == "auto" {
if engine::temporary_quick_thermostat_is_active(zone, Utc::now()) {
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
session.deferred_preset = Some(input.preset.clone());
}
} else if input.preset == "auto" {
zone.manual_preset = None;
zone.manual_setpoint = None;
zone.manual_override_until = None;
@@ -1384,7 +1541,9 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
}
let failed = command_all_enabled_devices_power(&state, true, "house_preset").await?;
// As with house mode/power ON, the central thermostat arbiter performs the physical
// start with a valid mode/target. This prevents unmanaged power-on while house mode=off.
let failed: Vec<Value> = 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<AppState>, Json(input): Json<Schedu
state.db.save_schedule(&item)?;
refresh_zone_override_boundary(&state, &item.zone_id)?;
state.broadcast("schedule.created", serde_json::to_value(&item)?);
state.wake_zone_control();
Ok((StatusCode::CREATED, Json(item)))
}
async fn update_schedule(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<ScheduleInput>) -> Result<Json<Schedule>, AppError> {
@@ -1563,6 +1736,7 @@ async fn update_schedule(State(state): State<AppState>, Path(id): Path<String>,
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<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
@@ -1570,6 +1744,7 @@ async fn delete_schedule(State(state): State<AppState>, Path(id): Path<String>)
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<AppState>) -> Json<Value> {
async fn update_settings(State(state): State<AppState>, Json(mut input): Json<RuntimeSettings>) -> Result<Json<Value>, 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<AppState>) -> Result<Json<ConfigurationExport>, 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<AppState>, Json(mut export): Json<ConfigurationExport>) -> Result<Json<Value>, 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<AppState>, Json(mut export): Json<C
for zone in &mut export.zones { canonicalize_zone_ha_entity(zone, &export.settings); }
validate_night_mode(&mut export.settings)?;
export.settings.influxdb.history_threshold_days = export.settings.influxdb.history_threshold_days.clamp(1, 3650);
// Before replacing ownership, safely stop every currently managed device whose zone is
// removed or rewired by the imported configuration. Otherwise an orphaned physical unit
// could keep running after its database owner disappears.
let imported_zone_map: std::collections::HashMap<String, String> = 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(&current.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<String> = export.groups.iter()
.filter(|group| !group.power_enabled)
.flat_map(|group| group.zone_ids.iter().cloned())
.collect();
let controllable_devices: std::collections::HashSet<String> = 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})))
+692 -148
View File
File diff suppressed because it is too large Load Diff
+52 -2
View File
@@ -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<DateTime<Utc>>,
/// 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<String>,
/// 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<bool>,
/// 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<bool>,
#[serde(default)]
pub restore_local_thermostat_resume_at: Option<DateTime<Utc>>,
#[serde(default)]
pub restore_local_thermostat_zone_enabled: Option<bool>,
#[serde(default)]
pub restore_manual_preset: Option<String>,
#[serde(default)]
pub restore_manual_setpoint: Option<f64>,
#[serde(default)]
pub restore_manual_override_until: Option<DateTime<Utc>>,
/// Hard end for duration/until/schedule-boundary modes.
#[serde(default)]
pub expires_at: Option<DateTime<Utc>>,
/// 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<u64>,
#[serde(default)]
pub safety_duration_seconds: Option<u64>,
/// Temperature condition used by reached/stable modes.
#[serde(default)]
pub temperature_target: Option<f64>,
@@ -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<DateTime<Utc>>,
/// 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<DateTime<Utc>>,
/// 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<DateTime<Utc>>,
/// 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<String>,
#[serde(default)]
pub deferred_preset: Option<String>,
/// Optional fail-safe for temperature-based modes.
#[serde(default)]
pub safety_expires_at: Option<DateTime<Utc>>,
@@ -425,6 +471,10 @@ pub struct Zone {
/// the current group/schedule state is evaluated again.
#[serde(default)]
pub local_thermostat_resume_at: Option<DateTime<Utc>>,
/// 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<bool>,
/// 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.
+51 -22
View File
@@ -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; }
});