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