v0.4.4
This commit is contained in:
+86
-28
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user