first commit
This commit is contained in:
+450
@@ -0,0 +1,450 @@
|
||||
use std::time::Duration;
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Datelike, Local, NaiveTime, Timelike, Utc, Weekday};
|
||||
use serde_json::json;
|
||||
use tokio::time::sleep;
|
||||
use crate::{
|
||||
error::AppError,
|
||||
home_assistant,
|
||||
models::{Automation, Device, DeviceCommand, Reading, Schedule, Zone},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub fn start(state: AppState) {
|
||||
let poll_state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
loop {
|
||||
if let Err(err) = poll_all(&poll_state).await {
|
||||
tracing::error!(error=?err, "device poll cycle failed");
|
||||
}
|
||||
let seconds = poll_state.settings.read().await.poll_interval_seconds.max(2);
|
||||
sleep(Duration::from_secs(seconds)).await;
|
||||
}
|
||||
});
|
||||
|
||||
let control_state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
loop {
|
||||
if let Err(err) = control_zones(&control_state).await {
|
||||
tracing::error!(error=?err, "zone cycle failed");
|
||||
}
|
||||
if let Err(err) = run_automations(&control_state).await {
|
||||
tracing::error!(error=?err, "automation cycle failed");
|
||||
}
|
||||
let seconds = control_state.settings.read().await.zone_interval_seconds.max(2);
|
||||
sleep(Duration::from_secs(seconds)).await;
|
||||
}
|
||||
});
|
||||
|
||||
let maintenance_state = state;
|
||||
tokio::spawn(async move {
|
||||
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"),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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())); }
|
||||
|
||||
if device.simulated {
|
||||
command.apply(&mut device);
|
||||
device.online = true;
|
||||
device.last_seen = Some(Utc::now());
|
||||
device.last_error = None;
|
||||
state.db.save_device(&device)?;
|
||||
} else {
|
||||
if device.key.as_deref().unwrap_or_default().is_empty() {
|
||||
match state.gree.bind(&device).await {
|
||||
Ok(key) => {
|
||||
device.key = Some(key);
|
||||
state.db.save_device(&device)?;
|
||||
state.log("info", "device.bound", &format!("Bound {}", device.name), json!({"device_id": device.id}));
|
||||
}
|
||||
Err(err) => {
|
||||
mark_device_error(state, &mut device, &err.to_string())?;
|
||||
return Err(AppError::Device(err.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(err) = state.gree.command(&device, &command).await {
|
||||
mark_device_error(state, &mut device, &err.to_string())?;
|
||||
return Err(AppError::Device(err.to_string()));
|
||||
}
|
||||
command.apply(&mut device);
|
||||
device.online = true;
|
||||
device.last_seen = Some(Utc::now());
|
||||
device.last_error = None;
|
||||
state.db.save_device(&device)?;
|
||||
}
|
||||
|
||||
state.log("info", "device.command", &format!("Updated {}", device.name), json!({
|
||||
"device_id": device.id,
|
||||
"command": command,
|
||||
}));
|
||||
state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default());
|
||||
Ok(device)
|
||||
}
|
||||
|
||||
pub async fn poll_one(state: &AppState, device_id: &str) -> Result<Device, AppError> {
|
||||
let mut device = state.db.get_device(device_id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
|
||||
poll_device(state, &mut device).await;
|
||||
state.db.save_device(&device)?;
|
||||
record_reading(state, &device)?;
|
||||
state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default());
|
||||
Ok(device)
|
||||
}
|
||||
|
||||
async fn poll_all(state: &AppState) -> Result<()> {
|
||||
for mut device in state.db.list_devices()? {
|
||||
if !device.enabled { continue; }
|
||||
poll_device(state, &mut device).await;
|
||||
state.db.save_device(&device)?;
|
||||
record_reading(state, &device)?;
|
||||
state.broadcast("device.updated", serde_json::to_value(&device)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn poll_device(state: &AppState, device: &mut Device) {
|
||||
if device.simulated {
|
||||
simulate_tick(device);
|
||||
return;
|
||||
}
|
||||
if device.key.as_deref().unwrap_or_default().is_empty() {
|
||||
match state.gree.bind(device).await {
|
||||
Ok(key) => device.key = Some(key),
|
||||
Err(err) => {
|
||||
device.online = false;
|
||||
device.last_error = Some(err.to_string());
|
||||
device.updated_at = Utc::now();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(err) = state.gree.poll(device).await {
|
||||
device.online = false;
|
||||
device.last_error = Some(err.to_string());
|
||||
device.updated_at = Utc::now();
|
||||
}
|
||||
}
|
||||
|
||||
fn simulate_tick(device: &mut Device) {
|
||||
let mut current = device.current_temperature.unwrap_or(25.0);
|
||||
let minute_wave = ((Utc::now().timestamp() % 3600) as f64 / 3600.0 * std::f64::consts::TAU).sin();
|
||||
let ambient = 25.5 + minute_wave * 0.35;
|
||||
if device.power {
|
||||
match device.mode.as_str() {
|
||||
"cool" => {
|
||||
let floor = device.target_temperature - 0.2;
|
||||
if current > floor { current -= if device.turbo { 0.25 } else { 0.12 }; }
|
||||
}
|
||||
"heat" => {
|
||||
let ceiling = device.target_temperature + 0.2;
|
||||
if current < ceiling { current += if device.turbo { 0.25 } else { 0.12 }; }
|
||||
}
|
||||
"dry" => current -= 0.04,
|
||||
_ => current += (ambient - current) * 0.02,
|
||||
}
|
||||
} else {
|
||||
current += (ambient - current) * 0.04;
|
||||
}
|
||||
device.current_temperature = Some((current * 10.0).round() / 10.0);
|
||||
device.outdoor_temperature = Some((30.0 + minute_wave * 1.2) * 10.0_f64.round() / 10.0);
|
||||
// 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.last_seen = Some(Utc::now());
|
||||
device.last_error = None;
|
||||
device.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
fn record_reading(state: &AppState, device: &Device) -> Result<()> {
|
||||
state.db.add_reading(&Reading {
|
||||
id: 0,
|
||||
device_id: device.id.clone(),
|
||||
timestamp: Utc::now(),
|
||||
indoor_temperature: device.current_temperature,
|
||||
outdoor_temperature: device.outdoor_temperature,
|
||||
target_temperature: device.target_temperature,
|
||||
power: device.power,
|
||||
source: if device.simulated { "simulator".into() } else { "gree".into() },
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mark_device_error(state: &AppState, device: &mut Device, error: &str) -> Result<(), AppError> {
|
||||
device.online = false;
|
||||
device.last_error = Some(error.to_string());
|
||||
device.updated_at = Utc::now();
|
||||
state.db.save_device(device)?;
|
||||
state.log("error", "device.error", &format!("{}: {error}", device.name), json!({"device_id": device.id}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_command(command: &DeviceCommand) -> Result<(), AppError> {
|
||||
if let Some(value) = command.target_temperature {
|
||||
if !(8.0..=32.0).contains(&value) { return Err(AppError::BadRequest("target temperature must be between 8 and 32 C".into())); }
|
||||
}
|
||||
if let Some(value) = command.fan_speed {
|
||||
if value > 5 { return Err(AppError::BadRequest("fan speed must be between 0 and 5".into())); }
|
||||
}
|
||||
if let Some(value) = &command.mode {
|
||||
if !matches!(value.as_str(), "auto" | "cool" | "dry" | "fan" | "heat") {
|
||||
return Err(AppError::BadRequest("unsupported HVAC mode".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn control_zones(state: &AppState) -> Result<()> {
|
||||
let schedules = state.db.list_schedules()?;
|
||||
let settings = state.settings.read().await.clone();
|
||||
for mut zone in state.db.list_zones()? {
|
||||
if !zone.enabled { continue; }
|
||||
if let Some(setpoint) = active_setpoint(&zone, &schedules, Local::now()) {
|
||||
zone.setpoint = setpoint;
|
||||
}
|
||||
let Some(device) = state.db.get_device(&zone.device_id)? else {
|
||||
state.log("error", "zone.device_missing", &format!("Zone {} has no device", zone.name), json!({"zone_id": zone.id}));
|
||||
continue;
|
||||
};
|
||||
let previous_source = zone.control_temperature_source.clone();
|
||||
let device_temperature = device.current_temperature;
|
||||
let external_temperature = if matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") {
|
||||
match home_assistant::read_temperature(&state.http, &settings.home_assistant, zone.ha_entity_id.as_deref()).await {
|
||||
Ok(value) => Some(value),
|
||||
Err(err) => {
|
||||
if !matches!(previous_source.as_str(), "device_fallback" | "device_discrepancy_fallback") {
|
||||
state.log("warn", "ha.sensor_error", &err.to_string(), json!({"zone_id": zone.id, "entity_id": zone.ha_entity_id.as_deref()}));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (temperature, control_source, discrepancy) = select_zone_temperature(&zone, device_temperature, external_temperature);
|
||||
zone.device_temperature = device_temperature;
|
||||
zone.external_temperature = external_temperature;
|
||||
zone.current_temperature = temperature;
|
||||
zone.control_temperature_source = control_source;
|
||||
zone.updated_at = Utc::now();
|
||||
|
||||
if discrepancy && previous_source != "device_discrepancy_fallback" {
|
||||
state.log("warn", "zone.sensor_discrepancy", &format!("Zone {} sensors differ by more than {:.1} C; using GREE sensor", zone.name, zone.max_sensor_difference), json!({
|
||||
"zone_id": zone.id,
|
||||
"device_temperature": zone.device_temperature,
|
||||
"external_temperature": zone.external_temperature,
|
||||
"max_difference": zone.max_sensor_difference,
|
||||
"entity_id": zone.ha_entity_id.as_deref(),
|
||||
}));
|
||||
}
|
||||
|
||||
let Some(temp) = temperature else { state.db.save_zone(&zone)?; continue; };
|
||||
|
||||
let half = zone.hysteresis.max(0.1) / 2.0;
|
||||
let desired = match zone.mode.as_str() {
|
||||
"heat" => if temp <= zone.setpoint - half { Some(true) } else if temp >= zone.setpoint + half { Some(false) } else { None },
|
||||
_ => if temp >= zone.setpoint + half { Some(true) } else if temp <= zone.setpoint - half { Some(false) } else { None },
|
||||
};
|
||||
if let Some(on) = desired {
|
||||
zone.demand = on;
|
||||
if on != device.power && cycle_allowed(&zone, device.power) {
|
||||
let command = DeviceCommand {
|
||||
power: Some(on),
|
||||
mode: if on { Some(zone.mode.clone()) } else { None },
|
||||
target_temperature: if on { Some(zone.setpoint) } else { None },
|
||||
..Default::default()
|
||||
};
|
||||
match send_command(state, &zone.device_id, command).await {
|
||||
Ok(_) => {
|
||||
zone.last_action_at = Some(Utc::now());
|
||||
state.log("info", "zone.action", &format!("Zone {} demand {}", zone.name, if on { "ON" } else { "OFF" }), json!({
|
||||
"zone_id": zone.id, "temperature": temp, "setpoint": zone.setpoint,
|
||||
}));
|
||||
}
|
||||
Err(err) => state.log("error", "zone.action_error", &err.to_string(), json!({"zone_id": zone.id})),
|
||||
}
|
||||
}
|
||||
}
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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) {
|
||||
(Some(value), _) => (Some(value), "external".into(), false),
|
||||
(None, Some(value)) => (Some(value), "device_fallback".into(), false),
|
||||
(None, None) => (None, "unavailable".into(), false),
|
||||
},
|
||||
"combined" => match (device_temperature, external_temperature) {
|
||||
(Some(device), Some(external)) => {
|
||||
if (device - external).abs() > zone.max_sensor_difference.max(0.1) {
|
||||
(Some(device), "device_discrepancy_fallback".into(), true)
|
||||
} else {
|
||||
let external_weight = zone.external_sensor_weight.clamp(0.0, 1.0);
|
||||
let value = device * (1.0 - external_weight) + external * external_weight;
|
||||
(Some((value * 10.0).round() / 10.0), "combined".into(), false)
|
||||
}
|
||||
}
|
||||
(Some(value), None) => (Some(value), "device_fallback".into(), false),
|
||||
(None, Some(value)) => (Some(value), "external".into(), false),
|
||||
(None, None) => (None, "unavailable".into(), false),
|
||||
},
|
||||
_ => match device_temperature {
|
||||
Some(value) => (Some(value), "device".into(), false),
|
||||
None => (None, "unavailable".into(), false),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn cycle_allowed(zone: &Zone, currently_on: bool) -> bool {
|
||||
let Some(last) = zone.last_action_at else { return true; };
|
||||
let elapsed = (Utc::now() - last).num_seconds().max(0) as u64;
|
||||
if currently_on { elapsed >= zone.min_on_seconds } else { elapsed >= zone.min_off_seconds }
|
||||
}
|
||||
|
||||
fn active_setpoint(zone: &Zone, schedules: &[Schedule], now: DateTime<Local>) -> Option<f64> {
|
||||
schedules.iter()
|
||||
.filter(|item| item.enabled && item.zone_id == zone.id && schedule_active(item, now))
|
||||
.last()
|
||||
.map(|item| item.setpoint)
|
||||
}
|
||||
|
||||
fn schedule_active(item: &Schedule, now: DateTime<Local>) -> bool {
|
||||
let Ok(start) = NaiveTime::parse_from_str(&item.start_time, "%H:%M") else { return false; };
|
||||
let Ok(end) = NaiveTime::parse_from_str(&item.end_time, "%H:%M") else { return false; };
|
||||
let time = now.time();
|
||||
let today = now.weekday().number_from_monday();
|
||||
if start <= end {
|
||||
item.weekdays.contains(&today) && time >= start && time < end
|
||||
} else if time >= start {
|
||||
item.weekdays.contains(&today)
|
||||
} else if time < end {
|
||||
let previous = previous_weekday(now.weekday()).number_from_monday();
|
||||
item.weekdays.contains(&previous)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn previous_weekday(day: Weekday) -> Weekday {
|
||||
match day {
|
||||
Weekday::Mon => Weekday::Sun, Weekday::Tue => Weekday::Mon, Weekday::Wed => Weekday::Tue,
|
||||
Weekday::Thu => Weekday::Wed, Weekday::Fri => Weekday::Thu, Weekday::Sat => Weekday::Fri,
|
||||
Weekday::Sun => Weekday::Sat,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_automations(state: &AppState) -> Result<()> {
|
||||
let devices = state.db.list_devices()?;
|
||||
for mut item in state.db.list_automations()? {
|
||||
if !item.enabled || !automation_ready(&item) { continue; }
|
||||
let should_fire = match item.trigger_kind.as_str() {
|
||||
"temperature_above" => find_temperature(&devices, item.trigger_device_id.as_deref())
|
||||
.zip(item.threshold).map(|(t, threshold)| t > threshold).unwrap_or(false),
|
||||
"temperature_below" => find_temperature(&devices, item.trigger_device_id.as_deref())
|
||||
.zip(item.threshold).map(|(t, threshold)| t < threshold).unwrap_or(false),
|
||||
"time" => item.at_time.as_deref().map(time_matches).unwrap_or(false),
|
||||
_ => false,
|
||||
};
|
||||
if !should_fire { continue; }
|
||||
match send_command(state, &item.action_device_id, item.action.clone()).await {
|
||||
Ok(_) => {
|
||||
item.last_fired_at = Some(Utc::now());
|
||||
item.updated_at = Utc::now();
|
||||
state.db.save_automation(&item)?;
|
||||
state.log("info", "automation.fired", &format!("Automation {} fired", item.name), json!({"automation_id": item.id}));
|
||||
}
|
||||
Err(err) => state.log("error", "automation.error", &err.to_string(), json!({"automation_id": item.id})),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_temperature(devices: &[Device], device_id: Option<&str>) -> Option<f64> {
|
||||
let id = device_id?;
|
||||
devices.iter().find(|d| d.id == id)?.current_temperature
|
||||
}
|
||||
|
||||
fn automation_ready(item: &Automation) -> bool {
|
||||
item.last_fired_at.map(|last| (Utc::now() - last).num_seconds().max(0) as u64 >= item.cooldown_seconds).unwrap_or(true)
|
||||
}
|
||||
|
||||
fn time_matches(expected: &str) -> bool {
|
||||
let Ok(value) = NaiveTime::parse_from_str(expected, "%H:%M") else { return false; };
|
||||
let now = Local::now().time();
|
||||
now.hour() == value.hour() && now.minute() == value.minute()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::TimeZone;
|
||||
|
||||
#[test]
|
||||
fn overnight_schedule_works() {
|
||||
let now = Utc.with_ymd_and_hms(2025, 1, 7, 1, 0, 0).unwrap().with_timezone(&Local); // Tuesday
|
||||
let item = Schedule {
|
||||
id: "1".into(), zone_id: "z".into(), name: "night".into(), enabled: true,
|
||||
weekdays: vec![1], start_time: "22:00".into(), end_time: "06:00".into(), setpoint: 20.0,
|
||||
created_at: Utc::now(), updated_at: Utc::now(),
|
||||
};
|
||||
assert!(schedule_active(&item, now));
|
||||
}
|
||||
|
||||
fn test_zone(source: &str) -> Zone {
|
||||
Zone {
|
||||
id: "z".into(), name: "Room".into(), device_id: "d".into(), enabled: true,
|
||||
mode: "heat".into(), setpoint: 21.0, hysteresis: 0.6, min_on_seconds: 180, min_off_seconds: 180,
|
||||
sensor_source: source.into(), ha_entity_id: Some("sensor.room_temperature".into()),
|
||||
external_sensor_weight: 0.4, max_sensor_difference: 3.0, device_temperature: None, external_temperature: None,
|
||||
current_temperature: None, control_temperature_source: "device".into(), demand: false, last_action_at: None,
|
||||
created_at: Utc::now(), updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_temperature_prefers_room_sensor_weight() {
|
||||
let zone = test_zone("combined");
|
||||
let (value, source, discrepancy) = select_zone_temperature(&zone, Some(22.0), Some(20.0));
|
||||
assert_eq!(value, Some(21.2));
|
||||
assert_eq!(source, "combined");
|
||||
assert!(!discrepancy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_temperature_falls_back_on_large_discrepancy() {
|
||||
let zone = test_zone("combined");
|
||||
let (value, source, discrepancy) = select_zone_temperature(&zone, Some(21.0), Some(27.0));
|
||||
assert_eq!(value, Some(21.0));
|
||||
assert_eq!(source, "device_discrepancy_fallback");
|
||||
assert!(discrepancy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_temperature_falls_back_when_external_is_missing() {
|
||||
let zone = test_zone("combined");
|
||||
let (value, source, discrepancy) = select_zone_temperature(&zone, Some(21.5), None);
|
||||
assert_eq!(value, Some(21.5));
|
||||
assert_eq!(source, "device_fallback");
|
||||
assert!(!discrepancy);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user