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
+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();
}