This commit is contained in:
Mateusz Gruszczyński
2026-08-25 22:14:14 +02:00
parent 0d65a6a946
commit 9fa0a5399e
16 changed files with 540 additions and 84 deletions
+69 -10
View File
@@ -78,7 +78,7 @@ pub fn router(state: AppState) -> Router {
let home_assistant_api = Router::new()
.route("/api/integrations/home-assistant/devices", get(list_devices))
.route("/api/integrations/home-assistant/devices/:id/command", post(command_device))
.route("/api/integrations/home-assistant/devices/:id/command", post(command_home_assistant_device))
.route("/api/integrations/home-assistant/control-plan", get(control_plan))
.route("/api/integrations/home-assistant/groups", get(list_home_assistant_groups))
.route("/api/integrations/home-assistant/groups/:id/control", post(update_home_assistant_group_control))
@@ -413,7 +413,14 @@ async fn poll_device(State(state): State<AppState>, Path(id): Path<String>) -> R
}
async fn command_device(State(state): State<AppState>, Path(id): Path<String>, Json(command): Json<DeviceCommand>) -> Result<Json<Device>, AppError> {
Ok(Json(engine::send_command(&state, &id, command).await?))
Ok(Json(engine::send_manual_command(&state, &id, command, "device.manual_control").await?))
}
async fn command_home_assistant_device(State(state): State<AppState>, Path(id): Path<String>, Json(command): Json<DeviceCommand>) -> Result<Json<Device>, AppError> {
if state.db.list_zones()?.iter().any(|zone| zone.device_id == id && !zone.enabled) {
return Err(AppError::BadRequest("device belongs to a disabled thermostat zone; use technical device control for manual operation".into()));
}
Ok(Json(engine::send_manual_command(&state, &id, command, "home_assistant.device_manual_control").await?))
}
#[derive(Debug, Deserialize)]
@@ -509,6 +516,7 @@ impl ZoneInput {
external_sensor_weight: self.external_sensor_weight, max_sensor_difference: self.max_sensor_difference,
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,
device_manual_override: false, device_manual_override_since: None, device_manual_override_until: None, device_manual_override_fields: Vec::new(),
effective_mode: String::new(), effective_setpoint: None, device_setpoint: None,
demand: false, demand_since: None, target_alerted_at: None, last_action_at: None,
created_at, updated_at: Utc::now(),
@@ -552,6 +560,10 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
zone.manual_preset = existing.manual_preset;
zone.manual_setpoint = existing.manual_setpoint;
zone.manual_override_until = existing.manual_override_until;
zone.device_manual_override = existing.device_manual_override;
zone.device_manual_override_since = existing.device_manual_override_since;
zone.device_manual_override_until = existing.device_manual_override_until;
zone.device_manual_override_fields = existing.device_manual_override_fields;
zone.effective_mode = existing.effective_mode;
zone.effective_setpoint = existing.effective_setpoint;
zone.device_setpoint = existing.device_setpoint;
@@ -561,13 +573,22 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
zone.last_action_at = existing.last_action_at;
let settings = state.settings.read().await.clone();
canonicalize_zone_ha_entity(&mut zone, &settings);
let power_off_device = existing.enabled && !zone.enabled;
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
if power_off_device {
power_off_zone_device(&state, &zone, "zone.disabled").await;
}
Ok(Json(zone))
}
async fn update_zone_control(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<ZoneControlPatch>) -> Result<Json<Zone>, AppError> {
let mut zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
let was_enabled = zone.enabled;
let schedules = state.db.list_schedules()?;
// Any explicit thermostat action means the user is handing ownership back to the zone
// controller. This includes the dedicated Resume automation button.
let resume_device_automation = patch.clear_device_manual_override.unwrap_or(false)
|| patch.setpoint.is_some() || patch.mode.is_some() || patch.preset.is_some() || patch.enabled.is_some();
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())); }
@@ -608,17 +629,35 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
zone.manual_override_until = None;
}
if let Some(value) = patch.enabled { zone.enabled = value; }
let device_override_cleared = if resume_device_automation { engine::reset_device_manual_override(&mut zone) } else { false };
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
if was_enabled && !zone.enabled {
power_off_zone_device(&state, &zone, "zone.quick_disabled").await;
}
state.log("info", "zone.quick_control", &format!("Quick control updated for {}", zone.name), json!({
"zone_id": zone.id, "setpoint": zone.setpoint, "manual_setpoint": zone.manual_setpoint, "mode": zone.mode,
"inherit_house_mode": zone.inherit_house_mode, "preset": zone.manual_preset,
"override_until": zone.manual_override_until, "enabled": zone.enabled
"override_until": zone.manual_override_until, "enabled": zone.enabled,
"device_manual_override_cleared": device_override_cleared
}));
Ok(Json(zone))
}
async fn power_off_zone_device(state: &AppState, zone: &Zone, source: &str) {
let Ok(Some(device)) = state.db.get_device(&zone.device_id) else { return; };
if !device.enabled || !device.power { return; }
if let Err(err) = engine::send_command(state, &device.id, DeviceCommand { power: Some(false), ..Default::default() }).await {
state.log("error", "zone.disable_power_error", &err.to_string(), json!({
"zone_id": zone.id,
"device_id": device.id,
"device_name": device.name,
"source": source,
}));
}
}
#[derive(Debug, Deserialize)]
struct GroupInput {
name: String,
@@ -825,6 +864,8 @@ async fn list_home_assistant_groups(State(state): State<AppState>) -> Result<Jso
"demand": zone.demand,
"control_source": zone.control_source,
"current_schedule": zone.current_schedule_name,
"device_manual_override": zone.device_manual_override,
"device_manual_override_until": zone.device_manual_override_until,
})).collect::<Vec<_>>();
output.push(json!({
@@ -863,10 +904,10 @@ async fn update_home_assistant_group_control(
#[derive(Debug, Deserialize)]
struct HouseControlPatch { mode: String }
fn enable_all_groups(state: &AppState) -> Result<(), AppError> {
fn set_all_groups_power(state: &AppState, power: bool) -> Result<(), AppError> {
for mut group in state.db.list_groups()? {
if group.power_enabled { continue; }
group.power_enabled = true;
if group.power_enabled == power { continue; }
group.power_enabled = power;
group.updated_at = Utc::now();
state.db.save_group(&group)?;
state.broadcast("group.updated", serde_json::to_value(&group)?);
@@ -876,8 +917,19 @@ fn enable_all_groups(state: &AppState) -> Result<(), AppError> {
async fn command_all_enabled_devices_power(state: &AppState, power: bool, source: &str) -> Result<Vec<Value>, AppError> {
let mut failed = Vec::new();
let enabled_zone_devices: std::collections::HashSet<String> = if power {
state.db.list_zones()?.into_iter()
.filter(|zone| zone.enabled && !zone.device_manual_override)
.map(|zone| zone.device_id)
.collect()
} else {
std::collections::HashSet::new()
};
for device in state.db.list_devices()? {
if !device.enabled || device.power == power { continue; }
// Whole-house ON only operates thermostat-managed, enabled zones. Devices with
// a disabled zone (or no zone at all) remain manual/technical Devices controls.
if power && !enabled_zone_devices.contains(&device.id) { continue; }
let command = DeviceCommand { power: Some(power), ..Default::default() };
if let Err(err) = engine::send_command(state, &device.id, command).await {
state.log("error", "house.power_all_error", &err.to_string(), json!({
@@ -914,7 +966,7 @@ async fn update_house_control(State(state): State<AppState>, Json(input): Json<H
};
state.broadcast("settings.updated", payload.clone());
if activate_all {
enable_all_groups(&state)?;
set_all_groups_power(&state, true)?;
let failed = command_all_enabled_devices_power(&state, true, "house_mode").await?;
if !failed.is_empty() {
state.log("warn", "house.mode_power_partial", "House mode enabled master power, but some devices could not be powered on", json!({
@@ -933,6 +985,9 @@ struct HousePowerPatch { power: bool }
async fn update_house_power(State(state): State<AppState>, Json(input): Json<HousePowerPatch>) -> Result<Json<Value>, AppError> {
// Whole-house power is independent from the thermostat mode. Turning it off is
// authoritative, while house mode `off` remains a separate "do not control" state.
if !input.power {
engine::clear_all_device_manual_overrides(&state, "house_power_off")?;
}
{
let mut settings = state.settings.write().await;
if settings.house_power_enabled != input.power {
@@ -943,20 +998,24 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
}
}
if input.power { enable_all_groups(&state)?; }
// Global power is a true cascade across group gates. Disabled thermostat zones stay
// disabled and are therefore not powered by the ON direction of this command.
set_all_groups_power(&state, input.power)?;
let failed = command_all_enabled_devices_power(&state, input.power, "house_power").await?;
let devices = state.db.list_devices()?;
let groups = state.db.list_groups()?;
let settings = state.settings.read().await;
let settings_payload = public_settings(&settings);
drop(settings);
state.log("info", "house.power_all", if input.power { "Whole-house power enabled; all enabled devices powered on" } else { "Whole-house power disabled; all enabled devices powered off" }, json!({
state.log("info", "house.power_all", if input.power { "Whole-house power enabled; all enabled thermostat zones powered on" } else { "Whole-house power disabled; all groups and enabled devices powered off" }, json!({
"power": input.power,
"failed": failed.len(),
}));
Ok(Json(json!({
"power": input.power,
"devices": devices,
"groups": groups,
"settings": settings_payload,
"failed": failed,
})))
@@ -979,7 +1038,7 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
public_settings(&settings)
};
state.broadcast("settings.updated", settings_payload.clone());
enable_all_groups(&state)?;
set_all_groups_power(&state, true)?;
let schedules = state.db.list_schedules()?;
let mut zones = state.db.list_zones()?;
+304 -29
View File
@@ -178,7 +178,14 @@ pub async fn send_command(state: &AppState, device_id: &str, command: DeviceComm
pub async fn poll_one(state: &AppState, device_id: &str) -> Result<Device, AppError> {
let mut device = state.db.get_device(device_id)?
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
let before = device.clone();
poll_device(state, &mut device).await;
let concurrent_command = state.db.get_device(device_id)?
.map(|current| current.updated_at > before.updated_at)
.unwrap_or(false);
if !concurrent_command && poll_completed_successfully(&device) {
detect_external_device_control(state, &before, &device)?;
}
state.db.save_device(&device)?;
record_reading(state, &device)?;
state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default());
@@ -188,7 +195,14 @@ pub async fn poll_one(state: &AppState, device_id: &str) -> Result<Device, AppEr
async fn poll_all(state: &AppState) -> Result<()> {
for mut device in state.db.list_devices()? {
if !device.enabled { continue; }
let before = device.clone();
poll_device(state, &mut device).await;
let concurrent_command = state.db.get_device(&device.id)?
.map(|current| current.updated_at > before.updated_at)
.unwrap_or(false);
if !concurrent_command && poll_completed_successfully(&device) {
detect_external_device_control(state, &before, &device)?;
}
state.db.save_device(&device)?;
record_reading(state, &device)?;
state.broadcast("device.updated", serde_json::to_value(&device)?);
@@ -331,6 +345,138 @@ pub(crate) fn validate_command(command: &DeviceCommand) -> Result<(), AppError>
Ok(())
}
fn poll_completed_successfully(device: &Device) -> bool {
device.online && device.communication_failures == 0 && device.last_error.is_none()
}
fn command_manual_control_fields(command: &DeviceCommand) -> Vec<String> {
let mut fields = Vec::new();
if command.power.is_some() { fields.push("power".to_string()); }
if command.mode.is_some() { fields.push("mode".to_string()); }
if command.target_temperature.is_some() { fields.push("target_temperature".to_string()); }
if command.fan_speed.is_some() { fields.push("fan_speed".to_string()); }
if command.quiet.is_some() { fields.push("quiet".to_string()); }
if command.sleep.is_some() { fields.push("sleep".to_string()); }
fields
}
fn externally_changed_control_fields(before: &Device, after: &Device, zone: &Zone) -> Vec<String> {
let mut fields = Vec::new();
if before.power != after.power { fields.push("power".to_string()); }
if before.mode != after.mode { fields.push("mode".to_string()); }
if (before.target_temperature - after.target_temperature).abs() >= 0.5 {
fields.push("target_temperature".to_string());
}
// Some GREE units accept the controller's standby Low fan hint and later report Auto
// again without user interaction. Treat that one known normalization as firmware drift,
// not as a remote-control takeover. Other fan changes remain meaningful manual input.
let standby_low_to_auto = zone.smart_fan && !zone.demand && before.fan_speed == 1 && after.fan_speed == 0;
if before.fan_speed != after.fan_speed && !standby_low_to_auto {
fields.push("fan_speed".to_string());
}
fields
}
pub fn reset_device_manual_override(zone: &mut Zone) -> bool {
let changed = zone.device_manual_override
|| zone.device_manual_override_since.is_some()
|| zone.device_manual_override_until.is_some()
|| !zone.device_manual_override_fields.is_empty();
zone.device_manual_override = false;
zone.device_manual_override_since = None;
zone.device_manual_override_until = None;
zone.device_manual_override_fields.clear();
changed
}
fn set_device_manual_override(state: &AppState, zone: &mut Zone, fields: Vec<String>, source: &str) -> Result<(), AppError> {
if fields.is_empty() { return Ok(()); }
let now = Utc::now();
if !zone.device_manual_override {
zone.device_manual_override_since = Some(now);
}
zone.device_manual_override = true;
zone.device_manual_override_until = if zone.enabled {
next_schedule_boundary_utc(&zone.id, &state.db.list_schedules()?, Local::now())
} else {
None
};
zone.device_manual_override_fields = fields.clone();
zone.demand = false;
zone.demand_since = None;
zone.updated_at = now;
state.db.save_zone(zone)?;
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
state.log("info", "zone.device_manual_override", &format!("Manual device control detected for {}", zone.name), json!({
"zone_id": zone.id,
"device_id": zone.device_id,
"fields": fields,
"source": source,
"override_until": zone.device_manual_override_until,
}));
Ok(())
}
fn detect_external_device_control(state: &AppState, before: &Device, after: &Device) -> Result<(), AppError> {
if before.id != after.id { return Ok(()); }
for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == after.id) {
let fields = externally_changed_control_fields(before, after, &zone);
if fields.is_empty() { continue; }
// A disabled zone is outside controller ownership. When its manually operated unit is
// switched off there is no takeover left to display or remember.
if !zone.enabled && !after.power {
if reset_device_manual_override(&mut zone) {
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
state.log("info", "zone.device_manual_override_cleared", &format!("Manual device control ended for {}", zone.name), json!({
"zone_id": zone.id, "device_id": zone.device_id, "source": "gree_poll"
}));
}
continue;
}
set_device_manual_override(state, &mut zone, fields, "gree_poll")?;
}
Ok(())
}
pub async fn send_manual_command(state: &AppState, device_id: &str, command: DeviceCommand, source: &str) -> Result<Device, AppError> {
let before = state.db.get_device(device_id)?
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
let effective_command = if before.online { command.changed_from(&before) } else { command.clone() };
let fields = command_manual_control_fields(&effective_command);
let updated = send_command(state, device_id, command).await?;
if !fields.is_empty() {
for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id) {
if !zone.enabled && !updated.power {
if reset_device_manual_override(&mut zone) {
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
}
continue;
}
set_device_manual_override(state, &mut zone, fields.clone(), source)?;
}
}
Ok(updated)
}
pub fn clear_all_device_manual_overrides(state: &AppState, source: &str) -> Result<usize, AppError> {
let mut cleared = 0usize;
for mut zone in state.db.list_zones()? {
if !reset_device_manual_override(&mut zone) { continue; }
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
state.log("info", "zone.device_manual_override_cleared", &format!("Automation resumed for {}", zone.name), json!({
"zone_id": zone.id, "device_id": zone.device_id, "source": source
}));
cleared += 1;
}
Ok(cleared)
}
pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControlPatch, source: &str) -> Result<Value, AppError> {
if let Some(mode) = patch.mode.as_deref() {
if !matches!(mode, "house" | "auto" | "cool" | "heat") {
@@ -398,10 +544,12 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
let mut seen = std::collections::HashSet::new();
for zone in &zones {
if !seen.insert(zone.device_id.clone()) { continue; }
// Group actions never own disabled zones and never override an active manual/pilot
// takeover. Whole-house OFF is handled separately and remains authoritative.
if !zone.enabled || zone.device_manual_override { continue; }
let Some(device) = state.db.get_device(&zone.device_id)? else { continue; };
if !device.enabled || device.power == desired_power { continue; }
if desired_power {
if !zone.enabled { continue; }
let blocked_by_other_group = group_snapshot.iter().any(|other| {
other.id != group.id && !other.power_enabled && other.zone_ids.iter().any(|zone_id| zone_id == &zone.id)
});
@@ -480,6 +628,9 @@ async fn control_zones(state: &AppState) -> Result<()> {
let night_active = night_mode_active(&settings.night_mode, Local::now().time());
if !settings.house_power_enabled {
// Whole-house OFF is the one deliberate authority above manual/pilot takeover.
// Clear remembered takeovers as well, so a later whole-house ON starts cleanly.
clear_all_device_manual_overrides(state, "house_master_off")?;
for device in &device_snapshot {
if !device.enabled || !device.power { continue; }
if let Err(err) = send_command(state, &device.id, DeviceCommand { power: Some(false), ..Default::default() }).await {
@@ -495,6 +646,12 @@ async fn control_zones(state: &AppState) -> Result<()> {
zone.manual_setpoint = None;
zone.manual_override_until = None;
}
if zone.device_manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) {
reset_device_manual_override(&mut zone);
state.log("info", "zone.device_manual_override_expired", &format!("Manual device control expired for {} at schedule transition", zone.name), json!({
"zone_id": zone.id, "device_id": zone.device_id
}));
}
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}));
@@ -543,6 +700,35 @@ async fn control_zones(state: &AppState) -> Result<()> {
zone.control_temperature_source = control_source;
zone.updated_at = Utc::now();
// A disabled thermostat zone is completely outside normal controller ownership.
// Keep its sensors fresh, but do not let group state, schedules or thermostat
// modulation touch the unit. Manual control from the technical Devices view may
// therefore remain active until the zone is explicitly enabled again.
if !zone.enabled {
zone.demand = false;
zone.demand_since = None;
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;
}
// A physical/manual takeover has higher priority than thermostat, schedule, group and
// automation control. Continue sensor/history updates, but reflect the unit's real state
// instead of sending corrective frames that would fight the person holding the remote.
if zone.device_manual_override {
zone.effective_mode = if device.power { device.mode.clone() } else { "off".into() };
zone.effective_setpoint = if device.power { Some(device.target_temperature) } else { None };
zone.device_setpoint = if device.power { Some(device.target_temperature) } else { None };
zone.demand = false;
zone.demand_since = None;
zone.target_alerted_at = None;
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;
}
let blocked_by_group = groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id));
if blocked_by_group {
zone.effective_mode = "off".into();
@@ -570,17 +756,6 @@ async fn control_zones(state: &AppState) -> Result<()> {
}));
}
// Disabled zones still refresh and publish their room temperature. Disabling a
// thermostat stops control actions, but it must not make the room sensor disappear
// from the dashboard or zone view.
if !zone.enabled {
zone.demand = false;
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;
}
// House "off" is a no-control state, not a power-off command. Keep polling and
// publishing the zone, but never overwrite manual device state while it follows
// the house mode. Explicit per-zone heat/cool bypasses this branch above.
@@ -655,6 +830,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
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 demand_changed = previous_demand != zone.demand;
let desired_fan = if night_active {
let max_fan = settings.night_mode.max_fan_speed.clamp(1, 5);
if zone.smart_fan {
@@ -694,10 +870,18 @@ async fn control_zones(state: &AppState) -> Result<()> {
device.sleep,
);
let needs_command = !device.power
let core_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)
|| (device.target_temperature - desired_device_target).abs() >= 0.5;
// In normal standby, Low fan is a transition hint rather than a state that should
// be reasserted forever. Some GREE firmwares accept the frame but later report Auto
// again; retrying every min_adjust_seconds only causes needless command beeps.
let fan_needs_command = desired_fan
.map(|fan| fan != device.fan_speed)
.unwrap_or(false)
&& (zone.demand || demand_changed || core_needs_command || night_active);
let needs_command = core_needs_command
|| fan_needs_command
|| desired_quiet.map(|quiet| quiet != device.quiet).unwrap_or(false)
|| desired_sleep.map(|sleep| sleep != device.sleep).unwrap_or(false);
@@ -707,7 +891,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
power: Some(true),
mode: Some(effective_mode.to_string()),
target_temperature: Some(desired_device_target),
fan_speed: desired_fan,
fan_speed: if fan_needs_command { desired_fan } else { None },
quiet: desired_quiet,
sleep: desired_sleep,
..Default::default()
@@ -914,13 +1098,20 @@ fn smart_quiet_command(
night_force_quiet: bool,
) -> Option<bool> {
if !quiet_supported { return None; }
if night_enabled && night_force_quiet {
if night_active { return Some(true); }
if device_quiet { return Some(false); }
if night_enabled && night_force_quiet && night_active {
return if device_quiet { None } else { Some(true) };
}
if !smart_fan { return None; }
if !demand { return Some(true); }
if !previous_demand && device_quiet { return Some(false); }
if smart_fan {
// Smart Quiet follows demand transitions. Do not keep reasserting Quiet while a
// satisfied room remains in standby: some units report Quiet=false again even after
// accepting the command, which otherwise produces a beep every adjustment interval.
if previous_demand && !demand && !device_quiet { return Some(true); }
if !previous_demand && demand && device_quiet { return Some(false); }
return None;
}
// Without Smart Fan, Quiet can only have been requested by scheduled night mode,
// so release it after the night window ends.
if night_enabled && night_force_quiet && device_quiet { return Some(false); }
None
}
@@ -1120,7 +1311,14 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
zone.mode.as_str()
};
let blocked_by_group = groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id));
let effective_mode = if blocked_by_group { "off" } else { configured_effective_mode };
let manual_device_mode = device.map(|item| if item.power { item.mode.as_str() } else { "off" });
let effective_mode = if zone.device_manual_override {
manual_device_mode.unwrap_or(configured_effective_mode)
} else if blocked_by_group {
"off"
} else {
configured_effective_mode
};
// Keep the thermostat target readable even while the zone/group/house control is off.
// Home Assistant climate entities otherwise expose target_temperature as unknown.
@@ -1143,21 +1341,25 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
zone_name: zone.name.clone(),
device_id: zone.device_id.clone(),
device_name: device.map(|item| item.name.clone()).unwrap_or_else(|| zone.device_id.clone()),
enabled: zone.enabled && !blocked_by_group,
enabled: zone.enabled && (!blocked_by_group || zone.device_manual_override),
mode: effective_mode.to_string(),
configured_mode: zone.mode.clone(),
inherit_house_mode: zone.inherit_house_mode,
preset: if zone.active_preset.is_empty() { resolved_preset } else { zone.active_preset.clone() },
current_temperature: zone.current_temperature,
target_temperature: if !zone.enabled || effective_mode == "off" {
target_temperature: if zone.device_manual_override {
device.filter(|item| item.power).map(|item| item.target_temperature).or(Some(resolved_target))
} else if !zone.enabled || effective_mode == "off" {
Some(resolved_target)
} else {
zone.effective_setpoint.or(Some(resolved_target))
},
device_setpoint: zone.device_setpoint.or_else(|| device.map(|item| item.target_temperature)),
demand: settings.house_power_enabled && zone.enabled && effective_mode != "off" && zone.demand,
demand: settings.house_power_enabled && zone.enabled && effective_mode != "off" && !zone.device_manual_override && zone.demand,
control_source: zone.control_temperature_source.clone(),
manual_override_until: zone.manual_override_until,
device_manual_override: zone.device_manual_override,
device_manual_override_until: zone.device_manual_override_until,
current_schedule_id: active.map(|item| item.id.clone()),
current_schedule_name: active.map(|item| item.name.clone()),
next_events,
@@ -1324,9 +1526,29 @@ async fn run_automations(state: &AppState) -> Result<()> {
_ => false,
};
if !should_fire { continue; }
if item.action_group_id.is_none() && device_blocked_by_disabled_zone(&item.action_device_id, &zones) {
// Disabled thermostat zones are outside normal automation. The underlying
// unit can still be operated manually from the technical Devices view.
item.last_fired_at = Some(Utc::now());
item.updated_at = Utc::now();
state.db.save_automation(&item)?;
state.log("info", "automation.blocked_by_zone", &format!("Automation {} suppressed by disabled zone", item.name), json!({
"automation_id": item.id, "device_id": item.action_device_id
}));
continue;
}
if item.action_group_id.is_none() && device_blocked_by_manual_override(&item.action_device_id, &zones) {
item.last_fired_at = Some(Utc::now());
item.updated_at = Utc::now();
state.db.save_automation(&item)?;
state.log("info", "automation.blocked_by_manual_override", &format!("Automation {} suppressed by manual device control", item.name), json!({
"automation_id": item.id, "device_id": item.action_device_id
}));
continue;
}
if item.action_group_id.is_none() && device_blocked_by_disabled_group(&item.action_device_id, &zones, &groups) {
// Group power-off is authoritative. Suppress a raw-device automation instead of
// waking the unit for one control cycle and immediately switching it off again.
// Group power-off is authoritative for normal controller-owned zones. A manual
// takeover is filtered above and therefore remains higher priority than the group.
item.last_fired_at = Some(Utc::now());
item.updated_at = Utc::now();
state.db.save_automation(&item)?;
@@ -1367,6 +1589,14 @@ async fn run_automations(state: &AppState) -> Result<()> {
Ok(())
}
fn device_blocked_by_disabled_zone(device_id: &str, zones: &[Zone]) -> bool {
zones.iter().any(|zone| zone.device_id == device_id && !zone.enabled)
}
fn device_blocked_by_manual_override(device_id: &str, zones: &[Zone]) -> bool {
zones.iter().any(|zone| zone.device_id == device_id && zone.device_manual_override)
}
fn device_blocked_by_disabled_group(device_id: &str, zones: &[Zone], groups: &[crate::models::ClimateGroup]) -> bool {
let zone_ids: std::collections::HashSet<&str> = zones.iter()
@@ -1508,11 +1738,53 @@ 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_setpoint: 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,
device_manual_override: false, device_manual_override_since: None, device_manual_override_until: None, device_manual_override_fields: Vec::new(),
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 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 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");
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()];
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());
}
#[test]
fn device_command_drops_unchanged_fields() {
let device = Device::simulated_default();
@@ -1603,6 +1875,8 @@ mod tests {
#[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);
@@ -1619,6 +1893,7 @@ mod tests {
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), None);
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(true, true, true, false, false), None);
+16
View File
@@ -322,6 +322,17 @@ pub struct Zone {
pub manual_setpoint: Option<f64>,
#[serde(default)]
pub manual_override_until: Option<DateTime<Utc>>,
/// True when the physical unit was changed outside the thermostat engine (for example by IR remote).
/// While active, normal zone/group/schedule automation observes the unit but does not overwrite it.
#[serde(default)]
pub device_manual_override: bool,
#[serde(default)]
pub device_manual_override_since: Option<DateTime<Utc>>,
#[serde(default)]
pub device_manual_override_until: Option<DateTime<Utc>>,
/// Climate-relevant fields that caused the most recent external/manual takeover.
#[serde(default)]
pub device_manual_override_fields: Vec<String>,
#[serde(default)]
pub effective_mode: String,
#[serde(default)]
@@ -380,6 +391,9 @@ pub struct ZoneControlPatch {
pub preset: Option<String>,
#[serde(default)]
pub clear_override: Option<bool>,
/// Explicitly hand control of a manually overridden physical unit back to the thermostat engine.
#[serde(default)]
pub clear_device_manual_override: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -680,6 +694,8 @@ pub struct ZoneControlPlan {
pub demand: bool,
pub control_source: String,
pub manual_override_until: Option<DateTime<Utc>>,
pub device_manual_override: bool,
pub device_manual_override_until: Option<DateTime<Utc>>,
pub current_schedule_id: Option<String>,
pub current_schedule_name: Option<String>,
pub next_events: Vec<ControlPlanEvent>,