1759 lines
64 KiB
Rust
1759 lines
64 KiB
Rust
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use chrono::TimeZone;
|
|
|
|
#[test]
|
|
fn heat_coasting_modulates_setpoint_without_power_cycle() {
|
|
assert!(!heat_coasting_next_state(false, "heat", 19.8, 20.8, 0.9));
|
|
assert!(heat_coasting_next_state(false, "heat", 19.9, 20.8, 0.9));
|
|
assert!(heat_coasting_next_state(true, "heat", 19.6, 20.8, 0.9));
|
|
assert!(!heat_coasting_next_state(true, "heat", 19.5, 20.8, 0.9));
|
|
assert!(!heat_coasting_next_state(true, "heat", 21.0, 20.8, 0.0));
|
|
assert!(!heat_coasting_next_state(true, "cool", 21.0, 20.8, 0.9));
|
|
assert_eq!(round_device_setpoint("heat", false, 20.8 - 5.0), 16.0);
|
|
}
|
|
|
|
#[test]
|
|
fn thermostat_target_normalization_preserves_tenths() {
|
|
assert!((normalize_thermostat_target(24.2) - 24.2).abs() < 1e-9);
|
|
assert!((normalize_thermostat_target(24.24) - 24.2).abs() < 1e-9);
|
|
assert!((normalize_thermostat_target(24.25) - 24.3).abs() < 1e-9);
|
|
assert!((normalize_thermostat_target(24.5) - 24.5).abs() < 1e-9);
|
|
}
|
|
|
|
#[test]
|
|
fn global_ha_sensor_stale_timeout_is_used_for_default_zone_value() {
|
|
assert_eq!(effective_sensor_stale_after_seconds(300, 600), 600);
|
|
assert_eq!(effective_sensor_stale_after_seconds(0, 900), 900);
|
|
assert_eq!(effective_sensor_stale_after_seconds(120, 600), 120);
|
|
assert_eq!(effective_sensor_stale_after_seconds(120_000, 600), 86_400);
|
|
}
|
|
|
|
#[test]
|
|
fn ha_sensor_log_message_identifies_sensor_and_zone() {
|
|
let message = home_assistant_sensor_log_message(
|
|
"Home Assistant sensor is stale: 3607s old (limit 3600s)",
|
|
Some("sensor.igor_temperature"),
|
|
"Igor",
|
|
);
|
|
assert_eq!(
|
|
message,
|
|
"Home Assistant sensor sensor.igor_temperature, zone Igor is stale: 3607s old (limit 3600s)"
|
|
);
|
|
|
|
let message = home_assistant_sensor_log_message(
|
|
"Home Assistant state is not a number",
|
|
Some("sensor.jan_temperature"),
|
|
"Jan",
|
|
);
|
|
assert_eq!(
|
|
message,
|
|
"Home Assistant state is not a number [sensor: sensor.jan_temperature, zone Jan]"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn control_plan_semantic_equality_ignores_only_generation_timestamp() {
|
|
let generated_at = Utc.with_ymd_and_hms(2026, 9, 4, 8, 0, 0).unwrap();
|
|
let left = ControlPlan {
|
|
generated_at: generated_at.clone(),
|
|
house_mode: "cool".into(),
|
|
emergency_stop_active: false,
|
|
emergency_stop_since: None,
|
|
house_preset: Some("comfort".into()),
|
|
house_power: true,
|
|
outdoor_temperature: Some(24.0),
|
|
control_strategy: "thermostat".into(),
|
|
night_mode_active: false,
|
|
night_mode_start: "22:00".into(),
|
|
night_mode_end: "06:00".into(),
|
|
night_mode_max_fan_speed: 2,
|
|
next_events: Vec::new(),
|
|
zones: Vec::new(),
|
|
rules: Vec::new(),
|
|
};
|
|
let mut right = left.clone();
|
|
right.generated_at = generated_at + chrono::Duration::seconds(30);
|
|
assert!(control_plans_semantically_equal(&left, &right).unwrap());
|
|
right.house_mode = "heat".into();
|
|
assert!(!control_plans_semantically_equal(&left, &right).unwrap());
|
|
right = left.clone();
|
|
right.emergency_stop_active = true;
|
|
assert!(!control_plans_semantically_equal(&left, &right).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn overnight_schedule_works() {
|
|
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(),
|
|
preset: "custom".into(),
|
|
setpoint: 20.0,
|
|
created_at: Utc::now(),
|
|
updated_at: Utc::now(),
|
|
flow_id: None,
|
|
flow_node_id: None,
|
|
};
|
|
assert!(schedule_active(&item, now));
|
|
}
|
|
|
|
fn test_schedule(id: &str, weekdays: Vec<u32>, start: &str, end: &str) -> Schedule {
|
|
Schedule {
|
|
id: id.into(),
|
|
zone_id: "z".into(),
|
|
name: id.into(),
|
|
enabled: true,
|
|
weekdays,
|
|
start_time: start.into(),
|
|
end_time: end.into(),
|
|
preset: "comfort".into(),
|
|
setpoint: 21.0,
|
|
created_at: Utc::now(),
|
|
updated_at: Utc::now(),
|
|
flow_id: None,
|
|
flow_node_id: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn equal_schedule_times_mean_a_full_day() {
|
|
let item = test_schedule("full", vec![1], "06:00", "06:00");
|
|
let monday_noon = Local
|
|
.with_ymd_and_hms(2025, 1, 6, 12, 0, 0)
|
|
.single()
|
|
.unwrap();
|
|
let tuesday_early = Local
|
|
.with_ymd_and_hms(2025, 1, 7, 5, 59, 0)
|
|
.single()
|
|
.unwrap();
|
|
let tuesday_after = Local
|
|
.with_ymd_and_hms(2025, 1, 7, 6, 1, 0)
|
|
.single()
|
|
.unwrap();
|
|
assert!(schedule_active(&item, monday_noon));
|
|
assert!(schedule_active(&item, tuesday_early));
|
|
assert!(!schedule_active(&item, tuesday_after));
|
|
}
|
|
|
|
#[test]
|
|
fn schedule_overlap_detection_handles_overnight_ranges() {
|
|
let daytime = test_schedule("day", vec![1, 2, 3, 4, 5, 6, 7], "06:30", "22:30");
|
|
let night = test_schedule("night", vec![1, 2, 3, 4, 5, 6, 7], "22:30", "06:30");
|
|
let conflict = test_schedule("conflict", vec![1], "22:00", "23:00");
|
|
assert!(!schedules_overlap(&daytime, &night));
|
|
assert!(schedules_overlap(&night, &conflict));
|
|
}
|
|
|
|
#[test]
|
|
fn next_schedule_boundary_scans_the_whole_week() {
|
|
let friday = test_schedule("friday", vec![5], "12:00", "13:00");
|
|
let monday = Local
|
|
.with_ymd_and_hms(2025, 1, 6, 10, 0, 30)
|
|
.single()
|
|
.unwrap();
|
|
let boundary = next_schedule_boundary_utc("z", &[friday], monday)
|
|
.unwrap()
|
|
.with_timezone(&Local);
|
|
assert_eq!(boundary.weekday(), Weekday::Fri);
|
|
assert_eq!(boundary.hour(), 12);
|
|
assert_eq!(boundary.minute(), 0);
|
|
assert_eq!(boundary.second(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn full_week_schedule_has_no_manual_override_boundary() {
|
|
let always = test_schedule("always", vec![1, 2, 3, 4, 5, 6, 7], "00:00", "00:00");
|
|
let now = Local
|
|
.with_ymd_and_hms(2025, 1, 6, 10, 0, 30)
|
|
.single()
|
|
.unwrap();
|
|
assert!(next_schedule_boundary_utc("z", &[always], now).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn workday_weekend_handoff_has_no_overlaps() {
|
|
let schedules = vec![
|
|
test_schedule("morning", vec![1, 2, 3, 4, 5], "06:30", "08:00"),
|
|
test_schedule("away", vec![1, 2, 3, 4, 5], "08:00", "16:00"),
|
|
test_schedule("evening", vec![1, 2, 3, 4, 5], "16:00", "22:30"),
|
|
test_schedule("sleep", vec![1, 2, 3, 4, 5], "22:30", "06:30"),
|
|
test_schedule("weekend", vec![6, 7], "08:00", "23:00"),
|
|
test_schedule("saturday-sleep", vec![6], "23:00", "08:00"),
|
|
test_schedule("sunday-sleep", vec![7], "23:00", "06:30"),
|
|
];
|
|
for (index, item) in schedules.iter().enumerate() {
|
|
for other in schedules.iter().skip(index + 1) {
|
|
assert!(
|
|
!schedules_overlap(item, other),
|
|
"{} overlaps {}",
|
|
item.name,
|
|
other.name
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn flow_cron_validation_and_matching_cover_ranges_steps_and_sunday_alias() {
|
|
assert!(cron_expression_valid("*/5 * * * *"));
|
|
assert!(cron_expression_valid("0,15,30,45 6-18 * * 1-5"));
|
|
for invalid in [
|
|
"* * * *",
|
|
"*/0 * * * *",
|
|
"61 * * * *",
|
|
"* 24 * * *",
|
|
"* * 0 * *",
|
|
"* * * 13 *",
|
|
"* * * * 8",
|
|
"*/100 * * * *",
|
|
] {
|
|
assert!(
|
|
!cron_expression_valid(invalid),
|
|
"accepted invalid cron: {invalid}"
|
|
);
|
|
}
|
|
let monday = Local
|
|
.with_ymd_and_hms(2026, 9, 7, 10, 15, 0)
|
|
.single()
|
|
.unwrap();
|
|
assert!(cron_matches("15 10 * * 1", &monday));
|
|
assert!(cron_matches("15 10 * * 1-7", &monday));
|
|
assert!(!cron_matches("16 10 * * 1", &monday));
|
|
let sunday = Local
|
|
.with_ymd_and_hms(2026, 9, 6, 8, 0, 0)
|
|
.single()
|
|
.unwrap();
|
|
assert!(cron_matches("0 8 * * 0", &sunday));
|
|
assert!(cron_matches("0 8 * * 7", &sunday));
|
|
assert!(cron_matches("0 8 * * 1-7", &sunday));
|
|
}
|
|
|
|
#[test]
|
|
fn flow_zone_action_noop_detects_persisted_on_intent_and_real_off_state() {
|
|
let mut zone = test_zone("device");
|
|
zone.inherit_house_mode = false;
|
|
zone.mode = "heat".into();
|
|
zone.manual_preset = Some("custom".into());
|
|
zone.manual_setpoint = Some(20.0);
|
|
zone.effective_mode = "heat".into();
|
|
let on = DeviceCommand {
|
|
power: Some(true),
|
|
mode: Some("heat".into()),
|
|
target_temperature: Some(20.0),
|
|
..Default::default()
|
|
};
|
|
assert!(flow_zone_action_already_satisfied(
|
|
&zone, false, 0, 0, Some("custom"), &on, None, None
|
|
));
|
|
assert!(!flow_zone_action_already_satisfied(
|
|
&zone, false, 0, 0, Some("custom"), &on, Some(1.1), Some(5.0)
|
|
));
|
|
zone.heat_anticipation_c = 1.1;
|
|
zone.standby_offset_c = 5.0;
|
|
assert!(flow_zone_action_already_satisfied(
|
|
&zone, false, 0, 0, Some("custom"), &on, Some(1.1), Some(5.0)
|
|
));
|
|
|
|
zone.manual_preset = None;
|
|
zone.manual_setpoint = None;
|
|
zone.manual_override_until = None;
|
|
zone.effective_mode = "off".into();
|
|
zone.device_setpoint = None;
|
|
zone.demand = false;
|
|
let off = DeviceCommand {
|
|
power: Some(false),
|
|
mode: Some("heat".into()),
|
|
..Default::default()
|
|
};
|
|
assert!(flow_zone_action_already_satisfied(
|
|
&zone, false, 0, 0, Some("auto"), &off, None, None
|
|
));
|
|
assert!(!flow_zone_action_already_satisfied(
|
|
&zone, true, 0, 0, Some("auto"), &off, None, None
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn flow_zone_action_noop_keeps_auxiliary_swing_changes_executable() {
|
|
let mut zone = test_zone("device");
|
|
zone.inherit_house_mode = false;
|
|
zone.mode = "heat".into();
|
|
zone.manual_preset = Some("custom".into());
|
|
zone.manual_setpoint = Some(20.0);
|
|
let action = DeviceCommand {
|
|
power: Some(true),
|
|
mode: Some("heat".into()),
|
|
target_temperature: Some(20.0),
|
|
swing_vertical: Some(1),
|
|
..Default::default()
|
|
};
|
|
assert!(!flow_zone_action_already_satisfied(
|
|
&zone, true, 0, 0, Some("custom"), &action, None, None
|
|
));
|
|
assert!(flow_zone_action_already_satisfied(
|
|
&zone, true, 1, 0, Some("custom"), &action, None, None
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn flow_off_normalizes_concrete_hvac_mode_without_mapping_auto_to_device_auto() {
|
|
let heat = DeviceCommand {
|
|
power: Some(false),
|
|
mode: Some("heat".into()),
|
|
..Default::default()
|
|
};
|
|
let cool = DeviceCommand {
|
|
power: Some(false),
|
|
mode: Some("cool".into()),
|
|
..Default::default()
|
|
};
|
|
let auto = DeviceCommand {
|
|
power: Some(false),
|
|
mode: Some("auto".into()),
|
|
..Default::default()
|
|
};
|
|
assert_eq!(flow_off_physical_mode(&heat).as_deref(), Some("heat"));
|
|
assert_eq!(flow_off_physical_mode(&cool).as_deref(), Some("cool"));
|
|
assert_eq!(flow_off_physical_mode(&auto), None);
|
|
}
|
|
|
|
#[test]
|
|
fn flow_owned_thermostat_does_not_receive_global_outdoor_assist() {
|
|
assert_eq!(
|
|
thermostat_outdoor_assist_temperature(true, "automation.flow", Some(-5.0)),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
thermostat_outdoor_assist_temperature(true, "schedule", Some(-5.0)),
|
|
Some(-5.0)
|
|
);
|
|
assert_eq!(
|
|
thermostat_outdoor_assist_temperature(false, "schedule", Some(-5.0)),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn flow_timed_gate_requires_continuity_and_resets_on_false() {
|
|
let mut state = crate::models::FlowRuntimeNodeState::default();
|
|
let start = Utc.with_ymd_and_hms(2026, 9, 2, 12, 0, 0).unwrap();
|
|
assert!(!flow_timed_gate_update(&mut state, true, 30, start));
|
|
assert!(!flow_timed_gate_update(
|
|
&mut state,
|
|
true,
|
|
30,
|
|
start + chrono::Duration::seconds(29)
|
|
));
|
|
assert!(flow_timed_gate_update(
|
|
&mut state,
|
|
true,
|
|
30,
|
|
start + chrono::Duration::seconds(30)
|
|
));
|
|
assert!(!flow_timed_gate_update(
|
|
&mut state,
|
|
false,
|
|
30,
|
|
start + chrono::Duration::seconds(31)
|
|
));
|
|
assert!(state.since.is_none());
|
|
assert!(!flow_timed_gate_update(
|
|
&mut state,
|
|
true,
|
|
30,
|
|
start + chrono::Duration::seconds(32)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn flow_state_duration_respects_minimum_maximum_and_reset() {
|
|
let mut state = crate::models::FlowRuntimeNodeState::default();
|
|
let start = Utc.with_ymd_and_hms(2026, 9, 2, 12, 0, 0).unwrap();
|
|
assert_eq!(
|
|
flow_state_duration_update(&mut state, true, 10, Some(30), start),
|
|
(false, 0)
|
|
);
|
|
assert_eq!(
|
|
flow_state_duration_update(
|
|
&mut state,
|
|
true,
|
|
10,
|
|
Some(30),
|
|
start + chrono::Duration::seconds(10)
|
|
),
|
|
(true, 10)
|
|
);
|
|
assert_eq!(
|
|
flow_state_duration_update(
|
|
&mut state,
|
|
true,
|
|
10,
|
|
Some(30),
|
|
start + chrono::Duration::seconds(30)
|
|
),
|
|
(true, 30)
|
|
);
|
|
assert_eq!(
|
|
flow_state_duration_update(
|
|
&mut state,
|
|
true,
|
|
10,
|
|
Some(30),
|
|
start + chrono::Duration::seconds(31)
|
|
),
|
|
(false, 31)
|
|
);
|
|
assert_eq!(
|
|
flow_state_duration_update(
|
|
&mut state,
|
|
false,
|
|
10,
|
|
Some(30),
|
|
start + chrono::Duration::seconds(32)
|
|
),
|
|
(false, 0)
|
|
);
|
|
assert!(state.since.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn flow_change_gate_latches_until_action_ack_or_input_reset() {
|
|
let mut state = crate::models::FlowRuntimeNodeState::default();
|
|
assert_eq!(flow_change_gate_update(&mut state, json!(false), false), (false, false));
|
|
assert_eq!(flow_change_gate_update(&mut state, json!(true), true), (true, true));
|
|
assert_eq!(flow_change_gate_update(&mut state, json!(true), true), (false, true));
|
|
state.pending = false;
|
|
assert_eq!(flow_change_gate_update(&mut state, json!(true), true), (false, false));
|
|
state.pending = true;
|
|
assert_eq!(flow_change_gate_update(&mut state, json!(false), false), (true, false));
|
|
}
|
|
|
|
#[test]
|
|
fn flow_rate_limit_prunes_rolling_window_and_blocks_at_limit() {
|
|
let mut state = crate::models::FlowRuntimeNodeState::default();
|
|
let start = Utc.with_ymd_and_hms(2026, 9, 2, 12, 0, 0).unwrap();
|
|
state.samples.push(crate::models::FlowRuntimeSample {
|
|
at: start,
|
|
value: 1.0,
|
|
});
|
|
state.samples.push(crate::models::FlowRuntimeSample {
|
|
at: start + chrono::Duration::seconds(10),
|
|
value: 1.0,
|
|
});
|
|
assert_eq!(
|
|
flow_rate_limit_status(&mut state, 2, 60, start + chrono::Duration::seconds(20)),
|
|
(false, 2)
|
|
);
|
|
assert_eq!(
|
|
flow_rate_limit_status(&mut state, 2, 60, start + chrono::Duration::seconds(61)),
|
|
(true, 1)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn flow_rolling_statistics_require_a_full_window_and_prune_samples() {
|
|
let mut state = crate::models::FlowRuntimeNodeState::default();
|
|
let start = Utc.with_ymd_and_hms(2026, 9, 2, 12, 0, 0).unwrap();
|
|
assert_eq!(flow_rolling_stat_update(&mut state, 10.0, 60, "mean", start), None);
|
|
assert_eq!(
|
|
flow_rolling_stat_update(
|
|
&mut state,
|
|
20.0,
|
|
60,
|
|
"mean",
|
|
start + chrono::Duration::seconds(30)
|
|
),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
flow_rolling_stat_update(
|
|
&mut state,
|
|
30.0,
|
|
60,
|
|
"mean",
|
|
start + chrono::Duration::seconds(60)
|
|
),
|
|
Some(20.0)
|
|
);
|
|
assert_eq!(
|
|
flow_rolling_stat_update(
|
|
&mut state,
|
|
40.0,
|
|
60,
|
|
"median",
|
|
start + chrono::Duration::seconds(90)
|
|
),
|
|
Some(30.0)
|
|
);
|
|
assert_eq!(state.samples.len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn flow_oscillation_requires_direction_changes_not_only_large_span() {
|
|
let start = Utc.with_ymd_and_hms(2026, 9, 2, 12, 0, 0).unwrap();
|
|
let bouncing = [20.0, 22.0, 19.0, 23.0, 20.0]
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(|(index, value)| crate::models::FlowRuntimeSample {
|
|
at: start + chrono::Duration::seconds(index as i64 * 10),
|
|
value,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let monotonic = [20.0, 21.0, 22.0, 23.0, 24.0]
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(|(index, value)| crate::models::FlowRuntimeSample {
|
|
at: start + chrono::Duration::seconds(index as i64 * 10),
|
|
value,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let (span, changes) = flow_oscillation_metrics(&bouncing).unwrap();
|
|
assert!(span >= 4.0);
|
|
assert!(changes >= 2);
|
|
let (_, monotonic_changes) = flow_oscillation_metrics(&monotonic).unwrap();
|
|
assert_eq!(monotonic_changes, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn heating_curve_interpolates_and_clamps_to_endpoints() {
|
|
let config = json!({
|
|
"points": [
|
|
{"x": -20.0, "y": 30.0},
|
|
{"x": 0.0, "y": 25.0},
|
|
{"x": 20.0, "y": 19.0}
|
|
]
|
|
});
|
|
|
|
assert_eq!(flow_heating_curve_output(&config, -30.0), Some(30.0));
|
|
assert_eq!(flow_heating_curve_output(&config, 30.0), Some(19.0));
|
|
assert_eq!(flow_heating_curve_output(&config, -10.0), Some(27.5));
|
|
assert_eq!(flow_heating_curve_output(&config, 10.0), Some(22.0));
|
|
}
|
|
|
|
#[test]
|
|
fn time_automation_fires_only_once_in_the_same_minute() {
|
|
let now = Local
|
|
.with_ymd_and_hms(2025, 1, 6, 10, 15, 40)
|
|
.single()
|
|
.unwrap();
|
|
let mut item = Automation {
|
|
id: "a".into(),
|
|
name: "at time".into(),
|
|
enabled: true,
|
|
trigger_kind: "time".into(),
|
|
trigger_device_id: None,
|
|
threshold: None,
|
|
at_time: Some("10:15".into()),
|
|
action_device_id: "d".into(),
|
|
action_group_id: None,
|
|
action_preset: None,
|
|
action: DeviceCommand {
|
|
power: Some(true),
|
|
..Default::default()
|
|
},
|
|
cooldown_seconds: 30,
|
|
last_fired_at: None,
|
|
last_trigger_trace: vec![],
|
|
action_zone_id: None,
|
|
action_zone_preset: None,
|
|
action_zone_heat_anticipation_c: None,
|
|
action_zone_standby_offset_c: None,
|
|
action_ha_domain: None,
|
|
action_ha_service: None,
|
|
action_ha_entity_id: None,
|
|
action_ha_data: Value::Null,
|
|
flow_value_source_id: None,
|
|
flow_value_target: None,
|
|
flow_conditions: vec![],
|
|
flow_id: None,
|
|
flow_node_id: None,
|
|
flow_runtime: Default::default(),
|
|
created_at: Utc::now(),
|
|
updated_at: Utc::now(),
|
|
};
|
|
assert!(time_automation_due(&item, now.clone()));
|
|
item.last_fired_at =
|
|
Some((now.clone() - chrono::Duration::seconds(35)).with_timezone(&Utc));
|
|
assert!(!time_automation_due(&item, now));
|
|
}
|
|
|
|
fn test_zone(source: &str) -> Zone {
|
|
Zone {
|
|
id: "z".into(),
|
|
name: "Room".into(),
|
|
device_id: "d".into(),
|
|
enabled: true,
|
|
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,
|
|
separate_hysteresis: false,
|
|
cool_hysteresis: 0.6,
|
|
heat_hysteresis: 0.6,
|
|
min_on_seconds: 180,
|
|
min_off_seconds: 180,
|
|
min_adjust_seconds: 120,
|
|
heat_anticipation_c: 0.0,
|
|
heat_coasting: false,
|
|
standby_offset_c: 2.0,
|
|
smart_fan: true,
|
|
sensor_source: source.into(),
|
|
ha_entity_id: Some("sensor.room_temperature".into()),
|
|
ha_outdoor_entity_id: None,
|
|
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(),
|
|
manual_preset: None,
|
|
manual_setpoint: None,
|
|
manual_override_until: None,
|
|
local_thermostat_power: None,
|
|
local_thermostat_resume_at: None,
|
|
local_thermostat_restore_zone_enabled: None,
|
|
temporary_quick_thermostat: None,
|
|
device_manual_override: false,
|
|
device_manual_override_since: None,
|
|
device_manual_override_until: None,
|
|
device_manual_override_fields: Vec::new(),
|
|
device_manual_override_baseline: None,
|
|
revision: 1,
|
|
control_owner: "automation".into(),
|
|
control_source: "automation".into(),
|
|
control_since: Some(Utc::now()),
|
|
control_resume_at: None,
|
|
control_reason: "test".into(),
|
|
last_power_change_at: None,
|
|
last_mode_change_at: None,
|
|
lockout_until: None,
|
|
lockout_reason: None,
|
|
compressor_pending_action: None,
|
|
compressor_pending_since: None,
|
|
compressor_pending_until: None,
|
|
compressor_cancelled_action: None,
|
|
effective_mode: "heat".into(),
|
|
effective_setpoint: Some(21.0),
|
|
device_setpoint: None,
|
|
demand: false,
|
|
demand_since: None,
|
|
target_alerted_at: None,
|
|
last_action_at: None,
|
|
created_at: Utc::now(),
|
|
updated_at: Utc::now(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn regulator_zone_event_dedup_ignores_only_updated_at_heartbeat() {
|
|
let zone = test_zone("device");
|
|
let mut heartbeat = zone.clone();
|
|
heartbeat.updated_at = zone.updated_at.clone() + chrono::Duration::seconds(5);
|
|
assert_eq!(
|
|
zone_event_semantic_value(&zone).unwrap(),
|
|
zone_event_semantic_value(&heartbeat).unwrap()
|
|
);
|
|
|
|
heartbeat.device_temperature = Some(24.5);
|
|
assert_ne!(
|
|
zone_event_semantic_value(&zone).unwrap(),
|
|
zone_event_semantic_value(&heartbeat).unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn regulator_zone_plan_invalidation_tracks_only_plan_inputs() {
|
|
let zone = test_zone("combined");
|
|
let mut diagnostics_only = zone.clone();
|
|
diagnostics_only.device_temperature = Some(24.0);
|
|
diagnostics_only.external_temperature = Some(23.0);
|
|
diagnostics_only.last_action_at = Some(Utc::now());
|
|
assert!(!zone_runtime_change_affects_control_plan(
|
|
&zone,
|
|
&diagnostics_only
|
|
));
|
|
|
|
let mut temperature_changed = zone.clone();
|
|
temperature_changed.current_temperature = Some(23.5);
|
|
assert!(zone_runtime_change_affects_control_plan(
|
|
&zone,
|
|
&temperature_changed
|
|
));
|
|
|
|
let mut demand_changed = zone.clone();
|
|
demand_changed.demand = !zone.demand;
|
|
assert!(zone_runtime_change_affects_control_plan(
|
|
&zone,
|
|
&demand_changed
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn device_poll_heartbeat_does_not_invalidate_control_plan() {
|
|
let device = Device::simulated_default();
|
|
let mut heartbeat = device.clone();
|
|
heartbeat.last_seen = Some(Utc::now() + chrono::Duration::seconds(15));
|
|
heartbeat.updated_at = Utc::now() + chrono::Duration::seconds(15);
|
|
heartbeat.response_time_ms = Some(42);
|
|
heartbeat.current_temperature = Some(25.1);
|
|
assert!(!device_runtime_change_affects_control_plan(
|
|
&device, &heartbeat
|
|
));
|
|
|
|
let mut power_changed = device.clone();
|
|
power_changed.power = !device.power;
|
|
assert!(device_runtime_change_affects_control_plan(
|
|
&device,
|
|
&power_changed
|
|
));
|
|
|
|
let mut health_changed = device.clone();
|
|
health_changed.communication_failures = 1;
|
|
assert!(device_runtime_change_affects_control_plan(
|
|
&device,
|
|
&health_changed
|
|
));
|
|
}
|
|
|
|
#[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");
|
|
let before = Device::simulated_default();
|
|
let mut after = before.clone();
|
|
after.power = !before.power;
|
|
after.target_temperature = before.target_temperature + 1.0;
|
|
after.fan_speed = 3;
|
|
let fields = externally_changed_control_fields(&before, &after, &zone);
|
|
assert!(fields.iter().any(|field| field == "power"));
|
|
assert!(fields.iter().any(|field| field == "target_temperature"));
|
|
assert!(fields.iter().any(|field| field == "fan_speed"));
|
|
}
|
|
|
|
#[test]
|
|
fn controller_expected_climate_change_is_recognized() {
|
|
let mut device = Device::simulated_default();
|
|
device.power = true;
|
|
device.mode = "cool".into();
|
|
device.target_temperature = 22.0;
|
|
let command = DeviceCommand {
|
|
power: Some(true),
|
|
mode: Some("cool".into()),
|
|
target_temperature: Some(22.0),
|
|
..Default::default()
|
|
};
|
|
assert!(command_field_matches_device(&command, "power", &device));
|
|
assert!(command_field_matches_device(&command, "mode", &device));
|
|
assert!(command_field_matches_device(
|
|
&command,
|
|
"target_temperature",
|
|
&device
|
|
));
|
|
device.target_temperature = 25.0;
|
|
assert!(!command_field_matches_device(
|
|
&command,
|
|
"target_temperature",
|
|
&device
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn conflicting_thermostat_output_fields_are_detected_for_direct_automation() {
|
|
assert!(!automation_action_conflicts_with_thermostat(
|
|
&DeviceCommand {
|
|
power: Some(true),
|
|
..Default::default()
|
|
}
|
|
));
|
|
assert!(!automation_action_conflicts_with_thermostat(
|
|
&DeviceCommand {
|
|
target_temperature: Some(22.0),
|
|
..Default::default()
|
|
}
|
|
));
|
|
assert!(automation_action_conflicts_with_thermostat(
|
|
&DeviceCommand {
|
|
fan_speed: Some(3),
|
|
..Default::default()
|
|
}
|
|
));
|
|
assert!(automation_action_conflicts_with_thermostat(
|
|
&DeviceCommand {
|
|
quiet: Some(true),
|
|
..Default::default()
|
|
}
|
|
));
|
|
assert!(automation_action_conflicts_with_thermostat(
|
|
&DeviceCommand {
|
|
mode: Some("fan".into()),
|
|
..Default::default()
|
|
}
|
|
));
|
|
assert!(automation_action_conflicts_with_thermostat(
|
|
&DeviceCommand {
|
|
mode: Some("dry".into()),
|
|
..Default::default()
|
|
}
|
|
));
|
|
assert!(!automation_action_conflicts_with_thermostat(
|
|
&DeviceCommand {
|
|
mode: Some("heat".into()),
|
|
..Default::default()
|
|
}
|
|
));
|
|
assert!(!automation_action_conflicts_with_thermostat(
|
|
&DeviceCommand {
|
|
light: Some(false),
|
|
turbo: Some(true),
|
|
..Default::default()
|
|
}
|
|
));
|
|
assert!(!automation_action_conflicts_with_thermostat(
|
|
&DeviceCommand {
|
|
swing_vertical: Some(1),
|
|
swing_horizontal: Some(0),
|
|
..Default::default()
|
|
}
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn local_thermostat_ownership_blocks_direct_automation() {
|
|
let mut zone = test_zone("device");
|
|
assert!(!device_blocked_by_local_thermostat(
|
|
&zone.device_id,
|
|
std::slice::from_ref(&zone),
|
|
));
|
|
zone.local_thermostat_power = Some(true);
|
|
assert!(device_blocked_by_local_thermostat(
|
|
&zone.device_id,
|
|
std::slice::from_ref(&zone),
|
|
));
|
|
zone.local_thermostat_power = Some(false);
|
|
assert!(device_blocked_by_local_thermostat(
|
|
&zone.device_id,
|
|
std::slice::from_ref(&zone),
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn active_temporary_thermostat_blocks_direct_automation() {
|
|
let now = Utc::now();
|
|
let mut zone = test_zone("device");
|
|
zone.temporary_quick_thermostat = Some(temporary_session(now));
|
|
assert!(device_blocked_by_temporary_thermostat(
|
|
&zone.device_id,
|
|
std::slice::from_ref(&zone)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn local_thermostat_resume_clears_only_local_quick_control_state() {
|
|
let mut zone = test_zone("device");
|
|
zone.local_thermostat_power = Some(false);
|
|
zone.local_thermostat_resume_at = Some(Utc::now() + chrono::Duration::minutes(15));
|
|
zone.manual_preset = Some("comfort".into());
|
|
zone.manual_setpoint = Some(23.0);
|
|
zone.manual_override_until = Some(Utc::now() + chrono::Duration::hours(1));
|
|
zone.device_manual_override = true;
|
|
|
|
assert!(reset_local_thermostat_override(&mut zone));
|
|
assert!(zone.local_thermostat_power.is_none());
|
|
assert!(zone.local_thermostat_resume_at.is_none());
|
|
assert!(zone.manual_preset.is_none());
|
|
assert!(zone.manual_setpoint.is_none());
|
|
assert!(zone.manual_override_until.is_none());
|
|
assert!(zone.device_manual_override);
|
|
assert_eq!(LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES, 15);
|
|
}
|
|
|
|
fn temporary_session(now: DateTime<Utc>) -> TemporaryQuickThermostat {
|
|
TemporaryQuickThermostat {
|
|
start_kind: "now".into(),
|
|
finish_kind: "temperature_stable".into(),
|
|
started_at: now.clone(),
|
|
activated_at: Some(now),
|
|
state: "active".into(),
|
|
active_mode: Some("heat".into()),
|
|
restore_zone_enabled: Some(true),
|
|
restore_local_thermostat_power: None,
|
|
restore_local_thermostat_resume_at: None,
|
|
restore_local_thermostat_zone_enabled: None,
|
|
restore_manual_preset: None,
|
|
restore_manual_setpoint: None,
|
|
restore_manual_override_until: None,
|
|
expires_at: None,
|
|
duration_seconds: None,
|
|
safety_duration_seconds: None,
|
|
temperature_target: Some(23.0),
|
|
temperature_operator: Some("within".into()),
|
|
tolerance_c: 0.3,
|
|
hold_seconds: 3600,
|
|
condition_started_at: None,
|
|
condition_last_observed_at: None,
|
|
paused_at: None,
|
|
deferred_mode: None,
|
|
deferred_preset: None,
|
|
deferred_setpoint: None,
|
|
safety_expires_at: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn scheduled_temporary_session_is_not_activated_by_unrelated_local_quick_on() {
|
|
let now = Utc::now();
|
|
let mut zone = test_zone("device");
|
|
zone.local_thermostat_power = Some(true);
|
|
let mut session = temporary_session(now + chrono::Duration::hours(1));
|
|
session.start_kind = "delay".into();
|
|
session.activated_at = None;
|
|
session.state = "scheduled".into();
|
|
zone.temporary_quick_thermostat = Some(session);
|
|
|
|
assert!(!temporary_quick_thermostat_is_active(&zone, now));
|
|
assert_eq!(
|
|
temporary_quick_thermostat_wakeup_at(&zone, now),
|
|
Some(now + chrono::Duration::hours(1))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn local_handback_cleanup_does_not_remove_pending_temporary_session() {
|
|
let now = Utc::now();
|
|
let mut zone = test_zone("device");
|
|
set_local_thermostat_power(&mut zone, false, now);
|
|
let mut session = temporary_session(now + chrono::Duration::minutes(30));
|
|
session.start_kind = "delay".into();
|
|
session.activated_at = None;
|
|
session.state = "scheduled".into();
|
|
zone.temporary_quick_thermostat = Some(session);
|
|
|
|
reset_local_thermostat_override(&mut zone);
|
|
assert!(zone.temporary_quick_thermostat.is_some());
|
|
assert!(zone.local_thermostat_power.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn temporary_quick_thermostat_restores_previous_zone_enabled_state() {
|
|
let now = Utc::now();
|
|
let mut zone = test_zone("device");
|
|
zone.enabled = true;
|
|
zone.local_thermostat_power = Some(true);
|
|
let mut session = temporary_session(now);
|
|
session.restore_zone_enabled = Some(false);
|
|
zone.temporary_quick_thermostat = Some(session);
|
|
zone.manual_setpoint = Some(23.0);
|
|
|
|
assert!(finish_temporary_quick_thermostat(&mut zone, &[], "heat"));
|
|
assert!(!zone.enabled);
|
|
assert!(zone.local_thermostat_power.is_none());
|
|
assert!(zone.temporary_quick_thermostat.is_none());
|
|
assert!(zone.manual_setpoint.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn temporary_quick_thermostat_returns_to_underlying_local_quick_state() {
|
|
let now = Utc::now();
|
|
let mut zone = test_zone("device");
|
|
zone.enabled = true;
|
|
zone.local_thermostat_power = Some(true);
|
|
zone.local_thermostat_restore_zone_enabled = None;
|
|
zone.manual_setpoint = Some(24.0);
|
|
let mut session = temporary_session(now);
|
|
session.restore_zone_enabled = Some(true);
|
|
session.restore_local_thermostat_power = Some(true);
|
|
session.restore_local_thermostat_zone_enabled = Some(false);
|
|
session.restore_manual_setpoint = Some(22.0);
|
|
zone.temporary_quick_thermostat = Some(session);
|
|
zone.manual_setpoint = Some(23.0);
|
|
|
|
assert!(finish_temporary_quick_thermostat(&mut zone, &[], "heat"));
|
|
assert!(zone.enabled);
|
|
assert_eq!(zone.local_thermostat_power, Some(true));
|
|
assert_eq!(zone.local_thermostat_restore_zone_enabled, Some(false));
|
|
assert_eq!(zone.manual_setpoint, Some(22.0));
|
|
}
|
|
|
|
#[test]
|
|
fn deferred_non_custom_preset_wins_over_stale_custom_setpoint() {
|
|
let now = Utc::now();
|
|
let mut zone = test_zone("device");
|
|
let mut session = temporary_session(now);
|
|
session.deferred_preset = Some("comfort".into());
|
|
session.deferred_setpoint = Some(27.0);
|
|
zone.temporary_quick_thermostat = Some(session);
|
|
|
|
assert!(finish_temporary_quick_thermostat(&mut zone, &[], "cool"));
|
|
assert_eq!(zone.manual_preset.as_deref(), Some("comfort"));
|
|
assert!(zone.manual_setpoint.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn temporary_session_without_activation_marker_is_never_active() {
|
|
let now = Utc::now();
|
|
let mut zone = test_zone("device");
|
|
zone.local_thermostat_power = Some(true);
|
|
let mut session = temporary_session(now);
|
|
session.activated_at = None;
|
|
session.state = "scheduled".into();
|
|
zone.temporary_quick_thermostat = Some(session);
|
|
|
|
assert!(!temporary_quick_thermostat_is_active(&zone, now));
|
|
}
|
|
|
|
#[test]
|
|
fn temporary_stable_condition_requires_continuous_hold_time() {
|
|
let now = Utc::now();
|
|
let mut zone = test_zone("device");
|
|
zone.current_temperature = Some(23.2);
|
|
let mut session = temporary_session(now.clone());
|
|
session.condition_started_at = Some(now.clone() - chrono::Duration::seconds(3599));
|
|
zone.temporary_quick_thermostat = Some(session);
|
|
|
|
assert!(evaluate_temporary_quick_thermostat_condition(
|
|
&mut zone,
|
|
now.clone(),
|
|
Some(now.clone()),
|
|
10
|
|
)
|
|
.is_none());
|
|
assert_eq!(
|
|
evaluate_temporary_quick_thermostat_condition(
|
|
&mut zone,
|
|
now + chrono::Duration::seconds(2),
|
|
Some(now + chrono::Duration::seconds(2)),
|
|
10
|
|
),
|
|
Some("temperature_stable".into())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn temporary_stable_condition_resets_when_temperature_leaves_range() {
|
|
let now = Utc::now();
|
|
let mut zone = test_zone("device");
|
|
zone.current_temperature = Some(24.0);
|
|
let mut session = temporary_session(now.clone());
|
|
session.condition_started_at = Some(now.clone() - chrono::Duration::minutes(30));
|
|
zone.temporary_quick_thermostat = Some(session);
|
|
|
|
assert!(
|
|
evaluate_temporary_quick_thermostat_condition(&mut zone, now, Some(now), 10).is_none()
|
|
);
|
|
assert!(zone
|
|
.temporary_quick_thermostat
|
|
.as_ref()
|
|
.unwrap()
|
|
.condition_started_at
|
|
.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn temporary_stable_condition_resets_after_observation_gap() {
|
|
let now = Utc::now();
|
|
let mut zone = test_zone("device");
|
|
zone.current_temperature = Some(23.0);
|
|
let mut session = temporary_session(now);
|
|
session.condition_started_at = Some(now - chrono::Duration::hours(1));
|
|
session.condition_last_observed_at = Some(now - chrono::Duration::minutes(10));
|
|
zone.temporary_quick_thermostat = Some(session);
|
|
|
|
assert!(
|
|
evaluate_temporary_quick_thermostat_condition(&mut zone, now, Some(now), 30).is_none()
|
|
);
|
|
assert_eq!(
|
|
zone.temporary_quick_thermostat
|
|
.as_ref()
|
|
.unwrap()
|
|
.condition_started_at,
|
|
Some(now)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn delayed_temporary_session_does_not_block_automation_before_start() {
|
|
let now = Utc::now();
|
|
let mut zone = test_zone("device");
|
|
let mut session = temporary_session(now.clone() + chrono::Duration::hours(1));
|
|
session.start_kind = "delay".into();
|
|
session.activated_at = None;
|
|
session.state = "scheduled".into();
|
|
session.expires_at = Some(now.clone() + chrono::Duration::hours(3));
|
|
zone.temporary_quick_thermostat = Some(session);
|
|
|
|
assert!(!device_blocked_by_local_thermostat(
|
|
&zone.device_id,
|
|
std::slice::from_ref(&zone)
|
|
));
|
|
assert_eq!(
|
|
temporary_quick_thermostat_wakeup_at(&zone, now.clone()),
|
|
Some(now + chrono::Duration::hours(1))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn delayed_temperature_condition_cannot_finish_before_activation() {
|
|
let now = Utc::now();
|
|
let mut zone = test_zone("device");
|
|
zone.current_temperature = Some(23.0);
|
|
let mut session = temporary_session(now.clone() + chrono::Duration::hours(1));
|
|
session.start_kind = "at".into();
|
|
session.activated_at = None;
|
|
zone.temporary_quick_thermostat = Some(session);
|
|
|
|
assert!(
|
|
evaluate_temporary_quick_thermostat_condition(&mut zone, now, Some(now), 10).is_none()
|
|
);
|
|
assert!(zone
|
|
.temporary_quick_thermostat
|
|
.as_ref()
|
|
.unwrap()
|
|
.condition_started_at
|
|
.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn temporary_setpoint_keeps_priority_over_active_schedule() {
|
|
let mut zone = test_zone("device");
|
|
zone.local_thermostat_power = Some(true);
|
|
zone.manual_setpoint = Some(23.0);
|
|
zone.temporary_quick_thermostat = Some(temporary_session(Utc::now()));
|
|
let mut schedule = test_schedule("night", vec![1, 2, 3, 4, 5, 6, 7], "00:00", "00:00");
|
|
schedule.preset = "custom".into();
|
|
schedule.setpoint = 19.0;
|
|
|
|
let (_preset, target) = resolve_zone_target(&zone, Some(&schedule), "cool");
|
|
assert_eq!(target, 23.0);
|
|
}
|
|
|
|
#[test]
|
|
fn local_quick_thermostat_can_run_when_inherited_house_mode_is_off() {
|
|
let mut zone = test_zone("device");
|
|
zone.inherit_house_mode = true;
|
|
zone.mode = "cool".into();
|
|
assert_eq!(effective_zone_mode(&zone, "off"), "off");
|
|
zone.local_thermostat_power = Some(true);
|
|
assert_eq!(effective_zone_mode(&zone, "off"), "cool");
|
|
}
|
|
|
|
#[test]
|
|
fn group_manual_control_can_run_when_house_mode_is_off() {
|
|
let mut zone = test_zone("device");
|
|
zone.inherit_house_mode = true;
|
|
zone.mode = "cool".into();
|
|
zone.control_source = "group:downstairs".into();
|
|
assert_eq!(effective_zone_mode(&zone, "off"), "cool");
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_global_power_flag_does_not_take_ownership() {
|
|
let mut zone = test_zone("device");
|
|
zone.local_thermostat_power = Some(true);
|
|
zone.control_source = "web_thermostat".into();
|
|
refresh_control_ownership(&mut zone);
|
|
assert_eq!(zone.control_owner, "local_thermostat");
|
|
assert_ne!(zone.control_source, "global");
|
|
}
|
|
|
|
#[test]
|
|
fn indefinite_local_off_does_not_create_or_rearm_handback() {
|
|
let mut zone = test_zone("device");
|
|
zone.local_thermostat_power = Some(false);
|
|
zone.local_thermostat_resume_at = None;
|
|
assert!(!local_thermostat_handback_is_active(&zone));
|
|
assert!(!rearm_local_thermostat_resume(&mut zone, Utc::now()));
|
|
assert_eq!(zone.local_thermostat_power, Some(false));
|
|
assert!(zone.local_thermostat_resume_at.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn timed_local_off_remains_blocking_until_explicit_deadline() {
|
|
let mut zone = test_zone("device");
|
|
let now = Utc::now();
|
|
set_local_thermostat_power(&mut zone, false, now);
|
|
assert!(local_thermostat_handback_is_active(&zone));
|
|
assert_eq!(zone.local_thermostat_power, Some(false));
|
|
assert!(zone.local_thermostat_resume_at.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn local_thermostat_off_restarts_backend_handback_deadline() {
|
|
let mut zone = test_zone("device");
|
|
let first = Utc::now();
|
|
set_local_thermostat_power(&mut zone, false, first.clone());
|
|
let first_deadline = zone.local_thermostat_resume_at.clone().unwrap();
|
|
assert_eq!(
|
|
first_deadline,
|
|
first.clone() + chrono::Duration::minutes(15)
|
|
);
|
|
|
|
let second = first + chrono::Duration::minutes(4);
|
|
set_local_thermostat_power(&mut zone, true, second.clone());
|
|
assert!(zone.local_thermostat_resume_at.is_none());
|
|
set_local_thermostat_power(&mut zone, false, second.clone());
|
|
assert_eq!(
|
|
zone.local_thermostat_resume_at,
|
|
Some(second + chrono::Duration::minutes(15))
|
|
);
|
|
assert!(zone.local_thermostat_resume_at.clone().unwrap() > first_deadline);
|
|
}
|
|
|
|
#[test]
|
|
fn manual_device_takeover_suspends_local_handback_until_control_returns() {
|
|
let mut zone = test_zone("device");
|
|
let now = Utc::now();
|
|
set_local_thermostat_power(&mut zone, false, now.clone());
|
|
assert!(local_thermostat_handback_is_active(&zone));
|
|
|
|
zone.device_manual_override = true;
|
|
assert!(!local_thermostat_handback_is_active(&zone));
|
|
|
|
zone.device_manual_override = false;
|
|
let returned = now + chrono::Duration::minutes(7);
|
|
assert!(rearm_local_thermostat_resume(&mut zone, returned.clone()));
|
|
assert_eq!(
|
|
zone.local_thermostat_resume_at,
|
|
Some(returned + chrono::Duration::minutes(15))
|
|
);
|
|
assert!(local_thermostat_handback_is_active(&zone));
|
|
}
|
|
|
|
#[test]
|
|
fn rounded_gree_setpoint_does_not_create_manual_override() {
|
|
let zone = test_zone("device");
|
|
let mut before = Device::simulated_default();
|
|
before.target_temperature = 23.5;
|
|
let mut after = before.clone();
|
|
after.target_temperature = 24.0;
|
|
assert!(externally_changed_control_fields(&before, &after, &zone).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn standby_low_to_auto_fan_drift_is_not_manual_override() {
|
|
let mut zone = test_zone("device");
|
|
zone.smart_fan = true;
|
|
zone.demand = false;
|
|
let mut before = Device::simulated_default();
|
|
before.fan_speed = 1;
|
|
let mut after = before.clone();
|
|
after.fan_speed = 0;
|
|
assert!(externally_changed_control_fields(&before, &after, &zone).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn reset_device_manual_override_clears_takeover_state() {
|
|
let mut zone = test_zone("device");
|
|
let device = Device::simulated_default();
|
|
zone.device_manual_override = true;
|
|
zone.device_manual_override_since = Some(Utc::now());
|
|
zone.device_manual_override_until = Some(Utc::now());
|
|
zone.device_manual_override_fields = vec!["target_temperature".into()];
|
|
zone.device_manual_override_baseline = Some((&device).into());
|
|
assert!(reset_device_manual_override(&mut zone));
|
|
assert!(!zone.device_manual_override);
|
|
assert!(zone.device_manual_override_since.is_none());
|
|
assert!(zone.device_manual_override_until.is_none());
|
|
assert!(zone.device_manual_override_fields.is_empty());
|
|
assert!(zone.device_manual_override_baseline.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn restored_manual_device_state_matches_original_takeover_baseline() {
|
|
let mut zone = test_zone("device");
|
|
let baseline = Device::simulated_default();
|
|
zone.device_manual_override = true;
|
|
zone.device_manual_override_fields = vec!["power".into(), "target_temperature".into()];
|
|
zone.device_manual_override_baseline = Some((&baseline).into());
|
|
|
|
let mut changed = baseline.clone();
|
|
changed.power = !baseline.power;
|
|
changed.target_temperature = baseline.target_temperature + 2.0;
|
|
assert!(!manual_override_matches_baseline(&zone, &changed));
|
|
|
|
let mut returned_off = baseline.clone();
|
|
returned_off.target_temperature = baseline.target_temperature + 3.0;
|
|
assert!(manual_override_matches_baseline(&zone, &returned_off));
|
|
assert!(manual_override_matches_baseline(&zone, &baseline));
|
|
}
|
|
|
|
#[test]
|
|
fn device_command_drops_unchanged_fields() {
|
|
let device = Device::simulated_default();
|
|
let command = DeviceCommand {
|
|
power: Some(false),
|
|
mode: Some("cool".into()),
|
|
target_temperature: Some(23.4),
|
|
fan_speed: Some(3),
|
|
light: Some(false),
|
|
..DeviceCommand::default()
|
|
};
|
|
let changed = command.changed_from(&device);
|
|
assert_eq!(changed.power, None);
|
|
assert_eq!(changed.mode, None);
|
|
assert_eq!(changed.target_temperature, None);
|
|
assert_eq!(changed.fan_speed, Some(3));
|
|
assert_eq!(changed.light, Some(false));
|
|
}
|
|
|
|
#[test]
|
|
fn combined_temperature_prefers_room_sensor_weight() {
|
|
let zone = test_zone("combined");
|
|
let (value, source, discrepancy) = select_zone_temperature(&zone, Some(22.0), Some(20.0));
|
|
assert_eq!(value, Some(21.2));
|
|
assert_eq!(source, "combined");
|
|
assert!(!discrepancy);
|
|
}
|
|
|
|
#[test]
|
|
fn combined_temperature_falls_back_on_large_discrepancy() {
|
|
let zone = test_zone("combined");
|
|
let (value, source, discrepancy) = select_zone_temperature(&zone, Some(21.0), Some(27.0));
|
|
assert_eq!(value, Some(21.0));
|
|
assert_eq!(source, "device_discrepancy_fallback");
|
|
assert!(discrepancy);
|
|
}
|
|
|
|
#[test]
|
|
fn combined_temperature_falls_back_when_external_is_missing() {
|
|
let zone = test_zone("combined");
|
|
let (value, source, discrepancy) = select_zone_temperature(&zone, Some(21.5), None);
|
|
assert_eq!(value, Some(21.5));
|
|
assert_eq!(source, "device_fallback");
|
|
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 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 automatic_thermostat_without_active_intent_stays_idle() {
|
|
let zone = test_zone("device");
|
|
assert!(!zone_has_thermostat_intent_source(&zone, None, Utc::now()));
|
|
assert!(!zone_has_active_thermostat_intent(&zone, None, Utc::now()));
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_thermostat_sources_activate_control_without_schedule() {
|
|
let mut zone = test_zone("device");
|
|
|
|
zone.manual_setpoint = Some(22.0);
|
|
assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now()));
|
|
|
|
zone.manual_setpoint = None;
|
|
zone.manual_preset = Some("sleep".into());
|
|
assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now()));
|
|
|
|
zone.manual_preset = None;
|
|
zone.local_thermostat_power = Some(true);
|
|
assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now()));
|
|
|
|
zone.local_thermostat_power = None;
|
|
zone.control_source = "group:upstairs".into();
|
|
assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now()));
|
|
|
|
zone.control_source = "automation.device".into();
|
|
refresh_control_ownership(&mut zone);
|
|
assert_eq!(zone.control_owner, "automation");
|
|
assert_eq!(zone.control_source, "automation.device");
|
|
assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now()));
|
|
|
|
zone.control_source = "automation.flow".into();
|
|
refresh_control_ownership(&mut zone);
|
|
assert_eq!(zone.control_owner, "automation");
|
|
assert_eq!(zone.control_source, "automation.flow");
|
|
assert!(!zone_has_active_thermostat_intent(&zone, None, Utc::now()));
|
|
}
|
|
|
|
#[test]
|
|
fn local_off_blocks_even_an_active_schedule_until_resume() {
|
|
let mut zone = test_zone("device");
|
|
let schedule = test_schedule("active", vec![1, 2, 3, 4, 5, 6, 7], "00:00", "00:00");
|
|
zone.local_thermostat_power = Some(false);
|
|
assert!(zone_has_thermostat_intent_source(
|
|
&zone,
|
|
Some(&schedule),
|
|
Utc::now()
|
|
));
|
|
assert!(!zone_has_active_thermostat_intent(
|
|
&zone,
|
|
Some(&schedule),
|
|
Utc::now()
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_target_refresh_has_no_implicit_comfort_activation() {
|
|
let mut zone = test_zone("device");
|
|
zone.inherit_house_mode = false;
|
|
zone.mode = "cool".into();
|
|
zone.effective_setpoint = Some(23.0);
|
|
|
|
refresh_zone_runtime_target(&mut zone, &[], "cool");
|
|
|
|
assert_eq!(zone.effective_mode, "off");
|
|
assert_eq!(zone.effective_setpoint, None);
|
|
}
|
|
|
|
#[test]
|
|
fn resuming_local_off_without_schedule_stays_off() {
|
|
let mut zone = test_zone("device");
|
|
zone.inherit_house_mode = false;
|
|
zone.mode = "cool".into();
|
|
zone.demand = true;
|
|
set_local_thermostat_power(&mut zone, false, Utc::now());
|
|
|
|
assert!(reset_local_thermostat_override(&mut zone));
|
|
refresh_control_ownership(&mut zone);
|
|
refresh_zone_runtime_target(&mut zone, &[], "cool");
|
|
|
|
assert!(zone.local_thermostat_power.is_none());
|
|
assert_eq!(zone.control_source, "automation");
|
|
assert_eq!(zone.effective_mode, "off");
|
|
assert_eq!(zone.effective_setpoint, None);
|
|
assert!(!zone.demand);
|
|
assert!(!zone_has_active_thermostat_intent(&zone, None, Utc::now()));
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_target_refresh_applies_manual_profile_immediately() {
|
|
let mut zone = test_zone("device");
|
|
zone.inherit_house_mode = false;
|
|
zone.mode = "cool".into();
|
|
zone.manual_preset = Some("sleep".into());
|
|
zone.active_preset = "comfort".into();
|
|
zone.effective_setpoint = Some(25.0);
|
|
refresh_zone_runtime_target(&mut zone, &[], "cool");
|
|
assert_eq!(zone.active_preset, "sleep");
|
|
assert_eq!(zone.effective_setpoint, Some(24.5));
|
|
assert_eq!(zone.effective_mode, "cool");
|
|
}
|
|
|
|
#[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 external_room_sensor_selects_lower_cooling_setpoint_only_when_used() {
|
|
assert_eq!(external_room_sensor_cooling_assist("cool", "external"), 1.0);
|
|
assert_eq!(external_room_sensor_cooling_assist("cool", "combined"), 1.0);
|
|
assert_eq!(
|
|
external_room_sensor_cooling_assist("cool", "device_fallback"),
|
|
0.0
|
|
);
|
|
assert_eq!(
|
|
external_room_sensor_cooling_assist("cool", "device_discrepancy_fallback"),
|
|
0.0
|
|
);
|
|
assert_eq!(external_room_sensor_cooling_assist("heat", "external"), 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn external_room_sensor_cooling_keeps_full_step_below_half_degree_target() {
|
|
let assist = external_room_sensor_cooling_assist("cool", "external");
|
|
assert_eq!(round_device_setpoint("cool", true, 23.0 - assist), 22.0);
|
|
assert_eq!(round_device_setpoint("cool", true, 23.5 - assist), 22.0);
|
|
assert_eq!(round_device_setpoint("cool", true, 24.0 - assist), 23.0);
|
|
assert_eq!(round_device_setpoint("cool", true, 24.5 - assist), 23.0);
|
|
}
|
|
|
|
#[test]
|
|
fn smart_fan_uses_low_speed_when_zone_is_satisfied() {
|
|
assert_eq!(smart_fan_speed("heat", 21.0, 21.0, None, false), 1);
|
|
assert_eq!(smart_fan_speed("cool", 23.0, 23.0, None, false), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn smart_quiet_follows_satisfied_transition_only_when_supported() {
|
|
assert_eq!(
|
|
smart_quiet_command(true, true, true, false, false, false, false, true),
|
|
Some(true)
|
|
);
|
|
assert_eq!(
|
|
smart_quiet_command(true, true, false, false, false, false, false, true),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
smart_quiet_command(true, true, false, false, true, false, false, true),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
smart_quiet_command(true, true, false, true, true, false, false, true),
|
|
Some(false)
|
|
);
|
|
assert_eq!(
|
|
smart_quiet_command(true, true, true, true, true, false, false, true),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
smart_quiet_command(true, false, true, false, false, false, false, true),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
smart_quiet_command(false, true, true, false, false, false, false, true),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn night_mode_handles_midnight_and_limits_auto_fan() {
|
|
let settings = NightModeSettings {
|
|
enabled: true,
|
|
start_time: "22:00".into(),
|
|
end_time: "06:00".into(),
|
|
max_fan_speed: 1,
|
|
force_quiet: true,
|
|
use_native_sleep: true,
|
|
};
|
|
assert!(night_mode_active(
|
|
&settings,
|
|
NaiveTime::from_hms_opt(23, 30, 0).unwrap()
|
|
));
|
|
assert!(night_mode_active(
|
|
&settings,
|
|
NaiveTime::from_hms_opt(5, 59, 0).unwrap()
|
|
));
|
|
assert!(!night_mode_active(
|
|
&settings,
|
|
NaiveTime::from_hms_opt(12, 0, 0).unwrap()
|
|
));
|
|
assert_eq!(night_limited_fan_speed(0, 1), 1);
|
|
assert_eq!(night_limited_fan_speed(3, 1), 1);
|
|
assert_eq!(
|
|
smart_quiet_command(false, true, true, true, false, true, true, true),
|
|
Some(true)
|
|
);
|
|
assert_eq!(
|
|
smart_quiet_command(false, true, true, true, true, true, false, true),
|
|
Some(false)
|
|
);
|
|
assert_eq!(
|
|
smart_quiet_command(true, true, false, false, true, true, false, true),
|
|
Some(false)
|
|
);
|
|
assert_eq!(
|
|
native_sleep_command(true, true, true, true, false),
|
|
Some(true)
|
|
);
|
|
assert_eq!(
|
|
native_sleep_command(true, false, true, true, true),
|
|
Some(false)
|
|
);
|
|
assert_eq!(
|
|
native_sleep_command(false, false, false, true, true),
|
|
Some(false)
|
|
);
|
|
assert_eq!(native_sleep_command(true, true, true, false, false), None);
|
|
}
|
|
|
|
#[test]
|
|
fn gree_outdoor_fallback_uses_median_of_online_units() {
|
|
let mut a = Device::simulated_default();
|
|
a.outdoor_temperature = Some(10.0);
|
|
let mut b = Device::simulated_default();
|
|
b.id = "sim-b".into();
|
|
b.outdoor_temperature = Some(12.0);
|
|
let mut c = Device::simulated_default();
|
|
c.id = "sim-c".into();
|
|
c.outdoor_temperature = Some(40.0);
|
|
assert_eq!(gree_outdoor_temperature(&[a, b, c], &[]), Some(12.0));
|
|
}
|
|
|
|
#[test]
|
|
fn gree_outdoor_fallback_counts_multisplit_once() {
|
|
let mut a = Device::simulated_default();
|
|
a.id = "a".into();
|
|
a.outdoor_temperature = Some(10.0);
|
|
let mut b = Device::simulated_default();
|
|
b.id = "b".into();
|
|
b.outdoor_temperature = Some(10.0);
|
|
let mut c = Device::simulated_default();
|
|
c.id = "c".into();
|
|
c.outdoor_temperature = Some(30.0);
|
|
let now = Utc::now();
|
|
let group = DeviceGroup {
|
|
id: "multi".into(),
|
|
name: "Multi".into(),
|
|
kind: crate::models::DeviceGroupKind::Multisplit,
|
|
device_ids: vec!["a".into(), "b".into()],
|
|
energy_source: EnergySourcePreference::Auto,
|
|
energy_device_id: None,
|
|
ha_energy_entity_id: None,
|
|
ha_energy_unit: None,
|
|
ha_energy_device_class: None,
|
|
ha_energy_state_class: None,
|
|
outdoor_temperature_device_id: Some("a".into()),
|
|
created_at: now,
|
|
updated_at: now,
|
|
};
|
|
assert_eq!(gree_outdoor_temperature(&[a, b, c], &[group]), Some(20.0));
|
|
}
|
|
|
|
#[test]
|
|
fn global_one_shot_queue_does_not_change_zone_ownership() {
|
|
let mut zone = test_zone("device");
|
|
zone.local_thermostat_power = Some(true);
|
|
zone.control_source = "web_thermostat".into();
|
|
refresh_control_ownership(&mut zone);
|
|
let owner = zone.control_owner.clone();
|
|
let source = zone.control_source.clone();
|
|
let until = Utc::now() + chrono::Duration::seconds(180);
|
|
queue_compressor_action(
|
|
&mut zone,
|
|
"global_power_on".into(),
|
|
until,
|
|
"minimum_off_before_global_start",
|
|
);
|
|
assert_eq!(zone.control_owner, owner);
|
|
assert_eq!(zone.control_source, source);
|
|
assert_eq!(
|
|
zone.compressor_pending_action.as_deref(),
|
|
Some("global_power_on")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn compressor_queue_helpers_track_and_clear_pending_intent() {
|
|
let mut zone = test_zone("device");
|
|
let until = Utc::now() + chrono::Duration::seconds(180);
|
|
let action = compressor_action_id("power_on", "cool", 23.04);
|
|
assert_eq!(action, "power_on:cool:23.0");
|
|
|
|
queue_compressor_action(
|
|
&mut zone,
|
|
action.clone(),
|
|
until.clone(),
|
|
"minimum_off_before_start",
|
|
);
|
|
assert_eq!(
|
|
zone.compressor_pending_action.as_deref(),
|
|
Some(action.as_str())
|
|
);
|
|
assert!(zone.compressor_pending_since.is_some());
|
|
assert_eq!(zone.compressor_pending_until, Some(until.clone()));
|
|
assert_eq!(zone.lockout_until, Some(until));
|
|
assert_eq!(
|
|
zone.lockout_reason.as_deref(),
|
|
Some("minimum_off_before_start")
|
|
);
|
|
|
|
zone.compressor_cancelled_action = Some(action.clone());
|
|
clear_compressor_pending(&mut zone, false);
|
|
assert!(zone.compressor_pending_action.is_none());
|
|
assert_eq!(
|
|
zone.compressor_cancelled_action.as_deref(),
|
|
Some(action.as_str())
|
|
);
|
|
|
|
rearm_compressor_queue(&mut zone);
|
|
assert!(zone.compressor_cancelled_action.is_none());
|
|
assert!(zone.lockout_until.is_none());
|
|
assert!(zone.lockout_reason.is_none());
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
|
|
#[test]
|
|
fn whole_house_off_is_indefinite_until_explicit_reenable() {
|
|
let mut zone = test_zone("device");
|
|
zone.local_thermostat_power = Some(true);
|
|
zone.local_thermostat_resume_at = Some(Utc::now() + chrono::Duration::minutes(15));
|
|
zone.demand = true;
|
|
zone.demand_since = Some(Utc::now());
|
|
|
|
assert!(set_house_bulk_thermostat_power(&mut zone, false));
|
|
assert_eq!(zone.local_thermostat_power, Some(false));
|
|
assert!(zone.local_thermostat_resume_at.is_none());
|
|
assert!(zone.local_thermostat_restore_zone_enabled.is_none());
|
|
assert!(!zone.demand);
|
|
assert!(zone.demand_since.is_none());
|
|
assert_eq!(zone.effective_mode, "off");
|
|
|
|
// A normal cycle must see an indefinite local OFF. Global/local/group ON releases it.
|
|
assert!(set_house_bulk_thermostat_power(&mut zone, true));
|
|
assert!(zone.local_thermostat_power.is_none());
|
|
assert!(zone.local_thermostat_resume_at.is_none());
|
|
assert_eq!(zone.control_source, "house_power");
|
|
assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now()));
|
|
}
|
|
|
|
#[test]
|
|
fn whole_house_on_creates_automatic_intent_without_local_ownership() {
|
|
let mut zone = test_zone("device");
|
|
zone.local_thermostat_power = None;
|
|
assert!(set_house_bulk_thermostat_power(&mut zone, true));
|
|
assert!(zone.local_thermostat_power.is_none());
|
|
assert_eq!(zone.control_source, "house_power");
|
|
refresh_control_ownership(&mut zone);
|
|
assert_eq!(zone.control_owner, "automation");
|
|
assert_eq!(zone.control_source, "house_power");
|
|
assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now()));
|
|
}
|
|
}
|