This commit is contained in:
Mateusz Gruszczyński
2026-09-02 22:58:55 +02:00
parent db6d2f09db
commit 563523d2e2
18 changed files with 420 additions and 62 deletions
+105 -8
View File
@@ -21,13 +21,11 @@ async fn run_automations(state: &AppState) -> Result<()> {
let mut claimed_devices = std::collections::HashSet::<String>::new();
for mut item in automations {
if !item.enabled || !automation_ready(&item) { continue; }
if !item.enabled { continue; }
let ready = automation_ready(&item);
let should_fire = match item.trigger_kind.as_str() {
"temperature_above" => find_temperature(&devices, item.trigger_device_id.as_deref())
.zip(item.threshold).map(|(t, threshold)| t > threshold).unwrap_or(false),
"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()),
// 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;
@@ -40,9 +38,14 @@ async fn run_automations(state: &AppState) -> Result<()> {
}
}
},
"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 !should_fire { continue; }
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
@@ -153,7 +156,9 @@ async fn run_automations(state: &AppState) -> Result<()> {
match result {
Ok(true) => {
for device_id in target_devices { claimed_devices.insert(device_id); }
item.last_fired_at = Some(Utc::now());
let fired_at = Utc::now();
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)?;
state.log("info", "automation.fired", &format!("Automation {} fired", item.name), json!({
@@ -369,6 +374,53 @@ fn flow_timed_gate_update(
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) -> bool {
let changed = state.last_value.as_ref().is_some_and(|previous| previous != &current);
state.last_value = Some(current);
changed
}
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,
@@ -606,6 +658,7 @@ pub async fn evaluate_flow_conditions_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)); }
@@ -634,6 +687,49 @@ pub async fn evaluate_flow_conditions_trace(
} 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 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 = flow_change_gate_update(state, current);
}
(input && changed, json!({"input": input, "mode": mode, "previous": previous, "current": observed, "changed": changed}))
}
"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("");
@@ -712,6 +808,7 @@ pub async fn evaluate_flow_conditions_trace(
_ => (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!({