v0.8.0
This commit is contained in:
+152
-10
@@ -280,6 +280,8 @@ async fn send_command_locked_inner(
|
||||
remember_controller_command(state, device_id, &applied_command, &controller_command_baseline).await;
|
||||
}
|
||||
|
||||
record_device_transition_timestamps(state, &controller_command_baseline, &device)?;
|
||||
|
||||
state.log("info", "device.command", &format!("Updated {}", device.name), json!({
|
||||
"device_id": device.id,
|
||||
"command": applied_command,
|
||||
@@ -289,6 +291,21 @@ async fn send_command_locked_inner(
|
||||
Ok(device)
|
||||
}
|
||||
|
||||
fn record_device_transition_timestamps(state: &AppState, before: &Device, after: &Device) -> Result<(), AppError> {
|
||||
if before.power == after.power && before.mode == after.mode { return Ok(()); }
|
||||
let now = Utc::now();
|
||||
for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == after.id) {
|
||||
if before.power != after.power { zone.last_power_change_at = Some(now); }
|
||||
if before.mode != after.mode { zone.last_mode_change_at = Some(now); }
|
||||
// Do not bump zone.updated_at here: an in-flight thermostat cycle uses that field
|
||||
// as its optimistic snapshot guard. The cycle mirrors these timestamps into its own
|
||||
// computed Zone after a successful automatic command.
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn poll_one(state: &AppState, device_id: &str) -> Result<Device, AppError> {
|
||||
let _device_guard = state.lock_device_operation(device_id).await;
|
||||
poll_one_locked(state, device_id).await
|
||||
@@ -300,6 +317,9 @@ async fn poll_one_locked(state: &AppState, device_id: &str) -> Result<Device, Ap
|
||||
let before = device.clone();
|
||||
poll_device(state, &mut device).await;
|
||||
if poll_completed_successfully(&device) {
|
||||
if state.initial_device_sync_complete.load(Ordering::Acquire) {
|
||||
record_device_transition_timestamps(state, &before, &device)?;
|
||||
}
|
||||
detect_external_device_control(state, &before, &device).await?;
|
||||
}
|
||||
state.db.save_device(&device)?;
|
||||
@@ -658,6 +678,44 @@ fn next_local_thermostat_resume_delay(state: &AppState) -> Result<Option<Duratio
|
||||
.min())
|
||||
}
|
||||
|
||||
pub fn refresh_control_ownership(zone: &mut Zone, house_power_enabled: bool, blocked_by_group: bool) {
|
||||
let now = Utc::now();
|
||||
let (owner, source, resume_at, reason) = if !house_power_enabled {
|
||||
("global_off", "global".to_string(), None, "Whole-house power is disabled".to_string())
|
||||
} else if zone.device_manual_override {
|
||||
let source = match zone.control_source.as_str() {
|
||||
"home_assistant_direct" | "web_direct" | "external" => zone.control_source.clone(),
|
||||
_ => "external".into(),
|
||||
};
|
||||
("direct_manual", source, zone.device_manual_override_until, "Direct/manual device control has priority".to_string())
|
||||
} else if zone.local_thermostat_power.is_some() {
|
||||
let source = match zone.control_source.as_str() {
|
||||
"home_assistant_thermostat" | "web_thermostat" => zone.control_source.clone(),
|
||||
_ => "local_thermostat".into(),
|
||||
};
|
||||
("local_thermostat", source, zone.local_thermostat_resume_at, if zone.local_thermostat_power == Some(false) { "Local thermostat is explicitly off".into() } else { "Local thermostat owns the zone".into() })
|
||||
} else if blocked_by_group {
|
||||
("automation", "group".to_string(), None, "Zone is blocked by a disabled group".to_string())
|
||||
} else {
|
||||
("automation", "automation".to_string(), zone.manual_override_until, "Automatic thermostat/schedule control".to_string())
|
||||
};
|
||||
if zone.control_owner != owner || zone.control_source != source {
|
||||
zone.control_since = Some(now);
|
||||
} else if zone.control_since.is_none() {
|
||||
zone.control_since = Some(now);
|
||||
}
|
||||
zone.control_owner = owner.into();
|
||||
zone.control_source = source;
|
||||
zone.control_resume_at = resume_at;
|
||||
zone.control_reason = reason;
|
||||
}
|
||||
|
||||
fn normalized_direct_source(source: &str) -> &'static str {
|
||||
if source.contains("home_assistant") { "home_assistant_direct" }
|
||||
else if source == "device.manual_control" { "web_direct" }
|
||||
else { "external" }
|
||||
}
|
||||
|
||||
pub fn reset_device_manual_override(zone: &mut Zone) -> bool {
|
||||
let changed = zone.device_manual_override
|
||||
|| zone.device_manual_override_since.is_some()
|
||||
@@ -669,6 +727,13 @@ pub fn reset_device_manual_override(zone: &mut Zone) -> bool {
|
||||
zone.device_manual_override_until = None;
|
||||
zone.device_manual_override_fields.clear();
|
||||
zone.device_manual_override_baseline = None;
|
||||
if zone.control_owner == "direct_manual" {
|
||||
zone.control_owner = "automation".into();
|
||||
zone.control_source = "automation".into();
|
||||
zone.control_since = Some(Utc::now());
|
||||
zone.control_resume_at = None;
|
||||
zone.control_reason = "Manual takeover cleared; automation may resume".into();
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
@@ -716,13 +781,18 @@ fn set_device_manual_override(state: &AppState, zone: &mut Zone, fields: Vec<Str
|
||||
if !zone.device_manual_override {
|
||||
zone.device_manual_override_since = Some(now);
|
||||
zone.device_manual_override_baseline = Some(baseline.into());
|
||||
zone.control_since = Some(now);
|
||||
}
|
||||
zone.device_manual_override = true;
|
||||
zone.control_owner = "direct_manual".into();
|
||||
zone.control_source = normalized_direct_source(source).into();
|
||||
zone.control_reason = "Direct/manual device control has priority".into();
|
||||
zone.device_manual_override_until = if zone.enabled {
|
||||
next_schedule_boundary_utc(&zone.id, &state.db.list_schedules()?, Local::now())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
zone.control_resume_at = zone.device_manual_override_until;
|
||||
for field in fields {
|
||||
if !zone.device_manual_override_fields.iter().any(|existing| existing == &field) {
|
||||
zone.device_manual_override_fields.push(field);
|
||||
@@ -768,6 +838,11 @@ async fn detect_external_device_control(state: &AppState, before: &Device, after
|
||||
}
|
||||
|
||||
pub async fn send_manual_command(state: &AppState, device_id: &str, command: DeviceCommand, source: &str) -> Result<Device, AppError> {
|
||||
// Keep zone -> device lock ordering consistent with Quick Thermostat/full-zone edits.
|
||||
// A device belongs to at most one thermostat zone, but keep this generic for legacy data.
|
||||
let zone_ids: Vec<String> = state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id).map(|zone| zone.id).collect();
|
||||
let mut _zone_guards = Vec::new();
|
||||
for zone_id in &zone_ids { _zone_guards.push(state.lock_zone_operation(zone_id).await); }
|
||||
// Keep the device lock until the zone takeover marker is persisted. Otherwise a poll
|
||||
// could observe our own just-sent command before the controller records manual ownership.
|
||||
let _device_guard = state.lock_device_operation(device_id).await;
|
||||
@@ -871,6 +946,9 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
|
||||
|
||||
let mut zones = Vec::new();
|
||||
for zone_id in &group.zone_ids {
|
||||
let _zone_guard = state.lock_zone_operation(zone_id).await;
|
||||
let Some(zone_snapshot) = state.db.get_zone(zone_id)? else { continue; };
|
||||
let _device_guard = state.lock_device_operation(&zone_snapshot.device_id).await;
|
||||
let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; };
|
||||
if let Some(mode) = patch.mode.as_deref() {
|
||||
match mode {
|
||||
@@ -893,6 +971,7 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
|
||||
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &schedules, Local::now());
|
||||
}
|
||||
}
|
||||
zone.revision = zone.revision.saturating_add(1);
|
||||
zone.updated_at = Utc::now();
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
@@ -1079,7 +1158,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
home_assistant::resolve_entity_id(&settings.home_assistant, Some(configured_outdoor))
|
||||
};
|
||||
let ha_outdoor_temperature = if let Some(entity_id) = resolved_outdoor.as_deref() {
|
||||
match home_assistant::read_temperature(&state.http, &settings.home_assistant, Some(entity_id)).await {
|
||||
match home_assistant::read_temperature(&state.http, &settings.home_assistant, Some(entity_id), Some(300)).await {
|
||||
Ok(value) => {
|
||||
record_ha_history(
|
||||
state,
|
||||
@@ -1126,7 +1205,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
let http = &state.http;
|
||||
let ha_settings = &settings.home_assistant;
|
||||
Some(async move {
|
||||
let result = home_assistant::read_temperature(http, ha_settings, resolved_entity.as_deref()).await
|
||||
let result = home_assistant::read_temperature(http, ha_settings, resolved_entity.as_deref(), Some(zone.sensor_stale_after_seconds)).await
|
||||
.map_err(|err| err.to_string());
|
||||
(zone_id, resolved_entity, result)
|
||||
})
|
||||
@@ -1162,6 +1241,9 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
zone.mode.as_str()
|
||||
};
|
||||
zone.effective_mode = effective_mode.to_string();
|
||||
let ownership_blocked_by_group = zone.local_thermostat_power != Some(true)
|
||||
&& groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id));
|
||||
refresh_control_ownership(&mut zone, settings.house_power_enabled, ownership_blocked_by_group);
|
||||
|
||||
let previous_source = zone.control_temperature_source.clone();
|
||||
// Never feed the thermostat a cached GREE temperature after any communication
|
||||
@@ -1278,8 +1360,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
continue;
|
||||
}
|
||||
|
||||
let blocked_by_group = zone.local_thermostat_power != Some(true)
|
||||
&& groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id));
|
||||
let blocked_by_group = ownership_blocked_by_group;
|
||||
if blocked_by_group {
|
||||
zone.effective_mode = "off".into();
|
||||
zone.demand = false;
|
||||
@@ -1436,8 +1517,52 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
device.sleep,
|
||||
);
|
||||
|
||||
// Compressor protection for automatic ownership. Direct/manual commands and global safety OFF
|
||||
// deliberately bypass this path, while the thermostat never performs an immediate Heat<->Cool swap.
|
||||
let now = Utc::now();
|
||||
if zone.lockout_until.map(|until| until <= now).unwrap_or(false) {
|
||||
zone.lockout_until = None;
|
||||
zone.lockout_reason = None;
|
||||
}
|
||||
if device.power && device.mode != effective_mode {
|
||||
let min_on = chrono::Duration::seconds(zone.min_on_seconds as i64);
|
||||
if zone.last_power_change_at.map(|at| now.signed_duration_since(at) < min_on).unwrap_or(false) {
|
||||
let until = zone.last_power_change_at.map(|at| at + min_on);
|
||||
zone.lockout_until = until;
|
||||
zone.lockout_reason = Some("minimum_on_before_mode_change".into());
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
|
||||
continue;
|
||||
}
|
||||
match send_zone_command_if_owned(state, &zone.id, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }, false).await {
|
||||
Ok(Some(_)) => {
|
||||
zone.last_power_change_at = Some(now);
|
||||
zone.lockout_until = Some(now + chrono::Duration::seconds(zone.min_off_seconds as i64));
|
||||
zone.lockout_reason = Some("mode_change_off_delay".into());
|
||||
state.log("info", "zone.mode_change_lockout", &format!("Zone {} switched off before {} mode", zone.name, effective_mode), json!({"zone_id": zone.id, "resume_at": zone.lockout_until}));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(err) => state.log("error", "zone.mode_change_off_error", &err.to_string(), json!({"zone_id": zone.id})),
|
||||
}
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
|
||||
continue;
|
||||
}
|
||||
if !device.power {
|
||||
let min_off = chrono::Duration::seconds(zone.min_off_seconds as i64);
|
||||
if zone.last_power_change_at.map(|at| now.signed_duration_since(at) < min_off).unwrap_or(false) {
|
||||
zone.lockout_until = zone.last_power_change_at.map(|at| at + min_off);
|
||||
zone.lockout_reason = Some("minimum_off_before_start".into());
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let core_needs_command = !device.power
|
||||
|| device.mode != effective_mode
|
||||
|| (device.target_temperature - desired_device_target).abs() >= 0.5;
|
||||
// In normal standby, Low fan is a transition hint rather than a state that should
|
||||
// be reasserted forever. Some GREE firmwares accept the frame but later report Auto
|
||||
@@ -1451,8 +1576,8 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
|| desired_quiet.map(|quiet| quiet != device.quiet).unwrap_or(false)
|
||||
|| desired_sleep.map(|sleep| sleep != device.sleep).unwrap_or(false);
|
||||
|
||||
let urgent_mode_change = !device.power || device.mode != effective_mode;
|
||||
if needs_command && (urgent_mode_change || adjustment_allowed(&zone)) {
|
||||
let urgent_start = !device.power;
|
||||
if needs_command && (urgent_start || adjustment_allowed(&zone)) {
|
||||
let command = DeviceCommand {
|
||||
power: Some(true),
|
||||
mode: Some(effective_mode.to_string()),
|
||||
@@ -1464,8 +1589,11 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
};
|
||||
match send_zone_command_if_owned(state, &zone.id, &zone.device_id, command, false).await {
|
||||
Ok(Some(updated_device)) => {
|
||||
let transition_at = Utc::now();
|
||||
if device.power != updated_device.power { zone.last_power_change_at = Some(transition_at); }
|
||||
if device.mode != updated_device.mode { zone.last_mode_change_at = Some(transition_at); }
|
||||
zone.device_setpoint = if updated_device.power { Some(updated_device.target_temperature) } else { None };
|
||||
zone.last_action_at = Some(Utc::now());
|
||||
zone.last_action_at = Some(transition_at);
|
||||
state.log("info", "zone.setpoint_modulation", &format!("Zone {} -> {:.1} C ({})", zone.name, desired_device_target, if zone.demand { "demand" } else { "standby" }), json!({
|
||||
"zone_id": zone.id,
|
||||
"room_temperature": temp,
|
||||
@@ -1900,7 +2028,7 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
|
||||
let mut zones_out = Vec::new();
|
||||
let mut house_events = next_night_mode_events(&settings.night_mode, now, 2);
|
||||
|
||||
for zone in zones {
|
||||
for mut zone in zones {
|
||||
let device = devices.iter().find(|item| item.id == zone.device_id);
|
||||
let configured_effective_mode = if zone.inherit_house_mode {
|
||||
settings.house_mode.as_str()
|
||||
@@ -1909,6 +2037,7 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
|
||||
};
|
||||
let blocked_by_group = zone.local_thermostat_power != Some(true)
|
||||
&& groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id));
|
||||
refresh_control_ownership(&mut zone, settings.house_power_enabled, blocked_by_group);
|
||||
let manual_device_mode = device.map(|item| if item.power { item.mode.as_str() } else { "off" });
|
||||
let effective_mode = if zone.device_manual_override {
|
||||
manual_device_mode.unwrap_or(configured_effective_mode)
|
||||
@@ -1958,6 +2087,11 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
|
||||
zone.effective_setpoint.or(Some(resolved_target))
|
||||
},
|
||||
device_setpoint: device.filter(|item| item.power).map(|item| item.target_temperature),
|
||||
desired_power: settings.house_power_enabled && zone.enabled && effective_mode != "off" && !zone.device_manual_override && !blocked_by_group,
|
||||
desired_mode: effective_mode.to_string(),
|
||||
actual_power: device.map(|item| item.power),
|
||||
actual_mode: device.map(|item| if item.power { item.mode.clone() } else { "off".into() }),
|
||||
actual_setpoint: device.filter(|item| item.power).map(|item| item.target_temperature),
|
||||
demand: settings.house_power_enabled && zone.enabled && effective_mode != "off" && !zone.device_manual_override && zone.demand,
|
||||
control_source: zone.control_temperature_source.clone(),
|
||||
manual_override_until: zone.manual_override_until,
|
||||
@@ -1965,6 +2099,13 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
|
||||
local_thermostat_resume_at: zone.local_thermostat_resume_at,
|
||||
device_manual_override: zone.device_manual_override,
|
||||
device_manual_override_until: zone.device_manual_override_until,
|
||||
control_owner: zone.control_owner.clone(),
|
||||
control_command_source: zone.control_source.clone(),
|
||||
control_since: zone.control_since,
|
||||
resume_at: zone.control_resume_at,
|
||||
control_reason: zone.control_reason.clone(),
|
||||
blocked_reason: if !settings.house_power_enabled { Some("global_off".into()) } else if zone.device_manual_override { Some("manual_override".into()) } else if blocked_by_group { Some("group_off".into()) } else if zone.lockout_until.map(|until| until > Utc::now()).unwrap_or(false) { Some(zone.lockout_reason.clone().unwrap_or_else(|| "lockout".into())) } else if !zone.enabled { Some("zone_disabled".into()) } else if device.map(|d| !d.online || d.communication_failures > 0).unwrap_or(true) { Some("offline".into()) } else { None },
|
||||
lockout_until: zone.lockout_until,
|
||||
current_schedule_id: active.map(|item| item.id.clone()),
|
||||
current_schedule_name: active.map(|item| item.name.clone()),
|
||||
next_events,
|
||||
@@ -2362,10 +2503,11 @@ mod tests {
|
||||
heat_comfort_setpoint: 21.0, heat_sleep_setpoint: 19.0, heat_away_setpoint: 17.0,
|
||||
hysteresis: 0.6, min_on_seconds: 180, min_off_seconds: 180, min_adjust_seconds: 120, standby_offset_c: 2.0, smart_fan: true,
|
||||
sensor_source: source.into(), ha_entity_id: Some("sensor.room_temperature".into()),
|
||||
external_sensor_weight: 0.4, max_sensor_difference: 3.0, device_temperature: None, external_temperature: None,
|
||||
external_sensor_weight: 0.4, max_sensor_difference: 3.0, sensor_stale_after_seconds: 300, device_temperature: None, external_temperature: None,
|
||||
current_temperature: None, control_temperature_source: "device".into(), active_preset: "comfort".into(),
|
||||
manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None, local_thermostat_resume_at: None,
|
||||
device_manual_override: false, device_manual_override_since: None, device_manual_override_until: None, device_manual_override_fields: Vec::new(), device_manual_override_baseline: None,
|
||||
revision: 1, control_owner: "automation".into(), control_source: "automation".into(), control_since: Some(Utc::now()), control_resume_at: None, control_reason: "test".into(), last_power_change_at: None, last_mode_change_at: None, lockout_until: None, lockout_reason: None,
|
||||
effective_mode: "heat".into(), effective_setpoint: Some(21.0), device_setpoint: None,
|
||||
demand: false, demand_since: None, target_alerted_at: None, last_action_at: None, created_at: Utc::now(), updated_at: Utc::now(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user