This commit is contained in:
Mateusz Gruszczyński
2026-09-02 22:41:27 +02:00
parent 8004be0841
commit db6d2f09db
20 changed files with 945 additions and 59 deletions
+216 -6
View File
@@ -28,12 +28,17 @@ 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 {
"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
}
}
},
_ => false,
};
@@ -92,7 +97,9 @@ async fn run_automations(state: &AppState) -> Result<()> {
continue;
}
let target_devices: Vec<String> = if let Some(zone_id) = item.action_zone_id.as_deref() {
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)
@@ -115,7 +122,13 @@ async fn run_automations(state: &AppState) -> Result<()> {
continue;
}
let result: Result<bool, AppError> = if let Some(zone_id) = item.action_zone_id.as_deref() {
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() });
@@ -275,6 +288,108 @@ fn flow_zone_state_value(zone: &Zone, field: &str) -> Option<Value> {
})
}
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_rolling_stat_update(
state: &mut crate::models::FlowRuntimeNodeState,
sample: f64,
window_seconds: u64,
statistic: &str,
now: DateTime<Utc>,
) -> Option<f64> {
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, value: sample });
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],
@@ -339,6 +454,10 @@ async fn flow_leaf_observation(
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);
@@ -468,6 +587,7 @@ pub async fn evaluate_flow_conditions_trace(
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();
@@ -502,6 +622,88 @@ pub async fn evaluate_flow_conditions_trace(
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}))
}
"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(sample), Some(map)) = (sample, runtime.as_deref_mut()) {
let node_state = map.entry(condition.id.clone()).or_default();
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();
}
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?;
@@ -524,14 +726,22 @@ pub async fn evaluate_flow_conditions_trace(
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> {
async fn flow_conditions_match(state: &AppState, devices: &[Device], item: &mut Automation) -> Result<bool, AppError> {
let overrides = HashMap::new();
evaluate_flow_conditions_trace(state, devices, conditions, Local::now(), &overrides).await.map(|(matched, _)| matched)
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" | "outdoor_temperature" | "device_temperature" |
"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"
)