Files
gree-controller/src/engine/control_plan.rs
T

379 lines
14 KiB
Rust

pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppError> {
let settings = state.settings.read().await.clone();
let schedules = state.db.list_schedules()?;
let devices = state.db.list_devices()?;
let zones = state.db.list_zones()?;
let groups = state.db.list_groups()?;
let zone_names: HashMap<String, String> = zones
.iter()
.map(|zone| (zone.id.clone(), zone.name.clone()))
.collect();
let house_preset = zones.first().and_then(|first| {
let first_preset = first.manual_preset.as_deref().unwrap_or("auto");
zones
.iter()
.all(|zone| zone.manual_preset.as_deref().unwrap_or("auto") == first_preset)
.then(|| first_preset.to_string())
});
let house_power = true;
let now = Local::now();
let night_active = night_mode_active(&settings.night_mode, now.time());
let mut zones_out = Vec::new();
let mut house_events = next_night_mode_events(&settings.night_mode, now, 2);
for mut zone in zones {
let device = devices.iter().find(|item| item.id == zone.device_id);
let configured_effective_mode_owned = effective_zone_mode(&zone, &settings.house_mode);
refresh_control_ownership(&mut zone);
let configured_effective_mode = configured_effective_mode_owned.as_str();
let target_mode = if configured_effective_mode == "off" {
zone.mode.as_str()
} else {
configured_effective_mode
};
let active_for_target = active_schedule_for_zone(&zone, &schedules, now);
let has_intent_source =
zone_has_thermostat_intent_source(&zone, active_for_target, Utc::now());
let automation_idle = zone.enabled
&& !zone.device_manual_override
&& zone.local_thermostat_power != Some(false)
&& configured_effective_mode != "off"
&& !has_intent_source;
let automatic_active = zone.enabled
&& !zone.device_manual_override
&& zone.local_thermostat_power != Some(false)
&& configured_effective_mode != "off"
&& has_intent_source;
let manual_device_mode = device.map(|item| {
if item.power {
item.mode.as_str()
} else {
"off"
}
});
let effective_mode = if zone.device_manual_override {
manual_device_mode.unwrap_or(configured_effective_mode)
} else if automatic_active {
configured_effective_mode
} else {
"off"
};
let (resolved_preset, resolved_target) =
resolve_zone_target(&zone, active_for_target, target_mode);
let active = if automatic_active {
active_for_target
} else {
None
};
// Future schedule events remain visible while the zone is currently idle between
// windows; idle must not be confused with a disabled schedule system.
let next_events = if configured_effective_mode == "off" {
Vec::new()
} else {
next_schedule_events(&zone, &schedules, configured_effective_mode, now, 8)
};
for event in next_events.iter().take(2) {
let mut event = event.clone();
event.label = format!("{}: {}", zone.name, event.label);
house_events.push(event);
}
let effective_enabled = zone.enabled && zone.local_thermostat_power != Some(false);
zones_out.push(ZoneControlPlan {
zone_id: zone.id.clone(),
zone_name: zone.name.clone(),
device_id: zone.device_id.clone(),
device_name: device
.map(|item| item.name.clone())
.unwrap_or_else(|| zone.device_id.clone()),
enabled: zone.enabled,
effective_enabled,
mode: effective_mode.to_string(),
configured_mode: zone.mode.clone(),
inherit_house_mode: zone.inherit_house_mode,
preset: if has_intent_source {
resolved_preset
} else {
"auto".into()
},
preset_override: zone.manual_preset.clone(),
current_temperature: zone.current_temperature,
target_temperature: if !has_intent_source {
None
} else if zone.device_manual_override || !zone.enabled || effective_mode == "off" {
// A remote/manual takeover may leave a physical standby target persisted in the
// zone runtime snapshot. Publish only the underlying explicit thermostat target.
Some(resolved_target)
} else {
zone.effective_setpoint.or(Some(resolved_target))
},
device_setpoint: device
.filter(|item| item.power)
.map(|item| item.target_temperature),
desired_power: automatic_active,
desired_mode: effective_mode.to_string(),
actual_power: device.map(|item| item.power),
actual_mode: device.map(|item| {
if item.power {
item.mode.clone()
} else {
"off".into()
}
}),
actual_setpoint: device
.filter(|item| item.power)
.map(|item| item.target_temperature),
demand: automatic_active && zone.demand,
control_source: zone.control_temperature_source.clone(),
manual_override_until: zone.manual_override_until,
local_thermostat_power: zone.local_thermostat_power,
local_thermostat_resume_at: zone.local_thermostat_resume_at,
device_manual_override: zone.device_manual_override,
device_manual_override_until: zone.device_manual_override_until,
control_owner: zone.control_owner.clone(),
control_command_source: zone.control_source.clone(),
control_since: zone.control_since,
resume_at: zone.control_resume_at,
control_reason: if automation_idle {
"Automation idle: no active schedule or explicit thermostat request".into()
} else {
zone.control_reason.clone()
},
blocked_reason: if zone.device_manual_override {
Some("manual_override".into())
} else if zone
.lockout_until
.map(|until| until > Utc::now())
.unwrap_or(false)
{
Some(
zone.lockout_reason
.clone()
.unwrap_or_else(|| "lockout".into()),
)
} else if !zone.enabled {
Some("zone_disabled".into())
} else if device
.map(|d| !d.online || d.communication_failures > 0)
.unwrap_or(true)
{
Some("offline".into())
} else if automation_idle {
Some("no_active_thermostat_intent".into())
} else {
None
},
lockout_until: zone.lockout_until,
current_schedule_id: active.map(|item| item.id.clone()),
current_schedule_name: active.map(|item| item.name.clone()),
next_events,
});
}
let mut rules = Vec::new();
for item in state.db.list_automations()? {
let action_zone_name = item
.action_zone_id
.as_deref()
.and_then(|id| zone_names.get(id))
.cloned();
let action_group_name = item
.action_group_id
.as_deref()
.and_then(|id| groups.iter().find(|group| group.id == id))
.map(|group| group.name.clone());
let action_name = action_zone_name
.or_else(|| action_group_name.clone())
.unwrap_or_else(|| {
devices
.iter()
.find(|device| device.id == item.action_device_id)
.map(|device| device.name.clone())
.unwrap_or_else(|| item.action_device_id.clone())
});
let trigger_name = item
.trigger_device_id
.as_deref()
.and_then(|id| devices.iter().find(|device| device.id == id))
.map(|device| device.name.clone());
let next_ready_at = item
.last_fired_at
.map(|last| last + chrono::Duration::seconds(item.cooldown_seconds as i64));
if item.enabled && item.trigger_kind == "time" {
if let Some(event) = next_time_automation_event(&item, &action_name, now) {
house_events.push(event);
}
}
rules.push(AutomationPlanRule {
id: item.id,
name: item.name,
enabled: item.enabled,
trigger_kind: item.trigger_kind,
trigger_device_id: item.trigger_device_id,
trigger_device_name: trigger_name,
threshold: item.threshold,
at_time: item.at_time,
action_device_id: item.action_device_id,
action_device_name: action_name,
action_group_id: item.action_group_id,
action_group_name,
action_preset: item.action_preset,
action: item.action,
last_fired_at: item.last_fired_at,
next_ready_at,
});
}
house_events.sort_by_key(|event| event.at);
house_events.truncate(12);
Ok(ControlPlan {
generated_at: Utc::now(),
house_mode: settings.house_mode,
house_preset,
house_power,
outdoor_temperature: *state.outdoor_temperature.read().await,
control_strategy: settings.control_strategy,
night_mode_active: night_active,
night_mode_start: settings.night_mode.start_time,
night_mode_end: settings.night_mode.end_time,
night_mode_max_fan_speed: settings.night_mode.max_fan_speed.clamp(1, 5),
next_events: house_events,
zones: zones_out,
rules,
})
}
fn next_night_mode_events(
settings: &NightModeSettings,
now: DateTime<Local>,
limit: usize,
) -> Vec<ControlPlanEvent> {
if !settings.enabled || limit == 0 {
return Vec::new();
}
let Ok(start) = NaiveTime::parse_from_str(&settings.start_time, "%H:%M") else {
return Vec::new();
};
let Ok(end) = NaiveTime::parse_from_str(&settings.end_time, "%H:%M") else {
return Vec::new();
};
let mut events = Vec::new();
let base = minute_floor(now);
for minute in 1..=(48 * 60) {
let candidate = base + chrono::Duration::minutes(minute);
let time = candidate.time();
let (kind, label) = if time.hour() == start.hour() && time.minute() == start.minute() {
let quiet = if settings.force_quiet { " + Quiet" } else { "" };
(
"night_mode_start",
format!(
"Night mode -> fan max {}{}",
settings.max_fan_speed.clamp(1, 5),
quiet
),
)
} else if time.hour() == end.hour() && time.minute() == end.minute() {
("night_mode_end", "Night mode ends".to_string())
} else {
continue;
};
events.push(ControlPlanEvent {
at: candidate.with_timezone(&Utc),
kind: kind.into(),
label,
preset: None,
target_temperature: None,
});
if events.len() >= limit {
break;
}
}
events
}
fn next_time_automation_event(
item: &Automation,
action_name: &str,
now: DateTime<Local>,
) -> Option<ControlPlanEvent> {
let expected = NaiveTime::parse_from_str(item.at_time.as_deref()?, "%H:%M").ok()?;
let base = minute_floor(now.clone());
if time_automation_due(item, now) {
return Some(ControlPlanEvent {
at: base.with_timezone(&Utc),
kind: "automation".into(),
label: format!("{} -> {}", item.name, action_name),
preset: None,
target_temperature: item.action.target_temperature,
});
}
// A local day can last 25 hours at the end of daylight saving time.
for minute in 1..=(26 * 60) {
let candidate = base + chrono::Duration::minutes(minute);
if candidate.hour() == expected.hour() && candidate.minute() == expected.minute() {
return Some(ControlPlanEvent {
at: candidate.with_timezone(&Utc),
kind: "automation".into(),
label: format!("{} -> {}", item.name, action_name),
preset: None,
target_temperature: item.action.target_temperature,
});
}
}
None
}
fn next_schedule_events(
zone: &Zone,
schedules: &[Schedule],
mode: &str,
now: DateTime<Local>,
limit: usize,
) -> Vec<ControlPlanEvent> {
if mode == "off" {
return Vec::new();
}
let mut events = Vec::new();
let mut current = active_schedule_for_zone(zone, schedules, now).map(|item| item.id.as_str());
let base = minute_floor(now);
for minute in 1..=(8 * 24 * 60) {
let candidate = base + chrono::Duration::minutes(minute);
let next = active_schedule_for_zone(zone, schedules, candidate);
let next_id = next.map(|item| item.id.as_str());
if next_id == current {
continue;
}
current = next_id;
let (preset, target, label) = if let Some(item) = next {
let target = if item.preset == "custom" {
item.setpoint
} else {
profile_setpoint(zone, &item.preset, mode)
};
(
Some(item.preset.clone()),
Some(target),
format!("{} -> {} {:.1} C", item.name, item.preset, target),
)
} else {
let target = profile_setpoint(zone, "comfort", mode);
(
Some("comfort".into()),
Some(target),
format!("comfort {:.1} C", target),
)
};
events.push(ControlPlanEvent {
at: candidate.with_timezone(&Utc),
kind: "schedule_transition".into(),
label,
preset,
target_temperature: target,
});
if events.len() >= limit {
break;
}
}
events
}