v0.8.17
This commit is contained in:
+14
-3
@@ -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
@@ -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");
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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() }
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user