v0.12.0-preety_code
This commit is contained in:
+497
-135
@@ -8,10 +8,17 @@ pub(crate) fn clear_compressor_pending(zone: &mut Zone, clear_cancelled: bool) {
|
||||
zone.compressor_pending_action = None;
|
||||
zone.compressor_pending_since = None;
|
||||
zone.compressor_pending_until = None;
|
||||
if clear_cancelled { zone.compressor_cancelled_action = None; }
|
||||
if clear_cancelled {
|
||||
zone.compressor_cancelled_action = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn queue_compressor_action(zone: &mut Zone, action: String, until: DateTime<Utc>, reason: &str) {
|
||||
pub(crate) fn queue_compressor_action(
|
||||
zone: &mut Zone,
|
||||
action: String,
|
||||
until: DateTime<Utc>,
|
||||
reason: &str,
|
||||
) {
|
||||
let now = Utc::now();
|
||||
if zone.compressor_pending_action.as_deref() != Some(action.as_str()) {
|
||||
zone.compressor_pending_since = Some(now);
|
||||
@@ -30,7 +37,6 @@ pub(crate) fn rearm_compressor_queue(zone: &mut Zone) {
|
||||
clear_compressor_pending(zone, true);
|
||||
}
|
||||
|
||||
|
||||
async fn resolve_cycle_outdoor_temperature(
|
||||
state: &AppState,
|
||||
settings: &RuntimeSettings,
|
||||
@@ -48,9 +54,18 @@ async fn resolve_cycle_outdoor_temperature(
|
||||
&settings.home_assistant,
|
||||
Some(entity_id),
|
||||
Some(settings.home_assistant.sensor_stale_after_seconds),
|
||||
).await {
|
||||
)
|
||||
.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) => {
|
||||
@@ -76,9 +91,14 @@ async fn read_cycle_room_sensors(
|
||||
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; }
|
||||
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 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(
|
||||
@@ -86,8 +106,14 @@ async fn read_cycle_room_sensors(
|
||||
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());
|
||||
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)
|
||||
})
|
||||
}))
|
||||
@@ -105,36 +131,58 @@ fn refresh_zone_temperature(
|
||||
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);
|
||||
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(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,
|
||||
}));
|
||||
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 => None,
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (temperature, source, discrepancy) = select_zone_temperature(zone, device_temperature, external_temperature);
|
||||
} 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;
|
||||
@@ -173,45 +221,92 @@ async fn handle_zone_pre_control_state(
|
||||
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)?;
|
||||
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);
|
||||
|| 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 {
|
||||
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()); }
|
||||
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
|
||||
}));
|
||||
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.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
|
||||
}));
|
||||
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)?;
|
||||
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) {
|
||||
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)?;
|
||||
persist_zone_cycle_with_history(
|
||||
state,
|
||||
zone,
|
||||
cycle_started_at,
|
||||
outdoor_temperature,
|
||||
settings.poll_interval_seconds,
|
||||
)?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
@@ -220,7 +315,13 @@ async fn handle_zone_pre_control_state(
|
||||
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)?;
|
||||
persist_zone_cycle_with_history(
|
||||
state,
|
||||
zone,
|
||||
cycle_started_at,
|
||||
outdoor_temperature,
|
||||
settings.poll_interval_seconds,
|
||||
)?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
@@ -230,23 +331,43 @@ async fn handle_zone_pre_control_state(
|
||||
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); }
|
||||
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 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.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)?;
|
||||
persist_zone_cycle_with_history(
|
||||
state,
|
||||
zone,
|
||||
cycle_started_at,
|
||||
outdoor_temperature,
|
||||
settings.poll_interval_seconds,
|
||||
)?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
@@ -254,7 +375,8 @@ async fn handle_zone_pre_control_state(
|
||||
"home_assistant" | "combined" => Some(zone.updated_at.clone()),
|
||||
_ => device.last_seen.clone(),
|
||||
};
|
||||
let max_condition_gap_seconds = settings.poll_interval_seconds
|
||||
let max_condition_gap_seconds = settings
|
||||
.poll_interval_seconds
|
||||
.max(settings.zone_interval_seconds)
|
||||
.saturating_mul(2)
|
||||
.saturating_add(5);
|
||||
@@ -265,9 +387,19 @@ async fn handle_zone_pre_control_state(
|
||||
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();
|
||||
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)?;
|
||||
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
|
||||
@@ -284,17 +416,39 @@ async fn handle_zone_pre_control_state(
|
||||
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 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}));
|
||||
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)?;
|
||||
persist_zone_cycle_with_history(
|
||||
state,
|
||||
zone,
|
||||
cycle_started_at,
|
||||
outdoor_temperature,
|
||||
settings.poll_interval_seconds,
|
||||
)?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
@@ -307,13 +461,31 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
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?;
|
||||
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 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;
|
||||
|
||||
@@ -322,9 +494,15 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
// Web/HA/manual control and polling. Re-read after taking the zone lock so an
|
||||
// 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 Some(mut zone) = state.db.get_zone(&zone_snapshot_item.id)? else {
|
||||
continue;
|
||||
};
|
||||
let cycle_started_at = zone.updated_at.clone();
|
||||
if zone.manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) {
|
||||
if zone
|
||||
.manual_override_until
|
||||
.map(|until| until <= Utc::now())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
zone.manual_preset = None;
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = None;
|
||||
@@ -338,13 +516,26 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
if zone.device_manual_override && zone.device_manual_override_until.is_some() {
|
||||
zone.device_manual_override_until = None;
|
||||
zone.control_resume_at = None;
|
||||
state.log("info", "zone.device_manual_override_migrated", &format!("Manual device control remains active for {} until explicit resume", zone.name), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id
|
||||
}));
|
||||
state.log(
|
||||
"info",
|
||||
"zone.device_manual_override_migrated",
|
||||
&format!(
|
||||
"Manual device control remains active for {} until explicit resume",
|
||||
zone.name
|
||||
),
|
||||
json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let Some(device) = state.db.get_device(&zone.device_id)? else {
|
||||
state.log("error", "zone.device_missing", &format!("Zone {} has no device", zone.name), json!({"zone_id": zone.id}));
|
||||
state.log(
|
||||
"error",
|
||||
"zone.device_missing",
|
||||
&format!("Zone {} has no device", zone.name),
|
||||
json!({"zone_id": zone.id}),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -360,7 +551,11 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
let effective_mode = effective_mode_owned.as_str();
|
||||
|
||||
let (previous_source, discrepancy) = refresh_zone_temperature(
|
||||
state, &settings, &mut zone, &device, &mut room_sensor_results,
|
||||
state,
|
||||
&settings,
|
||||
&mut zone,
|
||||
&device,
|
||||
&mut room_sensor_results,
|
||||
);
|
||||
|
||||
if handle_zone_pre_control_state(
|
||||
@@ -373,18 +568,28 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
effective_mode,
|
||||
cycle_started_at.clone(),
|
||||
outdoor_temperature,
|
||||
).await? {
|
||||
)
|
||||
.await?
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if discrepancy && previous_source != "device_discrepancy_fallback" {
|
||||
state.log("warn", "zone.sensor_discrepancy", &format!("Zone {} sensors differ by more than {:.1} C; using GREE sensor", zone.name, zone.max_sensor_difference), json!({
|
||||
"zone_id": zone.id,
|
||||
"device_temperature": zone.device_temperature,
|
||||
"external_temperature": zone.external_temperature,
|
||||
"max_difference": zone.max_sensor_difference,
|
||||
"entity_id": zone.ha_entity_id.as_deref(),
|
||||
}));
|
||||
state.log(
|
||||
"warn",
|
||||
"zone.sensor_discrepancy",
|
||||
&format!(
|
||||
"Zone {} sensors differ by more than {:.1} C; using GREE sensor",
|
||||
zone.name, zone.max_sensor_difference
|
||||
),
|
||||
json!({
|
||||
"zone_id": zone.id,
|
||||
"device_temperature": zone.device_temperature,
|
||||
"external_temperature": zone.external_temperature,
|
||||
"max_difference": zone.max_sensor_difference,
|
||||
"entity_id": zone.ha_entity_id.as_deref(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// House "off" is a no-control state, not a power-off command. Keep polling and
|
||||
@@ -396,7 +601,12 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
zone.target_alerted_at = None;
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
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;
|
||||
@@ -418,7 +628,8 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
zone.demand_since = None;
|
||||
zone.target_alerted_at = None;
|
||||
if zone.control_owner == "automation" {
|
||||
zone.control_reason = "Automation idle: no active schedule or explicit thermostat request".into();
|
||||
zone.control_reason =
|
||||
"Automation idle: no active schedule or explicit thermostat request".into();
|
||||
zone.control_resume_at = None;
|
||||
}
|
||||
|
||||
@@ -427,31 +638,54 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
state,
|
||||
&zone.id,
|
||||
&zone.device_id,
|
||||
DeviceCommand { power: Some(false), ..Default::default() },
|
||||
).await {
|
||||
DeviceCommand {
|
||||
power: Some(false),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(updated_device)) => {
|
||||
let transition_at = Utc::now();
|
||||
if device.power != updated_device.power {
|
||||
zone.last_power_change_at = Some(transition_at);
|
||||
}
|
||||
zone.last_action_at = Some(transition_at);
|
||||
state.log("info", "zone.automation_idle_off", &format!("Zone {} remains OFF: no active thermostat intent", zone.name), json!({
|
||||
"zone_id": zone.id,
|
||||
"device_id": zone.device_id,
|
||||
"active_schedule": false,
|
||||
"manual_preset": zone.manual_preset,
|
||||
"manual_setpoint": zone.manual_setpoint,
|
||||
"control_source": zone.control_source,
|
||||
}));
|
||||
state.log(
|
||||
"info",
|
||||
"zone.automation_idle_off",
|
||||
&format!(
|
||||
"Zone {} remains OFF: no active thermostat intent",
|
||||
zone.name
|
||||
),
|
||||
json!({
|
||||
"zone_id": zone.id,
|
||||
"device_id": zone.device_id,
|
||||
"active_schedule": false,
|
||||
"manual_preset": zone.manual_preset,
|
||||
"manual_setpoint": zone.manual_setpoint,
|
||||
"control_source": zone.control_source,
|
||||
}),
|
||||
);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(err) => state.log("error", "zone.automation_idle_off_error", &err.to_string(), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id
|
||||
})),
|
||||
Err(err) => state.log(
|
||||
"error",
|
||||
"zone.automation_idle_off_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);
|
||||
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;
|
||||
@@ -462,7 +696,12 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
zone.effective_setpoint = Some(target);
|
||||
|
||||
let Some(temp) = zone.current_temperature else {
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
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;
|
||||
@@ -472,14 +711,22 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
let previous_demand = zone.demand;
|
||||
zone.demand = match effective_mode {
|
||||
"heat" => {
|
||||
if temp <= target - half { true }
|
||||
else if temp >= target + half { false }
|
||||
else { zone.demand }
|
||||
if temp <= target - half {
|
||||
true
|
||||
} else if temp >= target + half {
|
||||
false
|
||||
} else {
|
||||
zone.demand
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if temp >= target + half { true }
|
||||
else if temp <= target - half { false }
|
||||
else { zone.demand }
|
||||
if temp >= target + half {
|
||||
true
|
||||
} else if temp <= target - half {
|
||||
false
|
||||
} else {
|
||||
zone.demand
|
||||
}
|
||||
}
|
||||
};
|
||||
if zone.demand && !previous_demand {
|
||||
@@ -503,13 +750,15 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
|
||||
// Setpoint modulation: keep the indoor unit powered and let its own inverter/compressor
|
||||
// stop naturally when we move the target to the satisfied side of room temperature.
|
||||
let outdoor_assist = outdoor_assist_offset(effective_mode, outdoor_assist_temperature, temp, target);
|
||||
let outdoor_assist =
|
||||
outdoor_assist_offset(effective_mode, outdoor_assist_temperature, temp, target);
|
||||
// When an independent room sensor is actually driving cooling, the indoor unit's
|
||||
// own sensor can satisfy too early. Apply a full-degree pre-rounding bias: because
|
||||
// GREE setpoints are sent as whole degrees, this keeps the unit at least one full
|
||||
// degree below the room target, including half-degree thermostat setpoints. Do not
|
||||
// stack it with outdoor assist or use it during device/fallback control.
|
||||
let room_sensor_assist = external_room_sensor_cooling_assist(effective_mode, &zone.control_temperature_source);
|
||||
let room_sensor_assist =
|
||||
external_room_sensor_cooling_assist(effective_mode, &zone.control_temperature_source);
|
||||
let demand_assist = outdoor_assist.max(room_sensor_assist);
|
||||
let active_target = match effective_mode {
|
||||
"heat" => target + outdoor_assist,
|
||||
@@ -519,17 +768,35 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
"heat" => target - zone.standby_offset_c.max(0.5),
|
||||
_ => target + zone.standby_offset_c.max(0.5),
|
||||
};
|
||||
let desired_device_target = round_device_setpoint(effective_mode, zone.demand, if zone.demand { active_target } else { standby_target });
|
||||
let desired_device_target = round_device_setpoint(
|
||||
effective_mode,
|
||||
zone.demand,
|
||||
if zone.demand {
|
||||
active_target
|
||||
} else {
|
||||
standby_target
|
||||
},
|
||||
);
|
||||
// Report only the last confirmed device state here. The desired target belongs to
|
||||
// effective_setpoint/command planning until a device command succeeds.
|
||||
zone.device_setpoint = if device.power { Some(device.target_temperature) } else { None };
|
||||
zone.device_setpoint = if device.power {
|
||||
Some(device.target_temperature)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let demand_changed = previous_demand != zone.demand;
|
||||
let desired_fan = if night_active {
|
||||
let max_fan = settings.night_mode.max_fan_speed.clamp(1, 5);
|
||||
if zone.smart_fan {
|
||||
Some(night_limited_fan_speed(
|
||||
smart_fan_speed(effective_mode, temp, target, outdoor_assist_temperature, zone.demand),
|
||||
smart_fan_speed(
|
||||
effective_mode,
|
||||
temp,
|
||||
target,
|
||||
outdoor_assist_temperature,
|
||||
zone.demand,
|
||||
),
|
||||
max_fan,
|
||||
))
|
||||
} else if device.fan_speed == 0 || device.fan_speed > max_fan {
|
||||
@@ -538,7 +805,13 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
Some(device.fan_speed)
|
||||
}
|
||||
} else if zone.smart_fan {
|
||||
Some(smart_fan_speed(effective_mode, temp, target, outdoor_assist_temperature, zone.demand))
|
||||
Some(smart_fan_speed(
|
||||
effective_mode,
|
||||
temp,
|
||||
target,
|
||||
outdoor_assist_temperature,
|
||||
zone.demand,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -572,7 +845,11 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
let now = Utc::now();
|
||||
if !settings.compressor_protection_enabled {
|
||||
clear_compressor_pending(&mut zone, true);
|
||||
} else if zone.lockout_until.map(|until| until <= now).unwrap_or(false) {
|
||||
} else if zone
|
||||
.lockout_until
|
||||
.map(|until| until <= now)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
zone.lockout_until = None;
|
||||
zone.lockout_reason = None;
|
||||
zone.compressor_pending_until = None;
|
||||
@@ -583,41 +860,90 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
let action = compressor_action_id("mode_change", effective_mode, desired_device_target);
|
||||
if compressor_action_is_cancelled(&zone, &action) {
|
||||
clear_compressor_pending(&mut zone, false);
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
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 let Some(last_change) = zone.last_power_change_at {
|
||||
if now.signed_duration_since(last_change) < protection {
|
||||
queue_compressor_action(&mut zone, action, last_change + protection, "minimum_on_before_mode_change");
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
queue_compressor_action(
|
||||
&mut zone,
|
||||
action,
|
||||
last_change + protection,
|
||||
"minimum_on_before_mode_change",
|
||||
);
|
||||
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() }).await {
|
||||
match send_zone_command_if_owned(
|
||||
state,
|
||||
&zone.id,
|
||||
&zone.device_id,
|
||||
DeviceCommand {
|
||||
power: Some(false),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(_)) => {
|
||||
zone.last_power_change_at = Some(now);
|
||||
queue_compressor_action(&mut zone, action, now + protection, "mode_change_off_delay");
|
||||
queue_compressor_action(
|
||||
&mut zone,
|
||||
action,
|
||||
now + protection,
|
||||
"mode_change_off_delay",
|
||||
);
|
||||
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, "compressor_protection_enabled": settings.compressor_protection_enabled}));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(err) => state.log("error", "zone.mode_change_off_error", &err.to_string(), json!({"zone_id": zone.id})),
|
||||
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);
|
||||
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 action = zone.compressor_pending_action.clone()
|
||||
let action = zone
|
||||
.compressor_pending_action
|
||||
.clone()
|
||||
.filter(|value| value.starts_with("mode_change:"))
|
||||
.unwrap_or_else(|| compressor_action_id("power_on", effective_mode, desired_device_target));
|
||||
.unwrap_or_else(|| {
|
||||
compressor_action_id("power_on", effective_mode, desired_device_target)
|
||||
});
|
||||
if compressor_action_is_cancelled(&zone, &action) {
|
||||
clear_compressor_pending(&mut zone, false);
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
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;
|
||||
@@ -625,8 +951,18 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
if settings.compressor_protection_enabled {
|
||||
if let Some(last_change) = zone.last_power_change_at {
|
||||
if now.signed_duration_since(last_change) < protection {
|
||||
queue_compressor_action(&mut zone, action, last_change + protection, "minimum_off_before_start");
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
queue_compressor_action(
|
||||
&mut zone,
|
||||
action,
|
||||
last_change + protection,
|
||||
"minimum_off_before_start",
|
||||
);
|
||||
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;
|
||||
@@ -658,8 +994,12 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
&& (zone.demand || demand_changed || core_needs_command || night_active);
|
||||
let needs_command = core_needs_command
|
||||
|| fan_needs_command
|
||||
|| desired_quiet.map(|quiet| quiet != device.quiet).unwrap_or(false)
|
||||
|| desired_sleep.map(|sleep| sleep != device.sleep).unwrap_or(false);
|
||||
|| desired_quiet
|
||||
.map(|quiet| quiet != device.quiet)
|
||||
.unwrap_or(false)
|
||||
|| desired_sleep
|
||||
.map(|sleep| sleep != device.sleep)
|
||||
.unwrap_or(false);
|
||||
|
||||
let urgent_start = !device.power;
|
||||
if needs_command && (urgent_start || adjustment_allowed(&zone)) {
|
||||
@@ -675,9 +1015,17 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
match send_zone_command_if_owned(state, &zone.id, &zone.device_id, command).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 };
|
||||
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(transition_at);
|
||||
clear_compressor_pending(&mut zone, true);
|
||||
state.log("info", "zone.setpoint_modulation", &format!("Zone {} -> {:.1} C ({})", zone.name, desired_device_target, if zone.demand { "demand" } else { "standby" }), json!({
|
||||
@@ -698,16 +1046,31 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
}));
|
||||
}
|
||||
Ok(None) => {
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
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;
|
||||
}
|
||||
Err(err) => state.log("error", "zone.action_error", &err.to_string(), json!({"zone_id": zone.id})),
|
||||
Err(err) => state.log(
|
||||
"error",
|
||||
"zone.action_error",
|
||||
&err.to_string(),
|
||||
json!({"zone_id": zone.id}),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
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)?);
|
||||
}
|
||||
@@ -715,7 +1078,6 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
/// Run one thermostat arbitration cycle immediately and wait for all currently eligible zones.
|
||||
/// The cycle lock prevents overlap with the background regulator.
|
||||
pub async fn run_zone_control_now(state: &AppState) -> Result<(), AppError> {
|
||||
|
||||
Reference in New Issue
Block a user