v0.12.0
This commit is contained in:
+276
-258
@@ -30,77 +30,292 @@ pub(crate) fn rearm_compressor_queue(zone: &mut Zone) {
|
||||
clear_compressor_pending(zone, true);
|
||||
}
|
||||
|
||||
async fn control_zones(state: &AppState) -> Result<()> {
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
let schedules = state.db.list_schedules()?;
|
||||
let settings = state.settings.read().await.clone();
|
||||
let mut zone_snapshot = state.db.list_zones()?;
|
||||
// Local/temporary thermostat ownership is independent from the legacy whole-house master gate
|
||||
// commands. Expire/activate sessions on their own deadlines.
|
||||
expire_local_thermostat_overrides(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?;
|
||||
let temporary_restored_disabled = expire_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?;
|
||||
activate_due_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode, true).await?;
|
||||
|
||||
// Outdoor temperature is deliberately optional. Prefer the configured Home
|
||||
// Assistant entity, but keep the dashboard/assist useful by falling back to the
|
||||
// outdoor sensors reported by GREE units when HA is temporarily unavailable.
|
||||
let device_snapshot = state.db.list_devices()?;
|
||||
let configured_outdoor = settings.home_assistant.outdoor_entity_id.trim();
|
||||
let resolved_outdoor = if configured_outdoor.is_empty() {
|
||||
async fn resolve_cycle_outdoor_temperature(
|
||||
state: &AppState,
|
||||
settings: &RuntimeSettings,
|
||||
devices: &[Device],
|
||||
) -> Option<f64> {
|
||||
let configured = settings.home_assistant.outdoor_entity_id.trim();
|
||||
let resolved = if configured.is_empty() {
|
||||
None
|
||||
} else {
|
||||
home_assistant::resolve_entity_id(&settings.home_assistant, Some(configured_outdoor))
|
||||
home_assistant::resolve_entity_id(&settings.home_assistant, Some(configured))
|
||||
};
|
||||
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), Some(settings.home_assistant.sensor_stale_after_seconds)).await {
|
||||
let from_home_assistant = if let Some(entity_id) = resolved.as_deref() {
|
||||
match home_assistant::read_temperature(
|
||||
&state.http,
|
||||
&settings.home_assistant,
|
||||
Some(entity_id),
|
||||
Some(settings.home_assistant.sensor_stale_after_seconds),
|
||||
).await {
|
||||
Ok(value) => {
|
||||
record_ha_history(
|
||||
state,
|
||||
entity_id,
|
||||
None,
|
||||
"outdoor",
|
||||
value,
|
||||
settings.poll_interval_seconds,
|
||||
);
|
||||
record_ha_history(state, entity_id, None, "outdoor", value, settings.poll_interval_seconds);
|
||||
Some(value)
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::debug!(configured_entity=%configured_outdoor, resolved_entity=%entity_id, error=?err, "outdoor Home Assistant sensor unavailable; trying GREE fallback");
|
||||
tracing::debug!(configured_entity=%configured, resolved_entity=%entity_id, error=?err, "outdoor Home Assistant sensor unavailable; trying GREE fallback");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let outdoor_temperature = ha_outdoor_temperature.or_else(|| gree_outdoor_temperature(&device_snapshot));
|
||||
{
|
||||
let mut current = state.outdoor_temperature.write().await;
|
||||
if *current != outdoor_temperature {
|
||||
*current = outdoor_temperature;
|
||||
state.broadcast("outdoor.updated", json!({"temperature": outdoor_temperature}));
|
||||
}
|
||||
let temperature = from_home_assistant.or_else(|| gree_outdoor_temperature(devices));
|
||||
let mut current = state.outdoor_temperature.write().await;
|
||||
if *current != temperature {
|
||||
*current = temperature;
|
||||
state.broadcast("outdoor.updated", json!({"temperature": temperature}));
|
||||
}
|
||||
let outdoor_assist_temperature = if settings.outdoor_assist_enabled { outdoor_temperature } else { None };
|
||||
let night_active = night_mode_active(&settings.night_mode, Local::now().time());
|
||||
temperature
|
||||
}
|
||||
|
||||
// Read all per-zone Home Assistant sensors concurrently. A down HA instance should cost
|
||||
// one request timeout per cycle, not one timeout multiplied by the number of zones.
|
||||
let room_sensor_reads = futures_util::future::join_all(zone_snapshot.iter().filter_map(|zone| {
|
||||
async fn read_cycle_room_sensors(
|
||||
state: &AppState,
|
||||
settings: &RuntimeSettings,
|
||||
zones: &[Zone],
|
||||
) -> HashMap<String, (Option<String>, Result<f64, String>)> {
|
||||
futures_util::future::join_all(zones.iter().filter_map(|zone| {
|
||||
if !matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") { return None; }
|
||||
let zone_id = zone.id.clone();
|
||||
let resolved_entity = home_assistant::resolve_entity_id(&settings.home_assistant, zone.ha_entity_id.as_deref());
|
||||
let http = &state.http;
|
||||
let ha_settings = &settings.home_assistant;
|
||||
let stale_after_seconds = effective_sensor_stale_after_seconds(zone.sensor_stale_after_seconds, ha_settings.sensor_stale_after_seconds);
|
||||
let stale_after_seconds = effective_sensor_stale_after_seconds(
|
||||
zone.sensor_stale_after_seconds,
|
||||
ha_settings.sensor_stale_after_seconds,
|
||||
);
|
||||
Some(async move {
|
||||
let result = home_assistant::read_temperature(http, ha_settings, resolved_entity.as_deref(), Some(stale_after_seconds)).await
|
||||
.map_err(|err| err.to_string());
|
||||
(zone_id, resolved_entity, result)
|
||||
})
|
||||
})).await;
|
||||
let mut room_sensor_results: HashMap<String, (Option<String>, Result<f64, String>)> = room_sensor_reads.into_iter()
|
||||
.map(|(zone_id, entity_id, result)| (zone_id, (entity_id, result)))
|
||||
.collect();
|
||||
}))
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|(zone_id, entity_id, result)| (zone_id, (entity_id, result)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn refresh_zone_temperature(
|
||||
state: &AppState,
|
||||
settings: &RuntimeSettings,
|
||||
zone: &mut Zone,
|
||||
device: &Device,
|
||||
room_sensor_results: &mut HashMap<String, (Option<String>, Result<f64, String>)>,
|
||||
) -> (String, bool) {
|
||||
let previous_source = zone.control_temperature_source.clone();
|
||||
let device_temperature = if device.enabled && device.online && device.communication_failures == 0 {
|
||||
device.current_temperature
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let external_temperature = if matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") {
|
||||
match room_sensor_results.remove(&zone.id) {
|
||||
Some((resolved_entity, Ok(value))) => {
|
||||
if let Some(entity_id) = resolved_entity.as_deref() {
|
||||
record_ha_history(state, entity_id, Some(&zone.id), "room", value, settings.poll_interval_seconds);
|
||||
}
|
||||
Some(value)
|
||||
}
|
||||
Some((resolved_entity, Err(err))) => {
|
||||
if !matches!(previous_source.as_str(), "device_fallback" | "device_discrepancy_fallback") {
|
||||
let kind = if err.contains("Home Assistant sensor is stale:") { "ha.sensor_stale" } else { "ha.sensor_error" };
|
||||
state.log("warn", kind, &err, json!({
|
||||
"zone_id": zone.id,
|
||||
"configured_entity_id": zone.ha_entity_id.as_deref(),
|
||||
"resolved_entity_id": resolved_entity,
|
||||
}));
|
||||
}
|
||||
None
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (temperature, source, discrepancy) = select_zone_temperature(zone, device_temperature, external_temperature);
|
||||
zone.device_temperature = device_temperature;
|
||||
zone.external_temperature = external_temperature;
|
||||
zone.current_temperature = temperature;
|
||||
zone.control_temperature_source = source;
|
||||
zone.updated_at = Utc::now();
|
||||
(previous_source, discrepancy)
|
||||
}
|
||||
|
||||
fn persist_zone_cycle_with_history(
|
||||
state: &AppState,
|
||||
zone: &Zone,
|
||||
cycle_started_at: DateTime<Utc>,
|
||||
outdoor_temperature: Option<f64>,
|
||||
poll_interval_seconds: u64,
|
||||
) -> Result<Zone> {
|
||||
record_zone_history(state, zone, outdoor_temperature, poll_interval_seconds);
|
||||
let persisted = persist_zone_cycle(state, zone, cycle_started_at)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&persisted)?);
|
||||
Ok(persisted)
|
||||
}
|
||||
|
||||
async fn handle_zone_pre_control_state(
|
||||
state: &AppState,
|
||||
settings: &RuntimeSettings,
|
||||
schedules: &[Schedule],
|
||||
temporary_restored_disabled: &[String],
|
||||
zone: &mut Zone,
|
||||
device: &Device,
|
||||
effective_mode: &str,
|
||||
cycle_started_at: DateTime<Utc>,
|
||||
outdoor_temperature: Option<f64>,
|
||||
) -> Result<bool> {
|
||||
// A queued whole-house ON is a delayed bulk physical action, not thermostat ownership.
|
||||
if zone.compressor_pending_action.as_deref() == Some("global_power_on") {
|
||||
let now = Utc::now();
|
||||
if device.power {
|
||||
clear_compressor_pending(zone, true);
|
||||
zone.updated_at = now;
|
||||
persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?;
|
||||
return Ok(true);
|
||||
}
|
||||
let due = !settings.compressor_protection_enabled
|
||||
|| zone.compressor_pending_until.as_ref().map(|until| until <= &now).unwrap_or(true);
|
||||
if due {
|
||||
let _device_guard = state.lock_device_operation(&zone.device_id).await;
|
||||
match send_command_locked(state, &zone.device_id, DeviceCommand { power: Some(true), ..Default::default() }).await {
|
||||
Ok(updated_device) => {
|
||||
if !device.power && updated_device.power { zone.last_power_change_at = Some(Utc::now()); }
|
||||
clear_compressor_pending(zone, true);
|
||||
zone.last_action_at = Some(Utc::now());
|
||||
state.log("info", "house.power_one_shot_executed", &format!("Executed queued global ON for {}", zone.name), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id
|
||||
}));
|
||||
}
|
||||
Err(err) => {
|
||||
zone.compressor_pending_until = Some(Utc::now() + chrono::Duration::seconds(10));
|
||||
zone.lockout_until = zone.compressor_pending_until.clone();
|
||||
zone.lockout_reason = Some("global_start_retry".into());
|
||||
state.log("error", "house.power_one_shot_error", &err.to_string(), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
zone.updated_at = Utc::now();
|
||||
persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if !zone.enabled {
|
||||
if temporary_restored_disabled.iter().any(|zone_id| zone_id == &zone.id) {
|
||||
ensure_device_off_after_temporary_disabled_restore(state, zone, device).await;
|
||||
}
|
||||
clear_compressor_pending(zone, true);
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if !device.enabled {
|
||||
clear_compressor_pending(zone, true);
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
zone.device_setpoint = None;
|
||||
persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if zone.device_manual_override {
|
||||
clear_compressor_pending(zone, true);
|
||||
let temporary_active = temporary_quick_thermostat_is_active(zone, zone.updated_at.clone());
|
||||
let pause_started_at = zone.updated_at.clone();
|
||||
if temporary_active {
|
||||
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
||||
if session.paused_at.is_none() { session.paused_at = Some(pause_started_at); }
|
||||
session.state = "paused_manual".into();
|
||||
session.condition_started_at = None;
|
||||
session.condition_last_observed_at = None;
|
||||
}
|
||||
}
|
||||
let target_mode = if effective_mode == "off" { zone.mode.as_str() } else { effective_mode };
|
||||
let active_schedule = active_schedule_for_zone(zone, schedules, Local::now());
|
||||
let (preset, target) = resolve_zone_target(zone, active_schedule, target_mode);
|
||||
zone.active_preset = preset;
|
||||
zone.effective_setpoint = Some(target);
|
||||
zone.effective_mode = if device.power { device.mode.clone() } else { "off".into() };
|
||||
zone.device_setpoint = if device.power { Some(device.target_temperature) } else { None };
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
zone.target_alerted_at = None;
|
||||
persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let condition_sample_at = match zone.control_temperature_source.as_str() {
|
||||
"home_assistant" | "combined" => Some(zone.updated_at.clone()),
|
||||
_ => device.last_seen.clone(),
|
||||
};
|
||||
let max_condition_gap_seconds = settings.poll_interval_seconds
|
||||
.max(settings.zone_interval_seconds)
|
||||
.saturating_mul(2)
|
||||
.saturating_add(5);
|
||||
let condition_now = zone.updated_at.clone();
|
||||
if let Some(reason) = evaluate_temporary_quick_thermostat_condition(
|
||||
zone,
|
||||
condition_now,
|
||||
condition_sample_at,
|
||||
max_condition_gap_seconds,
|
||||
) {
|
||||
let finish_kind = zone.temporary_quick_thermostat.as_ref().map(|item| item.finish_kind.clone()).unwrap_or_default();
|
||||
finish_temporary_quick_thermostat(zone, schedules, &settings.house_mode);
|
||||
let persisted = persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?;
|
||||
ensure_device_off_after_temporary_disabled_restore(state, &persisted, device).await;
|
||||
state.log("info", "zone.temporary_quick_thermostat_finished", &format!("Temporary Quick Thermostat finished for {}", zone.name), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id, "finish_kind": finish_kind, "reason": reason
|
||||
}));
|
||||
state.wake_zone_control();
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
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}));
|
||||
}
|
||||
}
|
||||
}
|
||||
persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn control_zones(state: &AppState) -> Result<()> {
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
let schedules = state.db.list_schedules()?;
|
||||
let settings = state.settings.read().await.clone();
|
||||
let mut zone_snapshot = state.db.list_zones()?;
|
||||
// Local/temporary thermostat ownership has its own deadlines. Expire and activate sessions independently.
|
||||
expire_local_thermostat_overrides(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?;
|
||||
let temporary_restored_disabled = expire_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?;
|
||||
activate_due_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?;
|
||||
|
||||
let device_snapshot = state.db.list_devices()?;
|
||||
let outdoor_temperature = resolve_cycle_outdoor_temperature(state, &settings, &device_snapshot).await;
|
||||
let outdoor_assist_temperature = if settings.outdoor_assist_enabled { outdoor_temperature } else { None };
|
||||
let night_active = night_mode_active(&settings.night_mode, Local::now().time());
|
||||
let mut room_sensor_results = read_cycle_room_sensors(state, &settings, &zone_snapshot).await;
|
||||
|
||||
for zone_snapshot_item in zone_snapshot {
|
||||
// Every thermostat decision participates in the same zone -> device ordering as
|
||||
@@ -108,7 +323,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
// interactive change cannot be evaluated from a stale snapshot.
|
||||
let _zone_guard = state.lock_zone_operation(&zone_snapshot_item.id).await;
|
||||
let Some(mut zone) = state.db.get_zone(&zone_snapshot_item.id)? else { continue; };
|
||||
let cycle_started_at = zone.updated_at;
|
||||
let cycle_started_at = zone.updated_at.clone();
|
||||
if zone.manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) {
|
||||
zone.manual_preset = None;
|
||||
zone.manual_setpoint = None;
|
||||
@@ -119,8 +334,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
zone.control_reason = "Group override expired at schedule boundary".into();
|
||||
}
|
||||
}
|
||||
// v0.8.20 makes direct/manual takeover persistent. Normalize any legacy persisted
|
||||
// boundary from older releases instead of silently returning ownership to schedules.
|
||||
// Direct/manual takeover stays active until the user explicitly resumes automation.
|
||||
if zone.device_manual_override && zone.device_manual_override_until.is_some() {
|
||||
zone.device_manual_override_until = None;
|
||||
zone.control_resume_at = None;
|
||||
@@ -142,220 +356,24 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
// persistent gate: later thermostat/group/manual intent may act independently.
|
||||
let effective_mode_owned = effective_zone_mode(&zone, &settings.house_mode);
|
||||
zone.effective_mode = effective_mode_owned.clone();
|
||||
refresh_control_ownership(&mut zone, true);
|
||||
refresh_control_ownership(&mut zone);
|
||||
let effective_mode = effective_mode_owned.as_str();
|
||||
|
||||
let previous_source = zone.control_temperature_source.clone();
|
||||
// Never feed the thermostat a cached GREE temperature after any communication
|
||||
// failure. External HA sensors may still keep a zone operational when configured.
|
||||
let device_temperature = if device.enabled && device.online && device.communication_failures == 0 {
|
||||
device.current_temperature
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let external_temperature = if matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") {
|
||||
match room_sensor_results.remove(&zone.id) {
|
||||
Some((resolved_entity, Ok(value))) => {
|
||||
if let Some(entity_id) = resolved_entity.as_deref() {
|
||||
record_ha_history(state, entity_id, Some(&zone.id), "room", value, settings.poll_interval_seconds);
|
||||
}
|
||||
Some(value)
|
||||
}
|
||||
Some((resolved_entity, Err(err))) => {
|
||||
if !matches!(previous_source.as_str(), "device_fallback" | "device_discrepancy_fallback") {
|
||||
let notification_kind = if err.contains("Home Assistant sensor is stale:") {
|
||||
"ha.sensor_stale"
|
||||
} else {
|
||||
"ha.sensor_error"
|
||||
};
|
||||
state.log("warn", notification_kind, &err, json!({
|
||||
"zone_id": zone.id,
|
||||
"configured_entity_id": zone.ha_entity_id.as_deref(),
|
||||
"resolved_entity_id": resolved_entity,
|
||||
}));
|
||||
}
|
||||
None
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (previous_source, discrepancy) = refresh_zone_temperature(
|
||||
state, &settings, &mut zone, &device, &mut room_sensor_results,
|
||||
);
|
||||
|
||||
let (temperature, control_source, discrepancy) = select_zone_temperature(&zone, device_temperature, external_temperature);
|
||||
zone.device_temperature = device_temperature;
|
||||
zone.external_temperature = external_temperature;
|
||||
zone.current_temperature = temperature;
|
||||
zone.control_temperature_source = control_source;
|
||||
zone.updated_at = Utc::now();
|
||||
|
||||
// A queued whole-house ON is a delayed bulk physical action, not thermostat
|
||||
// ownership. It must survive local/group/manual state while compressor protection is
|
||||
// active, then execute once and hand control straight back to the existing owner.
|
||||
if zone.compressor_pending_action.as_deref() == Some("global_power_on") {
|
||||
let now = Utc::now();
|
||||
if device.power {
|
||||
clear_compressor_pending(&mut zone, true);
|
||||
zone.updated_at = now;
|
||||
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
|
||||
continue;
|
||||
}
|
||||
let due = !settings.compressor_protection_enabled
|
||||
|| zone.compressor_pending_until.map(|until| until <= now).unwrap_or(true);
|
||||
if due {
|
||||
let _device_guard = state.lock_device_operation(&zone.device_id).await;
|
||||
match send_command_locked(state, &zone.device_id, DeviceCommand { power: Some(true), ..Default::default() }).await {
|
||||
Ok(updated_device) => {
|
||||
if !device.power && updated_device.power { zone.last_power_change_at = Some(Utc::now()); }
|
||||
clear_compressor_pending(&mut zone, true);
|
||||
zone.last_action_at = Some(Utc::now());
|
||||
state.log("info", "house.power_one_shot_executed", &format!("Executed queued global ON for {}", zone.name), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id
|
||||
}));
|
||||
}
|
||||
Err(err) => {
|
||||
// Keep the user-visible task and retry on a bounded deadline instead of
|
||||
// spinning immediately or silently dropping the requested global start.
|
||||
zone.compressor_pending_until = Some(Utc::now() + chrono::Duration::seconds(10));
|
||||
zone.lockout_until = zone.compressor_pending_until;
|
||||
zone.lockout_reason = Some("global_start_retry".into());
|
||||
state.log("error", "house.power_one_shot_error", &err.to_string(), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
zone.updated_at = Utc::now();
|
||||
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;
|
||||
}
|
||||
|
||||
// A disabled thermostat zone is completely outside normal controller ownership.
|
||||
// Keep its sensors fresh, but do not let group state, schedules or thermostat
|
||||
// modulation touch the unit. Manual control from the technical Devices view may
|
||||
// therefore remain active until the zone is explicitly enabled again.
|
||||
if !zone.enabled {
|
||||
if temporary_restored_disabled.iter().any(|zone_id| zone_id == &zone.id) {
|
||||
ensure_device_off_after_temporary_disabled_restore(state, &zone, &device).await;
|
||||
}
|
||||
clear_compressor_pending(&mut zone, true);
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
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;
|
||||
}
|
||||
|
||||
// A technically disabled device is outside thermostat ownership. Do not create
|
||||
// repeated command errors while keeping any available external sensor data visible.
|
||||
if !device.enabled {
|
||||
clear_compressor_pending(&mut zone, true);
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
zone.device_setpoint = None;
|
||||
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;
|
||||
}
|
||||
|
||||
// A physical/manual takeover has higher priority than thermostat, schedule, group and
|
||||
// automation control. Continue sensor/history updates, but reflect the unit's real state
|
||||
// instead of sending corrective frames that would fight the person holding the remote.
|
||||
if zone.device_manual_override {
|
||||
// Direct/manual ownership and the thermostat compressor queue are mutually
|
||||
// exclusive. Clean any stale persisted task before remaining passive.
|
||||
clear_compressor_pending(&mut zone, true);
|
||||
// Manual/remote takeover pauses commands, but it must not erase the thermostat's
|
||||
// selected profile/target. Keep the intended target visible and report the physical
|
||||
// unit target separately through device_setpoint. This makes Resume/Profile actions
|
||||
// deterministic and avoids a standby device target (for example 25 C) masquerading
|
||||
// as the zone's Sleep/Comfort target.
|
||||
let temporary_active = temporary_quick_thermostat_is_active(&zone, zone.updated_at.clone());
|
||||
let pause_started_at = zone.updated_at;
|
||||
if temporary_active {
|
||||
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
||||
if session.paused_at.is_none() { session.paused_at = Some(pause_started_at); }
|
||||
session.state = "paused_manual".into();
|
||||
session.condition_started_at = None;
|
||||
session.condition_last_observed_at = None;
|
||||
}
|
||||
}
|
||||
let target_mode = if effective_mode == "off" { zone.mode.as_str() } else { effective_mode };
|
||||
let active_schedule = active_schedule_for_zone(&zone, &schedules, Local::now());
|
||||
let (preset, target) = resolve_zone_target(&zone, active_schedule, target_mode);
|
||||
zone.active_preset = preset;
|
||||
zone.effective_setpoint = Some(target);
|
||||
// Keep effective_mode's existing meaning during takeover: it reflects the physical
|
||||
// unit, while effective_setpoint above remains the thermostat intent.
|
||||
zone.effective_mode = if device.power { device.mode.clone() } else { "off".into() };
|
||||
zone.device_setpoint = if device.power { Some(device.target_temperature) } else { None };
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
zone.target_alerted_at = None;
|
||||
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;
|
||||
}
|
||||
|
||||
// Temperature completion belongs to the temporary thermostat only while it truly owns
|
||||
// the zone. A manual/device takeover above therefore pauses the hold instead of silently
|
||||
// consuming it. GREE samples use last_seen; HA/combined samples were freshly read in this
|
||||
// control cycle. A long gap resets continuous-hold evidence after restart/stale sensors.
|
||||
let condition_sample_at = match zone.control_temperature_source.as_str() {
|
||||
"home_assistant" | "combined" => Some(zone.updated_at.clone()),
|
||||
_ => device.last_seen.clone(),
|
||||
};
|
||||
let max_condition_gap_seconds = settings.poll_interval_seconds
|
||||
.max(settings.zone_interval_seconds)
|
||||
.saturating_mul(2)
|
||||
.saturating_add(5);
|
||||
let condition_now = zone.updated_at.clone();
|
||||
if let Some(reason) = evaluate_temporary_quick_thermostat_condition(
|
||||
if handle_zone_pre_control_state(
|
||||
state,
|
||||
&settings,
|
||||
&schedules,
|
||||
&temporary_restored_disabled,
|
||||
&mut zone,
|
||||
condition_now,
|
||||
condition_sample_at,
|
||||
max_condition_gap_seconds,
|
||||
) {
|
||||
let finish_kind = zone.temporary_quick_thermostat.as_ref().map(|item| item.finish_kind.clone()).unwrap_or_default();
|
||||
finish_temporary_quick_thermostat(&mut zone, &schedules, &settings.house_mode);
|
||||
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)?);
|
||||
ensure_device_off_after_temporary_disabled_restore(state, &persisted_zone, &device).await;
|
||||
state.log("info", "zone.temporary_quick_thermostat_finished", &format!("Temporary Quick Thermostat finished for {}", zone.name), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id, "finish_kind": finish_kind, "reason": reason
|
||||
}));
|
||||
state.wake_zone_control();
|
||||
continue;
|
||||
}
|
||||
|
||||
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)?);
|
||||
&device,
|
||||
effective_mode,
|
||||
cycle_started_at.clone(),
|
||||
outdoor_temperature,
|
||||
).await? {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user