pub async fn build_control_plan(state: &AppState) -> Result { 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, limit: usize) -> Vec { 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) -> Option { 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, limit: usize) -> Vec { 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 }