v0.8.6
This commit is contained in:
+32
-21
@@ -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
@@ -633,7 +633,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gree-controller"
|
||||
version = "0.8.5"
|
||||
version = "0.8.6"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
||||
+1
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(¤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<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
File diff suppressed because it is too large
Load Diff
+52
-2
@@ -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
@@ -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; }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user