This commit is contained in:
Mateusz Gruszczyński
2026-08-23 23:20:00 +02:00
parent 8bb12a1782
commit b23578a0c8
34 changed files with 2300 additions and 447 deletions
+255 -34
View File
@@ -248,15 +248,54 @@ fn validate_command(command: &DeviceCommand) -> Result<(), AppError> {
async fn control_zones(state: &AppState) -> Result<()> {
let schedules = state.db.list_schedules()?;
let settings = state.settings.read().await.clone();
// 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() {
match home_assistant::read_temperature(
&state.http,
&settings.home_assistant,
Some(settings.home_assistant.outdoor_entity_id.trim()),
).await {
Ok(value) => Some(value),
Err(err) => {
tracing::debug!(error=?err, "outdoor Home Assistant sensor unavailable");
None
}
}
} else {
None
};
{
let mut current = state.outdoor_temperature.write().await;
if *current != outdoor_temperature {
*current = outdoor_temperature;
state.broadcast("outdoor.updated", json!({"temperature": outdoor_temperature}));
}
}
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;
if zone.manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) {
zone.manual_preset = None;
zone.manual_override_until = None;
}
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 effective_mode = if settings.house_mode == "off" {
"off"
} else if zone.inherit_house_mode {
settings.house_mode.as_str()
} else {
zone.mode.as_str()
};
zone.effective_mode = effective_mode.to_string();
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") {
@@ -290,33 +329,99 @@ async fn control_zones(state: &AppState) -> Result<()> {
}));
}
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,
}));
}
// Global Off is the only normal path that intentionally powers units down.
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})),
}
}
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
continue;
}
let active_schedule = active_schedule_for_zone(&zone, &schedules, Local::now());
let (preset, target) = resolve_zone_target(&zone, active_schedule, effective_mode);
zone.active_preset = preset;
zone.effective_setpoint = Some(target);
let Some(temp) = temperature else {
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
continue;
};
let half = zone.hysteresis.max(0.1) / 2.0;
zone.demand = match effective_mode {
"heat" => {
if temp <= target - half { true }
else if temp >= target + half { false }
else { zone.demand }
}
_ => {
if temp >= target + half { true }
else if temp <= target - half { false }
else { zone.demand }
}
};
// 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 active_target = match effective_mode {
"heat" => target + assist,
_ => target - assist,
};
let standby_target = match effective_mode {
"heat" => target - zone.standby_offset_c.max(0.5),
_ => target + zone.standby_offset_c.max(0.5),
};
let desired_device_target = round_device_setpoint(effective_mode, zone.demand, if zone.demand { active_target } else { standby_target });
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))
} else {
None
};
let needs_command = !device.power
|| device.mode != effective_mode
|| (device.target_temperature - desired_device_target).abs() >= 0.5
|| desired_fan.map(|fan| fan != device.fan_speed).unwrap_or(false);
let urgent_mode_change = !device.power || device.mode != effective_mode;
if needs_command && (urgent_mode_change || adjustment_allowed(&zone)) {
let command = DeviceCommand {
power: Some(true),
mode: Some(effective_mode.to_string()),
target_temperature: Some(desired_device_target),
fan_speed: desired_fan,
..Default::default()
};
match send_command(state, &zone.device_id, command).await {
Ok(_) => {
zone.last_action_at = Some(Utc::now());
state.log("info", "zone.setpoint_modulation", &format!("Zone {} -> {:.1} C ({})", zone.name, desired_device_target, if zone.demand { "demand" } else { "standby" }), json!({
"zone_id": zone.id,
"room_temperature": temp,
"comfort_target": target,
"device_target": desired_device_target,
"mode": effective_mode,
"preset": zone.active_preset,
"outdoor_temperature": outdoor_temperature,
}));
}
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)?);
}
@@ -351,17 +456,94 @@ fn select_zone_temperature(zone: &Zone, device_temperature: Option<f64>, externa
}
}
fn cycle_allowed(zone: &Zone, currently_on: bool) -> bool {
fn adjustment_allowed(zone: &Zone) -> 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 }
(Utc::now() - last).num_seconds().max(0) as u64 >= zone.min_adjust_seconds.max(15)
}
fn active_setpoint(zone: &Zone, schedules: &[Schedule], now: DateTime<Local>) -> Option<f64> {
fn round_device_setpoint(mode: &str, demand: bool, value: f64) -> f64 {
let value = value.clamp(16.0, 30.0);
match (mode, demand) {
("heat", true) => value.ceil(),
("heat", false) => value.floor(),
(_, true) => value.floor(),
(_, false) => value.ceil(),
}
}
fn outdoor_assist_offset(mode: &str, outdoor: Option<f64>, room: f64, target: f64) -> f64 {
let Some(outdoor) = outdoor else { return 0.0; };
let room_error = (room - target).abs();
let weather = match mode {
"heat" => ((5.0 - outdoor) / 15.0).clamp(0.0, 1.0),
_ => ((outdoor - 30.0) / 10.0).clamp(0.0, 1.0),
};
(weather * room_error.clamp(0.0, 2.0) * 0.5).clamp(0.0, 1.0)
}
fn smart_fan_speed(mode: &str, room: f64, target: f64, outdoor: Option<f64>, demand: bool) -> u8 {
if !demand { return 0; }
let error = (room - target).abs();
let extreme_weather = match (mode, outdoor) {
("heat", Some(value)) => value <= 0.0,
(_, Some(value)) => value >= 32.0,
_ => false,
};
if error >= 2.0 || extreme_weather { 3 } else if error >= 1.0 { 2 } else { 0 }
}
fn profile_setpoint(zone: &Zone, preset: &str, mode: &str) -> f64 {
if zone.profile_version == 0 && preset == "comfort" { return zone.setpoint; }
match (mode, preset) {
("heat", "sleep") => zone.heat_sleep_setpoint,
("heat", "away") => zone.heat_away_setpoint,
("heat", _) => zone.heat_comfort_setpoint,
(_, "sleep") => zone.cool_sleep_setpoint,
(_, "away") => zone.cool_away_setpoint,
(_, _) => zone.cool_comfort_setpoint,
}
}
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" {
("custom".into(), zone.setpoint)
} else {
(manual.to_string(), profile_setpoint(zone, manual, mode))
};
}
if let Some(item) = schedule {
return 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))
}
fn active_schedule_for_zone<'a>(zone: &Zone, schedules: &'a [Schedule], now: DateTime<Local>) -> Option<&'a Schedule> {
schedules.iter()
.filter(|item| item.enabled && item.zone_id == zone.id && schedule_active(item, now))
.last()
.map(|item| item.setpoint)
}
pub fn next_schedule_boundary_utc(zone_id: &str, schedules: &[Schedule], now: DateTime<Local>) -> DateTime<Utc> {
let current = schedules.iter()
.filter(|item| item.enabled && item.zone_id == zone_id && schedule_active(item, now))
.last()
.map(|item| item.id.as_str());
for minute in 1..=(48 * 60) {
let candidate = now + chrono::Duration::minutes(minute);
let next = schedules.iter()
.filter(|item| item.enabled && item.zone_id == zone_id && schedule_active(item, candidate))
.last()
.map(|item| item.id.as_str());
if next != current {
return candidate.with_timezone(&Utc);
}
}
(now + chrono::Duration::hours(8)).with_timezone(&Utc)
}
fn schedule_active(item: &Schedule, now: DateTime<Local>) -> bool {
@@ -440,7 +622,7 @@ mod tests {
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,
weekdays: vec![1], start_time: "22:00".into(), end_time: "06:00".into(), preset: "custom".into(), setpoint: 20.0,
created_at: Utc::now(), updated_at: Utc::now(),
};
assert!(schedule_active(&item, now));
@@ -449,11 +631,15 @@ mod tests {
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,
mode: "heat".into(), inherit_house_mode: true, setpoint: 21.0, profile_version: 1,
cool_comfort_setpoint: 23.0, cool_sleep_setpoint: 24.5, cool_away_setpoint: 27.0,
heat_comfort_setpoint: 21.0, heat_sleep_setpoint: 19.0, heat_away_setpoint: 17.0,
hysteresis: 0.6, min_on_seconds: 180, min_off_seconds: 180, min_adjust_seconds: 120, standby_offset_c: 2.0, smart_fan: true,
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(),
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,
demand: false, last_action_at: None, created_at: Utc::now(), updated_at: Utc::now(),
}
}
@@ -484,4 +670,39 @@ mod tests {
assert!(!discrepancy);
}
#[test]
fn seasonal_profiles_resolve_independently() {
let zone = test_zone("device");
assert_eq!(profile_setpoint(&zone, "comfort", "cool"), 23.0);
assert_eq!(profile_setpoint(&zone, "sleep", "cool"), 24.5);
assert_eq!(profile_setpoint(&zone, "comfort", "heat"), 21.0);
assert_eq!(profile_setpoint(&zone, "sleep", "heat"), 19.0);
}
#[test]
fn legacy_zone_keeps_old_comfort_setpoint() {
let mut zone = test_zone("device");
zone.profile_version = 0;
zone.setpoint = 22.5;
assert_eq!(profile_setpoint(&zone, "comfort", "cool"), 22.5);
assert_eq!(profile_setpoint(&zone, "comfort", "heat"), 22.5);
}
#[test]
fn device_setpoint_rounding_preserves_control_direction() {
assert_eq!(round_device_setpoint("cool", true, 23.5), 23.0);
assert_eq!(round_device_setpoint("cool", false, 25.5), 26.0);
assert_eq!(round_device_setpoint("heat", true, 21.5), 22.0);
assert_eq!(round_device_setpoint("heat", false, 19.5), 19.0);
}
#[test]
fn outdoor_assist_is_bounded_and_direction_neutral() {
let cool = outdoor_assist_offset("cool", Some(36.0), 27.0, 23.0);
let heat = outdoor_assist_offset("heat", Some(-5.0), 17.0, 21.0);
assert!(cool > 0.0 && cool <= 1.0);
assert!(heat > 0.0 && heat <= 1.0);
assert_eq!(outdoor_assist_offset("cool", None, 27.0, 23.0), 0.0);
}
}