This commit is contained in:
Mateusz Gruszczyński
2026-08-27 22:58:13 +02:00
parent b93a1f2d92
commit 6572ff368b
19 changed files with 398 additions and 51 deletions
+24
View File
@@ -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
Generated
+1 -1
View File
@@ -633,7 +633,7 @@ dependencies = [
[[package]]
name = "gree-controller"
version = "0.7.13"
version = "0.8.0"
dependencies = [
"aes",
"aes-gcm",
+1 -1
View File
@@ -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"
+18 -18
View File
@@ -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
+14 -2
View File
@@ -4,7 +4,7 @@
Standalone local GREE air-conditioner controller written in Rust. It runs on a regular Linux host or an LXC container and provides a mobile-first web interface without depending on the vendor cloud.
Current version: **0.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.
@@ -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"),
}
@@ -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",
+11 -1
View File
@@ -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"
}
+11 -1
View File
@@ -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"
}
+64 -9
View File
@@ -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<u64>,
}
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<AppState>, Json(input): Json<ZoneInput>
}
async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<ZoneInput>) -> Result<Json<Zone>, 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<AppState>, Path(id): Path<String>, 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<AppState>, Path(id): Path<String>, 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<Zone, AppError> {
async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControlPatch, source: &str) -> Result<Zone, AppError> {
// 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<AppState>, Path(id): Path<String>, Json(patch): Json<ZoneControlPatch>) -> Result<Json<Zone>, 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<AppState>, Path(id): Path<S
Ok(Json(json!({"zone": zone, "schedules": items})))
}
async fn update_home_assistant_zone_control(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<ZoneControlPatch>) -> Result<Json<Zone>, AppError> {
Ok(Json(apply_zone_control_patch(&state, &id, patch, "home_assistant.zone_thermostat").await?))
}
async fn delete_zone(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
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<String> }
async fn test_home_assistant(State(state): State<AppState>, Json(input): Json<HaTestRequest>) -> Result<Json<Value>, 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})))
}
+152 -10
View File
@@ -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<Device, AppError> {
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<Device, Ap
let before = device.clone();
poll_device(state, &mut device).await;
if poll_completed_successfully(&device) {
if state.initial_device_sync_complete.load(Ordering::Acquire) {
record_device_transition_timestamps(state, &before, &device)?;
}
detect_external_device_control(state, &before, &device).await?;
}
state.db.save_device(&device)?;
@@ -658,6 +678,44 @@ fn next_local_thermostat_resume_delay(state: &AppState) -> Result<Option<Duratio
.min())
}
pub fn refresh_control_ownership(zone: &mut Zone, house_power_enabled: bool, blocked_by_group: bool) {
let now = Utc::now();
let (owner, source, resume_at, reason) = if !house_power_enabled {
("global_off", "global".to_string(), None, "Whole-house power is disabled".to_string())
} else if zone.device_manual_override {
let source = match zone.control_source.as_str() {
"home_assistant_direct" | "web_direct" | "external" => 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<Str
if !zone.device_manual_override {
zone.device_manual_override_since = Some(now);
zone.device_manual_override_baseline = Some(baseline.into());
zone.control_since = Some(now);
}
zone.device_manual_override = true;
zone.control_owner = "direct_manual".into();
zone.control_source = normalized_direct_source(source).into();
zone.control_reason = "Direct/manual device control has priority".into();
zone.device_manual_override_until = if zone.enabled {
next_schedule_boundary_utc(&zone.id, &state.db.list_schedules()?, Local::now())
} else {
None
};
zone.control_resume_at = zone.device_manual_override_until;
for field in fields {
if !zone.device_manual_override_fields.iter().any(|existing| existing == &field) {
zone.device_manual_override_fields.push(field);
@@ -768,6 +838,11 @@ async fn detect_external_device_control(state: &AppState, before: &Device, after
}
pub async fn send_manual_command(state: &AppState, device_id: &str, command: DeviceCommand, source: &str) -> Result<Device, AppError> {
// 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<String> = 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<ControlPlan, AppErro
let mut zones_out = Vec::new();
let mut house_events = next_night_mode_events(&settings.night_mode, now, 2);
for zone in zones {
for mut zone in zones {
let device = devices.iter().find(|item| item.id == zone.device_id);
let configured_effective_mode = if zone.inherit_house_mode {
settings.house_mode.as_str()
@@ -1909,6 +2037,7 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
};
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));
refresh_control_ownership(&mut zone, settings.house_power_enabled, blocked_by_group);
let manual_device_mode = device.map(|item| if item.power { item.mode.as_str() } else { "off" });
let effective_mode = if zone.device_manual_override {
manual_device_mode.unwrap_or(configured_effective_mode)
@@ -1958,6 +2087,11 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
zone.effective_setpoint.or(Some(resolved_target))
},
device_setpoint: device.filter(|item| item.power).map(|item| item.target_temperature),
desired_power: settings.house_power_enabled && zone.enabled && effective_mode != "off" && !zone.device_manual_override && !blocked_by_group,
desired_mode: effective_mode.to_string(),
actual_power: device.map(|item| item.power),
actual_mode: device.map(|item| if item.power { item.mode.clone() } else { "off".into() }),
actual_setpoint: device.filter(|item| item.power).map(|item| item.target_temperature),
demand: settings.house_power_enabled && zone.enabled && effective_mode != "off" && !zone.device_manual_override && zone.demand,
control_source: zone.control_temperature_source.clone(),
manual_override_until: zone.manual_override_until,
@@ -1965,6 +2099,13 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
local_thermostat_resume_at: zone.local_thermostat_resume_at,
device_manual_override: zone.device_manual_override,
device_manual_override_until: zone.device_manual_override_until,
control_owner: zone.control_owner.clone(),
control_command_source: zone.control_source.clone(),
control_since: zone.control_since,
resume_at: zone.control_resume_at,
control_reason: zone.control_reason.clone(),
blocked_reason: if !settings.house_power_enabled { Some("global_off".into()) } else if zone.device_manual_override { Some("manual_override".into()) } else if blocked_by_group { Some("group_off".into()) } else if zone.lockout_until.map(|until| until > 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(),
}
+3
View File
@@ -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) => {
+8
View File
@@ -40,6 +40,7 @@ pub async fn read_temperature(
default_client: &reqwest::Client,
settings: &HomeAssistantSettings,
entity_override: Option<&str>,
stale_after_seconds: Option<u64>,
) -> Result<f64> {
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::<String>())
}
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")?;
+1
View File
@@ -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(),
};
+39
View File
@@ -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<f64>,
@@ -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<ManualDeviceBaseline>,
/// 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<DateTime<Utc>>,
#[serde(default)]
pub control_resume_at: Option<DateTime<Utc>>,
#[serde(default)]
pub control_reason: String,
/// Last physical power/mode transition timestamps used by compressor lockout.
#[serde(default)]
pub last_power_change_at: Option<DateTime<Utc>>,
#[serde(default)]
pub last_mode_change_at: Option<DateTime<Utc>>,
#[serde(default)]
pub lockout_until: Option<DateTime<Utc>>,
#[serde(default)]
pub lockout_reason: Option<String>,
#[serde(default)]
pub effective_mode: String,
#[serde(default)]
@@ -739,6 +766,11 @@ pub struct ZoneControlPlan {
pub current_temperature: Option<f64>,
pub target_temperature: Option<f64>,
pub device_setpoint: Option<f64>,
pub desired_power: bool,
pub desired_mode: String,
pub actual_power: Option<bool>,
pub actual_mode: Option<String>,
pub actual_setpoint: Option<f64>,
pub demand: bool,
pub control_source: String,
pub manual_override_until: Option<DateTime<Utc>>,
@@ -746,6 +778,13 @@ pub struct ZoneControlPlan {
pub local_thermostat_resume_at: Option<DateTime<Utc>>,
pub device_manual_override: bool,
pub device_manual_override_until: Option<DateTime<Utc>>,
pub control_owner: String,
pub control_command_source: String,
pub control_since: Option<DateTime<Utc>>,
pub resume_at: Option<DateTime<Utc>>,
pub control_reason: String,
pub blocked_reason: Option<String>,
pub lockout_until: Option<DateTime<Utc>>,
pub current_schedule_id: Option<String>,
pub current_schedule_name: Option<String>,
pub next_events: Vec<ControlPlanEvent>,
+9
View File
@@ -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<Notify>,
pub(crate) device_operation_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
pub(crate) zone_operation_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
/// 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<Mutex<HashMap<String, PendingControllerCommand>>>,
@@ -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();
}
+25 -3
View File
@@ -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) {
<div class="list-card-head"><div><span class="eyebrow">${esc(tr('zones.configuration'))}</span><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(tr('groups.group'))}: ${esc(groupText)}</p></div><span class="badge ${zone.enabled ? 'active' : ''}">${esc(state)}</span></div>
<div class="zone-config-status">
<div class="zone-config-temperature"><small>${esc(tr('zones.currentStatus'))}</small><div><span>${fmtTemp(roomTemperature)}</span><b></b><strong>${Number.isFinite(target) ? `${target.toFixed(1)}°C` : ''}</strong></div></div>
<div class="zone-config-runtime"><span class="badge ${zone.enabled && zone.demand ? 'active' : ''}">${esc(zoneRuntimeStatusLabel(zone, effectiveMode))}</span><span>${esc(houseModeLabel(effectiveMode))} · ${esc(zonePresetLabel(displayPreset))}</span><small>${esc(override)}</small></div>
<div class="zone-config-runtime"><span class="badge ${zone.enabled && zone.demand ? 'active' : ''}">${esc(zoneRuntimeStatusLabel(zone, effectiveMode))}</span><span>${esc(houseModeLabel(effectiveMode))} · ${esc(zonePresetLabel(displayPreset))}</span><small>${esc(override)}</small><small><strong>${esc(tr('zones.controlOwner'))}:</strong> ${esc(zoneControlOwnerLabel(zone))}${zoneControlOwnerMeta(zone) ? ` · ${esc(zoneControlOwnerMeta(zone))}` : ''}</small></div>
</div>
<div class="zone-config-grid">
<div><small>${esc(tr('zones.modePolicy'))}</small><strong>${esc(policy)}</strong></div>
@@ -787,6 +808,7 @@ function zoneCard(zone, detailed = true) {
</div>
<div class="mode-row zone-mode-row quick-control-row quick-control-row-3"><button class="${mode==='house'&&globalModeAvailable?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="house"${globalModeDisabled}>${esc(tr('zones.followHouseShort'))}</button><button class="${mode==='heat'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="heat">${esc(modeLabel('heat'))}</button><button class="${mode==='cool'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="cool">${esc(modeLabel('cool'))}</button></div>
<div class="zone-state-line"><span>${esc(zoneRuntimeStatusLabel(zone, effectiveMode))}</span><span>${esc(override)}</span></div>
<div class="zone-state-line control-owner-line"><span><strong>${esc(tr('zones.controlOwner'))}:</strong> ${esc(zoneControlOwnerLabel(zone))}</span><span>${esc(zoneControlOwnerMeta(zone))}</span></div>
${manualTakeover}
</article>`;
}
@@ -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);}
+1 -1
View File
@@ -394,7 +394,7 @@
</div>
<div class="two"><label><span data-i18n="zones.minAdjust">Minimum adjustment interval (s)</span><input type="number" min="15" name="min_adjust_seconds" value="120"></label><label><span data-i18n="zones.standbyOffset">Standby setpoint offset °C</span><input type="text" inputmode="decimal" step="0.5" min="0.5" max="8" name="standby_offset_c" value="2"></label></div>
<label class="check"><input type="checkbox" name="smart_fan" checked> <span data-i18n="zones.smartFan">Smart fan assist</span></label>
<input type="hidden" name="min_on_seconds" value="180"><input type="hidden" name="min_off_seconds" value="180">
<input type="hidden" name="min_on_seconds" value="180"><input type="hidden" name="min_off_seconds" value="180"><input type="hidden" name="sensor_stale_after_seconds" value="300"><input type="hidden" name="revision" value="">
<label class="check"><input type="checkbox" name="enabled" checked> <span data-i18n="common.enabled">Enabled</span></label>
<div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Cancel</button><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
</form>
+1 -1
View File
@@ -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')];