diff --git a/BUILD_REPORT.md b/BUILD_REPORT.md index c875981..e721314 100644 --- a/BUILD_REPORT.md +++ b/BUILD_REPORT.md @@ -1,3 +1,27 @@ +# GREE Controller v0.8.0 - build and validation report + +## 0.8.0 ownership/concurrency/safety release + +Version 0.8.0 keeps the 0.7.13 public behavior while adding explicit control ownership, per-zone serialization and optimistic revisions, stale Home Assistant sensor protection, schedule-boundary recalculation for physical manual takeover, automatic Heat/Cool compressor lockout, desired-vs-actual diagnostics, and owner/source status in Web/Home Assistant. + +### Compatibility + +- Existing zone JSON remains loadable because every new persisted field uses serde defaults. +- The `revision` request field is optional, so older Web/API clients continue to work; clients that send it receive HTTP 409 on stale writes. +- Existing `control-plan.control_source` keeps its historical meaning (temperature source). The new command provenance is exported as `control_command_source` to avoid breaking Home Assistant or third-party consumers. +- Existing physical and zone climate entity unique IDs remain unchanged. + +### Validation available in this environment + +- `node --check` passed for Web JavaScript. +- All JSON files parse successfully. +- Python Home Assistant integration and helper scripts pass `py_compile`. +- Shell scripts pass `bash -n`. +- Structural checks confirm all `Zone` and `ZoneControlPlan` literals contain the newly required fields. +- A Rust compiler/toolchain is not installed in this container and external network access from the container is disabled, so `cargo fmt/check/test` cannot be executed here. The release/update scripts must still run the normal Cargo build/test gate on the target host before replacing the service binary. + +--- + # GREE Controller v0.7.13 - build and validation report ## Scope diff --git a/Cargo.lock b/Cargo.lock index 99fd2d0..0b1b37f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.7.13" +version = "0.8.0" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index 7586bde..ad8fc9a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.7.13" +version = "0.8.0" edition = "2021" authors = ["GREE Controller contributors"] description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support" diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index 8da9e9c..69cb804 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,10 +1,10 @@ d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -f06554764445d6b205835f5dba8a03e31cb7eeb23712bfda6c9b7bb75a73bbd0 ./BUILD_REPORT.md -e44ec284006409396980d5d462bbb0b2620735d2112155302fec1cf45a406813 ./Cargo.lock -8ccdf12dfb6019cfe20a6bba3a6d06f01fe41ecf344c6943214ebdbd60f41180 ./Cargo.toml +06e42aabd06d4ef09df25ae15f8852154048f6d0b0a5cd8fb44ff0ffb0e118ea ./BUILD_REPORT.md +938bb478339eb1a2f27a4e049e208fb507ab94b538e37b9e639cbcb9c1ac97df ./Cargo.lock +2cbea84c5ae579cfee7000cd25139288ec468fc2e94eeb7e26984a82b1fa7e8a ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -9de6fcac8991224d629cca6eb6cf302df35b0166ac1d54ab18e44aca83c5eaec ./README.md +44218c5dcd772cd58d0dc10a99b7b02ef84b6ba7fe68870995c4e22e3047b2c4 ./README.md f2c8eb28fedb26bb8f5636e1805e7a782cc472b87d882f6752d520f16216a248 ./build.rs 3f774fbdc32de8f437d7cf9c1ceb2ae195c674d0f48b8891b25cf36d152ee2c4 ./docs/API.md 234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md @@ -17,14 +17,14 @@ c3f3f12206c552d3fb919022d64b3fa60342fc1031081e893b7efd743961c402 ./future.md 95e3c8c99ba439a86a2fb05cfd8ef00c75cb959a024a349a082da935b726f500 ./home-assistant/README.md f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py 6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py -3d0169ddabe6c664f71e76582995ac749c35214ca717654d296f4da8b909f2bf ./home-assistant/custom_components/gree_controller/climate.py +71c8058f85fd9ce9f063b7a84dcf2956f23cdbbd02bd4bd99d2fbb9d177c72ad ./home-assistant/custom_components/gree_controller/climate.py 5e4aef2143e81bedb5a15dd4be5c71b64a3ab448ec6d33a20851edd098e5f529 ./home-assistant/custom_components/gree_controller/config_flow.py b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./home-assistant/custom_components/gree_controller/const.py ab08fbe40e9bb48ebcbfff98760aaa0c9434b61b705aaaadea0c09c255d99b0e ./home-assistant/custom_components/gree_controller/coordinator.py 5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.png c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png -fceb28e4bf59829536bbe1a106edc55c685f1a53b6ec5aff12c6c01636159d67 ./home-assistant/custom_components/gree_controller/manifest.json +4f7a62c909a836b10d8b2c86685e156e785b97d2b8d3466a97698c41e0005302 ./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 @@ fceb28e4bf59829536bbe1a106edc55c685f1a53b6ec5aff12c6c01636159d67 ./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 -cb206af3652be23991686e18d84e3855f5782686a4b61ee4526ebcc9113f5a10 ./lang/en.json -d8fbf934dce8d5e295fcf992dd85cab787fd44cae7d36c70e0d943d56403d222 ./lang/pl.json +2bb4ce4b0eff314bd20288a0d93b0e4dd38fe22a2367bf912434c9b7e658b88f ./lang/en.json +11e23a02a9c979860f086d50f71591084629ae198df08ab3065fdb3e2236d6ee ./lang/pl.json 028e1f16e9fbaed57cadb88eff04e65b4bd67722c50b4d6b1fb525f5a2f39abf ./make_zip.py bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh @@ -47,26 +47,26 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh -496b8ff31ec187392ab19efc5834f4d2ad9d1bb27fed65b81a62e8cd7537c265 ./src/api.rs +723c2c18245079c44b24e66d738dd77a3e6b4d2ac934f2329b7cc7ae62f7a2e3 ./src/api.rs 32b1d0a7ac2060fd18fe7f1ca1d110ee45beaf67e81514d24d6bde3d36301387 ./src/config.rs 5dfda2f4dc540c502885b0cd7017dc77768684588acf528f01d1fd88f1af4aec ./src/db.rs -9328e7b1dfdedaaa6cdb12eb6d26d5b6d2d41afa7fac25745e4ed819ff997ce3 ./src/engine.rs -ae3b496749a3fd723b243d9bea92e5d76249f52814c80359ad9bac53abacb074 ./src/error.rs -081f6909e95839c7da8c40b866af9de237e0540f3e65e1ce739ce70a6fa94846 ./src/home_assistant.rs +c935a7b3e136a314c626d8e01da9613a56c7545db3868124b8640d7b1db63d79 ./src/engine.rs +4b271b6fc365b1078c01d6178eb563841b2ecaed5d8639196f58e1312d2236fe ./src/error.rs +fbe5b18bfe1a8298c99850bfab54c2bf13c1824c0df40669ad52eff125aab964 ./src/home_assistant.rs 190b0a33431539676e5dd7796698077f16c179d42eae4501ca96a91bf797cbf8 ./src/influxdb.rs -4ae0cc270938a9626148490c04c69b01cda75c30547a63a6a1b377b2109a0f9c ./src/main.rs -7de365e80b73a16b9429911fa683e0425830be7041fc889b594332de81d72cd5 ./src/models.rs +c67212da6a2bd5c2933ece31586ca4048b484fda3105f23a6e06e881b079d129 ./src/main.rs +dcd73be37622f06de921175ab4f9be8b7d22f23994c32496b7b0ccd39db7560f ./src/models.rs ea4bd7f20759101c2525600a1f342d2fc0946e2f0b3208b03cc5a415b6947c10 ./src/notifications.rs 7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs bc03d88e5476386747ff5e32f1788cdc1fcd4bef238ab66df2dada282c348c8f ./src/protocol/gree.rs a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs 6a1c0cab3eab80ecd254c5e486b1fba068523edb8d699c64054c17f67f4a31d0 ./src/queries.rs -d035b2348e1e02718e2c9cdca0ef672860bc19d5c705dab7c225040bb5c5c788 ./src/state.rs +2d69811db832c90ce06035ee29665205a04e4c514a21ecae9623991a9b14d825 ./src/state.rs b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service -1d7674e86a019bbd86557fcc9e6b67a100a1038907b84c7bc1cf6e504f7e1261 ./web/app.js +591b0153e307d025281cb72c8355d84cf8caab0db4633aca9cbec08676f25d2a ./web/app.js e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg -d22d27b9a9355ab5f67b49c38dd6d86e6fcf5c16292c767b7e0dc70573cc2898 ./web/index.html +e97db3feed31236152da42726e50455dcac08620a0da81cc4208c325b986fde5 ./web/index.html fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest 1bccea6bfdbd0d5fec82f777886ce4704823952012f6f5625c0d98bca74d2dc4 ./web/styles.css -a10004855fc92eb7572752a06bd2bfc89c1be1421fb9996af88527a94ceef2ad ./web/sw.js +f44697b9306c14224bed65b084cdcd2a453e876e1fc7e3db8d3d153a94c76ff3 ./web/sw.js d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js diff --git a/README.md b/README.md index cc85eed..91b8aa6 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Standalone local GREE air-conditioner controller written in Rust. It runs on a regular Linux host or an LXC container and provides a mobile-first web interface without depending on the vendor cloud. -Current version: **0.7.13**. +Current version: **0.8.0**. ## 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.7.13.zip +unzip gree-controller-v0.8.0.zip cd gree-controller chmod +x scripts/*.sh ./scripts/dev.sh @@ -478,6 +478,18 @@ Zones now use setpoint modulation, global seasonal house mode, cooling/heating C + +## 0.8.0 Control ownership and thermostat safety + +- Adds an explicit zone control-ownership model (`control_owner`, command source, since/resume/reason) shared by the engine, API, Web UI and Home Assistant control plan. +- Distinguishes direct Web, direct Home Assistant and external/remote takeovers. Quick Thermostat commands explicitly return ownership to the thermostat. +- Adds per-zone operation mutexes plus an optional monotonic `revision` check for conflict-safe zone updates without breaking older clients that do not send a revision. +- Recomputes both thermostat override and physical-device manual takeover deadlines after schedule edits. +- Rejects stale Home Assistant temperature samples (default 300 seconds per zone) and falls back to the GREE sensor through the existing fallback path. +- Enforces automatic HVAC compressor protection: minimum OFF time before restart and an OFF delay when automatically changing Heat/Cool modes. Direct/manual commands and global safety OFF remain authoritative. +- Extends control-plan with desired vs actual HVAC state, ownership, block reason and lockout deadline. +- Shows the current control owner and timing in the Web dashboard. Home Assistant zone climate exposes the same diagnostics; the physical climate is named `Direct control` to make its semantics explicit. + ## 0.7.13 Quick-thermostat race protection and GREE frame counters - Quick thermostat actions from Web/Home Assistant are serialized with device polling, so a poll running at the same time cannot restore an older `device_manual_override` snapshot after the thermostat deliberately takes control back. diff --git a/home-assistant/custom_components/gree_controller/climate.py b/home-assistant/custom_components/gree_controller/climate.py index 3a02e48..adc2d7b 100644 --- a/home-assistant/custom_components/gree_controller/climate.py +++ b/home-assistant/custom_components/gree_controller/climate.py @@ -152,7 +152,7 @@ class GreeControllerClimate(CoordinatorEntity[GreeControllerCoordinator], Climat super().__init__(coordinator) self._device_id = device_id self._attr_unique_id = f"{device_id}-climate" - self._attr_name = None + self._attr_name = "Direct control" if requested_entity_id: # This is intentionally limited to same-domain takeover migrations. # The setup guard above prevents accidental collisions. @@ -338,7 +338,19 @@ class GreeControllerZoneClimate(CoordinatorEntity[GreeControllerCoordinator], Cl "preset_override": zone.get("preset_override"), "demand": bool(zone.get("demand", False)), "device_setpoint": zone.get("device_setpoint"), - "control_source": zone.get("control_source"), + "temperature_source": zone.get("control_source"), + "control_owner": zone.get("control_owner"), + "control_source": zone.get("control_command_source"), + "control_since": zone.get("control_since"), + "resume_at": zone.get("resume_at"), + "control_reason": zone.get("control_reason"), + "blocked_reason": zone.get("blocked_reason"), + "lockout_until": zone.get("lockout_until"), + "desired_power": zone.get("desired_power"), + "desired_mode": zone.get("desired_mode"), + "actual_power": zone.get("actual_power"), + "actual_mode": zone.get("actual_mode"), + "actual_setpoint": zone.get("actual_setpoint"), "current_schedule": zone.get("current_schedule_name"), } diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index bb64616..c31a992 100644 --- a/home-assistant/custom_components/gree_controller/manifest.json +++ b/home-assistant/custom_components/gree_controller/manifest.json @@ -1,7 +1,7 @@ { "domain": "gree_controller", "name": "GREE Controller", - "version": "0.7.13", + "version": "0.8.0", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/lang/en.json b/lang/en.json index 9002148..d81b2ce 100644 --- a/lang/en.json +++ b/lang/en.json @@ -612,5 +612,15 @@ "zones.localThermostatOffDescriptionTimed": "This zone is locally off. Automation takes control again in {time}.", "zones.localThermostatResuming": "Timer elapsed — automation is taking control…", "zones.resumeNow": "Resume now" - } + }, + "zones.controlOwner": "Control owner", + "zones.ownerAutomation": "Automation", + "zones.ownerLocalThermostat": "Local thermostat", + "zones.ownerExternal": "Remote / external control", + "zones.ownerHaDirect": "HA — direct control", + "zones.ownerWebDirect": "Web — direct control", + "zones.ownerGlobalOff": "Globally off", + "zones.ownerSince": "since", + "zones.ownerResume": "until", + "zones.lockoutUntil": "lockout until" } diff --git a/lang/pl.json b/lang/pl.json index 395bd55..62e9bd3 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -612,5 +612,15 @@ "zones.localThermostatOffDescriptionTimed": "Strefa jest lokalnie wyłączona. Automatyka przejmie sterowanie za {time}.", "zones.localThermostatResuming": "Czas minął — automatyka przejmuje sterowanie…", "zones.resumeNow": "Wznów teraz" - } + }, + "zones.controlOwner": "Właściciel sterowania", + "zones.ownerAutomation": "Automatyka", + "zones.ownerLocalThermostat": "Termostat lokalny", + "zones.ownerExternal": "Pilot / sterowanie zewnętrzne", + "zones.ownerHaDirect": "HA — sterowanie bezpośrednie", + "zones.ownerWebDirect": "Web — sterowanie bezpośrednie", + "zones.ownerGlobalOff": "Globalnie wyłączone", + "zones.ownerSince": "od", + "zones.ownerResume": "do", + "zones.lockoutUntil": "blokada do" } diff --git a/src/api.rs b/src/api.rs index 5f008b9..533a2d7 100644 --- a/src/api.rs +++ b/src/api.rs @@ -85,7 +85,7 @@ pub fn router(state: AppState) -> Router { .route("/api/integrations/home-assistant/house/control", post(update_house_control)) .route("/api/integrations/home-assistant/house/preset", post(update_house_preset)) .route("/api/integrations/home-assistant/house/power", post(update_house_power)) - .route("/api/integrations/home-assistant/zones/:id/control", post(update_zone_control)) + .route("/api/integrations/home-assistant/zones/:id/control", post(update_home_assistant_zone_control)) .route_layer(middleware::from_fn_with_state(state.clone(), home_assistant_auth)); let app = Router::new() @@ -487,6 +487,10 @@ struct ZoneInput { external_sensor_weight: f64, #[serde(default = "max_sensor_difference")] max_sensor_difference: f64, + #[serde(default = "sensor_stale_after")] + sensor_stale_after_seconds: u64, + #[serde(default)] + revision: Option, } fn yes() -> bool { true } fn cool() -> String { "cool".into() } @@ -503,6 +507,7 @@ fn min_adjust() -> u64 { 120 } fn standby_offset() -> f64 { 2.0 } fn external_sensor_weight() -> f64 { 0.4 } fn max_sensor_difference() -> f64 { 3.0 } +fn sensor_stale_after() -> u64 { 300 } fn device_source() -> String { "device".into() } impl ZoneInput { @@ -533,10 +538,12 @@ impl ZoneInput { hysteresis: self.hysteresis, min_on_seconds: self.min_on_seconds, min_off_seconds: self.min_off_seconds, min_adjust_seconds: self.min_adjust_seconds, standby_offset_c: self.standby_offset_c, smart_fan: self.smart_fan, 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, + 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, 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, effective_mode: String::new(), effective_setpoint: None, device_setpoint: None, demand: false, demand_since: None, target_alerted_at: None, last_action_at: None, created_at, updated_at: Utc::now(), @@ -568,10 +575,30 @@ async fn create_zone(State(state): State, Json(input): Json } async fn update_zone(State(state): State, Path(id): Path, Json(input): Json) -> Result, AppError> { input.validate()?; - let existing = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; + let _zone_guard = state.lock_zone_operation(&id).await; + let mut existing = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; + if let Some(expected) = input.revision { + if expected != existing.revision { + return Err(AppError::Conflict(format!("zone {id} changed; expected revision {expected}, current revision {}", existing.revision))); + } + } if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); } validate_zone_device_assignment(&state, &input.device_id, Some(&id))?; let device_changed = existing.device_id != input.device_id; + // Serialize a normal zone edit with polling/manual-takeover detection for its device. + // Device reassignment uses ensure_device_stopped_for_detach below, which acquires the + // old device lock itself while this zone lock is held. + let _device_guard = if !device_changed { Some(state.lock_device_operation(&existing.device_id).await) } else { None }; + if !device_changed { + // Polling may have updated takeover/runtime state while we were waiting for the + // device lock. Re-read under both locks before building the replacement Zone. + existing = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; + if let Some(expected) = input.revision { + if expected != existing.revision { + return Err(AppError::Conflict(format!("zone {id} changed; expected revision {expected}, current revision {}", existing.revision))); + } + } + } let mut zone = input.into_zone(id, existing.created_at); if !device_changed { zone.device_temperature = existing.device_temperature; @@ -589,6 +616,16 @@ async fn update_zone(State(state): State, Path(id): Path, Json zone.device_manual_override_until = existing.device_manual_override_until; zone.device_manual_override_fields = existing.device_manual_override_fields; zone.device_manual_override_baseline = existing.device_manual_override_baseline; + zone.revision = existing.revision.saturating_add(1); + zone.control_owner = existing.control_owner; + zone.control_source = existing.control_source; + zone.control_since = existing.control_since; + zone.control_resume_at = existing.control_resume_at; + zone.control_reason = existing.control_reason; + zone.last_power_change_at = existing.last_power_change_at; + zone.last_mode_change_at = existing.last_mode_change_at; + zone.lockout_until = existing.lockout_until; + zone.lockout_reason = existing.lockout_reason; zone.effective_mode = existing.effective_mode; zone.effective_setpoint = existing.effective_setpoint; zone.device_setpoint = existing.device_setpoint; @@ -600,23 +637,26 @@ async fn update_zone(State(state): State, Path(id): Path, Json // A new physical unit starts with a clean ownership/runtime state. Never transfer // demand, sensor cache or remote-control takeover from the previous device. ensure_device_stopped_for_detach(&state, &existing.device_id, "zone.device_reassigned").await?; + zone.revision = existing.revision.saturating_add(1); } let settings = state.settings.read().await.clone(); canonicalize_zone_ha_entity(&mut zone, &settings); let power_off_device = !device_changed && existing.enabled && !zone.enabled; state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); + drop(_device_guard); if power_off_device { power_off_zone_device(&state, &zone, "zone.disabled").await; } Ok(Json(zone)) } -async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControlPatch) -> Result { +async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControlPatch, source: &str) -> Result { // Serialize quick-thermostat changes with the same device lock used by GREE polling and // manual-takeover detection. Without this, a poll that started just before a Web/HA // thermostat action could save an older zone snapshot afterwards and resurrect a false // "physical/pilot" takeover. + let _zone_guard = state.lock_zone_operation(id).await; let device_id = state.db.get_zone(id)? .ok_or_else(|| AppError::NotFound(format!("zone {id}")))? .device_id; @@ -631,6 +671,9 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl 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(); + if resume_device_automation || resume_local_thermostat { + zone.control_source = if source.contains("home_assistant") { "home_assistant_thermostat".into() } else { "web_thermostat".into() }; + } if resume_local_thermostat { engine::reset_local_thermostat_override(&mut zone); @@ -696,8 +739,13 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl if !value { engine::reset_local_thermostat_override(&mut zone); } } let device_override_cleared = if resume_device_automation { engine::reset_device_manual_override(&mut zone) } else { false }; - let house_mode = state.settings.read().await.house_mode.clone(); + let runtime = state.settings.read().await.clone(); + let house_mode = runtime.house_mode.clone(); + let blocked_by_group = zone.local_thermostat_power != Some(true) + && 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); + zone.revision = zone.revision.saturating_add(1); zone.updated_at = Utc::now(); state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); @@ -731,7 +779,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl } async fn update_zone_control(State(state): State, Path(id): Path, Json(patch): Json) -> Result, AppError> { - Ok(Json(apply_zone_control_patch(&state, &id, patch).await?)) + Ok(Json(apply_zone_control_patch(&state, &id, patch, "web.zone_thermostat").await?)) } @@ -1254,6 +1302,10 @@ async fn apply_schedule_template(State(state): State, Path(id): Path, Path(id): Path, Json(patch): Json) -> Result, AppError> { + Ok(Json(apply_zone_control_patch(&state, &id, patch, "home_assistant.zone_thermostat").await?)) +} + async fn delete_zone(State(state): State, Path(id): Path) -> Result { let zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; let mut removed = std::collections::HashSet::new(); @@ -1319,9 +1371,12 @@ 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() { return Ok(()); } + if zone.manual_preset.is_none() && zone.manual_setpoint.is_none() && !zone.device_manual_override { return Ok(()); } let schedules = state.db.list_schedules()?; - zone.manual_override_until = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()); + let boundary = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()); + if zone.manual_preset.is_some() || zone.manual_setpoint.is_some() { zone.manual_override_until = boundary; } + if zone.device_manual_override { zone.device_manual_override_until = boundary; zone.control_resume_at = boundary; } + zone.revision = zone.revision.saturating_add(1); zone.updated_at = Utc::now(); state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); @@ -2134,7 +2189,7 @@ struct HaTestRequest { entity_id: Option } async fn test_home_assistant(State(state): State, Json(input): Json) -> Result, AppError> { let settings = state.settings.read().await.clone(); let resolved_entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, input.entity_id.as_deref()); - let temperature = home_assistant::read_temperature(&state.http, &settings.home_assistant, resolved_entity_id.as_deref()) + let temperature = home_assistant::read_temperature(&state.http, &settings.home_assistant, resolved_entity_id.as_deref(), Some(300)) .await.map_err(|e| AppError::Device(e.to_string()))?; Ok(Json(json!({"ok": true, "temperature_c": temperature, "entity_id": resolved_entity_id}))) } diff --git a/src/engine.rs b/src/engine.rs index 2e33408..2af6735 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -280,6 +280,8 @@ async fn send_command_locked_inner( remember_controller_command(state, device_id, &applied_command, &controller_command_baseline).await; } + record_device_transition_timestamps(state, &controller_command_baseline, &device)?; + state.log("info", "device.command", &format!("Updated {}", device.name), json!({ "device_id": device.id, "command": applied_command, @@ -289,6 +291,21 @@ async fn send_command_locked_inner( Ok(device) } +fn record_device_transition_timestamps(state: &AppState, before: &Device, after: &Device) -> Result<(), AppError> { + if before.power == after.power && before.mode == after.mode { return Ok(()); } + let now = Utc::now(); + for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == after.id) { + if before.power != after.power { zone.last_power_change_at = Some(now); } + if before.mode != after.mode { zone.last_mode_change_at = Some(now); } + // Do not bump zone.updated_at here: an in-flight thermostat cycle uses that field + // as its optimistic snapshot guard. The cycle mirrors these timestamps into its own + // computed Zone after a successful automatic command. + state.db.save_zone(&zone)?; + state.broadcast("zone.updated", serde_json::to_value(&zone)?); + } + Ok(()) +} + pub async fn poll_one(state: &AppState, device_id: &str) -> Result { let _device_guard = state.lock_device_operation(device_id).await; poll_one_locked(state, device_id).await @@ -300,6 +317,9 @@ async fn poll_one_locked(state: &AppState, device_id: &str) -> Result Result zone.control_source.clone(), + _ => "external".into(), + }; + ("direct_manual", source, zone.device_manual_override_until, "Direct/manual device control has priority".to_string()) + } else if zone.local_thermostat_power.is_some() { + let source = match zone.control_source.as_str() { + "home_assistant_thermostat" | "web_thermostat" => zone.control_source.clone(), + _ => "local_thermostat".into(), + }; + ("local_thermostat", source, zone.local_thermostat_resume_at, if zone.local_thermostat_power == Some(false) { "Local thermostat is explicitly off".into() } else { "Local thermostat owns the zone".into() }) + } else if blocked_by_group { + ("automation", "group".to_string(), None, "Zone is blocked by a disabled group".to_string()) + } else { + ("automation", "automation".to_string(), zone.manual_override_until, "Automatic thermostat/schedule control".to_string()) + }; + if zone.control_owner != owner || zone.control_source != source { + zone.control_since = Some(now); + } else if zone.control_since.is_none() { + zone.control_since = Some(now); + } + zone.control_owner = owner.into(); + zone.control_source = source; + zone.control_resume_at = resume_at; + zone.control_reason = reason; +} + +fn normalized_direct_source(source: &str) -> &'static str { + if source.contains("home_assistant") { "home_assistant_direct" } + else if source == "device.manual_control" { "web_direct" } + else { "external" } +} + pub fn reset_device_manual_override(zone: &mut Zone) -> bool { let changed = zone.device_manual_override || zone.device_manual_override_since.is_some() @@ -669,6 +727,13 @@ pub fn reset_device_manual_override(zone: &mut Zone) -> bool { zone.device_manual_override_until = None; zone.device_manual_override_fields.clear(); zone.device_manual_override_baseline = None; + if zone.control_owner == "direct_manual" { + zone.control_owner = "automation".into(); + zone.control_source = "automation".into(); + zone.control_since = Some(Utc::now()); + zone.control_resume_at = None; + zone.control_reason = "Manual takeover cleared; automation may resume".into(); + } changed } @@ -716,13 +781,18 @@ fn set_device_manual_override(state: &AppState, zone: &mut Zone, fields: Vec Result { + // Keep zone -> device lock ordering consistent with Quick Thermostat/full-zone edits. + // A device belongs to at most one thermostat zone, but keep this generic for legacy data. + let zone_ids: Vec = state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id).map(|zone| zone.id).collect(); + let mut _zone_guards = Vec::new(); + for zone_id in &zone_ids { _zone_guards.push(state.lock_zone_operation(zone_id).await); } // Keep the device lock until the zone takeover marker is persisted. Otherwise a poll // could observe our own just-sent command before the controller records manual ownership. let _device_guard = state.lock_device_operation(device_id).await; @@ -871,6 +946,9 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl let mut zones = Vec::new(); for zone_id in &group.zone_ids { + let _zone_guard = state.lock_zone_operation(zone_id).await; + let Some(zone_snapshot) = state.db.get_zone(zone_id)? else { continue; }; + let _device_guard = state.lock_device_operation(&zone_snapshot.device_id).await; let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; }; if let Some(mode) = patch.mode.as_deref() { match mode { @@ -893,6 +971,7 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &schedules, Local::now()); } } + zone.revision = zone.revision.saturating_add(1); zone.updated_at = Utc::now(); state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); @@ -1079,7 +1158,7 @@ async fn control_zones(state: &AppState) -> Result<()> { home_assistant::resolve_entity_id(&settings.home_assistant, Some(configured_outdoor)) }; let ha_outdoor_temperature = if let Some(entity_id) = resolved_outdoor.as_deref() { - match home_assistant::read_temperature(&state.http, &settings.home_assistant, Some(entity_id)).await { + match home_assistant::read_temperature(&state.http, &settings.home_assistant, Some(entity_id), Some(300)).await { Ok(value) => { record_ha_history( state, @@ -1126,7 +1205,7 @@ async fn control_zones(state: &AppState) -> Result<()> { let http = &state.http; let ha_settings = &settings.home_assistant; Some(async move { - let result = home_assistant::read_temperature(http, ha_settings, resolved_entity.as_deref()).await + let result = home_assistant::read_temperature(http, ha_settings, resolved_entity.as_deref(), Some(zone.sensor_stale_after_seconds)).await .map_err(|err| err.to_string()); (zone_id, resolved_entity, result) }) @@ -1162,6 +1241,9 @@ async fn control_zones(state: &AppState) -> Result<()> { zone.mode.as_str() }; zone.effective_mode = effective_mode.to_string(); + let ownership_blocked_by_group = zone.local_thermostat_power != Some(true) + && groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id)); + refresh_control_ownership(&mut zone, settings.house_power_enabled, ownership_blocked_by_group); let previous_source = zone.control_temperature_source.clone(); // Never feed the thermostat a cached GREE temperature after any communication @@ -1278,8 +1360,7 @@ async fn control_zones(state: &AppState) -> Result<()> { continue; } - let blocked_by_group = zone.local_thermostat_power != Some(true) - && groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id)); + let blocked_by_group = ownership_blocked_by_group; if blocked_by_group { zone.effective_mode = "off".into(); zone.demand = false; @@ -1436,8 +1517,52 @@ async fn control_zones(state: &AppState) -> Result<()> { device.sleep, ); + // Compressor protection for automatic ownership. Direct/manual commands and global safety OFF + // deliberately bypass this path, while the thermostat never performs an immediate Heat<->Cool swap. + let now = Utc::now(); + if zone.lockout_until.map(|until| until <= now).unwrap_or(false) { + zone.lockout_until = None; + zone.lockout_reason = None; + } + if device.power && device.mode != effective_mode { + let min_on = chrono::Duration::seconds(zone.min_on_seconds as i64); + if zone.last_power_change_at.map(|at| now.signed_duration_since(at) < min_on).unwrap_or(false) { + let until = zone.last_power_change_at.map(|at| at + min_on); + zone.lockout_until = until; + zone.lockout_reason = Some("minimum_on_before_mode_change".into()); + record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); + let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; + state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); + continue; + } + match send_zone_command_if_owned(state, &zone.id, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }, false).await { + Ok(Some(_)) => { + zone.last_power_change_at = Some(now); + zone.lockout_until = Some(now + chrono::Duration::seconds(zone.min_off_seconds as i64)); + zone.lockout_reason = Some("mode_change_off_delay".into()); + state.log("info", "zone.mode_change_lockout", &format!("Zone {} switched off before {} mode", zone.name, effective_mode), json!({"zone_id": zone.id, "resume_at": zone.lockout_until})); + } + Ok(None) => {} + Err(err) => state.log("error", "zone.mode_change_off_error", &err.to_string(), json!({"zone_id": zone.id})), + } + record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); + let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; + state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); + continue; + } + if !device.power { + let min_off = chrono::Duration::seconds(zone.min_off_seconds as i64); + if zone.last_power_change_at.map(|at| now.signed_duration_since(at) < min_off).unwrap_or(false) { + zone.lockout_until = zone.last_power_change_at.map(|at| at + min_off); + zone.lockout_reason = Some("minimum_off_before_start".into()); + record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); + let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; + state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); + continue; + } + } + let core_needs_command = !device.power - || device.mode != effective_mode || (device.target_temperature - desired_device_target).abs() >= 0.5; // In normal standby, Low fan is a transition hint rather than a state that should // be reasserted forever. Some GREE firmwares accept the frame but later report Auto @@ -1451,8 +1576,8 @@ async fn control_zones(state: &AppState) -> Result<()> { || desired_quiet.map(|quiet| quiet != device.quiet).unwrap_or(false) || desired_sleep.map(|sleep| sleep != device.sleep).unwrap_or(false); - let urgent_mode_change = !device.power || device.mode != effective_mode; - if needs_command && (urgent_mode_change || adjustment_allowed(&zone)) { + let urgent_start = !device.power; + if needs_command && (urgent_start || adjustment_allowed(&zone)) { let command = DeviceCommand { power: Some(true), mode: Some(effective_mode.to_string()), @@ -1464,8 +1589,11 @@ async fn control_zones(state: &AppState) -> Result<()> { }; match send_zone_command_if_owned(state, &zone.id, &zone.device_id, command, false).await { Ok(Some(updated_device)) => { + let transition_at = Utc::now(); + if device.power != updated_device.power { zone.last_power_change_at = Some(transition_at); } + if device.mode != updated_device.mode { zone.last_mode_change_at = Some(transition_at); } zone.device_setpoint = if updated_device.power { Some(updated_device.target_temperature) } else { None }; - zone.last_action_at = Some(Utc::now()); + zone.last_action_at = Some(transition_at); state.log("info", "zone.setpoint_modulation", &format!("Zone {} -> {:.1} C ({})", zone.name, desired_device_target, if zone.demand { "demand" } else { "standby" }), json!({ "zone_id": zone.id, "room_temperature": temp, @@ -1900,7 +2028,7 @@ pub async fn build_control_plan(state: &AppState) -> Result Result Result Result Utc::now()).unwrap_or(false) { Some(zone.lockout_reason.clone().unwrap_or_else(|| "lockout".into())) } else if !zone.enabled { Some("zone_disabled".into()) } else if device.map(|d| !d.online || d.communication_failures > 0).unwrap_or(true) { Some("offline".into()) } else { None }, + lockout_until: zone.lockout_until, current_schedule_id: active.map(|item| item.id.clone()), current_schedule_name: active.map(|item| item.name.clone()), next_events, @@ -2362,10 +2503,11 @@ mod tests { heat_comfort_setpoint: 21.0, heat_sleep_setpoint: 19.0, heat_away_setpoint: 17.0, hysteresis: 0.6, min_on_seconds: 180, min_off_seconds: 180, min_adjust_seconds: 120, standby_offset_c: 2.0, smart_fan: true, sensor_source: source.into(), ha_entity_id: Some("sensor.room_temperature".into()), - external_sensor_weight: 0.4, max_sensor_difference: 3.0, device_temperature: None, external_temperature: None, + external_sensor_weight: 0.4, max_sensor_difference: 3.0, sensor_stale_after_seconds: 300, device_temperature: None, external_temperature: None, current_temperature: None, control_temperature_source: "device".into(), active_preset: "comfort".into(), manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None, local_thermostat_resume_at: None, device_manual_override: false, device_manual_override_since: None, device_manual_override_until: None, device_manual_override_fields: Vec::new(), device_manual_override_baseline: None, + revision: 1, control_owner: "automation".into(), control_source: "automation".into(), control_since: Some(Utc::now()), control_resume_at: None, control_reason: "test".into(), last_power_change_at: None, last_mode_change_at: None, lockout_until: None, lockout_reason: None, effective_mode: "heat".into(), effective_setpoint: Some(21.0), device_setpoint: None, demand: false, demand_since: None, target_alerted_at: None, last_action_at: None, created_at: Utc::now(), updated_at: Utc::now(), } diff --git a/src/error.rs b/src/error.rs index 8651959..716e5ad 100644 --- a/src/error.rs +++ b/src/error.rs @@ -8,6 +8,8 @@ pub enum AppError { NotFound(String), #[error("invalid request: {0}")] BadRequest(String), + #[error("conflict: {0}")] + Conflict(String), #[error("unauthorized")] Unauthorized, #[error("device communication failed: {0}")] @@ -21,6 +23,7 @@ impl IntoResponse for AppError { let (status, message) = match &self { Self::NotFound(v) => (StatusCode::NOT_FOUND, v.clone()), Self::BadRequest(v) => (StatusCode::BAD_REQUEST, v.clone()), + Self::Conflict(v) => (StatusCode::CONFLICT, v.clone()), Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".into()), Self::Device(v) => (StatusCode::BAD_GATEWAY, v.clone()), Self::Internal(v) => { diff --git a/src/home_assistant.rs b/src/home_assistant.rs index af4c3da..c67d096 100644 --- a/src/home_assistant.rs +++ b/src/home_assistant.rs @@ -40,6 +40,7 @@ pub async fn read_temperature( default_client: &reqwest::Client, settings: &HomeAssistantSettings, entity_override: Option<&str>, + stale_after_seconds: Option, ) -> Result { if settings.url.trim().is_empty() { bail!("Home Assistant URL is not configured") } if settings.token.trim().is_empty() { bail!("Home Assistant token is not configured") } @@ -62,6 +63,13 @@ pub async fn read_temperature( bail!("Home Assistant returned {status}: {}", body.chars().take(200).collect::()) } let payload: Value = response.json().await.context("invalid Home Assistant JSON")?; + if let Some(limit) = stale_after_seconds.filter(|value| *value > 0) { + let updated = payload.get("last_updated").and_then(Value::as_str) + .ok_or_else(|| anyhow!("Home Assistant last_updated is missing"))?; + let updated = chrono::DateTime::parse_from_rfc3339(updated).context("invalid Home Assistant last_updated")?.with_timezone(&chrono::Utc); + let age = chrono::Utc::now().signed_duration_since(updated).num_seconds().max(0) as u64; + if age > limit { bail!("Home Assistant sensor is stale: {age}s old (limit {limit}s)") } + } let state = payload.get("state").and_then(Value::as_str) .ok_or_else(|| anyhow!("Home Assistant state is missing"))?; let mut temperature: f64 = state.parse().context("Home Assistant state is not a number")?; diff --git a/src/main.rs b/src/main.rs index 9f6646d..25e8f0e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -69,6 +69,7 @@ async fn main() -> Result<()> { initial_device_sync_complete: Arc::new(AtomicBool::new(false)), zone_control_wakeup: Arc::new(Notify::new()), device_operation_locks: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + zone_operation_locks: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), pending_controller_commands: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), started: Instant::now(), }; diff --git a/src/models.rs b/src/models.rs index 03b6946..96c752c 100644 --- a/src/models.rs +++ b/src/models.rs @@ -14,6 +14,7 @@ fn default_external_sensor_weight() -> f64 { 0.4 } fn default_max_sensor_difference() -> f64 { 3.0 } fn default_control_temperature_source() -> String { "device".into() } fn default_min_cycle() -> u64 { 180 } +fn default_sensor_stale_after() -> u64 { 300 } fn default_cooldown() -> u64 { 300 } fn default_house_mode() -> String { "cool".into() } fn default_control_strategy() -> String { "setpoint".into() } @@ -322,6 +323,9 @@ pub struct Zone { /// If GREE and external sensor differ more than this, the controller falls back to GREE. #[serde(default = "default_max_sensor_difference")] pub max_sensor_difference: f64, + /// Maximum accepted age of a Home Assistant room sensor sample. + #[serde(default = "default_sensor_stale_after")] + pub sensor_stale_after_seconds: u64, /// Temperature reported by the GREE indoor sensor during the last zone cycle. #[serde(default)] pub device_temperature: Option, @@ -369,6 +373,29 @@ pub struct Zone { /// It lets us drop a stale "resume automation" prompt when the user restores that state. #[serde(default)] pub device_manual_override_baseline: Option, + /// Monotonic configuration/control revision used for optimistic concurrency. + #[serde(default)] + pub revision: u64, + /// Normalized control ownership exposed consistently to API/Web/Home Assistant. + #[serde(default)] + pub control_owner: String, + #[serde(default)] + pub control_source: String, + #[serde(default)] + pub control_since: Option>, + #[serde(default)] + pub control_resume_at: Option>, + #[serde(default)] + pub control_reason: String, + /// Last physical power/mode transition timestamps used by compressor lockout. + #[serde(default)] + pub last_power_change_at: Option>, + #[serde(default)] + pub last_mode_change_at: Option>, + #[serde(default)] + pub lockout_until: Option>, + #[serde(default)] + pub lockout_reason: Option, #[serde(default)] pub effective_mode: String, #[serde(default)] @@ -739,6 +766,11 @@ pub struct ZoneControlPlan { pub current_temperature: Option, pub target_temperature: Option, pub device_setpoint: Option, + pub desired_power: bool, + pub desired_mode: String, + pub actual_power: Option, + pub actual_mode: Option, + pub actual_setpoint: Option, pub demand: bool, pub control_source: String, pub manual_override_until: Option>, @@ -746,6 +778,13 @@ pub struct ZoneControlPlan { pub local_thermostat_resume_at: Option>, pub device_manual_override: bool, pub device_manual_override_until: Option>, + pub control_owner: String, + pub control_command_source: String, + pub control_since: Option>, + pub resume_at: Option>, + pub control_reason: String, + pub blocked_reason: Option, + pub lockout_until: Option>, pub current_schedule_id: Option, pub current_schedule_name: Option, pub next_events: Vec, diff --git a/src/state.rs b/src/state.rs index ed2c310..8c10620 100644 --- a/src/state.rs +++ b/src/state.rs @@ -31,6 +31,7 @@ pub struct AppState { /// Explicit thermostat changes wake the regulator instead of waiting for the next fixed interval. pub zone_control_wakeup: Arc, pub(crate) device_operation_locks: Arc>>>>, + pub(crate) zone_operation_locks: Arc>>>>, /// Short-lived expected climate state from controller-originated commands. It prevents /// a delayed GREE status update from being mistaken for remote/manual takeover. pub(crate) pending_controller_commands: Arc>>, @@ -46,6 +47,14 @@ impl AppState { lock.lock_owned().await } + pub async fn lock_zone_operation(&self, zone_id: &str) -> OwnedMutexGuard<()> { + let lock = { + let mut locks = self.zone_operation_locks.lock().await; + locks.entry(zone_id.to_string()).or_insert_with(|| Arc::new(Mutex::new(()))).clone() + }; + lock.lock_owned().await + } + pub fn wake_zone_control(&self) { self.zone_control_wakeup.notify_one(); } diff --git a/web/app.js b/web/app.js index a710b8b..2041947 100644 --- a/web/app.js +++ b/web/app.js @@ -708,6 +708,27 @@ function zonePresetLabel(preset) { return tr(key) === key ? (preset || 'comfort') : tr(key); } +function zoneControlOwnerLabel(zone) { + const owner=zone.control_owner || (zone.device_manual_override ? 'direct_manual' : (zone.local_thermostat_power != null ? 'local_thermostat' : 'automation')); + const source=zone.control_source || ''; + if(owner==='global_off') return tr('zones.ownerGlobalOff'); + if(owner==='direct_manual') { + if(source==='home_assistant_direct') return tr('zones.ownerHaDirect'); + if(source==='web_direct') return tr('zones.ownerWebDirect'); + return tr('zones.ownerExternal'); + } + if(owner==='local_thermostat') return tr('zones.ownerLocalThermostat'); + return tr('zones.ownerAutomation'); +} + +function zoneControlOwnerMeta(zone) { + const parts=[]; + if(zone.control_since) parts.push(`${tr('zones.ownerSince')} ${new Date(zone.control_since).toLocaleTimeString(locale(),{hour:'2-digit',minute:'2-digit'})}`); + if(zone.control_resume_at) parts.push(`${tr('zones.ownerResume')} ${new Date(zone.control_resume_at).toLocaleTimeString(locale(),{hour:'2-digit',minute:'2-digit'})}`); + if(zone.lockout_until && new Date(zone.lockout_until)>new Date()) parts.push(`${tr('zones.lockoutUntil')} ${new Date(zone.lockout_until).toLocaleTimeString(locale(),{hour:'2-digit',minute:'2-digit'})}`); + return parts.join(' · '); +} + function zoneRuntimeStatusLabel(zone, effectiveMode) { if (zone.device_manual_override === true) return tr('zones.manualDeviceControl'); if (!zone.enabled) return tr('common.disabled'); @@ -764,7 +785,7 @@ function zoneCard(zone, detailed = true) {
${esc(tr('zones.configuration'))}

${esc(zone.name)}

${esc(device?.name || tr('common.noDevice'))} · ${esc(tr('groups.group'))}: ${esc(groupText)}

${esc(state)}
${esc(tr('zones.currentStatus'))}
${fmtTemp(roomTemperature)}${Number.isFinite(target) ? `${target.toFixed(1)}°C` : '—'}
-
${esc(zoneRuntimeStatusLabel(zone, effectiveMode))}${esc(houseModeLabel(effectiveMode))} · ${esc(zonePresetLabel(displayPreset))}${esc(override)}
+
${esc(zoneRuntimeStatusLabel(zone, effectiveMode))}${esc(houseModeLabel(effectiveMode))} · ${esc(zonePresetLabel(displayPreset))}${esc(override)}${esc(tr('zones.controlOwner'))}: ${esc(zoneControlOwnerLabel(zone))}${zoneControlOwnerMeta(zone) ? ` · ${esc(zoneControlOwnerMeta(zone))}` : ''}
${esc(tr('zones.modePolicy'))}${esc(policy)}
@@ -787,6 +808,7 @@ function zoneCard(zone, detailed = true) {
${esc(zoneRuntimeStatusLabel(zone, effectiveMode))}${esc(override)}
+
${esc(tr('zones.controlOwner'))}: ${esc(zoneControlOwnerLabel(zone))}${esc(zoneControlOwnerMeta(zone))}
${manualTakeover} `; } @@ -1881,7 +1903,7 @@ $('#copyZoneSettings')?.addEventListener('click',()=>{ ha_entity_id:f.elements.ha_entity_id.value, enabled:f.elements.enabled.checked, }; - const copyFields=['setpoint','cool_comfort_setpoint','cool_sleep_setpoint','cool_away_setpoint','heat_comfort_setpoint','heat_sleep_setpoint','heat_away_setpoint','hysteresis','min_on_seconds','min_off_seconds','min_adjust_seconds','standby_offset_c','external_sensor_weight_percent','max_sensor_difference']; + const copyFields=['setpoint','cool_comfort_setpoint','cool_sleep_setpoint','cool_away_setpoint','heat_comfort_setpoint','heat_sleep_setpoint','heat_away_setpoint','hysteresis','min_on_seconds','min_off_seconds','min_adjust_seconds','standby_offset_c','external_sensor_weight_percent','max_sensor_difference','sensor_stale_after_seconds']; copyFields.forEach(name=>{ const input=f.elements[name]; if(!input)return; @@ -1955,7 +1977,7 @@ $('#zoneForm').addEventListener('submit', async event => { heat_comfort_setpoint:parseDecimal(raw.heat_comfort_setpoint),heat_sleep_setpoint:parseDecimal(raw.heat_sleep_setpoint),heat_away_setpoint:parseDecimal(raw.heat_away_setpoint), hysteresis:parseDecimal(raw.hysteresis),min_on_seconds:Number(raw.min_on_seconds),min_off_seconds:Number(raw.min_off_seconds), min_adjust_seconds:Number(raw.min_adjust_seconds),standby_offset_c:parseDecimal(raw.standby_offset_c),smart_fan:form.smart_fan.checked, - sensor_source:raw.sensor_source,ha_entity_id:raw.ha_entity_id||null,external_sensor_weight:Number(raw.external_sensor_weight_percent)/100,max_sensor_difference:parseDecimal(raw.max_sensor_difference) + sensor_source:raw.sensor_source,ha_entity_id:raw.ha_entity_id||null,external_sensor_weight:Number(raw.external_sensor_weight_percent)/100,max_sensor_difference:parseDecimal(raw.max_sensor_difference),sensor_stale_after_seconds:Number(raw.sensor_stale_after_seconds||300),revision:raw.revision?Number(raw.revision):null }; try { await api(id?`/api/zones/${encodeURIComponent(id)}`:'/api/zones',{method:id?'PUT':'POST',body}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved')); } catch(error){toast(error.message,true);} diff --git a/web/index.html b/web/index.html index eb773f2..d4c6cc1 100644 --- a/web/index.html +++ b/web/index.html @@ -394,7 +394,7 @@
- +
diff --git a/web/sw.js b/web/sw.js index a1f4ebd..ca35de5 100644 --- a/web/sw.js +++ b/web/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'gree-controller-v0713-race-frame-counters'; +const CACHE = 'gree-controller-v080-control-ownership'; const SCOPE = new URL(self.registration.scope).pathname.replace(/\/$/, ''); const path = value => `${SCOPE}${value.startsWith('/') ? value : `/${value}`}` || '/'; const ASSETS = [path('/'), path('/styles.css'), path('/app.js'), path('/theme-init.js'), path('/favicon.svg'), path('/manifest.webmanifest'), path('/lang/index.json'), path('/lang/en.json')];