|
|
|
@@ -0,0 +1,561 @@
|
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
struct FlowInput {
|
|
|
|
|
name: String,
|
|
|
|
|
#[serde(default = "yes")]
|
|
|
|
|
enabled: bool,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
description: String,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
nodes: Vec<crate::models::FlowNode>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
edges: Vec<crate::models::FlowEdge>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
expected_revision: Option<u64>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
struct FlowSimulationInput {
|
|
|
|
|
flow: FlowInput,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
flow_id: Option<String>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
at: Option<String>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
overrides: std::collections::HashMap<String, Value>,
|
|
|
|
|
#[serde(default = "yes")]
|
|
|
|
|
log: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
struct FlowLogsQuery { limit: Option<u32> }
|
|
|
|
|
|
|
|
|
|
fn flow_string(config: &Value, key: &str) -> Option<String> {
|
|
|
|
|
config.get(key).and_then(Value::as_str).map(str::trim).filter(|v| !v.is_empty()).map(str::to_string)
|
|
|
|
|
}
|
|
|
|
|
fn flow_f64(config: &Value, key: &str) -> Option<f64> { config.get(key).and_then(Value::as_f64) }
|
|
|
|
|
fn flow_u8(config: &Value, key: &str) -> Option<u8> { config.get(key).and_then(Value::as_u64).and_then(|value| u8::try_from(value).ok()) }
|
|
|
|
|
fn flow_bool(config: &Value, key: &str) -> Option<bool> { config.get(key).and_then(Value::as_bool) }
|
|
|
|
|
|
|
|
|
|
fn flow_condition_kind(kind: &str) -> bool {
|
|
|
|
|
matches!(kind,
|
|
|
|
|
"weekday" | "time_range" | "date_range" | "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"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
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 validate_flow_graph(input: &FlowInput) -> Result<(), AppError> {
|
|
|
|
|
if input.name.trim().is_empty() { return Err(AppError::BadRequest("flow name is required".into())); }
|
|
|
|
|
if input.nodes.is_empty() { return Err(AppError::BadRequest("flow needs at least one block".into())); }
|
|
|
|
|
let mut ids = std::collections::HashSet::<String>::new();
|
|
|
|
|
for node in &input.nodes {
|
|
|
|
|
if node.id.trim().is_empty() || !ids.insert(node.id.clone()) { return Err(AppError::BadRequest("flow contains duplicate or empty block IDs".into())); }
|
|
|
|
|
if !(flow_condition_kind(&node.kind) || flow_action_kind(&node.kind) || flow_logic_kind(&node.kind)) {
|
|
|
|
|
return Err(AppError::BadRequest(format!("unsupported flow block: {}", node.kind)));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !input.nodes.iter().any(|node| flow_action_kind(&node.kind)) { return Err(AppError::BadRequest("flow needs at least one action block".into())); }
|
|
|
|
|
let mut edge_ids = std::collections::HashSet::<String>::new();
|
|
|
|
|
let mut connections = std::collections::HashSet::<(String, String)>::new();
|
|
|
|
|
for edge in &input.edges {
|
|
|
|
|
if edge.id.trim().is_empty() || !edge_ids.insert(edge.id.clone()) || !connections.insert((edge.from.clone(), edge.to.clone())) {
|
|
|
|
|
return Err(AppError::BadRequest("flow contains duplicate or empty connection IDs".into()));
|
|
|
|
|
}
|
|
|
|
|
if edge.from == edge.to || !ids.contains(edge.from.as_str()) || !ids.contains(edge.to.as_str()) {
|
|
|
|
|
return Err(AppError::BadRequest("flow contains an invalid connection".into()));
|
|
|
|
|
}
|
|
|
|
|
let from = input.nodes.iter().find(|node| node.id == edge.from).expect("validated Flow source");
|
|
|
|
|
let to = input.nodes.iter().find(|node| node.id == edge.to).expect("validated Flow target");
|
|
|
|
|
if flow_action_kind(&from.kind) {
|
|
|
|
|
return Err(AppError::BadRequest("Flow action blocks must be terminal and cannot feed another block".into()));
|
|
|
|
|
}
|
|
|
|
|
if !(flow_condition_kind(&to.kind) || flow_logic_kind(&to.kind) || flow_action_kind(&to.kind)) {
|
|
|
|
|
return Err(AppError::BadRequest("Flow connection has an unsupported target".into()));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Reject cycles. Flow is deliberately a DAG: finite evaluation, deterministic topological order,
|
|
|
|
|
// and no hidden state machine semantics unless a dedicated stateful block is introduced later.
|
|
|
|
|
let mut outgoing = std::collections::HashMap::<String, Vec<String>>::new();
|
|
|
|
|
for edge in &input.edges { outgoing.entry(edge.from.clone()).or_default().push(edge.to.clone()); }
|
|
|
|
|
fn visit(id: &str, outgoing: &std::collections::HashMap<String, Vec<String>>, temp: &mut std::collections::HashSet<String>, done: &mut std::collections::HashSet<String>) -> bool {
|
|
|
|
|
if done.contains(id) { return false; }
|
|
|
|
|
if !temp.insert(id.to_string()) { return true; }
|
|
|
|
|
if outgoing.get(id).into_iter().flatten().any(|next| visit(next, outgoing, temp, done)) { return true; }
|
|
|
|
|
temp.remove(id); done.insert(id.to_string()); false
|
|
|
|
|
}
|
|
|
|
|
let mut temp = std::collections::HashSet::new(); let mut done = std::collections::HashSet::new();
|
|
|
|
|
for id in &ids { if visit(id, &outgoing, &mut temp, &mut done) { return Err(AppError::BadRequest("flow connections cannot contain a cycle".into())); } }
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn compile_flow_program(action_id: &str, nodes: &[crate::models::FlowNode], edges: &[crate::models::FlowEdge]) -> Result<Vec<crate::models::FlowCondition>, AppError> {
|
|
|
|
|
let by_id: std::collections::HashMap<String, crate::models::FlowNode> = nodes.iter().cloned().map(|node| (node.id.clone(), node)).collect();
|
|
|
|
|
let mut incoming = std::collections::HashMap::<String, Vec<String>>::new();
|
|
|
|
|
for edge in edges { incoming.entry(edge.to.clone()).or_default().push(edge.from.clone()); }
|
|
|
|
|
let action_inputs = incoming.get(action_id).cloned().unwrap_or_default();
|
|
|
|
|
if action_inputs.is_empty() { return Err(AppError::BadRequest(format!("action '{action_id}' needs at least one connected condition"))); }
|
|
|
|
|
|
|
|
|
|
fn visit(
|
|
|
|
|
id: &str,
|
|
|
|
|
by_id: &std::collections::HashMap<String, crate::models::FlowNode>,
|
|
|
|
|
incoming: &std::collections::HashMap<String, Vec<String>>,
|
|
|
|
|
seen: &mut std::collections::HashSet<String>,
|
|
|
|
|
out: &mut Vec<crate::models::FlowCondition>,
|
|
|
|
|
) -> Result<(), AppError> {
|
|
|
|
|
if !seen.insert(id.to_string()) { return Ok(()); }
|
|
|
|
|
let node = by_id.get(id).ok_or_else(|| AppError::BadRequest("Flow references a missing block".into()))?;
|
|
|
|
|
if !(flow_condition_kind(&node.kind) || flow_logic_kind(&node.kind)) {
|
|
|
|
|
return Err(AppError::BadRequest("only condition or logic blocks can feed a Flow action".into()));
|
|
|
|
|
}
|
|
|
|
|
let inputs = incoming.get(id).cloned().unwrap_or_default();
|
|
|
|
|
for input in &inputs { visit(input, by_id, incoming, seen, out)?; }
|
|
|
|
|
match node.kind.as_str() {
|
|
|
|
|
"logic_not" if inputs.len() != 1 => return Err(AppError::BadRequest("NOT block needs exactly one input".into())),
|
|
|
|
|
"logic_and" | "logic_or" if inputs.is_empty() => return Err(AppError::BadRequest(format!("{} block needs at least one input", node.kind))),
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
out.push(crate::models::FlowCondition { id: node.id.clone(), kind: node.kind.clone(), config: node.config.clone(), inputs });
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut out = Vec::new();
|
|
|
|
|
let mut seen = std::collections::HashSet::new();
|
|
|
|
|
for input in &action_inputs { visit(input, &by_id, &incoming, &mut seen, &mut out)?; }
|
|
|
|
|
out.push(crate::models::FlowCondition {
|
|
|
|
|
id: format!("__flow_action__:{action_id}"),
|
|
|
|
|
kind: "logic_and".into(),
|
|
|
|
|
config: json!({}),
|
|
|
|
|
inputs: action_inputs,
|
|
|
|
|
});
|
|
|
|
|
Ok(out)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn validate_text_comparison(config: &Value) -> Result<(), AppError> {
|
|
|
|
|
let op = flow_string(config, "operator").unwrap_or_else(|| "eq".into());
|
|
|
|
|
if !matches!(op.as_str(), "eq" | "neq") { return Err(AppError::BadRequest("state operator must be eq or neq".into())); }
|
|
|
|
|
if config.get("value").is_none() { return Err(AppError::BadRequest("state block needs a value".into())); }
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn validate_condition(condition: &crate::models::FlowCondition, state: &AppState) -> Result<(), AppError> {
|
|
|
|
|
match condition.kind.as_str() {
|
|
|
|
|
"weekday" => {
|
|
|
|
|
let days = condition.config.get("days").and_then(Value::as_array).ok_or_else(|| AppError::BadRequest("weekday block needs days".into()))?;
|
|
|
|
|
if days.is_empty() || days.iter().any(|v| v.as_u64().map(|d| !(1..=7).contains(&d)).unwrap_or(true)) { return Err(AppError::BadRequest("weekday block contains invalid days".into())); }
|
|
|
|
|
}
|
|
|
|
|
"time_range" => {
|
|
|
|
|
for key in ["start", "end"] { let value = flow_string(&condition.config, key).ok_or_else(|| AppError::BadRequest(format!("time range needs {key}")))?; chrono::NaiveTime::parse_from_str(&value, "%H:%M").map_err(|_| AppError::BadRequest("invalid time range".into()))?; }
|
|
|
|
|
}
|
|
|
|
|
"date_range" => {
|
|
|
|
|
let start = flow_string(&condition.config, "start").ok_or_else(|| AppError::BadRequest("date range needs start".into()))?;
|
|
|
|
|
let end = flow_string(&condition.config, "end").ok_or_else(|| AppError::BadRequest("date range needs end".into()))?;
|
|
|
|
|
let start = chrono::NaiveDate::parse_from_str(&start, "%Y-%m-%d").map_err(|_| AppError::BadRequest("invalid date range".into()))?;
|
|
|
|
|
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())); }
|
|
|
|
|
}
|
|
|
|
|
"outdoor_temperature" => { validate_flow_comparison(&condition.config)?; }
|
|
|
|
|
"device_temperature" => {
|
|
|
|
|
validate_flow_comparison(&condition.config)?;
|
|
|
|
|
let id = flow_string(&condition.config, "device_id").ok_or_else(|| AppError::BadRequest("device temperature block needs device".into()))?;
|
|
|
|
|
if state.db.get_device(&id)?.is_none() { return Err(AppError::BadRequest("flow references a missing device".into())); }
|
|
|
|
|
}
|
|
|
|
|
"zone_temperature" => {
|
|
|
|
|
validate_flow_comparison(&condition.config)?;
|
|
|
|
|
let id = flow_string(&condition.config, "zone_id").ok_or_else(|| AppError::BadRequest("zone temperature block needs zone".into()))?;
|
|
|
|
|
if state.db.get_zone(&id)?.is_none() { return Err(AppError::BadRequest("flow references a missing zone".into())); }
|
|
|
|
|
}
|
|
|
|
|
"ha_state" => {
|
|
|
|
|
if flow_string(&condition.config, "entity_id").is_none() { return Err(AppError::BadRequest("Home Assistant state block needs entity_id".into())); }
|
|
|
|
|
validate_text_comparison(&condition.config)?;
|
|
|
|
|
}
|
|
|
|
|
"ha_numeric" => {
|
|
|
|
|
if flow_string(&condition.config, "entity_id").is_none() { return Err(AppError::BadRequest("Home Assistant numeric block needs entity_id".into())); }
|
|
|
|
|
validate_flow_comparison(&condition.config)?;
|
|
|
|
|
}
|
|
|
|
|
"ha_attribute" => {
|
|
|
|
|
if flow_string(&condition.config, "entity_id").is_none() || flow_string(&condition.config, "attribute").is_none() {
|
|
|
|
|
return Err(AppError::BadRequest("Home Assistant attribute block needs entity_id and attribute".into()));
|
|
|
|
|
}
|
|
|
|
|
let op = flow_string(&condition.config, "operator").unwrap_or_else(|| "eq".into());
|
|
|
|
|
if !matches!(op.as_str(), "lt" | "lte" | "gt" | "gte" | "eq" | "neq") { return Err(AppError::BadRequest("unsupported Home Assistant attribute operator".into())); }
|
|
|
|
|
if condition.config.get("value").is_none() { return Err(AppError::BadRequest("Home Assistant attribute block needs a value".into())); }
|
|
|
|
|
}
|
|
|
|
|
"ha_available" => {
|
|
|
|
|
if flow_string(&condition.config, "entity_id").is_none() { return Err(AppError::BadRequest("Home Assistant availability block needs entity_id".into())); }
|
|
|
|
|
}
|
|
|
|
|
"house_mode" => {
|
|
|
|
|
let value = flow_string(&condition.config, "value").ok_or_else(|| AppError::BadRequest("house mode block needs a mode".into()))?;
|
|
|
|
|
if !matches!(value.as_str(), "cool" | "heat" | "off") { return Err(AppError::BadRequest("house mode must be cool, heat or off".into())); }
|
|
|
|
|
validate_text_comparison(&condition.config)?;
|
|
|
|
|
}
|
|
|
|
|
"device_state" => {
|
|
|
|
|
let id = flow_string(&condition.config, "device_id").ok_or_else(|| AppError::BadRequest("device state block needs device".into()))?;
|
|
|
|
|
if state.db.get_device(&id)?.is_none() { return Err(AppError::BadRequest("flow references a missing device".into())); }
|
|
|
|
|
let field = flow_string(&condition.config, "field").ok_or_else(|| AppError::BadRequest("device state block needs a field".into()))?;
|
|
|
|
|
if !matches!(field.as_str(), "enabled" | "online" | "power" | "mode" | "fan_speed" | "swing_vertical" | "swing_horizontal" | "quiet" | "turbo" | "light" | "air" | "xfan" | "health" | "sleep") { return Err(AppError::BadRequest("unsupported device state field".into())); }
|
|
|
|
|
validate_text_comparison(&condition.config)?;
|
|
|
|
|
}
|
|
|
|
|
"zone_state" => {
|
|
|
|
|
let id = flow_string(&condition.config, "zone_id").ok_or_else(|| AppError::BadRequest("zone state block needs zone".into()))?;
|
|
|
|
|
if state.db.get_zone(&id)?.is_none() { return Err(AppError::BadRequest("flow references a missing zone".into())); }
|
|
|
|
|
let field = flow_string(&condition.config, "field").ok_or_else(|| AppError::BadRequest("zone state block needs a field".into()))?;
|
|
|
|
|
if !matches!(field.as_str(), "enabled" | "mode" | "active_preset" | "demand" | "control_owner" | "device_manual_override" | "local_thermostat_power") { return Err(AppError::BadRequest("unsupported zone state field".into())); }
|
|
|
|
|
validate_text_comparison(&condition.config)?;
|
|
|
|
|
}
|
|
|
|
|
"group_state" => {
|
|
|
|
|
let id = flow_string(&condition.config, "group_id").ok_or_else(|| AppError::BadRequest("group state block needs group".into()))?;
|
|
|
|
|
if state.db.get_group(&id)?.is_none() { return Err(AppError::BadRequest("flow references a missing group".into())); }
|
|
|
|
|
let field = flow_string(&condition.config, "field").ok_or_else(|| AppError::BadRequest("group state block needs a field".into()))?;
|
|
|
|
|
if field != "power_enabled" { return Err(AppError::BadRequest("unsupported group state field".into())); }
|
|
|
|
|
validate_text_comparison(&condition.config)?;
|
|
|
|
|
}
|
|
|
|
|
"night_mode" => {}
|
|
|
|
|
"constant" => {
|
|
|
|
|
if condition.config.get("value").and_then(Value::as_bool).is_none() { return Err(AppError::BadRequest("constant block needs a boolean value".into())); }
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
fn validate_flow_comparison(config: &Value) -> Result<(), AppError> {
|
|
|
|
|
let op = flow_string(config, "operator").unwrap_or_else(|| "lt".into());
|
|
|
|
|
if !matches!(op.as_str(), "lt" | "lte" | "gt" | "gte" | "eq" | "neq") { return Err(AppError::BadRequest("unsupported comparison operator".into())); }
|
|
|
|
|
if flow_f64(config, "value").is_none() { return Err(AppError::BadRequest("comparison block needs a numeric value".into())); }
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn compile_flow(state: &AppState, mut flow: crate::models::Flow) -> Result<(crate::models::Flow, Vec<Schedule>, Vec<Automation>), AppError> {
|
|
|
|
|
let mut schedules = Vec::new();
|
|
|
|
|
let mut automations = Vec::new();
|
|
|
|
|
let now = Utc::now();
|
|
|
|
|
let zones = state.db.list_zones()?;
|
|
|
|
|
let groups = state.db.list_groups()?;
|
|
|
|
|
let devices = state.db.list_devices()?;
|
|
|
|
|
let previous_schedules: std::collections::HashMap<String, Schedule> = state.db.list_schedules()?.into_iter()
|
|
|
|
|
.filter(|item| item.flow_id.as_deref() == Some(flow.id.as_str())).map(|item| (item.id.clone(), item)).collect();
|
|
|
|
|
let previous_automations: std::collections::HashMap<String, Automation> = state.db.list_automations()?.into_iter()
|
|
|
|
|
.filter(|item| item.flow_id.as_deref() == Some(flow.id.as_str())).map(|item| (item.id.clone(), item)).collect();
|
|
|
|
|
let actions: Vec<_> = flow.nodes.iter().filter(|node| flow_action_kind(&node.kind)).cloned().collect();
|
|
|
|
|
for action_node in actions {
|
|
|
|
|
let conditions = compile_flow_program(&action_node.id, &flow.nodes, &flow.edges)?;
|
|
|
|
|
for condition in conditions.iter().filter(|condition| flow_condition_kind(&condition.kind)) { validate_condition(condition, state)?; }
|
|
|
|
|
let schedule_leaves: Vec<_> = conditions.iter().filter(|condition| flow_condition_kind(&condition.kind)).collect();
|
|
|
|
|
let preset_for_schedule = flow_string(&action_node.config, "preset").unwrap_or_else(|| "comfort".into());
|
|
|
|
|
let mode_for_schedule = flow_string(&action_node.config, "mode").unwrap_or_else(|| "auto".into());
|
|
|
|
|
let native_time_range = schedule_leaves.iter().find(|condition| condition.kind == "time_range")
|
|
|
|
|
.and_then(|condition| flow_string(&condition.config, "start").zip(flow_string(&condition.config, "end")))
|
|
|
|
|
.and_then(|(start, end)| chrono::NaiveTime::parse_from_str(&start, "%H:%M").ok().zip(chrono::NaiveTime::parse_from_str(&end, "%H:%M").ok()))
|
|
|
|
|
.map(|(start, end)| start < end)
|
|
|
|
|
.unwrap_or(false);
|
|
|
|
|
let schedule_only = action_node.kind == "zone_thermostat"
|
|
|
|
|
&& !conditions.iter().any(|condition| matches!(condition.kind.as_str(), "logic_or" | "logic_not"))
|
|
|
|
|
&& schedule_leaves.iter().all(|condition| matches!(condition.kind.as_str(), "weekday" | "time_range"))
|
|
|
|
|
&& schedule_leaves.iter().filter(|condition| condition.kind == "weekday").count() == 1
|
|
|
|
|
&& schedule_leaves.iter().filter(|condition| condition.kind == "time_range").count() == 1
|
|
|
|
|
&& native_time_range
|
|
|
|
|
&& preset_for_schedule != "auto"
|
|
|
|
|
&& mode_for_schedule == "auto"
|
|
|
|
|
&& flow_bool(&action_node.config, "power").is_none();
|
|
|
|
|
if schedule_only {
|
|
|
|
|
let zone_id = flow_string(&action_node.config, "zone_id").ok_or_else(|| AppError::BadRequest("thermostat block needs a zone".into()))?;
|
|
|
|
|
let zone = zones.iter().find(|z| z.id == zone_id).ok_or_else(|| AppError::BadRequest("thermostat block references a missing zone".into()))?;
|
|
|
|
|
let preset = flow_string(&action_node.config, "preset").unwrap_or_else(|| "comfort".into());
|
|
|
|
|
if !matches!(preset.as_str(), "comfort" | "sleep" | "away" | "custom") { return Err(AppError::BadRequest("unsupported thermostat preset".into())); }
|
|
|
|
|
let setpoint = flow_f64(&action_node.config, "setpoint").unwrap_or(zone.setpoint);
|
|
|
|
|
if preset == "custom" && !(8.0..=30.0).contains(&setpoint) { return Err(AppError::BadRequest("custom thermostat target must be between 8 and 30 C".into())); }
|
|
|
|
|
let weekday = schedule_leaves.iter().find(|c| c.kind == "weekday").copied().unwrap();
|
|
|
|
|
let time = schedule_leaves.iter().find(|c| c.kind == "time_range").copied().unwrap();
|
|
|
|
|
let weekdays = weekday.config.get("days").and_then(Value::as_array).unwrap().iter().filter_map(Value::as_u64).map(|v| v as u32).collect();
|
|
|
|
|
let id = format!("flow:{}:schedule:{}", flow.id, action_node.id);
|
|
|
|
|
let created_at = previous_schedules.get(&id).map(|item| item.created_at.clone()).unwrap_or_else(|| now.clone());
|
|
|
|
|
let schedule = Schedule {
|
|
|
|
|
id, zone_id, name: format!("{} · {}", flow.name, zone.name), enabled: flow.enabled,
|
|
|
|
|
weekdays, start_time: flow_string(&time.config, "start").unwrap(), end_time: flow_string(&time.config, "end").unwrap(), preset, setpoint,
|
|
|
|
|
created_at, updated_at: now.clone(), flow_id: Some(flow.id.clone()), flow_node_id: Some(action_node.id.clone()),
|
|
|
|
|
};
|
|
|
|
|
schedules.push(schedule);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
drop(schedule_leaves);
|
|
|
|
|
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 mut item = Automation {
|
|
|
|
|
id, name: format!("{} · {}", flow.name, 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(),
|
|
|
|
|
};
|
|
|
|
|
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() {
|
|
|
|
|
"zone_thermostat" => {
|
|
|
|
|
let zone_id = flow_string(&action_node.config, "zone_id").ok_or_else(|| AppError::BadRequest("thermostat block needs a zone".into()))?;
|
|
|
|
|
if !zones.iter().any(|z| z.id == zone_id) { return Err(AppError::BadRequest("thermostat block references a missing zone".into())); }
|
|
|
|
|
item.action_zone_id = Some(zone_id);
|
|
|
|
|
let preset = flow_string(&action_node.config, "preset").unwrap_or_else(|| "comfort".into());
|
|
|
|
|
if !matches!(preset.as_str(), "comfort" | "sleep" | "away" | "custom" | "auto") { return Err(AppError::BadRequest("unsupported thermostat preset".into())); }
|
|
|
|
|
item.action_zone_preset = Some(preset.clone());
|
|
|
|
|
if preset == "custom" { let target = flow_f64(&action_node.config, "setpoint").ok_or_else(|| AppError::BadRequest("custom thermostat block needs a target".into()))?; if !(8.0..=30.0).contains(&target) { return Err(AppError::BadRequest("custom thermostat target must be between 8 and 30 C".into())); } item.action.target_temperature = Some(target); }
|
|
|
|
|
if let Some(power) = flow_bool(&action_node.config, "power") { item.action.power = Some(power); }
|
|
|
|
|
if let Some(mode) = flow_string(&action_node.config, "mode") { if !matches!(mode.as_str(), "auto" | "heat" | "cool") { return Err(AppError::BadRequest("unsupported thermostat mode".into())); } item.action.mode = Some(mode); }
|
|
|
|
|
}
|
|
|
|
|
"device_action" => {
|
|
|
|
|
let id = flow_string(&action_node.config, "device_id").ok_or_else(|| AppError::BadRequest("device action needs a device".into()))?;
|
|
|
|
|
if !devices.iter().any(|d| d.id == id) { return Err(AppError::BadRequest("device action references a missing device".into())); }
|
|
|
|
|
item.action_device_id = id;
|
|
|
|
|
item.action.power = flow_bool(&action_node.config, "power");
|
|
|
|
|
item.action.mode = flow_string(&action_node.config, "mode");
|
|
|
|
|
item.action.target_temperature = flow_f64(&action_node.config, "target_temperature");
|
|
|
|
|
item.action.fan_speed = flow_u8(&action_node.config, "fan_speed");
|
|
|
|
|
item.action.swing_vertical = flow_bool(&action_node.config, "swing_vertical");
|
|
|
|
|
item.action.swing_horizontal = flow_bool(&action_node.config, "swing_horizontal");
|
|
|
|
|
item.action.quiet = flow_bool(&action_node.config, "quiet");
|
|
|
|
|
item.action.turbo = flow_bool(&action_node.config, "turbo");
|
|
|
|
|
item.action.light = flow_bool(&action_node.config, "light");
|
|
|
|
|
item.action.air = flow_bool(&action_node.config, "air");
|
|
|
|
|
item.action.xfan = flow_bool(&action_node.config, "xfan");
|
|
|
|
|
item.action.health = flow_bool(&action_node.config, "health");
|
|
|
|
|
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())); }
|
|
|
|
|
}
|
|
|
|
|
"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())); }
|
|
|
|
|
item.action_group_id = Some(id); item.action.power = flow_bool(&action_node.config, "power"); item.action.mode = flow_string(&action_node.config, "mode"); item.action_preset = flow_string(&action_node.config, "preset");
|
|
|
|
|
if let Some(mode) = item.action.mode.as_deref() {
|
|
|
|
|
if !matches!(mode, "auto" | "house" | "cool" | "heat") { return Err(AppError::BadRequest("unsupported Flow group mode".into())); }
|
|
|
|
|
}
|
|
|
|
|
if let Some(preset) = item.action_preset.as_deref() {
|
|
|
|
|
if !matches!(preset, "auto" | "comfort" | "sleep" | "away" | "custom") { return Err(AppError::BadRequest("unsupported Flow group preset".into())); }
|
|
|
|
|
if preset == "custom" {
|
|
|
|
|
let target = flow_f64(&action_node.config, "setpoint").ok_or_else(|| AppError::BadRequest("custom Flow group preset needs a target".into()))?;
|
|
|
|
|
if !(8.0..=30.0).contains(&target) { return Err(AppError::BadRequest("custom Flow group target must be between 8 and 30 C".into())); }
|
|
|
|
|
item.action.target_temperature = Some(target);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if item.action.power.is_none() && item.action.mode.is_none() && item.action_preset.is_none() { return Err(AppError::BadRequest("group action cannot be empty".into())); }
|
|
|
|
|
}
|
|
|
|
|
_ => unreachable!(),
|
|
|
|
|
}
|
|
|
|
|
automations.push(item);
|
|
|
|
|
}
|
|
|
|
|
let existing: Vec<_> = state.db.list_schedules()?.into_iter().filter(|s| s.flow_id.as_deref() != Some(flow.id.as_str())).collect();
|
|
|
|
|
for candidate in &schedules {
|
|
|
|
|
for other in existing.iter().chain(schedules.iter().filter(|s| s.id != candidate.id)) {
|
|
|
|
|
if engine::schedules_overlap(candidate, other) { return Err(AppError::BadRequest(format!("flow schedule '{}' overlaps with '{}'", candidate.name, other.name))); }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
flow.compiled_schedule_ids = schedules.iter().map(|s| s.id.clone()).collect();
|
|
|
|
|
flow.compiled_automation_ids = automations.iter().map(|a| a.id.clone()).collect();
|
|
|
|
|
flow.summary = format!("{} bloków · {} harmonogramów · {} automatyzacji", flow.nodes.len(), schedules.len(), automations.len());
|
|
|
|
|
Ok((flow, schedules, automations))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn flow_from_input(id: String, input: FlowInput, created_at: chrono::DateTime<Utc>, revision: u64) -> crate::models::Flow {
|
|
|
|
|
crate::models::Flow {
|
|
|
|
|
id, name: input.name.trim().into(), enabled: input.enabled, description: input.description.trim().into(), nodes: input.nodes, edges: input.edges,
|
|
|
|
|
summary: String::new(), compiled_schedule_ids: vec![], compiled_automation_ids: vec![], revision, created_at, updated_at: Utc::now(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn list_flows(State(state): State<AppState>) -> Result<Json<Vec<crate::models::Flow>>, AppError> { Ok(Json(state.db.list_flows()?)) }
|
|
|
|
|
async fn get_flow(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<crate::models::Flow>, AppError> { state.db.get_flow(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("flow {id}"))) }
|
|
|
|
|
|
|
|
|
|
async fn create_flow(State(state): State<AppState>, Json(input): Json<FlowInput>) -> Result<(StatusCode, Json<crate::models::Flow>), AppError> {
|
|
|
|
|
validate_flow_graph(&input)?;
|
|
|
|
|
let _configuration_guard = state.lock_configuration_operation().await;
|
|
|
|
|
let _automation_guard = state.lock_automation_operation().await;
|
|
|
|
|
let _schedule_guard = state.lock_schedule_operation().await;
|
|
|
|
|
let _cycle_guard = state.lock_zone_control_cycle().await;
|
|
|
|
|
let flow = flow_from_input(Uuid::new_v4().to_string(), input, Utc::now(), 1);
|
|
|
|
|
let (flow, schedules, automations) = compile_flow(&state, flow)?;
|
|
|
|
|
state.db.replace_flow_outputs(&flow, &schedules, &automations)?;
|
|
|
|
|
for zone_id in schedules.iter().map(|s| s.zone_id.as_str()).collect::<std::collections::HashSet<_>>() { refresh_zone_override_boundary(&state, zone_id).await?; }
|
|
|
|
|
state.log("info", "flow.created", &format!("Created Flow {}", flow.name), json!({"flow_id": flow.id, "revision": flow.revision, "schedules": schedules.len(), "automations": automations.len()}));
|
|
|
|
|
state.broadcast("flow.created", serde_json::to_value(&flow)?); state.wake_zone_control();
|
|
|
|
|
Ok((StatusCode::CREATED, Json(flow)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn update_flow(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<FlowInput>) -> Result<Json<crate::models::Flow>, AppError> {
|
|
|
|
|
validate_flow_graph(&input)?;
|
|
|
|
|
let _configuration_guard = state.lock_configuration_operation().await;
|
|
|
|
|
let _automation_guard = state.lock_automation_operation().await;
|
|
|
|
|
let _schedule_guard = state.lock_schedule_operation().await;
|
|
|
|
|
let _cycle_guard = state.lock_zone_control_cycle().await;
|
|
|
|
|
let existing = state.db.get_flow(&id)?.ok_or_else(|| AppError::NotFound(format!("flow {id}")))?;
|
|
|
|
|
let expected = input.expected_revision.ok_or_else(|| AppError::BadRequest("Flow update requires expected_revision".into()))?;
|
|
|
|
|
if expected != existing.revision { return Err(AppError::Conflict(format!("flow {id} changed; expected revision {expected}, current revision {}", existing.revision))); }
|
|
|
|
|
let old_zone_ids: std::collections::HashSet<String> = state.db.list_schedules()?.into_iter().filter(|s| s.flow_id.as_deref() == Some(id.as_str())).map(|s| s.zone_id).collect();
|
|
|
|
|
let next_revision = existing.revision.saturating_add(1).max(1);
|
|
|
|
|
let flow = flow_from_input(id, input, existing.created_at, next_revision);
|
|
|
|
|
let (flow, schedules, automations) = compile_flow(&state, flow)?;
|
|
|
|
|
state.db.replace_flow_outputs(&flow, &schedules, &automations)?;
|
|
|
|
|
let mut zone_ids = old_zone_ids; zone_ids.extend(schedules.iter().map(|s| s.zone_id.clone()));
|
|
|
|
|
for zone_id in zone_ids { refresh_zone_override_boundary(&state, &zone_id).await?; }
|
|
|
|
|
state.log("info", "flow.updated", &format!("Updated Flow {}", flow.name), json!({"flow_id": flow.id, "revision": flow.revision, "schedules": schedules.len(), "automations": automations.len()}));
|
|
|
|
|
state.broadcast("flow.updated", serde_json::to_value(&flow)?); state.wake_zone_control();
|
|
|
|
|
Ok(Json(flow))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn delete_flow(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
|
|
|
|
let _configuration_guard = state.lock_configuration_operation().await;
|
|
|
|
|
let _automation_guard = state.lock_automation_operation().await;
|
|
|
|
|
let _schedule_guard = state.lock_schedule_operation().await;
|
|
|
|
|
let _cycle_guard = state.lock_zone_control_cycle().await;
|
|
|
|
|
let zone_ids: std::collections::HashSet<String> = state.db.list_schedules()?.into_iter().filter(|s| s.flow_id.as_deref() == Some(id.as_str())).map(|s| s.zone_id).collect();
|
|
|
|
|
let existing = state.db.get_flow(&id)?.ok_or_else(|| AppError::NotFound(format!("flow {id}")))?;
|
|
|
|
|
if !state.db.delete_flow(&id)? { return Err(AppError::NotFound(format!("flow {id}"))); }
|
|
|
|
|
for zone_id in zone_ids { refresh_zone_override_boundary(&state, &zone_id).await?; }
|
|
|
|
|
state.log("info", "flow.deleted", &format!("Deleted Flow {}", existing.name), json!({"flow_id": id}));
|
|
|
|
|
state.broadcast("flow.deleted", json!({"id": id})); state.wake_zone_control();
|
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn export_flow(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Value>, AppError> {
|
|
|
|
|
let flow = state.db.get_flow(&id)?.ok_or_else(|| AppError::NotFound(format!("flow {id}")))?;
|
|
|
|
|
Ok(Json(json!({
|
|
|
|
|
"format": "gree-controller-flow",
|
|
|
|
|
"version": 1,
|
|
|
|
|
"exported_at": Utc::now(),
|
|
|
|
|
"flow": {
|
|
|
|
|
"name": flow.name,
|
|
|
|
|
"enabled": flow.enabled,
|
|
|
|
|
"description": flow.description,
|
|
|
|
|
"nodes": flow.nodes,
|
|
|
|
|
"edges": flow.edges
|
|
|
|
|
}
|
|
|
|
|
})))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn import_flow(State(state): State<AppState>, Json(document): Json<Value>) -> Result<(StatusCode, Json<crate::models::Flow>), AppError> {
|
|
|
|
|
if let Some(format) = document.get("format").and_then(Value::as_str) {
|
|
|
|
|
if format != "gree-controller-flow" { return Err(AppError::BadRequest("unsupported Flow import format".into())); }
|
|
|
|
|
}
|
|
|
|
|
let payload = document.get("flow").cloned().unwrap_or(document);
|
|
|
|
|
let mut input: FlowInput = serde_json::from_value(payload).map_err(|err| AppError::BadRequest(format!("invalid Flow import: {err}")))?;
|
|
|
|
|
input.expected_revision = None;
|
|
|
|
|
validate_flow_graph(&input)?;
|
|
|
|
|
let _configuration_guard = state.lock_configuration_operation().await;
|
|
|
|
|
let _automation_guard = state.lock_automation_operation().await;
|
|
|
|
|
let _schedule_guard = state.lock_schedule_operation().await;
|
|
|
|
|
let _cycle_guard = state.lock_zone_control_cycle().await;
|
|
|
|
|
let flow = flow_from_input(Uuid::new_v4().to_string(), input, Utc::now(), 1);
|
|
|
|
|
let (flow, schedules, automations) = compile_flow(&state, flow)?;
|
|
|
|
|
state.db.replace_flow_outputs(&flow, &schedules, &automations)?;
|
|
|
|
|
for zone_id in schedules.iter().map(|s| s.zone_id.as_str()).collect::<std::collections::HashSet<_>>() { refresh_zone_override_boundary(&state, zone_id).await?; }
|
|
|
|
|
state.log("info", "flow.imported", &format!("Imported Flow {}", flow.name), json!({"flow_id": flow.id, "revision": flow.revision}));
|
|
|
|
|
state.broadcast("flow.created", serde_json::to_value(&flow)?); state.wake_zone_control();
|
|
|
|
|
Ok((StatusCode::CREATED, Json(flow)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn dry_run_block_reason(state: &AppState, action: &crate::models::FlowNode) -> Result<Option<String>, AppError> {
|
|
|
|
|
match action.kind.as_str() {
|
|
|
|
|
"zone_thermostat" => {
|
|
|
|
|
let Some(zone_id) = flow_string(&action.config, "zone_id") else { return Ok(Some("missing_zone".into())); };
|
|
|
|
|
let Some(zone) = state.db.get_zone(&zone_id)? else { return Ok(Some("missing_zone".into())); };
|
|
|
|
|
if state.db.get_device(&zone.device_id)?.is_none() { return Ok(Some("missing_device".into())); }
|
|
|
|
|
if zone.device_manual_override { return Ok(Some("device_manual_override".into())); }
|
|
|
|
|
if zone.local_thermostat_power.is_some() { return Ok(Some("local_thermostat_override".into())); }
|
|
|
|
|
if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { return Ok(Some("temporary_quick_thermostat".into())); }
|
|
|
|
|
if !zone.enabled && flow_bool(&action.config, "power") != Some(true) { return Ok(Some("zone_disabled".into())); }
|
|
|
|
|
Ok(None)
|
|
|
|
|
}
|
|
|
|
|
"device_action" => {
|
|
|
|
|
let Some(device_id) = flow_string(&action.config, "device_id") else { return Ok(Some("missing_device".into())); };
|
|
|
|
|
let Some(device) = state.db.get_device(&device_id)? else { return Ok(Some("missing_device".into())); };
|
|
|
|
|
if !device.enabled { return Ok(Some("device_disabled".into())); }
|
|
|
|
|
let zones = state.db.list_zones()?;
|
|
|
|
|
if zones.iter().any(|z| z.device_id == device_id && z.device_manual_override) { return Ok(Some("device_manual_override".into())); }
|
|
|
|
|
if zones.iter().any(|z| z.device_id == device_id && z.local_thermostat_power.is_some()) { return Ok(Some("local_thermostat_override".into())); }
|
|
|
|
|
if zones.iter().any(|z| z.device_id == device_id && engine::temporary_quick_thermostat_is_active(z, Utc::now())) { return Ok(Some("temporary_quick_thermostat".into())); }
|
|
|
|
|
if zones.iter().any(|z| z.device_id == device_id && !z.enabled) && flow_bool(&action.config, "power") != Some(true) { return Ok(Some("zone_disabled".into())); }
|
|
|
|
|
let mut command = DeviceCommand::default();
|
|
|
|
|
command.power = flow_bool(&action.config, "power");
|
|
|
|
|
command.mode = flow_string(&action.config, "mode");
|
|
|
|
|
command.target_temperature = flow_f64(&action.config, "target_temperature");
|
|
|
|
|
command.fan_speed = flow_u8(&action.config, "fan_speed");
|
|
|
|
|
command.swing_vertical = flow_bool(&action.config, "swing_vertical");
|
|
|
|
|
command.swing_horizontal = flow_bool(&action.config, "swing_horizontal");
|
|
|
|
|
command.quiet = flow_bool(&action.config, "quiet");
|
|
|
|
|
command.turbo = flow_bool(&action.config, "turbo");
|
|
|
|
|
command.light = flow_bool(&action.config, "light");
|
|
|
|
|
command.air = flow_bool(&action.config, "air");
|
|
|
|
|
command.xfan = flow_bool(&action.config, "xfan");
|
|
|
|
|
command.health = flow_bool(&action.config, "health");
|
|
|
|
|
command.sleep = flow_bool(&action.config, "sleep");
|
|
|
|
|
if zones.iter().any(|z| z.device_id == device_id) && engine::automation_action_conflicts_with_thermostat(&command) {
|
|
|
|
|
return Ok(Some("thermostat_owner_conflict".into()));
|
|
|
|
|
}
|
|
|
|
|
Ok(None)
|
|
|
|
|
}
|
|
|
|
|
"group_action" => {
|
|
|
|
|
let Some(group_id) = flow_string(&action.config, "group_id") else { return Ok(Some("missing_group".into())); };
|
|
|
|
|
let Some(group) = state.db.get_group(&group_id)? else { return Ok(Some("missing_group".into())); };
|
|
|
|
|
let climate_change = flow_string(&action.config, "mode").is_some() || flow_string(&action.config, "preset").is_some();
|
|
|
|
|
let resulting_enabled = flow_bool(&action.config, "power").unwrap_or(group.power_enabled);
|
|
|
|
|
if climate_change && !resulting_enabled { return Ok(Some("group_control_disabled".into())); }
|
|
|
|
|
Ok(None)
|
|
|
|
|
}
|
|
|
|
|
_ => Ok(Some("unsupported_action".into())),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn simulate_flow(State(state): State<AppState>, Json(input): Json<FlowSimulationInput>) -> Result<Json<Value>, AppError> {
|
|
|
|
|
validate_flow_graph(&input.flow)?;
|
|
|
|
|
let at = match input.at.as_deref().map(str::trim).filter(|v| !v.is_empty()) {
|
|
|
|
|
Some(value) => chrono::DateTime::parse_from_rfc3339(value).map_err(|_| AppError::BadRequest("simulation time must be RFC3339".into()))?.with_timezone(&chrono::Local),
|
|
|
|
|
None => chrono::Local::now(),
|
|
|
|
|
};
|
|
|
|
|
let preview_id = input.flow_id.clone().filter(|v| !v.trim().is_empty()).unwrap_or_else(|| format!("dry-run-{}", Uuid::new_v4()));
|
|
|
|
|
let preview = crate::models::Flow {
|
|
|
|
|
id: preview_id.clone(), name: input.flow.name.trim().into(), enabled: input.flow.enabled, description: input.flow.description.trim().into(),
|
|
|
|
|
nodes: input.flow.nodes.clone(), edges: input.flow.edges.clone(), summary: String::new(), compiled_schedule_ids: vec![], compiled_automation_ids: vec![], revision: 0,
|
|
|
|
|
created_at: Utc::now(), updated_at: Utc::now(),
|
|
|
|
|
};
|
|
|
|
|
let (compiled, schedules, automations) = compile_flow(&state, preview)?;
|
|
|
|
|
let devices = state.db.list_devices()?;
|
|
|
|
|
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 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!({
|
|
|
|
|
"node_id": action.id,
|
|
|
|
|
"kind": action.kind,
|
|
|
|
|
"matched": matched,
|
|
|
|
|
"would_execute": matched && blocked_reason.is_none(),
|
|
|
|
|
"blocked_reason": blocked_reason,
|
|
|
|
|
"config": action.config,
|
|
|
|
|
"trace": trace
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
let result = json!({
|
|
|
|
|
"dry_run": true,
|
|
|
|
|
"at": at.to_rfc3339(),
|
|
|
|
|
"flow_id": input.flow_id,
|
|
|
|
|
"summary": compiled.summary,
|
|
|
|
|
"compiled": {"schedules": schedules.len(), "automations": automations.len()},
|
|
|
|
|
"actions": actions,
|
|
|
|
|
"note": "Dry-run never changes thermostat, device, group, schedule or automation state."
|
|
|
|
|
});
|
|
|
|
|
if input.log {
|
|
|
|
|
state.log("info", "flow.dry_run", &format!("Dry-run Flow {}", input.flow.name.trim()), json!({
|
|
|
|
|
"flow_id": input.flow_id, "at": at.to_rfc3339(), "actions": result.get("actions").cloned().unwrap_or(Value::Null)
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
Ok(Json(result))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn flow_logs(State(state): State<AppState>, Path(id): Path<String>, Query(query): Query<FlowLogsQuery>) -> Result<Json<Value>, AppError> {
|
|
|
|
|
if state.db.get_flow(&id)?.is_none() { return Err(AppError::NotFound(format!("flow {id}"))); }
|
|
|
|
|
let limit = query.limit.unwrap_or(100).clamp(1, 250) as usize;
|
|
|
|
|
let events = state.db.list_events(1000)?.into_iter().filter(|event| {
|
|
|
|
|
event.metadata.get("flow_id").and_then(Value::as_str) == Some(id.as_str())
|
|
|
|
|
|| event.metadata.get("automation_id").and_then(Value::as_str).map(|value| value.starts_with(&format!("flow:{id}:"))).unwrap_or(false)
|
|
|
|
|
}).take(limit).collect::<Vec<_>>();
|
|
|
|
|
Ok(Json(json!({"events": events})))
|
|
|
|
|
}
|