This commit is contained in:
Mateusz Gruszczyński
2026-08-24 15:20:29 +02:00
parent 4f70e36e89
commit 83f744e2cb
19 changed files with 744 additions and 176 deletions
+100 -11
View File
@@ -7,7 +7,7 @@ use crate::{
error::AppError,
home_assistant,
influxdb,
models::{Automation, AutomationPlanRule, ControlPlan, ControlPlanEvent, Device, DeviceCommand, HaReading, Reading, Schedule, Zone, ZoneControlPlan, ZoneReading},
models::{Automation, AutomationPlanRule, ControlPlan, ControlPlanEvent, Device, DeviceCommand, HaReading, NightModeSettings, Reading, Schedule, Zone, ZoneControlPlan, ZoneReading},
state::AppState,
};
@@ -68,6 +68,12 @@ pub fn start(state: AppState) {
Err(err) => tracing::warn!(error=?err, "cannot prune readings"),
}
}
let event_retention_days = settings.event_log_retention_days.max(1) as i64;
match maintenance_state.db.prune_events(event_retention_days) {
Ok(count) if count > 0 => tracing::info!(count, event_retention_days, "old event log rows pruned"),
Ok(_) => {}
Err(err) => tracing::warn!(error=?err, "cannot prune event log"),
}
sleep(Duration::from_secs(6 * 60 * 60)).await;
}
});
@@ -338,6 +344,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
}
}
let outdoor_assist_temperature = if settings.outdoor_assist_enabled { outdoor_temperature } else { None };
let night_active = night_mode_active(&settings.night_mode, Local::now().time());
for mut zone in state.db.list_zones()? {
if !zone.enabled { continue; }
@@ -459,21 +466,36 @@ async fn control_zones(state: &AppState) -> Result<()> {
let desired_device_target = round_device_setpoint(effective_mode, zone.demand, if zone.demand { active_target } else { standby_target });
zone.device_setpoint = Some(desired_device_target);
let desired_fan = if zone.smart_fan {
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
// only on that transition so a user's manual Quiet choice is not constantly
// overwritten while the zone is actively heating/cooling.
// 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 desired_quiet = smart_quiet_command(
zone.smart_fan,
state.gree.quiet_command_supported(&device.id),
previous_demand,
zone.demand,
device.quiet,
settings.night_mode.enabled,
night_active,
settings.night_mode.force_quiet,
);
let needs_command = !device.power
@@ -505,6 +527,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
"outdoor_temperature": outdoor_temperature,
"fan_speed": updated_device.fan_speed,
"quiet": updated_device.quiet,
"night_mode": night_active,
}));
}
Err(err) => state.log("error", "zone.action_error", &err.to_string(), json!({"zone_id": zone.id})),
@@ -669,13 +692,34 @@ fn smart_quiet_command(
previous_demand: bool,
demand: bool,
device_quiet: bool,
night_enabled: bool,
night_active: bool,
night_force_quiet: bool,
) -> Option<bool> {
if !smart_fan || !quiet_supported { return None; }
if !quiet_supported { return None; }
if night_enabled && night_force_quiet {
if night_active { return Some(true); }
if device_quiet { return Some(false); }
}
if !smart_fan { return None; }
if !demand { return Some(true); }
if !previous_demand && device_quiet { return Some(false); }
None
}
fn night_limited_fan_speed(requested: u8, max_fan: u8) -> u8 {
let max_fan = max_fan.clamp(1, 5);
if requested == 0 { 1 } else { requested.min(max_fan) }
}
pub fn night_mode_active(settings: &NightModeSettings, time: NaiveTime) -> bool {
if !settings.enabled { return false; }
let Ok(start) = NaiveTime::parse_from_str(&settings.start_time, "%H:%M") else { return false; };
let Ok(end) = NaiveTime::parse_from_str(&settings.end_time, "%H:%M") else { return false; };
if start == end { return true; }
if start < end { time >= start && time < end } else { time >= start || time < end }
}
fn smart_fan_speed(mode: &str, room: f64, target: f64, outdoor: Option<f64>, demand: bool) -> u8 {
// When the thermostat is satisfied, keep airflow quiet instead of leaving the
// unit in Auto. The caller sends this together with the standby setpoint in
@@ -777,8 +821,9 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
let schedules = state.db.list_schedules()?;
let devices = state.db.list_devices()?;
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 = Vec::new();
let mut house_events = next_night_mode_events(&settings.night_mode, now, 2);
for zone in state.db.list_zones()? {
let device = devices.iter().find(|item| item.id == zone.device_id);
@@ -856,6 +901,10 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
house_mode: settings.house_mode,
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,
@@ -863,6 +912,34 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
}
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();
for minute in 1..=(48 * 60) {
let candidate = now + 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()?;
for minute in 1..=(24 * 60) {
@@ -1070,11 +1147,23 @@ mod tests {
#[test]
fn smart_quiet_follows_satisfied_transition_only_when_supported() {
assert_eq!(smart_quiet_command(true, true, true, false, false), Some(true));
assert_eq!(smart_quiet_command(true, true, false, true, true), Some(false));
assert_eq!(smart_quiet_command(true, true, true, true, true), None);
assert_eq!(smart_quiet_command(true, false, true, false, false), None);
assert_eq!(smart_quiet_command(false, true, true, false, false), None);
assert_eq!(smart_quiet_command(true, true, true, false, false, false, false, true), Some(true));
assert_eq!(smart_quiet_command(true, true, false, true, true, false, false, true), Some(false));
assert_eq!(smart_quiet_command(true, true, true, true, true, false, false, true), None);
assert_eq!(smart_quiet_command(true, false, true, false, false, false, false, true), None);
assert_eq!(smart_quiet_command(false, true, true, false, false, false, false, true), None);
}
#[test]
fn night_mode_handles_midnight_and_limits_auto_fan() {
let settings = NightModeSettings { enabled: true, start_time: "22:00".into(), end_time: "06:00".into(), max_fan_speed: 1, force_quiet: true };
assert!(night_mode_active(&settings, NaiveTime::from_hms_opt(23, 30, 0).unwrap()));
assert!(night_mode_active(&settings, NaiveTime::from_hms_opt(5, 59, 0).unwrap()));
assert!(!night_mode_active(&settings, NaiveTime::from_hms_opt(12, 0, 0).unwrap()));
assert_eq!(night_limited_fan_speed(0, 1), 1);
assert_eq!(night_limited_fan_speed(3, 1), 1);
assert_eq!(smart_quiet_command(false, true, true, true, false, true, true, true), Some(true));
assert_eq!(smart_quiet_command(false, true, true, true, true, true, false, true), Some(false));
}
#[test]