This commit is contained in:
Mateusz Gruszczyński
2026-08-27 16:38:51 +02:00
parent 7102d05cb3
commit ba61cb77b1
19 changed files with 406 additions and 109 deletions
+182 -15
View File
@@ -8,7 +8,7 @@ use crate::{
home_assistant,
influxdb,
models::{Automation, AutomationPlanRule, ControlPlan, ControlPlanEvent, Device, DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, Schedule, Zone, ZoneControlPlan, ZoneReading},
state::AppState,
state::{AppState, PendingControllerCommand},
};
pub fn start(state: AppState) {
@@ -117,11 +117,11 @@ pub async fn send_command(state: &AppState, device_id: &str, command: DeviceComm
}
async fn send_command_locked(state: &AppState, device_id: &str, command: DeviceCommand) -> Result<Device, AppError> {
send_command_locked_inner(state, device_id, command, true).await
send_command_locked_inner(state, device_id, command, true, true).await
}
async fn send_command_locked_forced(state: &AppState, device_id: &str, command: DeviceCommand) -> Result<Device, AppError> {
send_command_locked_inner(state, device_id, command, false).await
send_command_locked_inner(state, device_id, command, false, true).await
}
async fn send_command_locked_inner(
@@ -129,6 +129,7 @@ async fn send_command_locked_inner(
device_id: &str,
command: DeviceCommand,
dedupe_against_cache: bool,
track_controller_command: bool,
) -> Result<Device, AppError> {
validate_command(&command)?;
let mut device = state.db.get_device(device_id)?
@@ -253,11 +254,24 @@ async fn send_command_locked_inner(
}
state.db.save_device(&device)?;
if !dedupe_against_cache && confirmed_state && !confirmed_requested_state {
if track_controller_command && !command_manual_control_fields(&applied_command).is_empty() {
remember_controller_command(state, device_id, &applied_command).await;
}
state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default());
return Err(AppError::Device("device did not confirm the requested forced state change".into()));
}
}
if track_controller_command {
if confirmed_state && confirmed_requested_state {
// A verified full status snapshot supersedes any older unsettled expectation for
// this device, so it must not mask a later real remote change.
clear_pending_controller_command(state, device_id).await;
} else if !command_manual_control_fields(&applied_command).is_empty() {
remember_controller_command(state, device_id, &applied_command).await;
}
}
state.log("info", "device.command", &format!("Updated {}", device.name), json!({
"device_id": device.id,
"command": applied_command,
@@ -278,7 +292,7 @@ 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) {
detect_external_device_control(state, &before, &device)?;
detect_external_device_control(state, &before, &device).await?;
}
state.db.save_device(&device)?;
record_reading(state, &device)?;
@@ -453,6 +467,83 @@ fn command_manual_control_fields(command: &DeviceCommand) -> Vec<String> {
fields
}
fn merge_device_command(base: &mut DeviceCommand, update: &DeviceCommand) {
if update.power.is_some() { base.power = update.power; }
if update.mode.is_some() { base.mode = update.mode.clone(); }
if update.target_temperature.is_some() { base.target_temperature = update.target_temperature; }
if update.fan_speed.is_some() { base.fan_speed = update.fan_speed; }
if update.swing_vertical.is_some() { base.swing_vertical = update.swing_vertical; }
if update.swing_horizontal.is_some() { base.swing_horizontal = update.swing_horizontal; }
if update.quiet.is_some() { base.quiet = update.quiet; }
if update.turbo.is_some() { base.turbo = update.turbo; }
if update.light.is_some() { base.light = update.light; }
if update.air.is_some() { base.air = update.air; }
if update.xfan.is_some() { base.xfan = update.xfan; }
if update.health.is_some() { base.health = update.health; }
if update.sleep.is_some() { base.sleep = update.sleep; }
}
async fn remember_controller_command(state: &AppState, device_id: &str, command: &DeviceCommand) {
let poll_seconds = state.settings.read().await.poll_interval_seconds.max(2);
let ttl = Duration::from_secs(poll_seconds.saturating_mul(2).saturating_add(5).min(120));
let mut pending = state.pending_controller_commands.lock().await;
let expires_at = Instant::now() + ttl;
if let Some(existing) = pending.get_mut(device_id) {
merge_device_command(&mut existing.command, command);
existing.expires_at = expires_at;
} else {
pending.insert(device_id.to_string(), PendingControllerCommand {
command: command.clone(),
expires_at,
});
}
}
async fn clear_pending_controller_command(state: &AppState, device_id: &str) {
state.pending_controller_commands.lock().await.remove(device_id);
}
fn command_field_matches_device(command: &DeviceCommand, field: &str, device: &Device) -> bool {
match field {
"power" => command.power.map(|value| value == device.power).unwrap_or(false),
"mode" => command.mode.as_deref().map(|value| value == device.mode.as_str()).unwrap_or(false),
"target_temperature" => command.target_temperature
.map(|value| value.clamp(8.0, 30.0).round() == device.target_temperature.clamp(8.0, 30.0).round())
.unwrap_or(false),
"fan_speed" => command.fan_speed.map(|value| value.min(5) == device.fan_speed).unwrap_or(false),
"quiet" => command.quiet.map(|value| value == device.quiet).unwrap_or(false),
"sleep" => command.sleep.map(|value| value == device.sleep).unwrap_or(false),
_ => false,
}
}
async fn suppress_expected_controller_changes(
state: &AppState,
device: &Device,
fields: Vec<String>,
) -> Vec<String> {
if fields.is_empty() { return fields; }
let mut pending = state.pending_controller_commands.lock().await;
let expired = pending.get(&device.id)
.map(|expected| Instant::now() > expected.expires_at)
.unwrap_or(false);
if expired {
pending.remove(&device.id);
return fields;
}
let Some(expected) = pending.get(&device.id).cloned() else { return fields; };
let filtered = fields.into_iter()
.filter(|field| !command_field_matches_device(&expected.command, field, device))
.collect();
let expected_fields = command_manual_control_fields(&expected.command);
if !expected_fields.is_empty()
&& expected_fields.iter().all(|field| command_field_matches_device(&expected.command, field, device))
{
pending.remove(&device.id);
}
filtered
}
fn externally_changed_control_fields(before: &Device, after: &Device, zone: &Zone) -> Vec<String> {
let mut fields = Vec::new();
if before.power != after.power { fields.push("power".to_string()); }
@@ -551,10 +642,14 @@ fn set_device_manual_override(state: &AppState, zone: &mut Zone, fields: Vec<Str
Ok(())
}
fn detect_external_device_control(state: &AppState, before: &Device, after: &Device) -> Result<(), AppError> {
async fn detect_external_device_control(state: &AppState, before: &Device, after: &Device) -> Result<(), AppError> {
if before.id != after.id { return Ok(()); }
for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == after.id) {
let fields = externally_changed_control_fields(before, after, &zone);
let fields = suppress_expected_controller_changes(
state,
after,
externally_changed_control_fields(before, after, &zone),
).await;
if zone.device_manual_override && manual_override_matches_baseline(&zone, after) {
persist_manual_override_clear(state, &mut zone, "gree_poll", true)?;
continue;
@@ -579,7 +674,7 @@ pub async fn send_manual_command(state: &AppState, device_id: &str, command: Dev
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
let effective_command = if before.online && before.communication_failures == 0 { command.changed_from(&before) } else { command.clone() };
let fields = command_manual_control_fields(&effective_command);
let updated = send_command_locked(state, device_id, command).await?;
let updated = send_command_locked_inner(state, device_id, command, true, false).await?;
if !fields.is_empty() {
for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id) {
if zone.device_manual_override && manual_override_matches_baseline(&zone, &updated) {
@@ -771,7 +866,8 @@ fn persist_zone_cycle(state: &AppState, computed: &Zone, cycle_started_at: DateT
async fn thermostat_ownership_is_current(state: &AppState, zone_id: &str, device_id: &str) -> Result<bool, AppError> {
if !state.settings.read().await.house_power_enabled { return Ok(false); }
let Some(zone) = state.db.get_zone(zone_id)? else { return Ok(false); };
if zone.device_id != device_id || !zone.enabled || zone.device_manual_override { return Ok(false); }
if zone.device_id != device_id || !zone.enabled || zone.device_manual_override || zone.local_thermostat_power == Some(false) { return Ok(false); }
if zone.local_thermostat_power == Some(true) { return Ok(true); }
let blocked = state.db.list_groups()?.iter().any(|group| {
!group.power_enabled && group.zone_ids.iter().any(|member| member == zone_id)
});
@@ -781,7 +877,7 @@ async fn thermostat_ownership_is_current(state: &AppState, zone_id: &str, device
async fn group_off_ownership_is_current(state: &AppState, zone_id: &str, device_id: &str) -> Result<bool, AppError> {
if !state.settings.read().await.house_power_enabled { return Ok(false); }
let Some(zone) = state.db.get_zone(zone_id)? else { return Ok(false); };
if zone.device_id != device_id || !zone.enabled || zone.device_manual_override { return Ok(false); }
if zone.device_id != device_id || !zone.enabled || zone.device_manual_override || zone.local_thermostat_power.is_some() { return Ok(false); }
Ok(state.db.list_groups()?.iter().any(|group| {
!group.power_enabled && group.zone_ids.iter().any(|member| member == zone_id)
}))
@@ -816,7 +912,7 @@ async fn send_group_power_if_current(
) -> Result<Option<Device>, AppError> {
let _device_guard = state.lock_device_operation(device_id).await;
let Some(zone) = state.db.get_zone(zone_id)? else { return Ok(None); };
if zone.device_id != device_id || !zone.enabled || zone.device_manual_override { return Ok(None); }
if zone.device_id != device_id || !zone.enabled || zone.device_manual_override || zone.local_thermostat_power.is_some() { return Ok(None); }
let groups = state.db.list_groups()?;
let Some(group) = groups.iter().find(|group| group.id == group_id) else { return Ok(None); };
if group.power_enabled != desired_power || !group.zone_ids.iter().any(|member| member == zone_id) { return Ok(None); }
@@ -853,6 +949,7 @@ async fn send_automatic_device_command_if_owned(
let zones = state.db.list_zones()?;
if device_blocked_by_disabled_zone(device_id, &zones)
|| device_blocked_by_manual_override(device_id, &zones)
|| device_blocked_by_local_thermostat(device_id, &zones)
|| device_blocked_by_disabled_group(device_id, &zones, &state.db.list_groups()?)
{
return Ok(None);
@@ -1052,7 +1149,32 @@ async fn control_zones(state: &AppState) -> Result<()> {
continue;
}
let blocked_by_group = groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id));
if zone.local_thermostat_power == Some(false) {
zone.effective_mode = "off".into();
zone.demand = false;
zone.demand_since = None;
zone.device_setpoint = None;
if device.online && device.communication_failures == 0 && device.power {
let _device_guard = state.lock_device_operation(&zone.device_id).await;
let latest = state.db.get_zone(&zone.id)?;
if latest.as_ref().map(|item| item.local_thermostat_power == Some(false) && !item.device_manual_override).unwrap_or(false) {
if let Err(err) = send_command_locked(
state,
&zone.device_id,
DeviceCommand { power: Some(false), ..Default::default() },
).await {
state.log("error", "zone.local_power_error", &err.to_string(), json!({"zone_id": zone.id, "device_id": zone.device_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;
}
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));
if blocked_by_group {
zone.effective_mode = "off".into();
zone.demand = false;
@@ -1680,11 +1802,12 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
} else {
zone.mode.as_str()
};
let blocked_by_group = groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id));
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 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)
} else if blocked_by_group {
} else if zone.local_thermostat_power == Some(false) || blocked_by_group {
"off"
} else {
configured_effective_mode
@@ -1706,7 +1829,9 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
event.label = format!("{}: {}", zone.name, event.label);
house_events.push(event);
}
let effective_enabled = zone.enabled && (!blocked_by_group || zone.device_manual_override);
let effective_enabled = zone.enabled
&& zone.local_thermostat_power != Some(false)
&& (!blocked_by_group || zone.device_manual_override);
zones_out.push(ZoneControlPlan {
zone_id: zone.id.clone(),
zone_name: zone.name.clone(),
@@ -1731,6 +1856,7 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
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,
local_thermostat_power: zone.local_thermostat_power,
device_manual_override: zone.device_manual_override,
device_manual_override_until: zone.device_manual_override_until,
current_schedule_id: active.map(|item| item.id.clone()),
@@ -1919,6 +2045,15 @@ async fn run_automations(state: &AppState) -> Result<()> {
}));
continue;
}
if item.action_group_id.is_none() && device_blocked_by_local_thermostat(&item.action_device_id, &zones) {
item.last_fired_at = Some(Utc::now());
item.updated_at = Utc::now();
state.db.save_automation(&item)?;
state.log("info", "automation.blocked_by_local_thermostat", &format!("Automation {} suppressed by local thermostat ownership", item.name), json!({
"automation_id": item.id, "device_id": item.action_device_id
}));
continue;
}
if item.action_group_id.is_none() && device_blocked_by_disabled_group(&item.action_device_id, &zones, &groups) {
// Group power-off is authoritative for normal controller-owned zones. A manual
// takeover is filtered above and therefore remains higher priority than the group.
@@ -1979,6 +2114,9 @@ fn device_blocked_by_manual_override(device_id: &str, zones: &[Zone]) -> bool {
zones.iter().any(|zone| zone.device_id == device_id && zone.device_manual_override)
}
fn device_blocked_by_local_thermostat(device_id: &str, zones: &[Zone]) -> bool {
zones.iter().any(|zone| zone.device_id == device_id && zone.local_thermostat_power.is_some())
}
fn device_blocked_by_disabled_group(device_id: &str, zones: &[Zone], groups: &[crate::models::ClimateGroup]) -> bool {
let zone_ids: std::collections::HashSet<&str> = zones.iter()
@@ -2120,7 +2258,7 @@ mod tests {
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,
current_temperature: None, control_temperature_source: "device".into(), active_preset: "comfort".into(),
manual_preset: None, manual_setpoint: None, manual_override_until: None,
manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: 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,
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(),
@@ -2141,6 +2279,35 @@ mod tests {
assert!(fields.iter().any(|field| field == "fan_speed"));
}
#[test]
fn controller_expected_climate_change_is_recognized() {
let mut device = Device::simulated_default();
device.power = true;
device.mode = "cool".into();
device.target_temperature = 22.0;
let command = DeviceCommand {
power: Some(true),
mode: Some("cool".into()),
target_temperature: Some(22.0),
..Default::default()
};
assert!(command_field_matches_device(&command, "power", &device));
assert!(command_field_matches_device(&command, "mode", &device));
assert!(command_field_matches_device(&command, "target_temperature", &device));
device.target_temperature = 25.0;
assert!(!command_field_matches_device(&command, "target_temperature", &device));
}
#[test]
fn local_thermostat_ownership_blocks_direct_automation() {
let mut zone = test_zone("device");
assert!(!device_blocked_by_local_thermostat(&zone.device_id, &[zone.clone()]));
zone.local_thermostat_power = Some(true);
assert!(device_blocked_by_local_thermostat(&zone.device_id, &[zone.clone()]));
zone.local_thermostat_power = Some(false);
assert!(device_blocked_by_local_thermostat(&zone.device_id, &[zone]));
}
#[test]
fn rounded_gree_setpoint_does_not_create_manual_override() {
let zone = test_zone("device");