This commit is contained in:
Mateusz Gruszczyński
2026-08-24 00:23:47 +02:00
parent 6e3075fae5
commit 10bc9aa099
21 changed files with 1110 additions and 260 deletions
+86 -28
View File
@@ -6,7 +6,7 @@ use tokio::time::sleep;
use crate::{
error::AppError,
home_assistant,
models::{Automation, Device, DeviceCommand, Reading, Schedule, Zone, ZoneReading},
models::{Automation, Device, DeviceCommand, HaReading, Reading, Schedule, Zone, ZoneReading},
state::AppState,
};
@@ -251,13 +251,23 @@ async fn control_zones(state: &AppState) -> Result<()> {
// Outdoor temperature is deliberately optional. It never replaces the room sensor;
// it only makes the active setpoint/fan a little more assertive in extreme weather.
let outdoor_temperature = if settings.outdoor_assist_enabled && !settings.home_assistant.outdoor_entity_id.trim().is_empty() {
let outdoor_temperature = if !settings.home_assistant.outdoor_entity_id.trim().is_empty() {
match home_assistant::read_temperature(
&state.http,
&settings.home_assistant,
Some(settings.home_assistant.outdoor_entity_id.trim()),
).await {
Ok(value) => Some(value),
Ok(value) => {
record_ha_history(
state,
settings.home_assistant.outdoor_entity_id.trim(),
None,
"outdoor",
value,
settings.poll_interval_seconds,
);
Some(value)
}
Err(err) => {
tracing::debug!(error=?err, "outdoor Home Assistant sensor unavailable");
None
@@ -273,12 +283,14 @@ async fn control_zones(state: &AppState) -> Result<()> {
state.broadcast("outdoor.updated", json!({"temperature": outdoor_temperature}));
}
}
let outdoor_assist_temperature = if settings.outdoor_assist_enabled { outdoor_temperature } else { None };
for mut zone in state.db.list_zones()? {
if !zone.enabled { continue; }
if zone.manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) {
zone.manual_preset = None;
zone.manual_setpoint = None;
zone.manual_override_until = None;
}
@@ -300,7 +312,12 @@ async fn control_zones(state: &AppState) -> Result<()> {
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),
Ok(value) => {
if let Some(entity_id) = zone.ha_entity_id.as_deref().filter(|value| !value.trim().is_empty()) {
record_ha_history(state, entity_id, Some(&zone.id), "room", value, settings.poll_interval_seconds);
}
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()}));
@@ -341,7 +358,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
Err(err) => state.log("error", "zone.action_error", &err.to_string(), json!({"zone_id": zone.id})),
}
}
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds)?;
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)?);
continue;
@@ -353,7 +370,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
zone.effective_setpoint = Some(target);
let Some(temp) = temperature else {
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds)?;
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)?);
continue;
@@ -375,7 +392,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
// Setpoint modulation: keep the indoor unit powered and let its own inverter/compressor
// stop naturally when we move the target to the satisfied side of room temperature.
let assist = outdoor_assist_offset(effective_mode, outdoor_temperature, temp, target);
let assist = outdoor_assist_offset(effective_mode, outdoor_assist_temperature, temp, target);
let active_target = match effective_mode {
"heat" => target + assist,
_ => target - assist,
@@ -388,7 +405,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
zone.device_setpoint = Some(desired_device_target);
let desired_fan = if zone.smart_fan {
Some(smart_fan_speed(effective_mode, temp, target, outdoor_temperature, zone.demand))
Some(smart_fan_speed(effective_mode, temp, target, outdoor_assist_temperature, zone.demand))
} else {
None
};
@@ -424,26 +441,33 @@ async fn control_zones(state: &AppState) -> Result<()> {
}
}
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds)?;
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)?);
}
Ok(())
}
fn record_zone_history(state: &AppState, zone: &Zone, outdoor_temperature: Option<f64>, poll_interval_seconds: u64) -> Result<()> {
let Some(device) = state.db.get_device(&zone.device_id)? else { return Ok(()); };
fn record_zone_history(state: &AppState, zone: &Zone, outdoor_temperature: Option<f64>, poll_interval_seconds: u64) {
let device = match state.db.get_device(&zone.device_id) {
Ok(Some(device)) => device,
Ok(None) => return,
Err(err) => {
tracing::warn!(error=?err, zone_id=%zone.id, "cannot load device for zone history");
return;
}
};
let reading = ZoneReading {
id: 0,
zone_id: zone.id.clone(),
device_id: zone.device_id.clone(),
timestamp: Utc::now(),
gree_temperature: zone.device_temperature,
gree_temperature: zone.device_temperature.or(device.current_temperature),
external_temperature: zone.external_temperature,
control_temperature: zone.current_temperature,
target_temperature: zone.effective_setpoint,
control_temperature: zone.current_temperature.or(zone.device_temperature).or(device.current_temperature),
target_temperature: zone.effective_setpoint.or(zone.manual_setpoint).or(Some(zone.setpoint)),
device_setpoint: zone.device_setpoint.or(Some(device.target_temperature)),
outdoor_temperature,
outdoor_temperature: outdoor_temperature.or(device.outdoor_temperature),
power: device.power,
mode: if zone.effective_mode.is_empty() { device.mode.clone() } else { zone.effective_mode.clone() },
fan_speed: device.fan_speed,
@@ -451,10 +475,32 @@ fn record_zone_history(state: &AppState, zone: &Zone, outdoor_temperature: Optio
control_source: zone.control_temperature_source.clone(),
active_preset: zone.active_preset.clone(),
};
// History is deliberately less frequent than the zone control loop to keep SQLite compact.
let interval = poll_interval_seconds.max(15) as i64;
state.db.add_zone_reading_if_due(&reading, interval)?;
Ok(())
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");
}
}
fn record_ha_history(
state: &AppState,
entity_id: &str,
zone_id: Option<&str>,
kind: &str,
temperature: f64,
poll_interval_seconds: u64,
) {
let reading = HaReading {
id: 0,
entity_id: entity_id.to_string(),
zone_id: zone_id.map(str::to_string),
kind: kind.to_string(),
timestamp: Utc::now(),
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");
}
}
fn select_zone_temperature(zone: &Zone, device_temperature: Option<f64>, external_temperature: Option<f64>) -> (Option<f64>, String, bool) {
@@ -534,21 +580,24 @@ fn profile_setpoint(zone: &Zone, preset: &str, mode: &str) -> f64 {
}
fn resolve_zone_target(zone: &Zone, schedule: Option<&Schedule>, mode: &str) -> (String, f64) {
if let Some(manual) = zone.manual_preset.as_deref() {
return if manual == "custom" {
let (preset, base_target) = if let Some(manual) = zone.manual_preset.as_deref() {
if manual == "custom" {
("custom".into(), zone.setpoint)
} else {
(manual.to_string(), profile_setpoint(zone, manual, mode))
};
}
if let Some(item) = schedule {
return if item.preset == "custom" {
}
} else if let Some(item) = schedule {
if item.preset == "custom" {
("custom".into(), item.setpoint)
} else {
(item.preset.clone(), profile_setpoint(zone, &item.preset, mode))
};
}
("comfort".into(), profile_setpoint(zone, "comfort", mode))
}
} else {
("comfort".into(), profile_setpoint(zone, "comfort", mode))
};
// Quick +/- temperature adjustments are independent from the selected preset.
// The UI can therefore stay in Auto/Sleep/Comfort while temporarily nudging the target.
(preset, zone.manual_setpoint.unwrap_or(base_target))
}
fn active_schedule_for_zone<'a>(zone: &Zone, schedules: &'a [Schedule], now: DateTime<Local>) -> Option<&'a Schedule> {
@@ -667,7 +716,7 @@ mod tests {
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(), active_preset: "comfort".into(),
manual_preset: None, manual_override_until: None, effective_mode: "heat".into(), effective_setpoint: Some(21.0), device_setpoint: None,
manual_preset: None, manual_setpoint: None, manual_override_until: None, effective_mode: "heat".into(), effective_setpoint: Some(21.0), device_setpoint: None,
demand: false, last_action_at: None, created_at: Utc::now(), updated_at: Utc::now(),
}
}
@@ -708,6 +757,15 @@ mod tests {
assert_eq!(profile_setpoint(&zone, "sleep", "heat"), 19.0);
}
#[test]
fn quick_setpoint_keeps_active_preset() {
let mut zone = test_zone("device");
zone.manual_setpoint = Some(22.5);
let (preset, target) = resolve_zone_target(&zone, None, "cool");
assert_eq!(preset, "comfort");
assert_eq!(target, 22.5);
}
#[test]
fn legacy_zone_keeps_old_comfort_setpoint() {
let mut zone = test_zone("device");