This commit is contained in:
Mateusz Gruszczyński
2026-09-19 21:37:46 +02:00
parent 044adef401
commit ac2dd8b62a
21 changed files with 288 additions and 97 deletions
+38 -10
View File
@@ -157,6 +157,7 @@ async fn run_automations(state: &AppState) -> 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();
@@ -405,10 +406,27 @@ fn flow_state_duration_update(
(within_min && within_max, elapsed)
}
fn flow_change_gate_update(state: &mut crate::models::FlowRuntimeNodeState, current: Value) -> bool {
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 != &current);
state.last_value = Some(current);
changed
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(
@@ -444,9 +462,13 @@ fn flow_rolling_stat_update(
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, value: sample });
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" {
@@ -730,13 +752,14 @@ pub async fn evaluate_flow_conditions_trace(
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 = flow_change_gate_update(state, current);
(changed, pending) = flow_change_gate_update(state, current, input);
}
(input && changed, json!({"input": input, "mode": mode, "previous": previous, "current": observed, "changed": changed}))
(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);
@@ -772,12 +795,17 @@ pub async fn evaluate_flow_conditions_trace(
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()) {
if let Some(map) = 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();
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);
+17 -27
View File
@@ -306,12 +306,15 @@ mod tests {
}
#[test]
fn flow_change_gate_ignores_first_observation_and_detects_later_change() {
fn flow_change_gate_latches_until_action_ack_or_input_reset() {
let mut state = crate::models::FlowRuntimeNodeState::default();
assert!(!flow_change_gate_update(&mut state, json!("off")));
assert!(!flow_change_gate_update(&mut state, json!("off")));
assert!(flow_change_gate_update(&mut state, json!("on")));
assert!(!flow_change_gate_update(&mut state, json!("on")));
assert_eq!(flow_change_gate_update(&mut state, json!(false), false), (false, false));
assert_eq!(flow_change_gate_update(&mut state, json!(true), true), (true, true));
assert_eq!(flow_change_gate_update(&mut state, json!(true), true), (false, true));
state.pending = false;
assert_eq!(flow_change_gate_update(&mut state, json!(true), true), (false, false));
state.pending = true;
assert_eq!(flow_change_gate_update(&mut state, json!(false), false), (true, false));
}
#[test]
@@ -337,30 +340,27 @@ mod tests {
}
#[test]
fn flow_rolling_statistics_compute_mean_median_and_prune_window() {
fn flow_rolling_statistics_require_a_full_window_and_prune_samples() {
let mut state = crate::models::FlowRuntimeNodeState::default();
let start = Utc.with_ymd_and_hms(2026, 9, 2, 12, 0, 0).unwrap();
assert_eq!(
flow_rolling_stat_update(&mut state, 10.0, 60, "mean", start),
Some(10.0)
);
assert_eq!(flow_rolling_stat_update(&mut state, 10.0, 60, "mean", start), None);
assert_eq!(
flow_rolling_stat_update(
&mut state,
20.0,
60,
"mean",
start + chrono::Duration::seconds(10)
start + chrono::Duration::seconds(30)
),
Some(15.0)
None
);
assert_eq!(
flow_rolling_stat_update(
&mut state,
30.0,
60,
"median",
start + chrono::Duration::seconds(20)
"mean",
start + chrono::Duration::seconds(60)
),
Some(20.0)
);
@@ -370,21 +370,11 @@ mod tests {
40.0,
60,
"median",
start + chrono::Duration::seconds(30)
start + chrono::Duration::seconds(90)
),
Some(25.0)
Some(30.0)
);
assert_eq!(
flow_rolling_stat_update(
&mut state,
100.0,
60,
"mean",
start + chrono::Duration::seconds(100)
),
Some(100.0)
);
assert_eq!(state.samples.len(), 1);
assert_eq!(state.samples.len(), 3);
}
#[test]
+3
View File
@@ -34,6 +34,9 @@ pub struct FlowRuntimeNodeState {
/// Last observed raw/result value for edge/change detection blocks.
#[serde(default)]
pub last_value: Option<Value>,
/// Latched change event waiting for its downstream action to execute successfully.
#[serde(default)]
pub pending: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]