This commit is contained in:
Mateusz Gruszczyński
2026-09-01 09:19:33 +02:00
parent ac5a464d96
commit 1a5c1305dc
20 changed files with 200 additions and 42 deletions
+14 -3
View File
@@ -24,6 +24,12 @@ struct ZoneInput {
heat_away_setpoint: f64,
#[serde(default = "hysteresis")]
hysteresis: f64,
#[serde(default)]
separate_hysteresis: bool,
#[serde(default = "hysteresis")]
cool_hysteresis: f64,
#[serde(default = "hysteresis")]
heat_hysteresis: f64,
#[serde(default = "cycle")]
min_on_seconds: u64,
#[serde(default = "cycle")]
@@ -73,6 +79,8 @@ impl ZoneInput {
if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("zone temperatures must be between 8 and 30 C".into())); }
}
if !(0.1..=5.0).contains(&self.hysteresis) { return Err(AppError::BadRequest("hysteresis must be between 0.1 and 5 C".into())); }
if !(0.1..=5.0).contains(&self.cool_hysteresis) { return Err(AppError::BadRequest("cooling hysteresis must be between 0.1 and 5 C".into())); }
if !(0.1..=5.0).contains(&self.heat_hysteresis) { return Err(AppError::BadRequest("heating hysteresis must be between 0.1 and 5 C".into())); }
if !(0.5..=8.0).contains(&self.standby_offset_c) { return Err(AppError::BadRequest("standby offset must be between 0.5 and 8 C".into())); }
if !matches!(self.mode.as_str(), "cool" | "heat") { return Err(AppError::BadRequest("zone mode must be cool or heat".into())); }
if !matches!(self.sensor_source.as_str(), "device" | "home_assistant" | "combined") { return Err(AppError::BadRequest("unsupported sensor source".into())); }
@@ -90,7 +98,9 @@ impl ZoneInput {
cool_comfort_setpoint: self.cool_comfort_setpoint, cool_sleep_setpoint: self.cool_sleep_setpoint,
cool_away_setpoint: self.cool_away_setpoint, heat_comfort_setpoint: self.heat_comfort_setpoint,
heat_sleep_setpoint: self.heat_sleep_setpoint, heat_away_setpoint: self.heat_away_setpoint,
hysteresis: self.hysteresis, min_on_seconds: self.min_on_seconds, min_off_seconds: self.min_off_seconds,
hysteresis: self.hysteresis, separate_hysteresis: self.separate_hysteresis,
cool_hysteresis: self.cool_hysteresis, heat_hysteresis: self.heat_hysteresis,
min_on_seconds: self.min_on_seconds, min_off_seconds: self.min_off_seconds,
min_adjust_seconds: self.min_adjust_seconds, standby_offset_c: self.standby_offset_c, smart_fan: self.smart_fan,
sensor_source: self.sensor_source, ha_entity_id: self.ha_entity_id.filter(|v| !v.trim().is_empty()),
external_sensor_weight: self.external_sensor_weight, max_sensor_difference: self.max_sensor_difference, sensor_stale_after_seconds: self.sensor_stale_after_seconds,
@@ -312,7 +322,8 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
return Err(AppError::BadRequest("temporary thermostat target must be between 8 and 30 C".into()));
}
let target = (target * 2.0).round() / 2.0;
let min_stable_tolerance = (zone.hysteresis.max(0.1) / 2.0 + 0.1).min(3.0);
let tolerance_mode = if zone.effective_mode.is_empty() { zone.mode.as_str() } else { zone.effective_mode.as_str() };
let min_stable_tolerance = (zone.hysteresis_for_mode(tolerance_mode) / 2.0 + 0.1).min(3.0);
let requested_tolerance = request.tolerance_c.unwrap_or(min_stable_tolerance.max(0.3));
if !(0.1..=3.0).contains(&requested_tolerance) {
return Err(AppError::BadRequest("temporary thermostat tolerance must be between 0.1 and 3 C".into()));
@@ -488,7 +499,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
}
if let Some(value) = patch.setpoint {
if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("zone setpoint must be between 8 and 30 C".into())); }
let value = (value * 2.0).round() / 2.0;
let value = (value * 10.0).round() / 10.0;
zone.setpoint = value;
zone.manual_setpoint = Some(value);
zone.effective_setpoint = Some(value);
+16 -1
View File
@@ -107,7 +107,8 @@ mod tests {
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,
hysteresis: 0.6, separate_hysteresis: false, cool_hysteresis: 0.6, heat_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, sensor_stale_after_seconds: 300, device_temperature: None, external_temperature: None,
current_temperature: None, control_temperature_source: "device".into(), active_preset: "comfort".into(),
@@ -119,6 +120,20 @@ mod tests {
}
}
#[test]
fn zone_can_use_separate_heating_and_cooling_hysteresis() {
let mut zone = test_zone("device");
zone.hysteresis = 0.6;
zone.separate_hysteresis = true;
zone.cool_hysteresis = 0.4;
zone.heat_hysteresis = 1.2;
assert_eq!(zone.hysteresis_for_mode("cool"), 0.4);
assert_eq!(zone.hysteresis_for_mode("heat"), 1.2);
zone.separate_hysteresis = false;
assert_eq!(zone.hysteresis_for_mode("cool"), 0.6);
assert_eq!(zone.hysteresis_for_mode("heat"), 0.6);
}
#[test]
fn external_device_change_detects_manual_climate_controls() {
let zone = test_zone("device");
+1 -1
View File
@@ -336,7 +336,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
continue;
};
let half = zone.hysteresis.max(0.1) / 2.0;
let half = zone.hysteresis_for_mode(effective_mode) / 2.0;
let previous_demand = zone.demand;
zone.demand = match effective_mode {
"heat" => {
+18
View File
@@ -30,6 +30,13 @@ pub struct Zone {
pub heat_away_setpoint: f64,
#[serde(default = "default_hysteresis")]
pub hysteresis: f64,
/// When enabled, heating and cooling use independent hysteresis values.
#[serde(default)]
pub separate_hysteresis: bool,
#[serde(default = "default_hysteresis")]
pub cool_hysteresis: f64,
#[serde(default = "default_hysteresis")]
pub heat_hysteresis: f64,
#[serde(default = "default_min_cycle")]
pub min_on_seconds: u64,
#[serde(default = "default_min_cycle")]
@@ -153,6 +160,17 @@ pub struct Zone {
pub updated_at: DateTime<Utc>,
}
impl Zone {
pub fn hysteresis_for_mode(&self, mode: &str) -> f64 {
let value = if self.separate_hysteresis {
if mode == "heat" { self.heat_hysteresis } else { self.cool_hysteresis }
} else {
self.hysteresis
};
value.max(0.1)
}
}
fn default_sensor_source() -> String { "device".into() }