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
+1 -1
View File
@@ -87,7 +87,7 @@ impl AutomationInput {
action_group_id: self.action_group_id.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()),
action_preset: self.action_preset.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()),
action: self.action, cooldown_seconds: self.cooldown_seconds.max(30), last_fired_at,
action_zone_id: None, action_zone_preset: None, flow_conditions: vec![], flow_id: None, flow_node_id: None,
action_zone_id: None, action_zone_preset: None, action_ha_domain: None, action_ha_service: None, action_ha_entity_id: None, action_ha_data: Value::Null, flow_conditions: vec![], flow_id: None, flow_node_id: None, flow_runtime: Default::default(),
created_at, updated_at: Utc::now() }
}
}
+69 -5
View File
@@ -43,13 +43,13 @@ fn generated_flow_name(flow_id: &str, action_node_id: &str) -> String {
fn flow_condition_kind(kind: &str) -> bool {
matches!(kind,
"weekday" | "time_range" | "date_range" | "outdoor_temperature" | "device_temperature" |
"weekday" | "time_range" | "date_range" | "cron_trigger" | "stable_for" | "delay" | "rolling_stat" | "oscillates" | "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"
)
}
fn flow_logic_kind(kind: &str) -> bool { matches!(kind, "logic_and" | "logic_or" | "logic_not") }
fn flow_action_kind(kind: &str) -> bool { matches!(kind, "zone_thermostat" | "device_action" | "group_action") }
fn flow_action_kind(kind: &str) -> bool { matches!(kind, "zone_thermostat" | "device_action" | "group_action" | "ha_service_action") }
fn shared_input_comparison_kind(kind: &str) -> bool {
matches!(kind,
@@ -216,6 +216,44 @@ fn validate_condition(condition: &crate::models::FlowCondition, state: &AppState
let end = chrono::NaiveDate::parse_from_str(&end, "%Y-%m-%d").map_err(|_| AppError::BadRequest("invalid date range".into()))?;
if start > end { return Err(AppError::BadRequest("date range start must not be after end".into())); }
}
"cron_trigger" => {
let expr = flow_string(&condition.config, "expression").ok_or_else(|| AppError::BadRequest("cron block needs an expression".into()))?;
if !engine::cron_expression_valid(&expr) { return Err(AppError::BadRequest("invalid cron expression; expected 5 fields with *, */N, ranges or lists".into())); }
}
"stable_for" => {
let seconds = condition.config.get("seconds").and_then(Value::as_u64).unwrap_or(0);
if seconds == 0 || seconds > 604800 { return Err(AppError::BadRequest("stable-for duration must be between 1 second and 7 days".into())); }
if condition.inputs.len() != 1 { return Err(AppError::BadRequest("stable-for block needs exactly one input".into())); }
}
"delay" => {
let seconds = condition.config.get("seconds").and_then(Value::as_u64).unwrap_or(0);
if seconds == 0 || seconds > 604800 { return Err(AppError::BadRequest("delay must be between 1 second and 7 days".into())); }
if condition.inputs.len() != 1 { return Err(AppError::BadRequest("delay block needs exactly one input".into())); }
}
"rolling_stat" => {
let source = flow_string(&condition.config, "source").unwrap_or_default();
if !matches!(source.as_str(), "outdoor_temperature" | "device_temperature" | "zone_temperature" | "ha_numeric") { return Err(AppError::BadRequest("rolling statistic has an unsupported source".into())); }
if source == "device_temperature" { let id=flow_string(&condition.config,"device_id").unwrap_or_default(); if state.db.get_device(&id)?.is_none(){return Err(AppError::BadRequest("rolling statistic references a missing device".into()));} }
if source == "zone_temperature" { let id=flow_string(&condition.config,"zone_id").unwrap_or_default(); if state.db.get_zone(&id)?.is_none(){return Err(AppError::BadRequest("rolling statistic references a missing zone".into()));} }
if source == "ha_numeric" && flow_string(&condition.config,"entity_id").is_none() { return Err(AppError::BadRequest("rolling HA statistic needs entity_id".into())); }
let window = condition.config.get("window_seconds").and_then(Value::as_u64).unwrap_or(0);
if window < 10 || window > 604800 { return Err(AppError::BadRequest("rolling statistic window must be between 10 seconds and 7 days".into())); }
if !matches!(flow_string(&condition.config,"statistic").as_deref(), Some("mean") | Some("median")) { return Err(AppError::BadRequest("rolling statistic must be mean or median".into())); }
validate_flow_comparison(&condition.config)?;
}
"oscillates" => {
let source = flow_string(&condition.config, "source").unwrap_or_default();
if !matches!(source.as_str(), "outdoor_temperature" | "device_temperature" | "zone_temperature" | "ha_numeric") { return Err(AppError::BadRequest("oscillation block has an unsupported source".into())); }
if source == "device_temperature" { let id=flow_string(&condition.config,"device_id").unwrap_or_default(); if state.db.get_device(&id)?.is_none(){return Err(AppError::BadRequest("oscillation block references a missing device".into()));} }
if source == "zone_temperature" { let id=flow_string(&condition.config,"zone_id").unwrap_or_default(); if state.db.get_zone(&id)?.is_none(){return Err(AppError::BadRequest("oscillation block references a missing zone".into()));} }
if source == "ha_numeric" && flow_string(&condition.config,"entity_id").is_none() { return Err(AppError::BadRequest("oscillation HA source needs entity_id".into())); }
let window = condition.config.get("window_seconds").and_then(Value::as_u64).unwrap_or(0);
if window < 10 || window > 604800 { return Err(AppError::BadRequest("oscillation window must be between 10 seconds and 7 days".into())); }
let min_span = flow_f64(&condition.config, "min_span").unwrap_or(0.0);
if !min_span.is_finite() || min_span <= 0.0 { return Err(AppError::BadRequest("oscillation minimum span must be greater than zero".into())); }
let min_changes = condition.config.get("min_direction_changes").and_then(Value::as_u64).unwrap_or(0);
if min_changes == 0 || min_changes > 1000 { return Err(AppError::BadRequest("oscillation direction changes must be between 1 and 1000".into())); }
}
"outdoor_temperature" => { validate_flow_comparison(&condition.config)?; }
"device_temperature" => {
validate_flow_comparison(&condition.config)?;
@@ -364,12 +402,26 @@ fn compile_flow(state: &AppState, mut flow: crate::models::Flow) -> Result<(crat
let id = format!("flow:{}:automation:{}", flow.id, action_node.id);
let created_at = previous_automations.get(&id).map(|item| item.created_at.clone()).unwrap_or_else(|| now.clone());
let last_fired_at = previous_automations.get(&id).and_then(|item| item.last_fired_at.clone());
let flow_runtime = previous_automations.get(&id).map(|item| {
let previous_conditions = item.flow_conditions.iter().map(|condition| (condition.id.as_str(), condition)).collect::<std::collections::HashMap<_, _>>();
let current_conditions = conditions.iter().map(|condition| (condition.id.as_str(), condition)).collect::<std::collections::HashMap<_, _>>();
let mut runtime = item.flow_runtime.clone();
runtime.retain(|node_id, _| {
let Some(previous) = previous_conditions.get(node_id.as_str()) else { return false; };
let Some(current) = current_conditions.get(node_id.as_str()) else { return false; };
matches!(current.kind.as_str(), "stable_for" | "delay" | "rolling_stat" | "oscillates")
&& previous.kind == current.kind
&& previous.config == current.config
&& previous.inputs == current.inputs
});
runtime
}).unwrap_or_default();
let mut item = Automation {
id, name: generated_flow_name(&flow.id, &action_node.id), enabled: flow.enabled,
trigger_kind: "flow".into(), trigger_device_id: None, threshold: None, at_time: None,
action_device_id: String::new(), action_group_id: None, action_preset: None, action: DeviceCommand::default(), cooldown_seconds: 60,
last_fired_at, action_zone_id: None, action_zone_preset: None, flow_conditions: conditions,
flow_id: Some(flow.id.clone()), flow_node_id: Some(action_node.id.clone()), created_at, updated_at: now.clone(),
last_fired_at, action_zone_id: None, action_zone_preset: None, action_ha_domain: None, action_ha_service: None, action_ha_entity_id: None, action_ha_data: Value::Null, flow_conditions: conditions,
flow_id: Some(flow.id.clone()), flow_node_id: Some(action_node.id.clone()), flow_runtime, created_at, updated_at: now.clone(),
};
if let Some(cooldown) = action_node.config.get("cooldown_seconds").and_then(Value::as_u64) { item.cooldown_seconds = cooldown.max(30); }
match action_node.kind.as_str() {
@@ -403,6 +455,15 @@ fn compile_flow(state: &AppState, mut flow: crate::models::Flow) -> Result<(crat
item.action.sleep = flow_bool(&action_node.config, "sleep");
engine::validate_command(&item.action)?; if item.action.is_empty() { return Err(AppError::BadRequest("device action cannot be empty".into())); }
}
"ha_service_action" => {
let domain = flow_string(&action_node.config, "domain").ok_or_else(|| AppError::BadRequest("Home Assistant action needs a domain".into()))?;
let service = flow_string(&action_node.config, "service").ok_or_else(|| AppError::BadRequest("Home Assistant action needs a service".into()))?;
item.action_ha_domain = Some(domain);
item.action_ha_service = Some(service);
item.action_ha_entity_id = flow_string(&action_node.config, "entity_id");
item.action_ha_data = action_node.config.get("data").cloned().unwrap_or_else(|| json!({}));
if !item.action_ha_data.is_object() { return Err(AppError::BadRequest("Home Assistant service data must be a JSON object".into())); }
}
"group_action" => {
let id = flow_string(&action_node.config, "group_id").ok_or_else(|| AppError::BadRequest("group action needs a group".into()))?;
if !groups.iter().any(|g| g.id == id) { return Err(AppError::BadRequest("group action references a missing group".into())); }
@@ -581,6 +642,7 @@ fn dry_run_block_reason(state: &AppState, action: &crate::models::FlowNode) -> R
if climate_change && !resulting_enabled { return Ok(Some("group_control_disabled".into())); }
Ok(None)
}
"ha_service_action" => Ok(None),
_ => Ok(Some("unsupported_action".into())),
}
}
@@ -602,7 +664,9 @@ async fn simulate_flow(State(state): State<AppState>, Json(input): Json<FlowSimu
let mut actions = Vec::new();
for action in input.flow.nodes.iter().filter(|node| flow_action_kind(&node.kind)) {
let program = compile_flow_program(&action.id, &input.flow.nodes, &input.flow.edges)?;
let (matched, trace) = engine::evaluate_flow_conditions_trace(&state, &devices, &program, at.clone(), &input.overrides).await?;
let automation_id = format!("flow:{preview_id}:automation:{}", action.id);
let mut runtime = state.db.get_automation(&automation_id)?.map(|item| item.flow_runtime).unwrap_or_default();
let (matched, trace) = engine::evaluate_flow_conditions_trace(&state, &devices, &program, at.clone(), &input.overrides, Some(&mut runtime)).await?;
let blocked_reason = if matched && !input.flow.enabled { Some("flow_disabled".into()) }
else if matched { dry_run_block_reason(&state, action)? } else { None };
actions.push(json!({