v0.7.10
This commit is contained in:
+182
-15
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user