v0.15.9
This commit is contained in:
+38
-10
@@ -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 != ¤t);
|
||||
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
@@ -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]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user