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