240 lines
12 KiB
Rust
240 lines
12 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 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 = settings.house_power_enabled;
|
|
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, settings.house_power_enabled);
|
|
let configured_effective_mode = configured_effective_mode_owned.as_str();
|
|
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 zone.local_thermostat_power == Some(false) {
|
|
"off"
|
|
} else {
|
|
configured_effective_mode
|
|
};
|
|
|
|
// Keep the thermostat target readable even while the zone/group/house control is off.
|
|
// Home Assistant climate entities otherwise expose target_temperature as unknown.
|
|
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 (resolved_preset, resolved_target) = resolve_zone_target(&zone, active_for_target, target_mode);
|
|
let active = if effective_mode == "off" { None } else { active_for_target };
|
|
let next_events = if effective_mode == "off" {
|
|
Vec::new()
|
|
} else {
|
|
next_schedule_events(&zone, &schedules, 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: resolved_preset,
|
|
preset_override: zone.manual_preset.clone(),
|
|
current_temperature: zone.current_temperature,
|
|
target_temperature: 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. Never publish that value as the 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: settings.house_power_enabled && zone.enabled && effective_mode != "off" && !zone.device_manual_override,
|
|
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: settings.house_power_enabled && zone.enabled && effective_mode != "off" && !zone.device_manual_override && 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: zone.control_reason.clone(),
|
|
blocked_reason: if !settings.house_power_enabled { Some("global_off".into()) } else 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 { 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_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_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
|
|
}
|
|
|