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