This commit is contained in:
Mateusz Gruszczyński
2026-09-01 22:20:10 +02:00
parent 1cb62c8d0b
commit 16e0d94564
47 changed files with 2565 additions and 117 deletions
+411 -22
View File
@@ -1,14 +1,15 @@
pub fn automation_action_conflicts_with_thermostat(command: &DeviceCommand) -> bool {
// Power/mode/target are translated by apply_automatic_device_action into durable zone
// state, so they do not fight the thermostat. Fan/quiet/sleep are thermostat outputs with
// no independent zone override model; accepting them as one-shot direct automation would
// let the next thermostat cycle immediately overwrite them.
command.fan_speed.is_some() || command.quiet.is_some() || command.sleep.is_some()
// Power/auto-heat-cool/target are translated by apply_automatic_device_action into durable
// zone state, so they do not fight the thermostat. Dry/fan HVAC modes do not belong to the
// thermostat domain. Fan/quiet/sleep are outputs continuously owned by the regulator and
// have no independent zone override model, so a one-shot automation would be overwritten.
command.mode.as_deref().is_some_and(|mode| !matches!(mode, "auto" | "heat" | "cool"))
|| command.fan_speed.is_some() || command.quiet.is_some() || command.sleep.is_some()
}
fn device_has_enabled_thermostat_zone(device_id: &str, zones: &[Zone]) -> bool {
zones.iter().any(|zone| zone.device_id == device_id && zone.enabled)
fn device_has_thermostat_zone(device_id: &str, zones: &[Zone]) -> bool {
zones.iter().any(|zone| zone.device_id == device_id)
}
async fn run_automations(state: &AppState) -> Result<()> {
@@ -27,6 +28,13 @@ async fn run_automations(state: &AppState) -> Result<()> {
"temperature_below" => find_temperature(&devices, item.trigger_device_id.as_deref())
.zip(item.threshold).map(|(t, threshold)| t < threshold).unwrap_or(false),
"time" => time_automation_due(&item, Local::now()),
"flow" => match flow_conditions_match(state, &devices, &item.flow_conditions).await {
Ok(value) => value,
Err(err) => {
state.log("warn", "flow.condition_error", &format!("Flow automation {} condition evaluation failed: {}", item.name, err), json!({"automation_id": item.id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id}));
false
}
},
_ => false,
};
if !should_fire { continue; }
@@ -44,41 +52,49 @@ async fn run_automations(state: &AppState) -> Result<()> {
let zones = state.db.list_zones()?;
let groups = state.db.list_groups()?;
if item.action_group_id.is_none()
if item.action_group_id.is_none() && item.action_zone_id.is_none()
&& device_blocked_by_disabled_zone(&item.action_device_id, &zones)
&& item.action.power != Some(true)
{
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
"automation_id": item.id, "device_id": item.action_device_id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id
}));
continue;
}
if item.action_group_id.is_none() && device_blocked_by_manual_override(&item.action_device_id, &zones) {
if item.action_group_id.is_none() && item.action_zone_id.is_none() && device_blocked_by_manual_override(&item.action_device_id, &zones) {
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
"automation_id": item.id, "device_id": item.action_device_id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id
}));
continue;
}
if item.action_group_id.is_none() && device_blocked_by_local_thermostat(&item.action_device_id, &zones) {
if item.action_group_id.is_none() && item.action_zone_id.is_none() && device_blocked_by_local_thermostat(&item.action_device_id, &zones) {
state.log("info", "automation.blocked_by_local_thermostat", &format!("Automation {} suppressed by local thermostat ownership", item.name), json!({
"automation_id": item.id, "device_id": item.action_device_id
"automation_id": item.id, "device_id": item.action_device_id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id
}));
continue;
}
if item.action_group_id.is_none()
&& device_has_enabled_thermostat_zone(&item.action_device_id, &zones)
if item.action_group_id.is_none() && item.action_zone_id.is_none() && device_blocked_by_temporary_thermostat(&item.action_device_id, &zones) {
state.log("info", "automation.blocked_by_temporary_thermostat", &format!("Automation {} suppressed by Temporary Quick Thermostat ownership", item.name), json!({
"automation_id": item.id, "device_id": item.action_device_id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id
}));
continue;
}
if item.action_group_id.is_none() && item.action_zone_id.is_none()
&& device_has_thermostat_zone(&item.action_device_id, &zones)
&& automation_action_conflicts_with_thermostat(&item.action)
{
// Fan/quiet/sleep are outputs continuously managed by the thermostat. Unlike
// power/mode/target they cannot be translated into durable zone state, so a direct
// automation would be immediately overwritten by the next thermostat cycle.
state.log("warn", "automation.blocked_by_thermostat_owner", &format!("Automation {} suppressed because the device is owned by an enabled thermostat zone", item.name), json!({
"automation_id": item.id, "device_id": item.action_device_id
"automation_id": item.id, "device_id": item.action_device_id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id
}));
continue;
}
let target_devices: Vec<String> = if let Some(group_id) = item.action_group_id.as_deref() {
let target_devices: Vec<String> = if let Some(zone_id) = item.action_zone_id.as_deref() {
zones.iter().find(|zone| zone.id == zone_id).map(|zone| vec![zone.device_id.clone()]).unwrap_or_default()
} else if let Some(group_id) = item.action_group_id.as_deref() {
groups.iter().find(|group| group.id == group_id)
.map(|group| group.zone_ids.iter()
.filter_map(|zone_id| zones.iter().find(|zone| &zone.id == zone_id).map(|zone| zone.device_id.clone()))
@@ -90,6 +106,8 @@ async fn run_automations(state: &AppState) -> Result<()> {
if target_devices.iter().any(|device_id| claimed_devices.contains(device_id)) {
state.log("warn", "automation.conflict", &format!("Automation {} skipped because an older due automation already claimed the same target", item.name), json!({
"automation_id": item.id,
"flow_id": item.flow_id,
"flow_node_id": item.flow_node_id,
"group_id": item.action_group_id,
"device_id": item.action_device_id,
"target_devices": target_devices,
@@ -97,20 +115,22 @@ async fn run_automations(state: &AppState) -> Result<()> {
continue;
}
let result: Result<bool, AppError> = if let Some(group_id) = item.action_group_id.as_deref() {
let result: Result<bool, AppError> = if let Some(zone_id) = item.action_zone_id.as_deref() {
apply_flow_zone_action(state, zone_id, item.action_zone_preset.as_deref(), &item.action).await
} else if let Some(group_id) = item.action_group_id.as_deref() {
let group_mode = item.action.mode.as_deref().map(|mode| if mode == "auto" { "house".to_string() } else { mode.to_string() });
control_group(state, group_id, GroupControlPatch {
power: item.action.power,
mode: group_mode,
preset: item.action_preset.clone(),
setpoint: None,
setpoint: item.action.target_temperature,
}, "automation.group").await.map(|value| !value.get("suppressed").and_then(Value::as_bool).unwrap_or(false))
} else {
match apply_automatic_device_action(state, &item.action_device_id, item.action.clone()).await {
Ok(Some(_)) => Ok(true),
Ok(None) => {
state.log("info", "automation.blocked_by_fresh_ownership", &format!("Automation {} was suppressed after ownership changed", item.name), json!({
"automation_id": item.id, "device_id": item.action_device_id
"automation_id": item.id, "device_id": item.action_device_id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id
}));
Ok(false)
}
@@ -124,12 +144,18 @@ async fn run_automations(state: &AppState) -> Result<()> {
item.updated_at = Utc::now();
state.db.save_automation(&item)?;
state.log("info", "automation.fired", &format!("Automation {} fired", item.name), json!({
"automation_id": item.id, "group_id": item.action_group_id, "device_id": item.action_device_id
"automation_id": item.id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id, "group_id": item.action_group_id, "device_id": item.action_device_id
}));
}
Ok(false) => {
// Ownership suppression is not an execution. Do not consume cooldown (M3),
// so a still-valid trigger may run as soon as the higher-priority owner leaves.
if item.flow_id.is_some() {
state.log("info", "flow.action_suppressed", &format!("Flow action {} was suppressed by current ownership", item.name), json!({
"automation_id": item.id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id,
"group_id": item.action_group_id, "zone_id": item.action_zone_id, "device_id": item.action_device_id
}));
}
}
Err(err) => {
// A failed action is still an execution attempt. Apply the configured cooldown
@@ -137,7 +163,7 @@ async fn run_automations(state: &AppState) -> Result<()> {
item.last_fired_at = Some(Utc::now());
item.updated_at = Utc::now();
state.db.save_automation(&item)?;
state.log("error", "automation.error", &err.to_string(), json!({"automation_id": item.id}));
state.log("error", "automation.error", &err.to_string(), json!({"automation_id": item.id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id}));
}
}
}
@@ -156,6 +182,11 @@ fn device_blocked_by_local_thermostat(device_id: &str, zones: &[Zone]) -> bool {
zones.iter().any(|zone| zone.device_id == device_id && zone.local_thermostat_power.is_some())
}
fn device_blocked_by_temporary_thermostat(device_id: &str, zones: &[Zone]) -> bool {
let now = Utc::now();
zones.iter().any(|zone| zone.device_id == device_id && temporary_quick_thermostat_is_active(zone, now.clone()))
}
fn find_temperature(devices: &[Device], device_id: Option<&str>) -> Option<f64> {
let id = device_id?;
// Never fire a temperature automation from stale cached data of an offline/disabled unit.
@@ -182,3 +213,361 @@ fn time_automation_due(item: &Automation, now: DateTime<Local>) -> bool {
true
}
fn flow_compare(actual: f64, operator: &str, expected: f64) -> bool {
match operator { "lt" => actual < expected, "lte" => actual <= expected, "gt" => actual > expected, "gte" => actual >= expected, "eq" => (actual - expected).abs() < 0.0001, "neq" => (actual - expected).abs() >= 0.0001, _ => false }
}
fn flow_value_text(value: &Value) -> String {
match value {
Value::String(v) => v.clone(),
Value::Bool(v) => v.to_string(),
Value::Number(v) => v.to_string(),
Value::Null => "null".into(),
other => other.to_string(),
}
}
fn flow_compare_value(actual: &Value, operator: &str, expected: &Value) -> bool {
if let (Some(a), Some(e)) = (actual.as_f64(), expected.as_f64()) {
return flow_compare(a, operator, e);
}
if matches!(operator, "lt" | "lte" | "gt" | "gte") {
let a = flow_value_text(actual).parse::<f64>().ok();
let e = flow_value_text(expected).parse::<f64>().ok();
return a.zip(e).map(|(a, e)| flow_compare(a, operator, e)).unwrap_or(false);
}
let equal = flow_value_text(actual).eq_ignore_ascii_case(&flow_value_text(expected));
if operator == "neq" { !equal } else { equal }
}
fn flow_device_state_value(device: &Device, field: &str) -> Option<Value> {
Some(match field {
"enabled" => json!(device.enabled),
"online" => json!(device.online),
"power" => json!(device.power),
"mode" => json!(device.mode),
"fan_speed" => json!(device.fan_speed),
"swing_vertical" => json!(device.swing_vertical),
"swing_horizontal" => json!(device.swing_horizontal),
"quiet" => json!(device.quiet),
"turbo" => json!(device.turbo),
"light" => json!(device.light),
"air" => json!(device.air),
"xfan" => json!(device.xfan),
"health" => json!(device.health),
"sleep" => json!(device.sleep),
_ => return None,
})
}
fn flow_zone_state_value(zone: &Zone, field: &str) -> Option<Value> {
Some(match field {
"enabled" => json!(zone.enabled),
"mode" => json!(zone.mode),
"active_preset" => json!(zone.active_preset),
"demand" => json!(zone.demand),
"control_owner" => json!(zone.control_owner),
"device_manual_override" => json!(zone.device_manual_override),
"local_thermostat_power" => zone.local_thermostat_power.map(Value::Bool).unwrap_or(Value::Null),
_ => return None,
})
}
async fn flow_leaf_observation(
state: &AppState,
devices: &[Device],
zones: &[Zone],
outdoor_temperature: Option<f64>,
condition: &crate::models::FlowCondition,
now: &DateTime<Local>,
settings: &crate::models::RuntimeSettings,
overrides: &HashMap<String, Value>,
) -> Result<(bool, Value), AppError> {
let c = &condition.config;
let override_value = overrides.get(&condition.id).cloned();
let (matched, actual) = match condition.kind.as_str() {
"weekday" => {
let actual = override_value.unwrap_or_else(|| json!(now.weekday().number_from_monday()));
let day = actual.as_u64().unwrap_or(now.weekday().number_from_monday() as u64);
let matched = c.get("days").and_then(Value::as_array)
.map(|days| days.iter().filter_map(Value::as_u64).any(|expected| expected == day)).unwrap_or(false);
(matched, json!(day))
}
"time_range" => {
let actual = override_value.and_then(|v| v.as_str().map(str::to_string)).unwrap_or_else(|| now.format("%H:%M").to_string());
let current = NaiveTime::parse_from_str(&actual, "%H:%M").unwrap_or_else(|_| now.time());
let start = c.get("start").and_then(Value::as_str).and_then(|v| NaiveTime::parse_from_str(v, "%H:%M").ok());
let end = c.get("end").and_then(Value::as_str).and_then(|v| NaiveTime::parse_from_str(v, "%H:%M").ok());
let matched = match (start, end) {
(Some(start), Some(end)) if start == end => true,
(Some(start), Some(end)) if start < end => current >= start && current < end,
(Some(start), Some(end)) => current >= start || current < end,
_ => false,
};
(matched, json!(actual))
}
"date_range" => {
let actual = override_value.and_then(|v| v.as_str().map(str::to_string)).unwrap_or_else(|| now.date_naive().format("%Y-%m-%d").to_string());
let current = chrono::NaiveDate::parse_from_str(&actual, "%Y-%m-%d").unwrap_or_else(|_| now.date_naive());
let start = c.get("start").and_then(Value::as_str).and_then(|v| chrono::NaiveDate::parse_from_str(v, "%Y-%m-%d").ok());
let end = c.get("end").and_then(Value::as_str).and_then(|v| chrono::NaiveDate::parse_from_str(v, "%Y-%m-%d").ok());
(matches!((start, end), (Some(a), Some(b)) if current >= a && current <= b), json!(actual))
}
"outdoor_temperature" => {
let actual = if let Some(v) = override_value { v.as_f64() } else { outdoor_temperature };
let expected = c.get("value").and_then(Value::as_f64);
(actual.zip(expected).map(|(a, e)| flow_compare(a, c.get("operator").and_then(Value::as_str).unwrap_or("lt"), e)).unwrap_or(false), actual.map_or(Value::Null, |v| json!(v)))
}
"device_temperature" => {
let id = c.get("device_id").and_then(Value::as_str).unwrap_or("");
let actual = if let Some(v) = override_value { v.as_f64() } else { find_temperature(devices, Some(id)) };
let expected = c.get("value").and_then(Value::as_f64);
(actual.zip(expected).map(|(a, e)| flow_compare(a, c.get("operator").and_then(Value::as_str).unwrap_or("lt"), e)).unwrap_or(false), actual.map_or(Value::Null, |v| json!(v)))
}
"zone_temperature" => {
let id = c.get("zone_id").and_then(Value::as_str).unwrap_or("");
let actual = if let Some(v) = override_value { v.as_f64() } else { zones.iter().find(|zone| zone.id == id).and_then(|zone| zone.current_temperature) };
let expected = c.get("value").and_then(Value::as_f64);
(actual.zip(expected).map(|(a, e)| flow_compare(a, c.get("operator").and_then(Value::as_str).unwrap_or("lt"), e)).unwrap_or(false), actual.map_or(Value::Null, |v| json!(v)))
}
"ha_state" => {
let entity = c.get("entity_id").and_then(Value::as_str).unwrap_or("");
let actual = match override_value {
Some(value) => value,
None => match home_assistant::read_state(&state.http, &settings.home_assistant, Some(entity)).await {
Ok(value) => json!(value),
Err(err) => return Ok((false, json!({"error": err.to_string(), "entity_id": entity}))),
},
};
let expected = c.get("value").cloned().unwrap_or(Value::Null);
(flow_compare_value(&actual, c.get("operator").and_then(Value::as_str).unwrap_or("eq"), &expected), actual)
}
"ha_numeric" => {
let entity = c.get("entity_id").and_then(Value::as_str).unwrap_or("");
let actual = match override_value {
Some(value) => value,
None => {
let raw = match home_assistant::read_state(&state.http, &settings.home_assistant, Some(entity)).await {
Ok(value) => value,
Err(err) => return Ok((false, json!({"error": err.to_string(), "entity_id": entity}))),
};
match raw.parse::<f64>() {
Ok(value) => json!(value),
Err(_) => return Ok((false, json!({"error": "non_numeric_state", "entity_id": entity, "state": raw}))),
}
}
};
let expected = c.get("value").cloned().unwrap_or(Value::Null);
(flow_compare_value(&actual, c.get("operator").and_then(Value::as_str).unwrap_or("lt"), &expected), actual)
}
"ha_attribute" => {
let entity = c.get("entity_id").and_then(Value::as_str).unwrap_or("");
let attribute = c.get("attribute").and_then(Value::as_str).unwrap_or("");
let actual = match override_value {
Some(value) => value,
None => {
let payload = match home_assistant::read_entity(&state.http, &settings.home_assistant, Some(entity)).await {
Ok(value) => value,
Err(err) => return Ok((false, json!({"error": err.to_string(), "entity_id": entity, "attribute": attribute}))),
};
let Some(value) = payload.get("attributes").and_then(|attrs| attrs.get(attribute)).cloned() else {
return Ok((false, json!({"error": "missing_attribute", "entity_id": entity, "attribute": attribute})));
};
value
}
};
let expected = c.get("value").cloned().unwrap_or(Value::Null);
(flow_compare_value(&actual, c.get("operator").and_then(Value::as_str).unwrap_or("eq"), &expected), actual)
}
"ha_available" => {
let entity = c.get("entity_id").and_then(Value::as_str).unwrap_or("");
let actual = match override_value {
Some(value) => value,
None => match home_assistant::read_state(&state.http, &settings.home_assistant, Some(entity)).await {
Ok(value) => Value::String(value),
Err(err) => return Ok((false, json!({"error": err.to_string(), "entity_id": entity}))),
},
};
let matched = actual.as_bool().unwrap_or_else(|| actual.as_str().map(|value| {
let value = value.trim();
!value.is_empty() && !value.eq_ignore_ascii_case("unknown") && !value.eq_ignore_ascii_case("unavailable")
}).unwrap_or(false));
(matched, actual)
}
"house_mode" => {
let actual = override_value.unwrap_or_else(|| json!(settings.house_mode));
let expected = c.get("value").cloned().unwrap_or(Value::Null);
(flow_compare_value(&actual, c.get("operator").and_then(Value::as_str).unwrap_or("eq"), &expected), actual)
}
"device_state" => {
let id = c.get("device_id").and_then(Value::as_str).unwrap_or("");
let field = c.get("field").and_then(Value::as_str).unwrap_or("");
let actual = override_value.unwrap_or_else(|| devices.iter().find(|device| device.id == id).and_then(|device| flow_device_state_value(device, field)).unwrap_or(Value::Null));
let expected = c.get("value").cloned().unwrap_or(Value::Null);
(flow_compare_value(&actual, c.get("operator").and_then(Value::as_str).unwrap_or("eq"), &expected), actual)
}
"zone_state" => {
let id = c.get("zone_id").and_then(Value::as_str).unwrap_or("");
let field = c.get("field").and_then(Value::as_str).unwrap_or("");
let actual = override_value.unwrap_or_else(|| zones.iter().find(|zone| zone.id == id).and_then(|zone| flow_zone_state_value(zone, field)).unwrap_or(Value::Null));
let expected = c.get("value").cloned().unwrap_or(Value::Null);
(flow_compare_value(&actual, c.get("operator").and_then(Value::as_str).unwrap_or("eq"), &expected), actual)
}
"group_state" => {
let id = c.get("group_id").and_then(Value::as_str).unwrap_or("");
let field = c.get("field").and_then(Value::as_str).unwrap_or("");
let actual = override_value.unwrap_or_else(|| state.db.get_group(id).ok().flatten().and_then(|group| match field {
"power_enabled" => Some(json!(group.power_enabled)),
_ => None,
}).unwrap_or(Value::Null));
let expected = c.get("value").cloned().unwrap_or(Value::Null);
(flow_compare_value(&actual, c.get("operator").and_then(Value::as_str).unwrap_or("eq"), &expected), actual)
}
"night_mode" => {
let actual = override_value.unwrap_or_else(|| json!(night_mode_active(&settings.night_mode, now.time())));
(actual.as_bool().unwrap_or(false), actual)
}
"constant" => {
let actual = override_value.unwrap_or_else(|| c.get("value").cloned().unwrap_or(Value::Bool(false)));
(actual.as_bool().unwrap_or(false), actual)
}
_ => (false, Value::Null),
};
Ok((matched, actual))
}
pub async fn evaluate_flow_conditions_trace(
state: &AppState,
devices: &[Device],
conditions: &[crate::models::FlowCondition],
now: DateTime<Local>,
overrides: &HashMap<String, Value>,
) -> Result<(bool, Vec<Value>), AppError> {
if conditions.is_empty() { return Ok((false, Vec::new())); }
let settings = state.settings.read().await.clone();
let zones = state.db.list_zones()?;
let outdoor_temperature = *state.outdoor_temperature.read().await;
let mut trace = Vec::new();
if conditions.iter().all(|condition| condition.id.is_empty()) {
let mut final_value = true;
for condition in conditions {
let (matched, actual) = flow_leaf_observation(state, devices, &zones, outdoor_temperature, condition, &now, &settings, overrides).await?;
final_value &= matched;
trace.push(json!({"node_id": condition.id, "kind": condition.kind, "matched": matched, "actual": actual, "expected": condition.config.get("value")}));
}
return Ok((final_value, trace));
}
let mut values = HashMap::<String, bool>::new();
let mut final_id = None::<String>;
for condition in conditions {
if condition.id.is_empty() { return Ok((false, trace)); }
let (matched, actual) = match condition.kind.as_str() {
"logic_and" => {
let value = !condition.inputs.is_empty() && condition.inputs.iter().all(|id| values.get(id).copied().unwrap_or(false));
(value, json!(condition.inputs.iter().map(|id| values.get(id).copied().unwrap_or(false)).collect::<Vec<_>>()))
}
"logic_or" => {
let value = !condition.inputs.is_empty() && condition.inputs.iter().any(|id| values.get(id).copied().unwrap_or(false));
(value, json!(condition.inputs.iter().map(|id| values.get(id).copied().unwrap_or(false)).collect::<Vec<_>>()))
}
"logic_not" => {
let value = condition.inputs.len() == 1 && !values.get(&condition.inputs[0]).copied().unwrap_or(false);
(value, json!(condition.inputs.first().and_then(|id| values.get(id)).copied()))
}
_ if flow_condition_kind_runtime(&condition.kind) => {
let predecessors_match = condition.inputs.iter().all(|id| values.get(id).copied().unwrap_or(false));
let (leaf, actual) = flow_leaf_observation(state, devices, &zones, outdoor_temperature, condition, &now, &settings, overrides).await?;
(predecessors_match && leaf, actual)
}
_ => (false, Value::Null),
};
values.insert(condition.id.clone(), matched);
final_id = Some(condition.id.clone());
trace.push(json!({
"node_id": condition.id,
"kind": condition.kind,
"matched": matched,
"actual": actual,
"expected": condition.config.get("value"),
"inputs": condition.inputs
}));
}
Ok((final_id.and_then(|id| values.get(&id).copied()).unwrap_or(false), trace))
}
async fn flow_conditions_match(state: &AppState, devices: &[Device], conditions: &[crate::models::FlowCondition]) -> Result<bool, AppError> {
let overrides = HashMap::new();
evaluate_flow_conditions_trace(state, devices, conditions, Local::now(), &overrides).await.map(|(matched, _)| matched)
}
fn flow_condition_kind_runtime(kind: &str) -> bool {
matches!(kind,
"weekday" | "time_range" | "date_range" | "outdoor_temperature" | "device_temperature" |
"zone_temperature" | "ha_state" | "ha_numeric" | "ha_attribute" | "ha_available" | "house_mode" |
"device_state" | "zone_state" | "group_state" | "night_mode" | "constant"
)
}
async fn apply_flow_zone_action(state: &AppState, zone_id: &str, preset: Option<&str>, action: &DeviceCommand) -> Result<bool, AppError> {
let _schedule_guard = state.lock_schedule_operation().await;
// Match the canonical thermostat ordering used by Web/HA zone control. This prevents
// a Flow action from racing a thermostat arbitration cycle that already holds a stale snapshot.
let _cycle_guard = state.lock_zone_control_cycle().await;
let _zone_guard = state.lock_zone_operation(zone_id).await;
let Some(mut zone) = state.db.get_zone(zone_id)? else { return Err(AppError::NotFound(format!("zone {zone_id}"))); };
if zone.device_manual_override || zone.local_thermostat_power.is_some() || temporary_quick_thermostat_is_active(&zone, Utc::now()) { return Ok(false); }
// Match the existing automatic-device ownership semantics: a disabled thermostat zone
// remains an explicit gate unless this Flow action is the actor re-enabling it.
if !zone.enabled && action.power != Some(true) { return Ok(false); }
let device_id = zone.device_id.clone();
if let Some(power) = action.power { zone.enabled = power; }
if let Some(mode) = action.mode.as_deref() {
match mode { "auto" => zone.inherit_house_mode = true, "heat" | "cool" => { zone.mode = mode.to_string(); zone.inherit_house_mode = false; }, _ => return Err(AppError::BadRequest("unsupported Flow thermostat mode".into())) }
}
match preset.unwrap_or("auto") {
"auto" => { zone.manual_preset = None; zone.manual_setpoint = None; zone.manual_override_until = None; }
"custom" => {
let target = action.target_temperature.ok_or_else(|| AppError::BadRequest("Flow custom thermostat action has no target".into()))?;
zone.manual_preset = Some("custom".into()); zone.manual_setpoint = Some((target.clamp(8.0,30.0)*2.0).round()/2.0);
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &state.db.list_schedules()?, Local::now());
}
value @ ("comfort" | "sleep" | "away") => {
zone.manual_preset = Some(value.into()); zone.manual_setpoint = None;
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &state.db.list_schedules()?, Local::now());
}
_ => return Err(AppError::BadRequest("unsupported Flow thermostat preset".into())),
}
rearm_compressor_queue(&mut zone);
if action.power == Some(false) {
zone.demand = false;
zone.demand_since = None;
zone.effective_mode = "off".into();
zone.device_setpoint = None;
}
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now();
zone.control_owner = "automation".into();
zone.control_source = "automation.flow".into();
zone.control_reason = "Visual Flow automation".into();
let schedules = state.db.list_schedules()?;
let house_mode = state.settings.read().await.house_mode.clone();
refresh_control_ownership(&mut zone, true);
if zone.control_owner == "automation" {
zone.control_source = "automation.flow".into();
zone.control_reason = "Visual Flow automation".into();
}
refresh_zone_runtime_target(&mut zone, &schedules, &house_mode);
state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); state.wake_zone_control();
if action.power == Some(false) {
// Disabled zones are intentionally skipped by the normal thermostat cycle. Perform the
// physical OFF under the canonical zone -> device lock order so the durable Flow intent
// cannot leave a unit running and polling/manual control cannot interleave with the frame.
let _device_guard = state.lock_device_operation(&device_id).await;
send_command_locked_forced(state, &device_id, DeviceCommand { power: Some(false), ..Default::default() }).await?;
}
Ok(true)
}
+5 -1
View File
@@ -4,6 +4,7 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
let devices = state.db.list_devices()?;
let zones = state.db.list_zones()?;
let groups = state.db.list_groups()?;
let zone_names: HashMap<String, String> = zones.iter().map(|zone| (zone.id.clone(), zone.name.clone())).collect();
let house_preset = zones.first().and_then(|first| {
let first_preset = first.manual_preset.as_deref().unwrap_or("auto");
zones.iter().all(|zone| zone.manual_preset.as_deref().unwrap_or("auto") == first_preset)
@@ -111,10 +112,13 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
}
let mut rules = Vec::new();
for item in state.db.list_automations()? {
let action_zone_name = item.action_zone_id.as_deref()
.and_then(|id| zone_names.get(id))
.cloned();
let action_group_name = item.action_group_id.as_deref()
.and_then(|id| groups.iter().find(|group| group.id == id))
.map(|group| group.name.clone());
let action_name = action_group_name.clone().unwrap_or_else(|| {
let action_name = action_zone_name.or_else(|| action_group_name.clone()).unwrap_or_else(|| {
devices.iter().find(|device| device.id == item.action_device_id)
.map(|device| device.name.clone())
.unwrap_or_else(|| item.action_device_id.clone())
+14 -3
View File
@@ -17,7 +17,7 @@ mod tests {
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(),
created_at: Utc::now(), updated_at: Utc::now(), flow_id: None, flow_node_id: None,
};
assert!(schedule_active(&item, now));
}
@@ -26,7 +26,7 @@ mod tests {
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(),
created_at: Utc::now(), updated_at: Utc::now(), flow_id: None, flow_node_id: None,
}
}
@@ -94,7 +94,7 @@ mod tests {
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, created_at: Utc::now(), updated_at: Utc::now(),
last_fired_at: None, action_zone_id: None, action_zone_preset: None, flow_conditions: vec![], flow_id: None, flow_node_id: None, 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));
@@ -173,6 +173,9 @@ mod tests {
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() }));
}
@@ -195,6 +198,14 @@ mod tests {
));
}
#[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");
+5 -1
View File
@@ -49,6 +49,7 @@ async fn send_automatic_device_command_if_owned(
if device_blocked_by_disabled_zone(device_id, &zones)
|| device_blocked_by_manual_override(device_id, &zones)
|| device_blocked_by_local_thermostat(device_id, &zones)
|| device_blocked_by_temporary_thermostat(device_id, &zones)
{
return Ok(None);
}
@@ -63,6 +64,9 @@ async fn apply_automatic_device_action(
// Direct automation target/setpoint is translated into zone state and may use the next
// schedule boundary. Serialize that derivation with schedule edits before locking the zone.
let _schedule_guard = state.lock_schedule_operation().await;
// Device automations can alter durable thermostat state. Serialize that transition with
// the thermostat cycle so it cannot act on a snapshot taken before this automation.
let _cycle_guard = state.lock_zone_control_cycle().await;
let zones = state.db.list_zones()?;
let Some(zone_id) = zones.iter().find(|zone| zone.device_id == device_id).map(|zone| zone.id.clone()) else {
return send_automatic_device_command_if_owned(state, device_id, command).await;
@@ -71,7 +75,7 @@ async fn apply_automatic_device_action(
let _zone_guard = state.lock_zone_operation(&zone_id).await;
let _device_guard = state.lock_device_operation(device_id).await;
let mut zone = state.db.get_zone(&zone_id)?.ok_or_else(|| AppError::NotFound(format!("zone {zone_id}")))?;
if zone.device_manual_override || zone.local_thermostat_power.is_some() {
if zone.device_manual_override || zone.local_thermostat_power.is_some() || temporary_quick_thermostat_is_active(&zone, Utc::now()) {
return Ok(None);
}
// A power-on automation is an explicit domain transition and may re-enable a zone that
+3
View File
@@ -674,6 +674,9 @@ async fn control_zones(state: &AppState) -> Result<()> {
"quiet": updated_device.quiet,
"sleep": updated_device.sleep,
"night_mode": night_active,
"schedule_id": active_schedule.map(|item| item.id.as_str()),
"flow_id": active_schedule.and_then(|item| item.flow_id.as_deref()),
"flow_node_id": active_schedule.and_then(|item| item.flow_node_id.as_deref()),
}));
}
Ok(None) => {