544 lines
30 KiB
Rust
544 lines
30 KiB
Rust
async fn control_zones(state: &AppState) -> Result<()> {
|
|
let schedules = state.db.list_schedules()?;
|
|
let groups = state.db.list_groups()?;
|
|
let settings = state.settings.read().await.clone();
|
|
let mut zone_snapshot = state.db.list_zones()?;
|
|
// Local quick-thermostat OFF is intentionally temporary. Expire the ownership marker
|
|
// before the house-power early return so the hand-back still happens while the master
|
|
// is off; no physical state is restored here, only automation ownership.
|
|
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, settings.house_power_enabled).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() {
|
|
None
|
|
} else {
|
|
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), Some(settings.home_assistant.sensor_stale_after_seconds)).await {
|
|
Ok(value) => {
|
|
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");
|
|
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 outdoor_assist_temperature = if settings.outdoor_assist_enabled { outdoor_temperature } else { None };
|
|
let night_active = night_mode_active(&settings.night_mode, Local::now().time());
|
|
|
|
if !settings.house_power_enabled {
|
|
// Whole-house OFF is a one-shot action performed by the API endpoint. While the
|
|
// master remains off the regulator stays passive. A later physical/remote change
|
|
// is therefore detected as manual takeover and is not erased or forced OFF again.
|
|
return Ok(());
|
|
}
|
|
|
|
// 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| {
|
|
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);
|
|
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();
|
|
|
|
for mut zone in zone_snapshot {
|
|
let cycle_started_at = zone.updated_at;
|
|
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;
|
|
}
|
|
if zone.device_manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) {
|
|
reset_device_manual_override(&mut zone);
|
|
state.log("info", "zone.device_manual_override_expired", &format!("Manual device control expired for {} at schedule transition", 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}));
|
|
continue;
|
|
};
|
|
|
|
// House "off" means the smart thermostat does not control inherited zones.
|
|
// A zone explicitly switched to heat/cool remains independent and may still run.
|
|
// Local Quick Thermostat is an explicit per-zone request. If the inherited house
|
|
// climate mode is "off" (no automatic climate control), use the zone's last local
|
|
// heat/cool mode while local ownership is ON. The separate whole-house master power
|
|
// remains authoritative and is checked before this loop.
|
|
let effective_mode_owned = effective_zone_mode(&zone, &settings.house_mode);
|
|
zone.effective_mode = effective_mode_owned.clone();
|
|
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 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 (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 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;
|
|
}
|
|
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 {
|
|
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 {
|
|
// 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(
|
|
&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)?);
|
|
continue;
|
|
}
|
|
|
|
let blocked_by_group = ownership_blocked_by_group;
|
|
if blocked_by_group {
|
|
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 {
|
|
match send_zone_command_if_owned(
|
|
state,
|
|
&zone.id,
|
|
&zone.device_id,
|
|
DeviceCommand { power: Some(false), ..Default::default() },
|
|
true,
|
|
).await {
|
|
Ok(_) => {}
|
|
Err(err) => state.log("error", "group.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;
|
|
}
|
|
|
|
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(),
|
|
}));
|
|
}
|
|
|
|
// House "off" is a no-control state, not a power-off command. Keep polling and
|
|
// publishing the zone, but never overwrite manual device state while it follows
|
|
// the house mode. Explicit per-zone heat/cool bypasses this branch above.
|
|
if effective_mode == "off" {
|
|
zone.effective_setpoint = None;
|
|
zone.device_setpoint = 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;
|
|
}
|
|
|
|
let active_schedule = active_schedule_for_zone(&zone, &schedules, Local::now());
|
|
let (preset, target) = resolve_zone_target(&zone, active_schedule, effective_mode);
|
|
zone.active_preset = preset;
|
|
zone.effective_setpoint = Some(target);
|
|
|
|
let Some(temp) = temperature else {
|
|
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 half = zone.hysteresis_for_mode(effective_mode) / 2.0;
|
|
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 zone.demand && !previous_demand {
|
|
zone.demand_since = Some(Utc::now());
|
|
zone.target_alerted_at = None;
|
|
} else if !zone.demand {
|
|
zone.demand_since = None;
|
|
zone.target_alerted_at = None;
|
|
}
|
|
if zone.demand && zone.target_alerted_at.is_none() {
|
|
let timeout_minutes = settings.notifications.target_timeout_minutes.max(5) as i64;
|
|
if let Some(since) = zone.demand_since {
|
|
if (Utc::now() - since).num_minutes() >= timeout_minutes {
|
|
state.log("warn", "zone.target_timeout", &format!("Zone {} has not reached {:.1} C within {} minutes", zone.name, target, timeout_minutes), json!({
|
|
"zone_id": zone.id, "room_temperature": temp, "target_temperature": target, "minutes": timeout_minutes
|
|
}));
|
|
zone.target_alerted_at = Some(Utc::now());
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
// 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 demand_assist = outdoor_assist.max(room_sensor_assist);
|
|
let active_target = match effective_mode {
|
|
"heat" => target + outdoor_assist,
|
|
_ => target - demand_assist,
|
|
};
|
|
let standby_target = match effective_mode {
|
|
"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 });
|
|
// 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 };
|
|
|
|
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),
|
|
max_fan,
|
|
))
|
|
} else if device.fan_speed == 0 || device.fan_speed > max_fan {
|
|
Some(max_fan)
|
|
} else {
|
|
Some(device.fan_speed)
|
|
}
|
|
} else if zone.smart_fan {
|
|
Some(smart_fan_speed(effective_mode, temp, target, outdoor_assist_temperature, zone.demand))
|
|
} else {
|
|
None
|
|
};
|
|
// When the room becomes satisfied, ask compatible units for Quiet in the same
|
|
// frame as the standby setpoint and Low fan. When demand returns, disable Quiet
|
|
// on the normal smart-fan transition. When scheduled night mode owns Quiet, it
|
|
// explicitly enables it inside the window and releases it outside the window.
|
|
let desired_quiet = smart_quiet_command(
|
|
zone.smart_fan,
|
|
state.gree.quiet_command_supported(&device.id),
|
|
previous_demand,
|
|
zone.demand,
|
|
device.quiet,
|
|
settings.night_mode.enabled,
|
|
night_active,
|
|
settings.night_mode.force_quiet,
|
|
);
|
|
let desired_sleep = native_sleep_command(
|
|
settings.night_mode.enabled,
|
|
night_active,
|
|
settings.night_mode.use_native_sleep,
|
|
device.supports_sleep == Some(true) && state.gree.sleep_command_supported(&device.id),
|
|
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.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
|
|
// again; retrying every min_adjust_seconds only causes needless command beeps.
|
|
let fan_needs_command = desired_fan
|
|
.map(|fan| fan != device.fan_speed)
|
|
.unwrap_or(false)
|
|
&& (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);
|
|
|
|
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()),
|
|
target_temperature: Some(desired_device_target),
|
|
fan_speed: if fan_needs_command { desired_fan } else { None },
|
|
quiet: desired_quiet,
|
|
sleep: desired_sleep,
|
|
..Default::default()
|
|
};
|
|
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(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,
|
|
"comfort_target": target,
|
|
"device_target": desired_device_target,
|
|
"mode": effective_mode,
|
|
"preset": zone.active_preset,
|
|
"outdoor_temperature": outdoor_temperature,
|
|
"fan_speed": updated_device.fan_speed,
|
|
"quiet": updated_device.quiet,
|
|
"sleep": updated_device.sleep,
|
|
"night_mode": night_active,
|
|
}));
|
|
}
|
|
Ok(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;
|
|
}
|
|
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);
|
|
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
|
|
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|