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
+81 -22
View File
@@ -530,7 +530,7 @@ impl ZoneInput {
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,
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,
active_preset: "comfort".into(), 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: String::new(), effective_setpoint: None, device_setpoint: None,
demand: false, demand_since: None, target_alerted_at: None, last_action_at: None,
@@ -577,6 +577,7 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
zone.manual_preset = existing.manual_preset;
zone.manual_setpoint = existing.manual_setpoint;
zone.manual_override_until = existing.manual_override_until;
zone.local_thermostat_power = existing.local_thermostat_power;
zone.device_manual_override = existing.device_manual_override;
zone.device_manual_override_since = existing.device_manual_override_since;
zone.device_manual_override_until = existing.device_manual_override_until;
@@ -605,22 +606,44 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
Ok(Json(zone))
}
async fn update_zone_control(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<ZoneControlPatch>) -> Result<Json<Zone>, AppError> {
let mut zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControlPatch) -> Result<Zone, AppError> {
let mut zone = state.db.get_zone(id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
let was_enabled = zone.enabled;
let schedules = state.db.list_schedules()?;
// Any explicit thermostat action means the user is handing ownership back to the zone
// controller. This includes the dedicated Resume automation button.
let resume_device_automation = patch.clear_device_manual_override.unwrap_or(false)
|| patch.setpoint.is_some() || patch.mode.is_some() || patch.preset.is_some() || patch.enabled.is_some();
let resume_device_takeover = patch.clear_device_manual_override.unwrap_or(false);
let resume_local_thermostat = patch.clear_local_thermostat_override.unwrap_or(false);
// Any thermostat action takes ownership back from a physical/pilot takeover. Local power
// is a thermostat state of its own and must never be recorded as device manual control.
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_local_thermostat {
zone.local_thermostat_power = None;
zone.manual_preset = None;
zone.manual_setpoint = None;
zone.manual_override_until = None;
}
if let Some(power) = patch.power {
zone.local_thermostat_power = Some(power);
if power { zone.enabled = true; }
// A manually started local thermostat keeps an already selected target/profile until
// the user chooses Auto or resumes global automation.
if power && (zone.manual_preset.is_some() || zone.manual_setpoint.is_some()) {
zone.manual_override_until = None;
}
}
if let Some(value) = patch.setpoint {
if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("zone setpoint must be between 8 and 30 C".into())); }
let value = (value * 2.0).round() / 2.0;
zone.setpoint = value;
zone.manual_setpoint = Some(value);
zone.effective_setpoint = Some(value);
zone.manual_override_until = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
zone.manual_override_until = if zone.local_thermostat_power == Some(true) {
None
} else {
engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now())
};
}
if let Some(value) = patch.mode.as_deref() {
match value {
@@ -642,7 +665,11 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
"comfort" | "sleep" | "away" | "custom" => {
zone.manual_preset = Some(value.to_string());
zone.manual_setpoint = None;
zone.manual_override_until = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
zone.manual_override_until = if zone.local_thermostat_power == Some(true) {
None
} else {
engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now())
};
}
_ => return Err(AppError::BadRequest("unsupported zone preset".into())),
}
@@ -652,7 +679,10 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
zone.manual_setpoint = None;
zone.manual_override_until = None;
}
if let Some(value) = patch.enabled { zone.enabled = value; }
if let Some(value) = patch.enabled {
zone.enabled = value;
if !value { zone.local_thermostat_power = None; }
}
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();
engine::refresh_zone_runtime_target(&mut zone, &schedules, &house_mode);
@@ -660,20 +690,33 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
if was_enabled && !zone.enabled {
power_off_zone_device(&state, &zone, "zone.quick_disabled").await;
power_off_zone_device(state, &zone, "zone.quick_disabled").await;
} else if patch.power == Some(false) {
if let Err(err) = engine::send_command(
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, "power": false
}));
}
state.wake_zone_control();
} else {
// Do not force users to wait for the fixed zone interval after selecting a profile,
// changing the target/mode or re-enabling a thermostat. The regulator still owns the
// physical command and therefore keeps all group/master/manual-override safeguards.
state.wake_zone_control();
}
state.log("info", "zone.quick_control", &format!("Quick control updated for {}", zone.name), json!({
"zone_id": zone.id, "setpoint": zone.setpoint, "manual_setpoint": zone.manual_setpoint, "mode": zone.mode,
"inherit_house_mode": zone.inherit_house_mode, "preset": zone.manual_preset,
"override_until": zone.manual_override_until, "enabled": zone.enabled,
"local_thermostat_power": zone.local_thermostat_power,
"device_manual_override_cleared": device_override_cleared
}));
Ok(Json(zone))
Ok(zone)
}
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?))
}
@@ -685,17 +728,18 @@ async fn update_zone_manual_power(
Path(id): Path<String>,
Json(input): Json<ZoneManualPowerPatch>,
) -> Result<Json<Value>, AppError> {
let zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
let device = engine::send_manual_command(
// Backward-compatible route: despite the historical name this now controls the local
// thermostat, not the physical unit like a pilot. Direct/pilot semantics stay on /devices.
let zone = apply_zone_control_patch(
&state,
&zone.device_id,
DeviceCommand { power: Some(input.power), ..Default::default() },
"zone.quick_manual_power",
&id,
ZoneControlPatch { power: Some(input.power), ..Default::default() },
).await?;
let zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
let device = state.db.get_device(&zone.device_id)?;
Ok(Json(json!({"zone": zone, "device": device})))
}
async fn ensure_device_stopped_for_detach(state: &AppState, device_id: &str, source: &str) -> Result<(), AppError> {
let Some(device) = state.db.get_device(device_id)? else { return Ok(()); };
if !device.enabled {
@@ -930,6 +974,7 @@ async fn list_home_assistant_groups(State(state): State<AppState>) -> Result<Jso
"demand": zone.demand,
"control_source": zone.control_source,
"current_schedule": zone.current_schedule_name,
"local_thermostat_power": zone.local_thermostat_power,
"device_manual_override": zone.device_manual_override,
"device_manual_override_until": zone.device_manual_override_until,
})).collect::<Vec<_>>();
@@ -981,11 +1026,24 @@ fn set_all_groups_power(state: &AppState, power: bool) -> Result<(), AppError> {
Ok(())
}
fn clear_all_local_thermostat_overrides(state: &AppState) -> Result<usize, AppError> {
let mut cleared = 0;
for mut zone in state.db.list_zones()? {
if zone.local_thermostat_power.is_none() { continue; }
zone.local_thermostat_power = None;
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
cleared += 1;
}
Ok(cleared)
}
async fn command_all_enabled_devices_power(state: &AppState, power: bool, source: &str) -> Result<Vec<Value>, AppError> {
let mut failed = Vec::new();
let enabled_zone_devices: std::collections::HashSet<String> = if power {
state.db.list_zones()?.into_iter()
.filter(|zone| zone.enabled && !zone.device_manual_override)
.filter(|zone| zone.enabled && !zone.device_manual_override && zone.local_thermostat_power != Some(false))
.map(|zone| zone.device_id)
.collect()
} else {
@@ -1074,6 +1132,7 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
set_all_groups_power(&state, input.power)?;
if !input.power {
engine::clear_all_device_manual_overrides(&state, "house_power_off")?;
clear_all_local_thermostat_overrides(&state)?;
}
let failed = command_all_enabled_devices_power(&state, input.power, "house_power").await?;
+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");
+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())),
pending_controller_commands: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
started: Instant::now(),
};
+11
View File
@@ -345,6 +345,10 @@ pub struct Zone {
pub manual_setpoint: Option<f64>,
#[serde(default)]
pub manual_override_until: Option<DateTime<Utc>>,
/// Local quick-thermostat power override. None follows group/global power gates;
/// Some(true) runs this zone locally through the full thermostat; Some(false) keeps it locally off.
#[serde(default)]
pub local_thermostat_power: Option<bool>,
/// True when the physical unit was changed outside the thermostat engine (for example by IR remote).
/// While active, normal zone/group/schedule automation observes the unit but does not overwrite it.
#[serde(default)]
@@ -409,6 +413,9 @@ pub struct GroupControlPatch {
pub struct ZoneControlPatch {
#[serde(default)]
pub setpoint: Option<f64>,
/// Local quick-thermostat power. This is thermostat ownership, not direct device/pilot control.
#[serde(default)]
pub power: Option<bool>,
#[serde(default)]
pub mode: Option<String>,
#[serde(default)]
@@ -421,6 +428,9 @@ pub struct ZoneControlPatch {
/// Explicitly hand control of a manually overridden physical unit back to the thermostat engine.
#[serde(default)]
pub clear_device_manual_override: Option<bool>,
/// Return a locally forced quick thermostat to normal group/schedule ownership.
#[serde(default)]
pub clear_local_thermostat_override: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -727,6 +737,7 @@ pub struct ZoneControlPlan {
pub demand: bool,
pub control_source: String,
pub manual_override_until: Option<DateTime<Utc>>,
pub local_thermostat_power: Option<bool>,
pub device_manual_override: bool,
pub device_manual_override_until: Option<DateTime<Utc>>,
pub current_schedule_id: Option<String>,
+10 -1
View File
@@ -2,7 +2,13 @@ use std::{collections::HashMap, sync::{Arc, atomic::AtomicBool}, time::Instant};
use chrono::Utc;
use serde_json::Value;
use tokio::sync::{broadcast, Mutex, Notify, OwnedMutexGuard, RwLock};
use crate::{config::Config, db::Db, models::{ApiEvent, RuntimeSettings}, protocol::GreeClient};
use crate::{config::Config, db::Db, models::{ApiEvent, DeviceCommand, RuntimeSettings}, protocol::GreeClient};
#[derive(Debug, Clone)]
pub(crate) struct PendingControllerCommand {
pub command: DeviceCommand,
pub expires_at: Instant,
}
#[derive(Clone)]
pub struct AppState {
@@ -20,6 +26,9 @@ 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<()>>>>>,
/// 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>>>,
pub started: Instant,
}