Files
gree-controller/src/engine/zone_control.rs
T
2026-09-16 11:09:38 +02:00

1213 lines
46 KiB
Rust

fn compressor_action_id(kind: &str, mode: &str, target: f64) -> String {
format!("{kind}:{mode}:{target:.1}")
}
pub(crate) fn clear_compressor_pending(zone: &mut Zone, clear_cancelled: bool) {
zone.lockout_until = None;
zone.lockout_reason = None;
zone.compressor_pending_action = None;
zone.compressor_pending_since = None;
zone.compressor_pending_until = 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,
) {
let now = Utc::now();
if zone.compressor_pending_action.as_deref() != Some(action.as_str()) {
zone.compressor_pending_since = Some(now);
}
zone.compressor_pending_action = Some(action);
zone.compressor_pending_until = Some(until.clone());
zone.lockout_until = Some(until);
zone.lockout_reason = Some(reason.to_string());
}
fn compressor_action_is_cancelled(zone: &Zone, action: &str) -> bool {
zone.compressor_cancelled_action.as_deref() == Some(action)
}
pub(crate) fn rearm_compressor_queue(zone: &mut Zone) {
clear_compressor_pending(zone, true);
}
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))
};
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,
);
Some(value)
}
Err(err) => {
tracing::debug!(configured_entity=%configured, resolved_entity=%entity_id, error=?err, "outdoor Home Assistant sensor unavailable; trying GREE fallback");
None
}
}
} else {
None
};
let device_groups = state.db.list_device_groups().unwrap_or_default();
let temperature = from_home_assistant.or_else(|| gree_outdoor_temperature(devices, &device_groups));
let mut current = state.outdoor_temperature.write().await;
if *current != temperature {
*current = temperature;
state.broadcast("outdoor.updated", json!({"temperature": temperature}));
}
temperature
}
async fn read_cycle_zone_outdoor_sensors(
state: &AppState,
settings: &RuntimeSettings,
zones: &[Zone],
) -> HashMap<String, Option<f64>> {
futures_util::future::join_all(zones.iter().filter_map(|zone| {
let configured = zone
.ha_outdoor_entity_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())?;
let zone_id = zone.id.clone();
let resolved_entity =
home_assistant::resolve_entity_id(&settings.home_assistant, Some(configured));
let http = &state.http;
let ha_settings = &settings.home_assistant;
let poll_interval_seconds = settings.poll_interval_seconds;
Some(async move {
let Some(entity_id) = resolved_entity else {
return (zone_id, None);
};
match home_assistant::read_temperature(
http,
ha_settings,
Some(&entity_id),
Some(ha_settings.sensor_stale_after_seconds),
)
.await
{
Ok(value) => {
record_ha_history(
state,
&entity_id,
Some(&zone_id),
"outdoor",
value,
poll_interval_seconds,
);
(zone_id, Some(value))
}
Err(err) => {
tracing::debug!(zone_id=%zone_id, resolved_entity=%entity_id, error=?err, "zone outdoor Home Assistant sensor unavailable; using global outdoor fallback");
(zone_id, None)
}
}
})
}))
.await
.into_iter()
.collect()
}
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,
);
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
.into_iter()
.map(|(zone_id, entity_id, result)| (zone_id, (entity_id, result)))
.collect()
}
fn home_assistant_sensor_log_message(
error: &str,
entity_id: Option<&str>,
zone_name: &str,
) -> String {
let entity_id = entity_id.map(str::trim).filter(|value| !value.is_empty());
let zone_name = zone_name.trim();
let context = match (entity_id, zone_name.is_empty()) {
(Some(entity_id), false) => format!("{entity_id}, zone {zone_name}"),
(Some(entity_id), true) => entity_id.to_string(),
(None, false) => format!("zone {zone_name}"),
(None, true) => return error.to_string(),
};
if let Some(detail) = error.strip_prefix("Home Assistant sensor is stale:") {
format!("Home Assistant sensor {context} is stale:{detail}")
} else {
format!("{error} [sensor: {context}]")
}
}
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"
};
let entity_id = resolved_entity
.as_deref()
.or(zone.ha_entity_id.as_deref());
let message = home_assistant_sensor_log_message(
&err,
entity_id,
&zone.name,
);
state.log(
"warn",
kind,
&message,
json!({
"zone_id": zone.id,
"zone_name": zone.name,
"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)?;
Ok(publish_persisted_zone_cycle(state, 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 global_outdoor_temperature =
resolve_cycle_outdoor_temperature(state, &settings, &device_snapshot).await;
let mut zone_outdoor_sensor_results =
read_cycle_zone_outdoor_sensors(state, &settings, &zone_snapshot).await;
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
// 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 cycle_started_at = zone.updated_at.clone();
let outdoor_temperature = zone_outdoor_sensor_results
.remove(&zone.id)
.flatten()
.or(global_outdoor_temperature);
let outdoor_assist_temperature = if settings.outdoor_assist_enabled {
outdoor_temperature
} else {
None
};
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.control_source.starts_with("group:") {
zone.control_source = "automation".into();
zone.control_since = Some(Utc::now());
zone.control_reason = "Group override expired at schedule boundary".into();
}
}
// 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;
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}),
);
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. Global ON/OFF is intentionally not a
// 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);
let effective_mode = effective_mode_owned.as_str();
let (previous_source, discrepancy) = refresh_zone_temperature(
state,
&settings,
&mut zone,
&device,
&mut room_sensor_results,
);
if handle_zone_pre_control_state(
state,
&settings,
&schedules,
&temporary_restored_disabled,
&mut zone,
&device,
effective_mode,
cycle_started_at.clone(),
outdoor_temperature,
)
.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(),
}),
);
}
// 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,
);
publish_persisted_zone_cycle(
state,
persist_zone_cycle(state, &zone, cycle_started_at)?,
)?;
continue;
}
let active_schedule = active_schedule_for_zone(&zone, &schedules, Local::now());
// Heat/Cool configuration alone is not an instruction to run forever on the implicit
// Comfort profile. After local/global/group OFF is handed back to automation, a zone
// with no active schedule, quick/manual target, temporary session or active scoped
// controller stays physically OFF. This also makes gaps between schedule windows true
// OFF periods instead of silently falling back to Comfort and recreating demand.
if !zone_has_active_thermostat_intent(&zone, active_schedule, Utc::now()) {
clear_compressor_pending(&mut zone, true);
zone.effective_mode = "off".into();
zone.effective_setpoint = None;
zone.device_setpoint = None;
zone.demand = false;
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_resume_at = 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()
},
)
.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,
}),
);
}
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
}),
),
}
}
record_zone_history(
state,
&zone,
outdoor_temperature,
settings.poll_interval_seconds,
);
publish_persisted_zone_cycle(
state,
persist_zone_cycle(state, &zone, cycle_started_at)?,
)?;
continue;
}
let (preset, target) = resolve_zone_target(&zone, active_schedule, effective_mode);
zone.active_preset = preset;
zone.effective_setpoint = Some(target);
let Some(temp) = zone.current_temperature else {
record_zone_history(
state,
&zone,
outdoor_temperature,
settings.poll_interval_seconds,
);
publish_persisted_zone_cycle(
state,
persist_zone_cycle(state, &zone, cycle_started_at)?,
)?;
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 quiet_supported = if device.connection_type == ConnectionType::GreeCloud {
device.capabilities.quiet
} else {
state
.providers
.local()
.client()
.quiet_command_supported(&device.id)
};
let sleep_supported = if device.connection_type == ConnectionType::GreeCloud {
device.capabilities.sleep
} else {
device.supports_sleep == Some(true)
&& state
.providers
.local()
.client()
.sleep_command_supported(&device.id)
};
let desired_quiet = smart_quiet_command(
zone.smart_fan,
quiet_supported,
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,
sleep_supported,
device.sleep,
);
// Global compressor protection. Automatic thermostat/group/house requests are not
// discarded while the protection window is active: they become a visible pending
// task which can be cancelled from the thermostat UI. Safety OFF paths still bypass
// protection. A cancelled task is not silently re-created until a new control intent
// re-arms the queue (or a different mode/target produces a different task id).
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)
{
zone.lockout_until = None;
zone.lockout_reason = None;
zone.compressor_pending_until = None;
}
let protection = chrono::Duration::seconds(settings.compressor_protection_seconds as i64);
if settings.compressor_protection_enabled && device.power && device.mode != effective_mode {
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,
);
publish_persisted_zone_cycle(
state,
persist_zone_cycle(state, &zone, cycle_started_at)?,
)?;
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,
);
publish_persisted_zone_cycle(
state,
persist_zone_cycle(state, &zone, cycle_started_at)?,
)?;
continue;
}
}
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",
);
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}),
),
}
record_zone_history(
state,
&zone,
outdoor_temperature,
settings.poll_interval_seconds,
);
publish_persisted_zone_cycle(
state,
persist_zone_cycle(state, &zone, cycle_started_at)?,
)?;
continue;
}
if !device.power {
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)
});
if compressor_action_is_cancelled(&zone, &action) {
clear_compressor_pending(&mut zone, false);
record_zone_history(
state,
&zone,
outdoor_temperature,
settings.poll_interval_seconds,
);
publish_persisted_zone_cycle(
state,
persist_zone_cycle(state, &zone, cycle_started_at)?,
)?;
continue;
}
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,
);
publish_persisted_zone_cycle(
state,
persist_zone_cycle(state, &zone, cycle_started_at)?,
)?;
continue;
}
}
}
// The safe window is open. Remove the pending marker before attempting the
// command; a transport error will be retried by the normal thermostat cycle.
zone.compressor_pending_action = None;
zone.compressor_pending_since = None;
zone.compressor_pending_until = None;
zone.lockout_until = None;
zone.lockout_reason = None;
} else {
// Reaching the intended powered/mode state retires both pending and cancelled
// markers so a future independent request starts with a clean queue.
clear_compressor_pending(&mut zone, true);
}
let core_needs_command = !device.power
|| device.mode != effective_mode
|| (device.target_temperature - desired_device_target).abs() >= 0.5;
// In normal standby, Low fan is a transition hint rather than a state that should
// be reasserted forever. Some GREE firmwares accept the frame but later report Auto
// 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).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);
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!({
"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,
"schedule_id": active_schedule.map(|item| item.id.as_str()),
"flow_id": active_schedule.and_then(|item| item.flow_id.as_deref()),
"flow_node_id": active_schedule.and_then(|item| item.flow_node_id.as_deref()),
}));
}
Ok(None) => {
record_zone_history(
state,
&zone,
outdoor_temperature,
settings.poll_interval_seconds,
);
publish_persisted_zone_cycle(
state,
persist_zone_cycle(state, &zone, cycle_started_at)?,
)?;
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,
);
publish_persisted_zone_cycle(
state,
persist_zone_cycle(state, &zone, cycle_started_at)?,
)?;
}
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> {
control_zones(state).await.map_err(AppError::from)
}