This commit is contained in:
Mateusz Gruszczyński
2026-08-25 18:54:35 +02:00
parent a8b80bdba9
commit 3ee93b7225
18 changed files with 560 additions and 93 deletions
+210 -21
View File
@@ -316,7 +316,7 @@ fn register_device_failure(state: &AppState, device: &mut Device, error: &str) -
Ok(())
}
fn validate_command(command: &DeviceCommand) -> Result<(), AppError> {
pub(crate) fn validate_command(command: &DeviceCommand) -> Result<(), AppError> {
if let Some(value) = command.target_temperature {
if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("target temperature must be between 8 and 30 C".into())); }
}
@@ -377,7 +377,7 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
} else {
zone.manual_preset = Some(preset.to_string());
zone.manual_setpoint = None;
zone.manual_override_until = Some(next_schedule_boundary_utc(&zone.id, &schedules, Local::now()));
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &schedules, Local::now());
}
}
zone.updated_at = Utc::now();
@@ -390,6 +390,9 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
let master_power_enabled = runtime.house_power_enabled;
let should_command_power = patch.power.is_some() || activates_group;
let desired_power = group.power_enabled;
// A zone may intentionally belong to more than one group. Power-off is authoritative:
// turning one group on must never briefly wake a member that is still blocked by another group.
let group_snapshot = state.db.list_groups()?;
let mut failed = Vec::new();
if should_command_power && (!desired_power || master_power_enabled) {
let mut seen = std::collections::HashSet::new();
@@ -399,6 +402,10 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
if !device.enabled || device.power == desired_power { continue; }
if desired_power {
if !zone.enabled { continue; }
let blocked_by_other_group = group_snapshot.iter().any(|other| {
other.id != group.id && !other.power_enabled && other.zone_ids.iter().any(|zone_id| zone_id == &zone.id)
});
if blocked_by_other_group { continue; }
let zone_mode = if zone.inherit_house_mode { runtime.house_mode.as_str() } else { zone.mode.as_str() };
if zone_mode == "off" { continue; }
}
@@ -993,25 +1000,34 @@ fn resolve_zone_target(zone: &Zone, schedule: Option<&Schedule>, mode: &str) ->
fn active_schedule_for_zone<'a>(zone: &Zone, schedules: &'a [Schedule], now: DateTime<Local>) -> Option<&'a Schedule> {
schedules.iter()
.filter(|item| item.enabled && item.zone_id == zone.id && schedule_active(item, now))
.last()
// Overlaps are rejected by the API, but imported/legacy data may still contain one.
// Prefer the most recently edited entry instead of depending on database/name order.
.max_by_key(|item| item.updated_at)
}
pub fn next_schedule_boundary_utc(zone_id: &str, schedules: &[Schedule], now: DateTime<Local>) -> DateTime<Utc> {
fn minute_floor(now: DateTime<Local>) -> DateTime<Local> {
now.with_second(0).and_then(|value| value.with_nanosecond(0)).unwrap_or(now)
}
pub fn next_schedule_boundary_utc(zone_id: &str, schedules: &[Schedule], now: DateTime<Local>) -> Option<DateTime<Utc>> {
let current = schedules.iter()
.filter(|item| item.enabled && item.zone_id == zone_id && schedule_active(item, now))
.last()
.max_by_key(|item| item.updated_at)
.map(|item| item.id.as_str());
for minute in 1..=(48 * 60) {
let candidate = now + chrono::Duration::minutes(minute);
let base = minute_floor(now);
// Eight days cover a complete weekly schedule plus the next transition.
for minute in 1..=(8 * 24 * 60) {
let candidate = base + chrono::Duration::minutes(minute);
let next = schedules.iter()
.filter(|item| item.enabled && item.zone_id == zone_id && schedule_active(item, candidate))
.last()
.max_by_key(|item| item.updated_at)
.map(|item| item.id.as_str());
if next != current {
return candidate.with_timezone(&Utc);
return Some(candidate.with_timezone(&Utc));
}
}
(now + chrono::Duration::hours(8)).with_timezone(&Utc)
// No schedule transition exists: keep a manual override until the user clears it.
None
}
fn schedule_active(item: &Schedule, now: DateTime<Local>) -> bool {
@@ -1019,7 +1035,15 @@ fn schedule_active(item: &Schedule, now: DateTime<Local>) -> bool {
let Ok(end) = NaiveTime::parse_from_str(&item.end_time, "%H:%M") else { return false; };
let time = now.time();
let today = now.weekday().number_from_monday();
if start <= end {
if start == end {
// Equal times mean a 24-hour block starting on each selected weekday.
if time >= start {
item.weekdays.contains(&today)
} else {
let previous = previous_weekday(now.weekday()).number_from_monday();
item.weekdays.contains(&previous)
}
} else if start < end {
item.weekdays.contains(&today) && time >= start && time < end
} else if time >= start {
item.weekdays.contains(&today)
@@ -1031,6 +1055,38 @@ fn schedule_active(item: &Schedule, now: DateTime<Local>) -> bool {
}
}
fn schedule_week_mask(item: &Schedule) -> Option<Vec<bool>> {
let start = NaiveTime::parse_from_str(&item.start_time, "%H:%M").ok()?;
let end = NaiveTime::parse_from_str(&item.end_time, "%H:%M").ok()?;
let start_minute = (start.hour() * 60 + start.minute()) as usize;
let end_minute = (end.hour() * 60 + end.minute()) as usize;
let mut mask = vec![false; 7 * 24 * 60];
for weekday in &item.weekdays {
if !(1..=7).contains(weekday) { return None; }
let day = (*weekday as usize) - 1;
let mark = |mask: &mut [bool], day: usize, from: usize, to: usize| {
let base = (day % 7) * 24 * 60;
for minute in from..to { mask[base + minute] = true; }
};
if start_minute == end_minute {
mark(&mut mask, day, start_minute, 24 * 60);
mark(&mut mask, day + 1, 0, end_minute);
} else if start_minute < end_minute {
mark(&mut mask, day, start_minute, end_minute);
} else {
mark(&mut mask, day, start_minute, 24 * 60);
mark(&mut mask, day + 1, 0, end_minute);
}
}
Some(mask)
}
pub(crate) fn schedules_overlap(a: &Schedule, b: &Schedule) -> bool {
if !a.enabled || !b.enabled || a.zone_id != b.zone_id { return false; }
let (Some(left), Some(right)) = (schedule_week_mask(a), schedule_week_mask(b)) else { return false; };
left.iter().zip(right.iter()).any(|(a, b)| *a && *b)
}
fn previous_weekday(day: Weekday) -> Weekday {
match day {
Weekday::Mon => Weekday::Sun, Weekday::Tue => Weekday::Mon, Weekday::Wed => Weekday::Tue,
@@ -1170,8 +1226,9 @@ fn next_night_mode_events(settings: &NightModeSettings, now: DateTime<Local>, li
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 = now + chrono::Duration::minutes(minute);
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 { "" };
@@ -1195,8 +1252,19 @@ fn next_night_mode_events(settings: &NightModeSettings, now: DateTime<Local>, li
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()?;
for minute in 1..=(24 * 60) {
let candidate = now + chrono::Duration::minutes(minute);
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),
@@ -1214,8 +1282,9 @@ fn next_schedule_events(zone: &Zone, schedules: &[Schedule], mode: &str, now: Da
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 = now + chrono::Duration::minutes(minute);
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; }
@@ -1242,6 +1311,8 @@ fn next_schedule_events(zone: &Zone, schedules: &[Schedule], mode: &str, now: Da
async fn run_automations(state: &AppState) -> Result<()> {
if !state.settings.read().await.house_power_enabled { return Ok(()); }
let devices = state.db.list_devices()?;
let zones = state.db.list_zones()?;
let groups = state.db.list_groups()?;
for mut item in state.db.list_automations()? {
if !item.enabled || !automation_ready(&item) { continue; }
let should_fire = match item.trigger_kind.as_str() {
@@ -1249,10 +1320,21 @@ async fn run_automations(state: &AppState) -> Result<()> {
.zip(item.threshold).map(|(t, threshold)| t > threshold).unwrap_or(false),
"temperature_below" => find_temperature(&devices, item.trigger_device_id.as_deref())
.zip(item.threshold).map(|(t, threshold)| t < threshold).unwrap_or(false),
"time" => item.at_time.as_deref().map(time_matches).unwrap_or(false),
"time" => time_automation_due(&item, Local::now()),
_ => false,
};
if !should_fire { continue; }
if item.action_group_id.is_none() && device_blocked_by_disabled_group(&item.action_device_id, &zones, &groups) {
// Group power-off is authoritative. Suppress a raw-device automation instead of
// waking the unit for one control cycle and immediately switching it off again.
item.last_fired_at = Some(Utc::now());
item.updated_at = Utc::now();
state.db.save_automation(&item)?;
state.log("info", "automation.blocked_by_group", &format!("Automation {} suppressed by disabled group", item.name), json!({
"automation_id": item.id, "device_id": item.action_device_id
}));
continue;
}
let result = if let Some(group_id) = item.action_group_id.as_deref() {
let group_mode = item.action.mode.as_deref().map(|mode| if mode == "auto" { "house".to_string() } else { mode.to_string() });
control_group(state, group_id, GroupControlPatch {
@@ -1272,25 +1354,53 @@ async fn run_automations(state: &AppState) -> Result<()> {
"automation_id": item.id, "group_id": item.action_group_id, "device_id": item.action_device_id
}));
}
Err(err) => state.log("error", "automation.error", &err.to_string(), json!({"automation_id": item.id})),
Err(err) => {
// A failed action is still an execution attempt. Apply the configured cooldown
// so an offline/disabled target cannot be hammered on every automation cycle.
item.last_fired_at = Some(Utc::now());
item.updated_at = Utc::now();
state.db.save_automation(&item)?;
state.log("error", "automation.error", &err.to_string(), json!({"automation_id": item.id}));
}
}
}
Ok(())
}
fn device_blocked_by_disabled_group(device_id: &str, zones: &[Zone], groups: &[crate::models::ClimateGroup]) -> bool {
let zone_ids: std::collections::HashSet<&str> = zones.iter()
.filter(|zone| zone.device_id == device_id)
.map(|zone| zone.id.as_str())
.collect();
if zone_ids.is_empty() { return false; }
groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_ids.contains(zone_id.as_str())))
}
fn find_temperature(devices: &[Device], device_id: Option<&str>) -> Option<f64> {
let id = device_id?;
devices.iter().find(|d| d.id == id)?.current_temperature
// Never fire a temperature automation from stale cached data of an offline/disabled unit.
devices.iter().find(|d| d.id == id && d.enabled && d.online)?.current_temperature
}
fn automation_ready(item: &Automation) -> bool {
item.last_fired_at.map(|last| (Utc::now() - last).num_seconds().max(0) as u64 >= item.cooldown_seconds).unwrap_or(true)
}
fn time_matches(expected: &str) -> bool {
fn time_automation_due(item: &Automation, now: DateTime<Local>) -> bool {
let Some(expected) = item.at_time.as_deref() else { return false; };
let Ok(value) = NaiveTime::parse_from_str(expected, "%H:%M") else { return false; };
let now = Local::now().time();
now.hour() == value.hour() && now.minute() == value.minute()
if now.hour() != value.hour() || now.minute() != value.minute() { return false; }
if let Some(last) = item.last_fired_at {
let local_last = last.with_timezone(&Local);
if local_last.date_naive() == now.date_naive()
&& local_last.hour() == now.hour()
&& local_last.minute() == now.minute()
{
return false;
}
}
true
}
#[cfg(test)]
@@ -1309,6 +1419,85 @@ mod tests {
assert!(schedule_active(&item, now));
}
fn test_schedule(id: &str, weekdays: Vec<u32>, start: &str, end: &str) -> Schedule {
Schedule {
id: id.into(), zone_id: "z".into(), name: id.into(), enabled: true,
weekdays, start_time: start.into(), end_time: end.into(), preset: "comfort".into(), setpoint: 21.0,
created_at: Utc::now(), updated_at: Utc::now(),
}
}
#[test]
fn equal_schedule_times_mean_a_full_day() {
let item = test_schedule("full", vec![1], "06:00", "06:00");
let monday_noon = Local.with_ymd_and_hms(2025, 1, 6, 12, 0, 0).single().unwrap();
let tuesday_early = Local.with_ymd_and_hms(2025, 1, 7, 5, 59, 0).single().unwrap();
let tuesday_after = Local.with_ymd_and_hms(2025, 1, 7, 6, 1, 0).single().unwrap();
assert!(schedule_active(&item, monday_noon));
assert!(schedule_active(&item, tuesday_early));
assert!(!schedule_active(&item, tuesday_after));
}
#[test]
fn schedule_overlap_detection_handles_overnight_ranges() {
let daytime = test_schedule("day", vec![1,2,3,4,5,6,7], "06:30", "22:30");
let night = test_schedule("night", vec![1,2,3,4,5,6,7], "22:30", "06:30");
let conflict = test_schedule("conflict", vec![1], "22:00", "23:00");
assert!(!schedules_overlap(&daytime, &night));
assert!(schedules_overlap(&night, &conflict));
}
#[test]
fn next_schedule_boundary_scans_the_whole_week() {
let friday = test_schedule("friday", vec![5], "12:00", "13:00");
let monday = Local.with_ymd_and_hms(2025, 1, 6, 10, 0, 30).single().unwrap();
let boundary = next_schedule_boundary_utc("z", &[friday], monday).unwrap().with_timezone(&Local);
assert_eq!(boundary.weekday(), Weekday::Fri);
assert_eq!(boundary.hour(), 12);
assert_eq!(boundary.minute(), 0);
assert_eq!(boundary.second(), 0);
}
#[test]
fn full_week_schedule_has_no_manual_override_boundary() {
let always = test_schedule("always", vec![1,2,3,4,5,6,7], "00:00", "00:00");
let now = Local.with_ymd_and_hms(2025, 1, 6, 10, 0, 30).single().unwrap();
assert!(next_schedule_boundary_utc("z", &[always], now).is_none());
}
#[test]
fn workday_weekend_handoff_has_no_overlaps() {
let schedules = vec![
test_schedule("morning", vec![1,2,3,4,5], "06:30", "08:00"),
test_schedule("away", vec![1,2,3,4,5], "08:00", "16:00"),
test_schedule("evening", vec![1,2,3,4,5], "16:00", "22:30"),
test_schedule("sleep", vec![1,2,3,4,5], "22:30", "06:30"),
test_schedule("weekend", vec![6,7], "08:00", "23:00"),
test_schedule("saturday-sleep", vec![6], "23:00", "08:00"),
test_schedule("sunday-sleep", vec![7], "23:00", "06:30"),
];
for (index, item) in schedules.iter().enumerate() {
for other in schedules.iter().skip(index + 1) {
assert!(!schedules_overlap(item, other), "{} overlaps {}", item.name, other.name);
}
}
}
#[test]
fn time_automation_fires_only_once_in_the_same_minute() {
let now = Local.with_ymd_and_hms(2025, 1, 6, 10, 15, 40).single().unwrap();
let mut item = Automation {
id: "a".into(), name: "at time".into(), enabled: true, trigger_kind: "time".into(),
trigger_device_id: None, threshold: None, at_time: Some("10:15".into()),
action_device_id: "d".into(), action_group_id: None, action_preset: None,
action: DeviceCommand { power: Some(true), ..Default::default() }, cooldown_seconds: 30,
last_fired_at: None, created_at: Utc::now(), updated_at: Utc::now(),
};
assert!(time_automation_due(&item, now.clone()));
item.last_fired_at = Some((now.clone() - chrono::Duration::seconds(35)).with_timezone(&Utc));
assert!(!time_automation_due(&item, now));
}
fn test_zone(source: &str) -> Zone {
Zone {
id: "z".into(), name: "Room".into(), device_id: "d".into(), enabled: true,