979 lines
54 KiB
Rust
979 lines
54 KiB
Rust
|
|
pub fn automation_action_conflicts_with_thermostat(command: &DeviceCommand) -> bool {
|
|
// 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_thermostat_zone(device_id: &str, zones: &[Zone]) -> bool {
|
|
zones.iter().any(|zone| zone.device_id == device_id)
|
|
}
|
|
|
|
async fn run_automations(state: &AppState) -> Result<()> {
|
|
let devices = state.db.list_devices()?;
|
|
let mut automations = state.db.list_automations()?;
|
|
// Stable arbitration for same-cycle conflicts: the oldest configured rule wins, then ID.
|
|
// This avoids database row order deciding the physical outcome (M2).
|
|
automations.sort_by(|a, b| a.created_at.cmp(&b.created_at).then_with(|| a.id.cmp(&b.id)));
|
|
let mut claimed_devices = std::collections::HashSet::<String>::new();
|
|
|
|
for mut item in automations {
|
|
if !item.enabled { continue; }
|
|
let ready = automation_ready(&item);
|
|
let should_fire = match item.trigger_kind.as_str() {
|
|
// Stateful Flow blocks must keep observing while an action is in cooldown. Otherwise
|
|
// stable/change-duration state could silently span an unobserved interval.
|
|
"flow" => {
|
|
let before_runtime = item.flow_runtime.clone();
|
|
let result = flow_conditions_match(state, &devices, &mut item).await;
|
|
if item.flow_runtime != before_runtime { state.db.save_automation(&item)?; }
|
|
match result {
|
|
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
|
|
}
|
|
}
|
|
},
|
|
"temperature_above" if ready => find_temperature(&devices, item.trigger_device_id.as_deref())
|
|
.zip(item.threshold).map(|(t, threshold)| t > threshold).unwrap_or(false),
|
|
"temperature_below" if ready => find_temperature(&devices, item.trigger_device_id.as_deref())
|
|
.zip(item.threshold).map(|(t, threshold)| t < threshold).unwrap_or(false),
|
|
"time" if ready => time_automation_due(&item, Local::now()),
|
|
_ => false,
|
|
};
|
|
if !ready || !should_fire { continue; }
|
|
|
|
// API edits/deletes and execution share one short ownership window. If the rule
|
|
// changed since this cycle snapshot was taken, skip it now and evaluate the new
|
|
// definition on the next cycle instead of firing stale configuration.
|
|
let _automation_guard = state.lock_automation_operation().await;
|
|
let Some(latest_item) = state.db.get_automation(&item.id)? else { continue; };
|
|
if latest_item.updated_at != item.updated_at { continue; }
|
|
item = latest_item;
|
|
// Group membership and zone ownership may have changed after the cycle snapshot but
|
|
// before we acquired the automation lock. Reload them inside this serialized window so
|
|
// same-cycle conflict arbitration claims the actual current target set.
|
|
let zones = state.db.list_zones()?;
|
|
let groups = state.db.list_groups()?;
|
|
|
|
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, "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_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, "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_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, "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_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, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id
|
|
}));
|
|
continue;
|
|
}
|
|
|
|
let target_devices: Vec<String> = if item.action_ha_domain.is_some() {
|
|
item.action_ha_entity_id.as_ref().map(|entity_id| vec![format!("ha:{entity_id}")]).unwrap_or_default()
|
|
} else 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()))
|
|
.collect())
|
|
.unwrap_or_default()
|
|
} else {
|
|
vec![item.action_device_id.clone()]
|
|
};
|
|
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,
|
|
}));
|
|
continue;
|
|
}
|
|
|
|
let result: Result<bool, AppError> = if let (Some(domain), Some(service)) = (item.action_ha_domain.as_deref(), item.action_ha_service.as_deref()) {
|
|
let settings = state.settings.read().await.clone();
|
|
match home_assistant::call_service(&state.http, &settings.home_assistant, domain, service, item.action_ha_entity_id.as_deref(), &item.action_ha_data).await {
|
|
Ok(_) => Ok(true),
|
|
Err(err) => Err(AppError::BadRequest(format!("Home Assistant service action failed: {err}"))),
|
|
}
|
|
} else 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: 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, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id
|
|
}));
|
|
Ok(false)
|
|
}
|
|
Err(err) => Err(err),
|
|
}
|
|
};
|
|
match result {
|
|
Ok(true) => {
|
|
for device_id in target_devices { claimed_devices.insert(device_id); }
|
|
let fired_at = Utc::now();
|
|
flow_acknowledge_change_gates(&mut item);
|
|
flow_record_rate_limited_execution(&mut item, fired_at.clone());
|
|
item.last_fired_at = Some(fired_at);
|
|
item.updated_at = Utc::now();
|
|
state.db.save_automation(&item)?;
|
|
// Keep connected clients in sync with runtime metadata such as last_fired_at.
|
|
// automation.updated also invalidates the materialized control plan.
|
|
state.broadcast("automation.updated", serde_json::to_value(&item)?);
|
|
state.log("info", "automation.fired", &format!("Automation {} fired", 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
|
|
}));
|
|
}
|
|
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
|
|
// so an offline/disabled target cannot be hammered on every automation cycle.
|
|
item.last_fired_at = Some(Utc::now());
|
|
item.updated_at = Utc::now();
|
|
state.db.save_automation(&item)?;
|
|
state.broadcast("automation.updated", serde_json::to_value(&item)?);
|
|
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}));
|
|
}
|
|
}
|
|
}
|
|
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_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.
|
|
devices.iter().find(|d| d.id == id && d.enabled && d.online && d.communication_failures == 0)?.current_temperature
|
|
}
|
|
|
|
fn automation_ready(item: &Automation) -> bool {
|
|
item.last_fired_at.map(|last| (Utc::now() - last).num_seconds().max(0) as u64 >= item.cooldown_seconds).unwrap_or(true)
|
|
}
|
|
|
|
fn time_automation_due(item: &Automation, now: DateTime<Local>) -> bool {
|
|
let Some(expected) = item.at_time.as_deref() else { return false; };
|
|
let Ok(value) = NaiveTime::parse_from_str(expected, "%H:%M") else { return false; };
|
|
if now.hour() != value.hour() || now.minute() != value.minute() { return false; }
|
|
if let Some(last) = item.last_fired_at {
|
|
let local_last = last.with_timezone(&Local);
|
|
if local_last.date_naive() == now.date_naive()
|
|
&& local_last.hour() == now.hour()
|
|
&& local_last.minute() == now.minute()
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
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_louver_compat_value(field: &str, value: Value) -> Value {
|
|
if !matches!(field, "swing_vertical" | "swing_horizontal") {
|
|
return value;
|
|
}
|
|
match value {
|
|
Value::Bool(value) => json!(u8::from(value)),
|
|
Value::String(value) if value.eq_ignore_ascii_case("true") => json!(1),
|
|
Value::String(value) if value.eq_ignore_ascii_case("false") => json!(0),
|
|
value => value,
|
|
}
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
|
|
fn cron_field_matches(field: &str, value: u32, min: u32, max: u32) -> bool {
|
|
fn part_matches(part: &str, value: u32, min: u32, max: u32) -> bool {
|
|
if part == "*" { return true; }
|
|
if let Some(step) = part.strip_prefix("*/").and_then(|v| v.parse::<u32>().ok()).filter(|v| *v > 0) {
|
|
return value >= min && value <= max && (value - min) % step == 0;
|
|
}
|
|
if let Some((a,b)) = part.split_once('-') {
|
|
if let (Ok(a), Ok(b)) = (a.parse::<u32>(), b.parse::<u32>()) { return a <= value && value <= b && a >= min && b <= max; }
|
|
return false;
|
|
}
|
|
part.parse::<u32>().ok().is_some_and(|v| v == value && v >= min && v <= max)
|
|
}
|
|
field.split(',').any(|part| part_matches(part.trim(), value, min, max))
|
|
}
|
|
|
|
pub(crate) fn cron_expression_valid(expression: &str) -> bool {
|
|
fn valid_field(field: &str, min: u32, max: u32, allow_seven: bool) -> bool {
|
|
let upper = if allow_seven { 7 } else { max };
|
|
if field.trim().is_empty() { return false; }
|
|
field.split(',').all(|part| {
|
|
let part = part.trim();
|
|
if part == "*" { return true; }
|
|
if let Some(raw) = part.strip_prefix("*/") {
|
|
return raw.parse::<u32>().ok().is_some_and(|step| step > 0 && step <= (max - min + 1));
|
|
}
|
|
if let Some((a, b)) = part.split_once('-') {
|
|
return a.parse::<u32>().ok().zip(b.parse::<u32>().ok())
|
|
.is_some_and(|(a, b)| a >= min && b <= upper && a <= b);
|
|
}
|
|
part.parse::<u32>().ok().is_some_and(|value| value >= min && value <= upper)
|
|
})
|
|
}
|
|
let fields: Vec<_> = expression.split_whitespace().collect();
|
|
fields.len() == 5
|
|
&& valid_field(fields[0], 0, 59, false)
|
|
&& valid_field(fields[1], 0, 23, false)
|
|
&& valid_field(fields[2], 1, 31, false)
|
|
&& valid_field(fields[3], 1, 12, false)
|
|
&& valid_field(fields[4], 0, 6, true)
|
|
}
|
|
|
|
fn cron_matches(expression: &str, now: &DateTime<Local>) -> bool {
|
|
if !cron_expression_valid(expression) { return false; }
|
|
let fields: Vec<_> = expression.split_whitespace().collect();
|
|
let weekday = now.weekday().num_days_from_sunday();
|
|
cron_field_matches(fields[0], now.minute(), 0, 59)
|
|
&& cron_field_matches(fields[1], now.hour(), 0, 23)
|
|
&& cron_field_matches(fields[2], now.day(), 1, 31)
|
|
&& cron_field_matches(fields[3], now.month(), 1, 12)
|
|
&& (cron_field_matches(fields[4], weekday, 0, 7) || (weekday == 0 && cron_field_matches(fields[4], 7, 0, 7)))
|
|
}
|
|
|
|
fn flow_oscillation_metrics(samples: &[crate::models::FlowRuntimeSample]) -> Option<(f64, usize)> {
|
|
if samples.len() < 3 { return None; }
|
|
let min = samples.iter().map(|item| item.value).fold(f64::INFINITY, f64::min);
|
|
let max = samples.iter().map(|item| item.value).fold(f64::NEG_INFINITY, f64::max);
|
|
if !min.is_finite() || !max.is_finite() { return None; }
|
|
let mut previous_sign = 0i8;
|
|
let mut direction_changes = 0usize;
|
|
for pair in samples.windows(2) {
|
|
let delta = pair[1].value - pair[0].value;
|
|
let sign = if delta > 0.000001 { 1 } else if delta < -0.000001 { -1 } else { 0 };
|
|
if sign == 0 { continue; }
|
|
if previous_sign != 0 && sign != previous_sign { direction_changes += 1; }
|
|
previous_sign = sign;
|
|
}
|
|
Some((max - min, direction_changes))
|
|
}
|
|
|
|
fn flow_timed_gate_update(
|
|
state: &mut crate::models::FlowRuntimeNodeState,
|
|
input: bool,
|
|
seconds: u64,
|
|
now: DateTime<Utc>,
|
|
) -> bool {
|
|
if !input { state.since = None; return false; }
|
|
let since = state.since.get_or_insert_with(|| now.clone());
|
|
now.signed_duration_since(since.clone()).num_seconds() >= seconds as i64
|
|
}
|
|
|
|
fn flow_state_duration_update(
|
|
state: &mut crate::models::FlowRuntimeNodeState,
|
|
input: bool,
|
|
min_seconds: u64,
|
|
max_seconds: Option<u64>,
|
|
now: DateTime<Utc>,
|
|
) -> (bool, u64) {
|
|
if !input { state.since = None; return (false, 0); }
|
|
let since = state.since.get_or_insert_with(|| now.clone());
|
|
let elapsed = now.signed_duration_since(since.clone()).num_seconds().max(0) as u64;
|
|
let within_min = elapsed >= min_seconds;
|
|
let within_max = max_seconds.map(|max| elapsed <= max).unwrap_or(true);
|
|
(within_min && within_max, elapsed)
|
|
}
|
|
|
|
fn flow_change_gate_update(
|
|
state: &mut crate::models::FlowRuntimeNodeState,
|
|
current: Value,
|
|
input: bool,
|
|
) -> (bool, bool) {
|
|
let changed = state.last_value.as_ref().is_some_and(|previous| previous != ¤t);
|
|
state.last_value = Some(current);
|
|
if !input {
|
|
state.pending = false;
|
|
} else if changed {
|
|
state.pending = true;
|
|
}
|
|
(changed, state.pending)
|
|
}
|
|
|
|
fn flow_acknowledge_change_gates(item: &mut Automation) {
|
|
for condition in item.flow_conditions.iter().filter(|condition| condition.kind == "on_change") {
|
|
if let Some(state) = item.flow_runtime.get_mut(&condition.id) {
|
|
state.pending = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn flow_rate_limit_status(
|
|
state: &mut crate::models::FlowRuntimeNodeState,
|
|
max_count: usize,
|
|
period_seconds: u64,
|
|
now: DateTime<Utc>,
|
|
) -> (bool, usize) {
|
|
let cutoff = now - chrono::Duration::seconds(period_seconds as i64);
|
|
state.samples.retain(|item| item.at >= cutoff);
|
|
let used = state.samples.len();
|
|
(used < max_count, used)
|
|
}
|
|
|
|
fn flow_record_rate_limited_execution(item: &mut Automation, now: DateTime<Utc>) {
|
|
let rate_limits = item.flow_conditions.iter()
|
|
.filter(|condition| condition.kind == "rate_limit")
|
|
.filter(|condition| item.flow_runtime.get(&condition.id).and_then(|state| state.last_value.as_ref()).and_then(Value::as_bool) == Some(true))
|
|
.map(|condition| (condition.id.clone(), condition.config.get("period_seconds").and_then(Value::as_u64).unwrap_or(3600)))
|
|
.collect::<Vec<_>>();
|
|
for (node_id, period_seconds) in rate_limits {
|
|
let state = item.flow_runtime.entry(node_id).or_default();
|
|
let cutoff = now.clone() - chrono::Duration::seconds(period_seconds as i64);
|
|
state.samples.retain(|sample| sample.at >= cutoff);
|
|
state.samples.push(crate::models::FlowRuntimeSample { at: now.clone(), value: 1.0 });
|
|
}
|
|
}
|
|
|
|
fn flow_rolling_stat_update(
|
|
state: &mut crate::models::FlowRuntimeNodeState,
|
|
sample: f64,
|
|
window_seconds: u64,
|
|
statistic: &str,
|
|
now: DateTime<Utc>,
|
|
) -> Option<f64> {
|
|
let started_at = state.since.get_or_insert_with(|| now.clone()).clone();
|
|
let cutoff = now.clone() - chrono::Duration::seconds(window_seconds as i64);
|
|
state.samples.retain(|item| item.at >= cutoff);
|
|
state.samples.push(crate::models::FlowRuntimeSample { at: now.clone(), value: sample });
|
|
if now.signed_duration_since(started_at).num_seconds().max(0) < window_seconds as i64 {
|
|
return None;
|
|
}
|
|
let mut values = state.samples.iter().map(|item| item.value).collect::<Vec<_>>();
|
|
if values.is_empty() { return None; }
|
|
if statistic == "median" {
|
|
values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
|
let mid = values.len() / 2;
|
|
Some(if values.len() % 2 == 0 { (values[mid - 1] + values[mid]) / 2.0 } else { values[mid] })
|
|
} else {
|
|
Some(values.iter().sum::<f64>() / values.len() as f64)
|
|
}
|
|
}
|
|
|
|
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 mut resolved_kind = condition.kind.as_str();
|
|
let mut resolved_config: Option<Value> = None;
|
|
if condition.kind == "shared_input" {
|
|
let input_id = condition.config.get("input_id").and_then(Value::as_str).unwrap_or("");
|
|
let Some(shared) = settings.home_assistant.flow_inputs.iter().find(|item| item.id == input_id) else {
|
|
return Ok((false, json!({"error": "missing_shared_input", "input_id": input_id})));
|
|
};
|
|
resolved_kind = shared.kind.as_str();
|
|
let mut config = shared.config.clone();
|
|
if matches!(shared.kind.as_str(),
|
|
"outdoor_temperature" | "device_temperature" | "zone_temperature" |
|
|
"ha_state" | "ha_numeric" | "ha_attribute" | "house_mode" |
|
|
"device_state" | "zone_state" | "group_state") {
|
|
let Some(operator) = condition.config.get("operator").and_then(Value::as_str).map(str::trim).filter(|value| !value.is_empty()) else {
|
|
return Ok((false, json!({"error": "missing_shared_input_operator", "input_id": input_id})));
|
|
};
|
|
let Some(map) = config.as_object_mut() else {
|
|
return Ok((false, json!({"error": "invalid_shared_input_config", "input_id": input_id})));
|
|
};
|
|
map.insert("operator".into(), Value::String(operator.to_string()));
|
|
map.insert("value".into(), condition.config.get("value").cloned().unwrap_or(Value::Null));
|
|
}
|
|
resolved_config = Some(config);
|
|
}
|
|
let c = resolved_config.as_ref().unwrap_or(&condition.config);
|
|
let override_value = overrides.get(&condition.id).cloned();
|
|
let (matched, actual) = match resolved_kind {
|
|
"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))
|
|
}
|
|
"cron_trigger" => {
|
|
let expression = c.get("expression").and_then(Value::as_str).unwrap_or("");
|
|
(cron_matches(expression, now), json!(now.format("%Y-%m-%d %H:%M").to_string()))
|
|
}
|
|
"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 = flow_louver_compat_value(
|
|
field,
|
|
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 = flow_louver_compat_value(
|
|
field,
|
|
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>,
|
|
mut runtime: Option<&mut std::collections::BTreeMap<String, crate::models::FlowRuntimeNodeState>>,
|
|
) -> 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 actual_values = HashMap::<String, Value>::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()))
|
|
}
|
|
"stable_for" | "delay" => {
|
|
let input = condition.inputs.len() == 1 && values.get(&condition.inputs[0]).copied().unwrap_or(false);
|
|
let seconds = condition.config.get("seconds").and_then(Value::as_u64).unwrap_or(1);
|
|
let mut since_value = None;
|
|
let value = if let Some(map) = runtime.as_deref_mut() {
|
|
let state = map.entry(condition.id.clone()).or_default();
|
|
let value = flow_timed_gate_update(state, input, seconds, now.with_timezone(&Utc));
|
|
since_value = state.since.clone();
|
|
value
|
|
} else { false };
|
|
(value, json!({"input": input, "since": since_value, "seconds": seconds}))
|
|
}
|
|
"state_duration" => {
|
|
let input = condition.inputs.len() == 1 && values.get(&condition.inputs[0]).copied().unwrap_or(false);
|
|
let min_seconds = condition.config.get("min_seconds").and_then(Value::as_u64).unwrap_or(0);
|
|
let max_seconds = condition.config.get("max_seconds").and_then(Value::as_u64);
|
|
let mut since_value = None;
|
|
let mut elapsed_seconds = 0u64;
|
|
let value = if let Some(map) = runtime.as_deref_mut() {
|
|
let state = map.entry(condition.id.clone()).or_default();
|
|
let (value, elapsed) = flow_state_duration_update(state, input, min_seconds, max_seconds, now.with_timezone(&Utc));
|
|
since_value = state.since.clone();
|
|
elapsed_seconds = elapsed;
|
|
value
|
|
} else { false };
|
|
(value, json!({"input": input, "since": since_value, "elapsed_seconds": elapsed_seconds, "min_seconds": min_seconds, "max_seconds": max_seconds}))
|
|
}
|
|
"on_change" => {
|
|
let input_id = condition.inputs.first().cloned().unwrap_or_default();
|
|
let input = condition.inputs.len() == 1 && values.get(&input_id).copied().unwrap_or(false);
|
|
let mode = condition.config.get("mode").and_then(Value::as_str).unwrap_or("result");
|
|
let observed = if mode == "value" { actual_values.get(&input_id).cloned() } else { Some(json!(input)) };
|
|
let mut changed = false;
|
|
let mut pending = false;
|
|
let mut previous = None;
|
|
if let (Some(current), Some(map)) = (observed.clone(), runtime.as_deref_mut()) {
|
|
let state = map.entry(condition.id.clone()).or_default();
|
|
previous = state.last_value.clone();
|
|
(changed, pending) = flow_change_gate_update(state, current, input);
|
|
}
|
|
(input && pending, json!({"input": input, "mode": mode, "previous": previous, "current": observed, "changed": changed, "pending": pending}))
|
|
}
|
|
"rate_limit" => {
|
|
let input = condition.inputs.len() == 1 && values.get(&condition.inputs[0]).copied().unwrap_or(false);
|
|
let max_count = condition.config.get("max_count").and_then(Value::as_u64).unwrap_or(1) as usize;
|
|
let period_seconds = condition.config.get("period_seconds").and_then(Value::as_u64).unwrap_or(3600);
|
|
let mut used = 0usize;
|
|
let available = if let Some(map) = runtime.as_deref_mut() {
|
|
let state = map.entry(condition.id.clone()).or_default();
|
|
let (available, count) = flow_rate_limit_status(state, max_count, period_seconds, now.with_timezone(&Utc));
|
|
used = count;
|
|
state.last_value = Some(json!(input && available));
|
|
available
|
|
} else { false };
|
|
(input && available, json!({"input": input, "used": used, "max_count": max_count, "period_seconds": period_seconds, "remaining": max_count.saturating_sub(used)}))
|
|
}
|
|
"rolling_stat" => {
|
|
let predecessors_match = condition.inputs.iter().all(|id| values.get(id).copied().unwrap_or(false));
|
|
let source = condition.config.get("source").and_then(Value::as_str).unwrap_or("");
|
|
let sample = if let Some(value) = overrides.get(&condition.id).and_then(Value::as_f64) {
|
|
Some(value)
|
|
} else {
|
|
match source {
|
|
"outdoor_temperature" => outdoor_temperature,
|
|
"device_temperature" => condition.config.get("device_id").and_then(Value::as_str).and_then(|id| find_temperature(devices, Some(id))),
|
|
"zone_temperature" => condition.config.get("zone_id").and_then(Value::as_str).and_then(|id| zones.iter().find(|zone| zone.id == id).and_then(|zone| zone.current_temperature)),
|
|
"ha_numeric" => {
|
|
let entity = condition.config.get("entity_id").and_then(Value::as_str).unwrap_or("");
|
|
match home_assistant::read_state(&state.http, &settings.home_assistant, Some(entity)).await { Ok(raw) => raw.parse::<f64>().ok(), Err(_) => None }
|
|
}
|
|
_ => None,
|
|
}
|
|
};
|
|
let window = condition.config.get("window_seconds").and_then(Value::as_u64).unwrap_or(60);
|
|
let mut aggregate = None;
|
|
let mut count = 0usize;
|
|
if let Some(map) = runtime.as_deref_mut() {
|
|
let node_state = map.entry(condition.id.clone()).or_default();
|
|
if let Some(sample) = sample {
|
|
aggregate = flow_rolling_stat_update(
|
|
node_state, sample, window, condition.config.get("statistic").and_then(Value::as_str).unwrap_or("mean"), now.with_timezone(&Utc),
|
|
);
|
|
count = node_state.samples.len();
|
|
} else {
|
|
node_state.since = None;
|
|
node_state.samples.clear();
|
|
}
|
|
}
|
|
let expected = condition.config.get("value").and_then(Value::as_f64);
|
|
let matched = predecessors_match && aggregate.zip(expected).map(|(a,e)| flow_compare(a, condition.config.get("operator").and_then(Value::as_str).unwrap_or("lt"), e)).unwrap_or(false);
|
|
(matched, json!({"sample": sample, "aggregate": aggregate, "samples": count, "window_seconds": window, "statistic": condition.config.get("statistic")}))
|
|
}
|
|
"oscillates" => {
|
|
let predecessors_match = condition.inputs.iter().all(|id| values.get(id).copied().unwrap_or(false));
|
|
let source = condition.config.get("source").and_then(Value::as_str).unwrap_or("");
|
|
let sample = if let Some(value) = overrides.get(&condition.id).and_then(Value::as_f64) {
|
|
Some(value)
|
|
} else {
|
|
match source {
|
|
"outdoor_temperature" => outdoor_temperature,
|
|
"device_temperature" => condition.config.get("device_id").and_then(Value::as_str).and_then(|id| find_temperature(devices, Some(id))),
|
|
"zone_temperature" => condition.config.get("zone_id").and_then(Value::as_str).and_then(|id| zones.iter().find(|zone| zone.id == id).and_then(|zone| zone.current_temperature)),
|
|
"ha_numeric" => {
|
|
let entity = condition.config.get("entity_id").and_then(Value::as_str).unwrap_or("");
|
|
match home_assistant::read_state(&state.http, &settings.home_assistant, Some(entity)).await { Ok(raw) => raw.parse::<f64>().ok(), Err(_) => None }
|
|
}
|
|
_ => None,
|
|
}
|
|
};
|
|
let window = condition.config.get("window_seconds").and_then(Value::as_u64).unwrap_or(300);
|
|
let min_span = condition.config.get("min_span").and_then(Value::as_f64).unwrap_or(1.0);
|
|
let min_changes = condition.config.get("min_direction_changes").and_then(Value::as_u64).unwrap_or(2) as usize;
|
|
let cutoff = now.with_timezone(&Utc) - chrono::Duration::seconds(window as i64);
|
|
let mut count = 0usize;
|
|
let mut span = None;
|
|
let mut direction_changes = 0usize;
|
|
if let (Some(sample), Some(map)) = (sample, runtime.as_deref_mut()) {
|
|
let node_state = map.entry(condition.id.clone()).or_default();
|
|
node_state.samples.retain(|item| item.at >= cutoff);
|
|
node_state.samples.push(crate::models::FlowRuntimeSample { at: now.with_timezone(&Utc), value: sample });
|
|
count = node_state.samples.len();
|
|
if let Some((value_span, changes)) = flow_oscillation_metrics(&node_state.samples) {
|
|
span = Some(value_span);
|
|
direction_changes = changes;
|
|
}
|
|
}
|
|
let matched = predecessors_match
|
|
&& span.is_some_and(|value| value >= min_span)
|
|
&& direction_changes >= min_changes;
|
|
(matched, json!({"sample": sample, "samples": count, "window_seconds": window, "span": span, "min_span": min_span, "direction_changes": direction_changes, "min_direction_changes": min_changes}))
|
|
}
|
|
_ 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);
|
|
actual_values.insert(condition.id.clone(), actual.clone());
|
|
final_id = Some(condition.id.clone());
|
|
let expected = condition.config.get("value").cloned();
|
|
trace.push(json!({
|
|
"node_id": condition.id,
|
|
"kind": condition.kind,
|
|
"matched": matched,
|
|
"actual": actual,
|
|
"expected": expected,
|
|
"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], item: &mut Automation) -> Result<bool, AppError> {
|
|
let overrides = HashMap::new();
|
|
let conditions = item.flow_conditions.clone();
|
|
let now = Local::now();
|
|
let (matched, _) = evaluate_flow_conditions_trace(state, devices, &conditions, now.clone(), &overrides, Some(&mut item.flow_runtime)).await?;
|
|
if matched && conditions.iter().any(|condition| condition.kind == "cron_trigger") {
|
|
if let Some(last) = item.last_fired_at.as_ref().map(|value| value.with_timezone(&Local)) {
|
|
if last.year() == now.year() && last.ordinal() == now.ordinal() && last.hour() == now.hour() && last.minute() == now.minute() { return Ok(false); }
|
|
}
|
|
}
|
|
Ok(matched)
|
|
}
|
|
|
|
fn flow_condition_kind_runtime(kind: &str) -> bool {
|
|
matches!(kind,
|
|
"weekday" | "time_range" | "date_range" | "cron_trigger" | "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" | "shared_input"
|
|
)
|
|
}
|
|
|
|
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(normalize_thermostat_target(target.clamp(8.0, 30.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);
|
|
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)?);
|
|
|
|
let swing_command = DeviceCommand {
|
|
swing_vertical: action.swing_vertical,
|
|
swing_horizontal: action.swing_horizontal,
|
|
..Default::default()
|
|
};
|
|
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.
|
|
// Swing can safely share this explicit frame because it is outside thermostat regulation.
|
|
let _device_guard = state.lock_device_operation(&device_id).await;
|
|
send_command_locked_forced(
|
|
state,
|
|
&device_id,
|
|
DeviceCommand {
|
|
power: Some(false),
|
|
swing_vertical: swing_command.swing_vertical,
|
|
swing_horizontal: swing_command.swing_horizontal,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await?;
|
|
} else if !swing_command.is_empty() {
|
|
// Swing is intentionally a one-shot auxiliary unit setting. It does not participate in
|
|
// temperature/fan regulation, so the thermostat keeps ownership of the zone.
|
|
let _ = send_automatic_device_command_if_owned(state, &device_id, swing_command).await?;
|
|
}
|
|
state.wake_zone_control();
|
|
Ok(true)
|
|
}
|