This commit is contained in:
Mateusz Gruszczyński
2026-08-24 14:05:43 +02:00
parent eb02f66056
commit 04fc91b9f4
30 changed files with 2257 additions and 175 deletions
+254 -14
View File
@@ -6,7 +6,8 @@ use tokio::time::sleep;
use crate::{
error::AppError,
home_assistant,
models::{Automation, Device, DeviceCommand, HaReading, Reading, Schedule, Zone, ZoneReading},
influxdb,
models::{Automation, AutomationPlanRule, ControlPlan, ControlPlanEvent, Device, DeviceCommand, HaReading, Reading, Schedule, Zone, ZoneControlPlan, ZoneReading},
state::AppState,
};
@@ -40,23 +41,67 @@ pub fn start(state: AppState) {
let maintenance_state = state;
tokio::spawn(async move {
sleep(Duration::from_secs(60)).await;
loop {
sleep(Duration::from_secs(6 * 60 * 60)).await;
match maintenance_state.db.prune_readings(30) {
Ok(count) if count > 0 => tracing::info!(count, "old readings pruned"),
Ok(_) => {}
Err(err) => tracing::warn!(error=?err, "cannot prune readings"),
let settings = maintenance_state.settings.read().await.clone();
// When InfluxDB is enabled, compact all locally retained legacy history before
// transferring old buckets. Without Influx, compact only the configured retention window.
let compaction_days = if settings.influxdb.enabled { 3650 } else { settings.history_retention_days.max(1) } as i64;
if settings.history_compaction_enabled {
match maintenance_state.db.compact_history(compaction_days) {
Ok(count) if count > 0 => tracing::info!(count, "history samples compacted"),
Ok(_) => {}
Err(err) => tracing::warn!(error=?err, "cannot compact history"),
}
}
if settings.influxdb.enabled {
match archive_old_history(&maintenance_state, settings.influxdb.history_threshold_days.max(1)).await {
Ok(count) if count > 0 => tracing::info!(count, "old local readings archived to InfluxDB and removed from SQLite"),
Ok(_) => {}
Err(err) => tracing::warn!(error=?err, "cannot archive old history to InfluxDB; SQLite copies were kept"),
}
} else {
let retention_days = settings.history_retention_days.max(1) as i64;
match maintenance_state.db.prune_readings(retention_days) {
Ok(count) if count > 0 => tracing::info!(count, retention_days, "old local readings pruned"),
Ok(_) => {}
Err(err) => tracing::warn!(error=?err, "cannot prune readings"),
}
}
sleep(Duration::from_secs(6 * 60 * 60)).await;
}
});
}
async fn archive_old_history(state: &AppState, threshold_days: u32) -> Result<u64> {
let cutoff = Utc::now() - chrono::Duration::days(threshold_days.max(1) as i64);
let settings = state.settings.read().await.influxdb.clone();
let mut moved = 0_u64;
// Bound one maintenance pass so a very large legacy database never monopolizes the runtime.
// Successful batches are deleted from SQLite, so the next pass naturally continues forward.
for _ in 0..50 {
let (devices, zones, ha) = state.db.history_before(cutoff, 1_000)?;
if devices.is_empty() && zones.is_empty() && ha.is_empty() { break; }
influxdb::write_batch(&state.http, &settings, &devices, &zones, &ha).await?;
let deleted = state.db.delete_history_batch(&devices, &zones, &ha)?;
moved += deleted;
if deleted == 0 { break; }
}
Ok(moved)
}
pub async fn send_command(state: &AppState, device_id: &str, command: DeviceCommand) -> Result<Device, AppError> {
validate_command(&command)?;
let mut device = state.db.get_device(device_id)?
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
if !device.enabled { return Err(AppError::BadRequest("device is disabled".into())); }
// Do not wake/beep a unit for fields that already match the last known state.
// Offline devices still receive the full request because their cached state may be stale.
let command = if device.online { command.changed_from(&device) } else { command };
if command.is_empty() { return Ok(device); }
let suppress_beep = state.settings.read().await.suppress_device_beep;
if device.simulated {
command.apply(&mut device);
device.online = true;
@@ -79,7 +124,7 @@ pub async fn send_command(state: &AppState, device_id: &str, command: DeviceComm
}
}
}
if let Err(first_err) = state.gree.command(&device, &command).await {
if let Err(first_err) = state.gree.command(&device, &command, suppress_beep).await {
// Retry once after a fresh bind. This covers stale keys and devices that
// switched between ECB/GCM after a firmware update.
let retry_result = match state.gree.bind(&device).await {
@@ -87,7 +132,7 @@ pub async fn send_command(state: &AppState, device_id: &str, command: DeviceComm
device.key = Some(bound.key);
device.protocol_version = bound.protocol_version;
state.db.save_device(&device)?;
state.gree.command(&device, &command).await
state.gree.command(&device, &command, suppress_beep).await
}
Err(_) => Err(first_err),
};
@@ -198,7 +243,7 @@ fn simulate_tick(device: &mut Device) {
}
fn record_reading(state: &AppState, device: &Device) -> Result<()> {
state.db.add_reading(&Reading {
let reading = Reading {
id: 0,
device_id: device.id.clone(),
timestamp: Utc::now(),
@@ -207,7 +252,9 @@ fn record_reading(state: &AppState, device: &Device) -> Result<()> {
target_temperature: device.target_temperature,
power: device.power,
source: if device.simulated { "simulator".into() } else { "gree".into() },
})?;
};
state.db.add_reading(&reading)?;
queue_influx_device(state, reading);
Ok(())
}
@@ -476,8 +523,10 @@ fn record_zone_history(state: &AppState, zone: &Zone, outdoor_temperature: Optio
active_preset: zone.active_preset.clone(),
};
let interval = poll_interval_seconds.max(15) as i64;
if let Err(err) = state.db.add_zone_reading_if_due(&reading, interval) {
tracing::warn!(error=?err, zone_id=%zone.id, "cannot save zone history sample");
match state.db.add_zone_reading_if_due(&reading, interval) {
Ok(true) => queue_influx_zone(state, reading),
Ok(false) => {}
Err(err) => tracing::warn!(error=?err, zone_id=%zone.id, "cannot save zone history sample"),
}
}
@@ -498,11 +547,46 @@ fn record_ha_history(
temperature,
};
let interval = poll_interval_seconds.max(15) as i64;
if let Err(err) = state.db.add_ha_reading_if_due(&reading, interval) {
tracing::warn!(error=?err, entity_id=%entity_id, "cannot save Home Assistant history sample");
match state.db.add_ha_reading_if_due(&reading, interval) {
Ok(true) => queue_influx_ha(state, reading),
Ok(false) => {}
Err(err) => tracing::warn!(error=?err, entity_id=%entity_id, "cannot save Home Assistant history sample"),
}
}
fn queue_influx_device(state: &AppState, reading: Reading) {
let state = state.clone();
tokio::spawn(async move {
let settings = state.settings.read().await.influxdb.clone();
if !settings.enabled { return; }
if let Err(err) = influxdb::write_device(&state.http, &settings, &reading).await {
tracing::warn!(error=?err, device_id=%reading.device_id, "cannot write device metric to InfluxDB");
}
});
}
fn queue_influx_zone(state: &AppState, reading: ZoneReading) {
let state = state.clone();
tokio::spawn(async move {
let settings = state.settings.read().await.influxdb.clone();
if !settings.enabled { return; }
if let Err(err) = influxdb::write_zone(&state.http, &settings, &reading).await {
tracing::warn!(error=?err, zone_id=%reading.zone_id, "cannot write zone metric to InfluxDB");
}
});
}
fn queue_influx_ha(state: &AppState, reading: HaReading) {
let state = state.clone();
tokio::spawn(async move {
let settings = state.settings.read().await.influxdb.clone();
if !settings.enabled { return; }
if let Err(err) = influxdb::write_ha(&state.http, &settings, &reading).await {
tracing::warn!(error=?err, entity_id=%reading.entity_id, "cannot write HA metric to InfluxDB");
}
});
}
fn select_zone_temperature(zone: &Zone, device_temperature: Option<f64>, external_temperature: Option<f64>) -> (Option<f64>, String, bool) {
match zone.sensor_source.as_str() {
"home_assistant" => match (external_temperature, device_temperature) {
@@ -649,6 +733,143 @@ fn previous_weekday(day: Weekday) -> Weekday {
}
}
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 now = Local::now();
let mut zones_out = Vec::new();
let mut house_events = Vec::new();
for zone in state.db.list_zones()? {
let device = devices.iter().find(|item| item.id == zone.device_id);
let effective_mode = if settings.house_mode == "off" {
"off"
} else if zone.inherit_house_mode {
settings.house_mode.as_str()
} else {
zone.mode.as_str()
};
let active = active_schedule_for_zone(&zone, &schedules, now);
let (preset, target) = if effective_mode == "off" {
("off".to_string(), None)
} else {
let (preset, target) = resolve_zone_target(&zone, active, effective_mode);
(preset, Some(target))
};
let next_events = 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);
}
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,
mode: effective_mode.to_string(),
preset: if effective_mode == "off" { "off".into() } else if zone.active_preset.is_empty() { preset } else { zone.active_preset.clone() },
current_temperature: zone.current_temperature,
target_temperature: if effective_mode == "off" { None } else { zone.effective_setpoint.or(target) },
device_setpoint: zone.device_setpoint.or_else(|| device.map(|item| item.target_temperature)),
demand: zone.enabled && effective_mode != "off" && zone.demand,
control_source: zone.control_temperature_source.clone(),
manual_override_until: zone.manual_override_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_name = 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: 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,
outdoor_temperature: *state.outdoor_temperature.read().await,
control_strategy: settings.control_strategy,
next_events: house_events,
zones: zones_out,
rules,
})
}
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);
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());
for minute in 1..=(8 * 24 * 60) {
let candidate = now + 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
}
async fn run_automations(state: &AppState) -> Result<()> {
let devices = state.db.list_devices()?;
for mut item in state.db.list_automations()? {
@@ -721,6 +942,25 @@ mod tests {
}
}
#[test]
fn device_command_drops_unchanged_fields() {
let device = Device::simulated_default();
let command = DeviceCommand {
power: Some(false),
mode: Some("cool".into()),
target_temperature: Some(23.4),
fan_speed: Some(3),
light: Some(false),
..DeviceCommand::default()
};
let changed = command.changed_from(&device);
assert_eq!(changed.power, None);
assert_eq!(changed.mode, None);
assert_eq!(changed.target_temperature, None);
assert_eq!(changed.fan_speed, Some(3));
assert_eq!(changed.light, Some(false));
}
#[test]
fn combined_temperature_prefers_room_sensor_weight() {
let zone = test_zone("combined");