This commit is contained in:
Mateusz Gruszczyński
2026-08-25 09:31:15 +02:00
parent 25e1a854e3
commit fd26d6877f
25 changed files with 460 additions and 99 deletions
+53 -21
View File
@@ -1,4 +1,4 @@
use std::time::Duration;
use std::time::{Duration, Instant};
use anyhow::Result;
use chrono::{DateTime, Datelike, Local, NaiveTime, Timelike, Utc, Weekday};
use serde_json::json;
@@ -107,11 +107,13 @@ pub async fn send_command(state: &AppState, device_id: &str, command: DeviceComm
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;
let response_started = Instant::now();
let mut applied_command = command.clone();
if device.simulated {
applied_command.apply(&mut device);
device.online = true;
device.response_time_ms = Some(0);
device.last_seen = Some(Utc::now());
device.last_error = None;
state.db.save_device(&device)?;
@@ -159,6 +161,7 @@ pub async fn send_command(state: &AppState, device_id: &str, command: DeviceComm
applied_command.apply(&mut device);
device.online = true;
device.communication_failures = 0;
device.response_time_ms = Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64);
device.last_seen = Some(Utc::now());
device.last_error = None;
state.db.save_device(&device)?;
@@ -199,6 +202,7 @@ async fn poll_device(state: &AppState, device: &mut Device) {
return;
}
let previous_failures = device.communication_failures;
let response_started = Instant::now();
if device.key.as_deref().unwrap_or_default().is_empty() {
match state.gree.bind(device).await {
Ok(bound) => {
@@ -227,6 +231,9 @@ async fn poll_device(state: &AppState, device: &mut Device) {
Err(_) => record_poll_failure(device, &first_err.to_string()),
}
}
if device.communication_failures == 0 && device.online {
device.response_time_ms = Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64);
}
log_poll_health_transition(state, device, previous_failures).await;
}
@@ -268,6 +275,7 @@ fn simulate_tick(device: &mut Device) {
// Correct rounding for outdoor temperature without accumulating precision noise.
device.outdoor_temperature = device.outdoor_temperature.map(|v| (v * 10.0).round() / 10.0);
device.online = true;
device.response_time_ms = Some(0);
device.last_seen = Some(Utc::now());
device.last_error = None;
device.updated_at = Utc::now();
@@ -369,6 +377,16 @@ 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());
if !settings.house_power_enabled {
for device in &device_snapshot {
if !device.enabled || !device.power { continue; }
if let Err(err) = send_command(state, &device.id, DeviceCommand { power: Some(false), ..Default::default() }).await {
state.log("error", "house.master_power_error", &err.to_string(), json!({"device_id": device.id}));
}
}
return Ok(());
}
for mut zone in state.db.list_zones()? {
if zone.manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) {
zone.manual_preset = None;
@@ -381,9 +399,9 @@ async fn control_zones(state: &AppState) -> Result<()> {
continue;
};
let effective_mode = if settings.house_mode == "off" {
"off"
} else if zone.inherit_house_mode {
// House "off" means the smart thermostat does not control inherited zones.
// A zone explicitly switched to heat/cool remains independent and may still run.
let effective_mode = if zone.inherit_house_mode {
settings.house_mode.as_str()
} else {
zone.mode.as_str()
@@ -444,18 +462,15 @@ async fn control_zones(state: &AppState) -> Result<()> {
continue;
}
// Global Off is the only normal path that intentionally powers units down.
// House "off" is a no-control state, not a power-off command. Keep polling and
// publishing the zone, but never overwrite manual device state while it follows
// the house mode. Explicit per-zone heat/cool bypasses this branch above.
if effective_mode == "off" {
zone.active_preset = "off".into();
zone.effective_setpoint = None;
zone.device_setpoint = None;
zone.demand = false;
if device.power {
match send_command(state, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }).await {
Ok(_) => zone.last_action_at = Some(Utc::now()),
Err(err) => state.log("error", "zone.action_error", &err.to_string(), json!({"zone_id": zone.id})),
}
}
zone.demand_since = None;
zone.target_alerted_at = None;
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
@@ -603,6 +618,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
}
Ok(())
}
@@ -915,28 +931,41 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
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 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 zone in state.db.list_zones()? {
for zone in 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 {
let effective_mode = 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 active = if effective_mode == "off" {
None
} else {
active_schedule_for_zone(&zone, &schedules, now)
};
let (preset, target) = if effective_mode == "off" {
("off".to_string(), None)
("manual".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);
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);
@@ -951,11 +980,11 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
mode: effective_mode.to_string(),
configured_mode: zone.mode.clone(),
inherit_house_mode: zone.inherit_house_mode,
preset: if effective_mode == "off" { "off".into() } else if zone.active_preset.is_empty() { preset } else { zone.active_preset.clone() },
preset: if effective_mode == "off" { "manual".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,
demand: settings.house_power_enabled && 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()),
@@ -996,6 +1025,8 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
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,
@@ -1084,6 +1115,7 @@ 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()?;
for mut item in state.db.list_automations()? {
if !item.enabled || !automation_ready(&item) { continue; }