v0.6.5
This commit is contained in:
+53
-21
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user