v0.11.1
This commit is contained in:
+105
-8
@@ -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 != ¤t);
|
||||
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!({
|
||||
|
||||
Reference in New Issue
Block a user