v0.5.3
This commit is contained in:
+100
-11
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user