2223 lines
84 KiB
Rust
2223 lines
84 KiB
Rust
#[derive(Debug, Deserialize)]
|
|
struct FlowInput {
|
|
name: String,
|
|
#[serde(default = "yes")]
|
|
enabled: bool,
|
|
#[serde(default)]
|
|
draft: 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_referenced_shared_input_ids(
|
|
nodes: &[crate::models::FlowNode],
|
|
) -> std::collections::HashSet<String> {
|
|
nodes
|
|
.iter()
|
|
.filter(|node| node.kind == "shared_input")
|
|
.filter_map(|node| flow_string(&node.config, "input_id"))
|
|
.collect()
|
|
}
|
|
|
|
fn same_shared_input_source(
|
|
left: &crate::models::FlowSharedInput,
|
|
right: &crate::models::FlowSharedInput,
|
|
) -> bool {
|
|
left.kind == right.kind && left.config == right.config
|
|
}
|
|
|
|
fn prepare_imported_shared_inputs(
|
|
input: &mut FlowInput,
|
|
imported: Vec<crate::models::FlowSharedInput>,
|
|
current: &[crate::models::FlowSharedInput],
|
|
) -> Result<(Vec<crate::models::FlowSharedInput>, bool), AppError> {
|
|
let mut imported_ids = std::collections::HashSet::new();
|
|
let mut normalized_imported = Vec::with_capacity(imported.len());
|
|
for mut item in imported {
|
|
item.id = item.id.trim().chars().take(120).collect();
|
|
item.name = item.name.trim().chars().take(100).collect();
|
|
item.kind = item.kind.trim().to_string();
|
|
if item.id.is_empty() || item.name.is_empty() {
|
|
return Err(AppError::BadRequest(
|
|
"imported shared Flow input requires id and name".into(),
|
|
));
|
|
}
|
|
if !imported_ids.insert(item.id.clone()) {
|
|
return Err(AppError::BadRequest(
|
|
"imported shared Flow input IDs must be unique".into(),
|
|
));
|
|
}
|
|
normalized_imported.push(item);
|
|
}
|
|
|
|
let referenced_import_ids = flow_referenced_shared_input_ids(&input.nodes);
|
|
let mut next = current.to_vec();
|
|
let mut remap = std::collections::HashMap::<String, String>::new();
|
|
let mut changed = false;
|
|
|
|
for item in normalized_imported
|
|
.into_iter()
|
|
.filter(|item| referenced_import_ids.contains(&item.id))
|
|
{
|
|
if let Some(existing) = next.iter().find(|existing| existing.id == item.id) {
|
|
if same_shared_input_source(existing, &item) {
|
|
remap.insert(item.id.clone(), existing.id.clone());
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if let Some(existing) = next
|
|
.iter()
|
|
.find(|existing| same_shared_input_source(existing, &item))
|
|
{
|
|
remap.insert(item.id.clone(), existing.id.clone());
|
|
continue;
|
|
}
|
|
|
|
let original_id = item.id.clone();
|
|
let mut to_add = item;
|
|
if next.iter().any(|existing| existing.id == to_add.id) {
|
|
to_add.id = format!("shared-{}", Uuid::new_v4());
|
|
}
|
|
remap.insert(original_id, to_add.id.clone());
|
|
next.push(to_add);
|
|
changed = true;
|
|
}
|
|
|
|
for node in &mut input.nodes {
|
|
if node.kind != "shared_input" {
|
|
continue;
|
|
}
|
|
let Some(input_id) = flow_string(&node.config, "input_id") else {
|
|
continue;
|
|
};
|
|
if let Some(mapped) = remap.get(&input_id) {
|
|
if mapped != &input_id {
|
|
let config = node.config.as_object_mut().ok_or_else(|| {
|
|
AppError::BadRequest("shared Flow input block config must be an object".into())
|
|
})?;
|
|
config.insert("input_id".into(), Value::String(mapped.clone()));
|
|
}
|
|
}
|
|
}
|
|
|
|
for input_id in flow_referenced_shared_input_ids(&input.nodes) {
|
|
if !next.iter().any(|item| item.id == input_id) {
|
|
return Err(AppError::BadRequest(format!(
|
|
"shared Flow input block references missing input {input_id}; export the Flow again with shared input definitions or create the input first"
|
|
)));
|
|
}
|
|
}
|
|
|
|
Ok((next, changed))
|
|
}
|
|
fn flow_louver_position(config: &Value, key: &str) -> Option<u8> {
|
|
match config.get(key) {
|
|
Some(Value::Bool(value)) => Some(u8::from(*value)),
|
|
Some(value) => value
|
|
.as_u64()
|
|
.and_then(|raw| u8::try_from(raw).ok()),
|
|
None => None,
|
|
}
|
|
}
|
|
|
|
fn flow_device_feature_command(config: &Value) -> Result<DeviceCommand, AppError> {
|
|
let feature = flow_string(config, "feature")
|
|
.ok_or_else(|| AppError::BadRequest("device feature action needs a feature".into()))?;
|
|
let mut command = DeviceCommand::default();
|
|
match feature.as_str() {
|
|
"power" => {
|
|
command.power = Some(flow_bool(config, "value").ok_or_else(|| {
|
|
AppError::BadRequest("device feature power needs a boolean value".into())
|
|
})?);
|
|
}
|
|
"mode" => {
|
|
command.mode = Some(flow_string(config, "value").ok_or_else(|| {
|
|
AppError::BadRequest("device feature mode needs a value".into())
|
|
})?);
|
|
}
|
|
"target_temperature" => {
|
|
command.target_temperature = Some(flow_f64(config, "value").ok_or_else(|| {
|
|
AppError::BadRequest("device feature target temperature needs a numeric value".into())
|
|
})?);
|
|
}
|
|
"fan_speed" => {
|
|
command.fan_speed = Some(flow_u8(config, "value").ok_or_else(|| {
|
|
AppError::BadRequest("device feature fan speed needs an integer value".into())
|
|
})?);
|
|
}
|
|
"swing_vertical" => {
|
|
command.swing_vertical = Some(flow_louver_position(config, "value").ok_or_else(|| {
|
|
AppError::BadRequest("device feature vertical louver needs a position".into())
|
|
})?);
|
|
}
|
|
"swing_horizontal" => {
|
|
command.swing_horizontal = Some(flow_louver_position(config, "value").ok_or_else(|| {
|
|
AppError::BadRequest("device feature horizontal louver needs a position".into())
|
|
})?);
|
|
}
|
|
"quiet" | "turbo" | "light" | "air" | "xfan" | "health" | "sleep" => {
|
|
let value = flow_bool(config, "value").ok_or_else(|| {
|
|
AppError::BadRequest("device feature action needs an on/off value".into())
|
|
})?;
|
|
match feature.as_str() {
|
|
"quiet" => command.quiet = Some(value),
|
|
"turbo" => command.turbo = Some(value),
|
|
"light" => command.light = Some(value),
|
|
"air" => command.air = Some(value),
|
|
"xfan" => command.xfan = Some(value),
|
|
"health" => command.health = Some(value),
|
|
"sleep" => command.sleep = Some(value),
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
_ => {
|
|
return Err(AppError::BadRequest(format!(
|
|
"unsupported device feature action: {feature}"
|
|
)))
|
|
}
|
|
}
|
|
engine::validate_command(&command)?;
|
|
Ok(command)
|
|
}
|
|
|
|
fn generated_flow_name(flow_id: &str, action_node_id: &str) -> String {
|
|
let digest = Sha256::digest(format!("{flow_id}:{action_node_id}").as_bytes());
|
|
format!("flow-{}", URL_SAFE_NO_PAD.encode(&digest[..12]))
|
|
}
|
|
|
|
fn flow_condition_kind(kind: &str) -> bool {
|
|
matches!(
|
|
kind,
|
|
"weekday"
|
|
| "time_range"
|
|
| "date_range"
|
|
| "cron_trigger"
|
|
| "stable_for"
|
|
| "delay"
|
|
| "state_duration"
|
|
| "on_change"
|
|
| "rate_limit"
|
|
| "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"
|
|
| "device_feature_action"
|
|
| "group_action"
|
|
| "ha_service_action"
|
|
)
|
|
}
|
|
|
|
fn shared_input_comparison_kind(kind: &str) -> bool {
|
|
matches!(
|
|
kind,
|
|
"outdoor_temperature"
|
|
| "device_temperature"
|
|
| "zone_temperature"
|
|
| "ha_state"
|
|
| "ha_numeric"
|
|
| "ha_attribute"
|
|
| "house_mode"
|
|
| "device_state"
|
|
| "zone_state"
|
|
| "group_state"
|
|
)
|
|
}
|
|
|
|
enum SharedInputResourceReference {
|
|
Device(String),
|
|
Zone(String),
|
|
Group(String),
|
|
}
|
|
|
|
fn shared_input_resource_reference(
|
|
kind: &str,
|
|
config: &Value,
|
|
) -> Result<Option<SharedInputResourceReference>, AppError> {
|
|
let reference = match kind {
|
|
"outdoor_temperature" | "house_mode" | "night_mode" => None,
|
|
"device_temperature" => {
|
|
let id = flow_string(config, "device_id").ok_or_else(|| {
|
|
AppError::BadRequest("shared device temperature input needs device".into())
|
|
})?;
|
|
Some(SharedInputResourceReference::Device(id))
|
|
}
|
|
"zone_temperature" => {
|
|
let id = flow_string(config, "zone_id").ok_or_else(|| {
|
|
AppError::BadRequest("shared zone temperature input needs zone".into())
|
|
})?;
|
|
Some(SharedInputResourceReference::Zone(id))
|
|
}
|
|
"ha_state" | "ha_numeric" | "ha_available" => {
|
|
if flow_string(config, "entity_id").is_none() {
|
|
return Err(AppError::BadRequest(
|
|
"shared Home Assistant input needs entity_id".into(),
|
|
));
|
|
}
|
|
None
|
|
}
|
|
"ha_attribute" => {
|
|
if flow_string(config, "entity_id").is_none()
|
|
|| flow_string(config, "attribute").is_none()
|
|
{
|
|
return Err(AppError::BadRequest(
|
|
"shared Home Assistant attribute input needs entity_id and attribute".into(),
|
|
));
|
|
}
|
|
None
|
|
}
|
|
"device_state" => {
|
|
let id = flow_string(config, "device_id").ok_or_else(|| {
|
|
AppError::BadRequest("shared device state input needs device".into())
|
|
})?;
|
|
let field = flow_string(config, "field").ok_or_else(|| {
|
|
AppError::BadRequest("shared device state input 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 shared device state field".into(),
|
|
));
|
|
}
|
|
Some(SharedInputResourceReference::Device(id))
|
|
}
|
|
"zone_state" => {
|
|
let id = flow_string(config, "zone_id")
|
|
.ok_or_else(|| AppError::BadRequest("shared zone state input needs zone".into()))?;
|
|
let field = flow_string(config, "field").ok_or_else(|| {
|
|
AppError::BadRequest("shared zone state input 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 shared zone state field".into(),
|
|
));
|
|
}
|
|
Some(SharedInputResourceReference::Zone(id))
|
|
}
|
|
"group_state" => {
|
|
let id = flow_string(config, "group_id").ok_or_else(|| {
|
|
AppError::BadRequest("shared group state input needs group".into())
|
|
})?;
|
|
if flow_string(config, "field").as_deref() != Some("power_enabled") {
|
|
return Err(AppError::BadRequest(
|
|
"unsupported shared group state field".into(),
|
|
));
|
|
}
|
|
Some(SharedInputResourceReference::Group(id))
|
|
}
|
|
"constant" => {
|
|
if config.get("value").and_then(Value::as_bool).is_none() {
|
|
return Err(AppError::BadRequest(
|
|
"shared constant input needs a boolean value".into(),
|
|
));
|
|
}
|
|
None
|
|
}
|
|
_ => {
|
|
return Err(AppError::BadRequest(format!(
|
|
"unsupported shared Flow input kind: {kind}"
|
|
)))
|
|
}
|
|
};
|
|
Ok(reference)
|
|
}
|
|
|
|
fn validate_shared_input_source(
|
|
kind: &str,
|
|
config: &Value,
|
|
state: &AppState,
|
|
) -> Result<(), AppError> {
|
|
let Some(reference) = shared_input_resource_reference(kind, config)? else {
|
|
return Ok(());
|
|
};
|
|
let exists = match reference {
|
|
SharedInputResourceReference::Device(id) => state.db.get_device(&id)?.is_some(),
|
|
SharedInputResourceReference::Zone(id) => state.db.get_zone(&id)?.is_some(),
|
|
SharedInputResourceReference::Group(id) => state.db.get_group(&id)?.is_some(),
|
|
};
|
|
if !exists {
|
|
return Err(AppError::BadRequest(
|
|
"shared Flow input references a missing resource".into(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_flow_draft_graph(input: &FlowInput) -> Result<(), AppError> {
|
|
if input.name.trim().is_empty() {
|
|
return Err(AppError::BadRequest("flow name is required".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
|
|
)));
|
|
}
|
|
}
|
|
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 !ids.contains(edge.from.as_str()) || !ids.contains(edge.to.as_str()) {
|
|
return Err(AppError::BadRequest(
|
|
"flow contains a connection to a missing block".into(),
|
|
));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_flow_graph(input: &FlowInput) -> Result<(), AppError> {
|
|
validate_flow_draft_graph(input)?;
|
|
if input.nodes.is_empty() {
|
|
return Err(AppError::BadRequest("flow needs at least one block".into()));
|
|
}
|
|
let ids = input
|
|
.nodes
|
|
.iter()
|
|
.map(|node| node.id.clone())
|
|
.collect::<std::collections::HashSet<_>>();
|
|
if !input.nodes.iter().any(|node| flow_action_kind(&node.kind)) {
|
|
return Err(AppError::BadRequest(
|
|
"flow needs at least one action block".into(),
|
|
));
|
|
}
|
|
for edge in &input.edges {
|
|
if edge.from == edge.to {
|
|
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(),
|
|
));
|
|
}
|
|
}
|
|
for node in input.nodes.iter().filter(|node| node.kind == "rate_limit") {
|
|
let targets = input
|
|
.edges
|
|
.iter()
|
|
.filter(|edge| edge.from == node.id)
|
|
.filter_map(|edge| input.nodes.iter().find(|target| target.id == edge.to))
|
|
.collect::<Vec<_>>();
|
|
if targets.is_empty() || targets.iter().any(|target| !flow_action_kind(&target.kind)) {
|
|
return Err(AppError::BadRequest(
|
|
"rate-limit block must be placed directly before an action".into(),
|
|
));
|
|
}
|
|
}
|
|
|
|
for node in input.nodes.iter().filter(|node| {
|
|
node.kind == "on_change" && flow_string(&node.config, "mode").as_deref() == Some("value")
|
|
}) {
|
|
let sources = input
|
|
.edges
|
|
.iter()
|
|
.filter(|edge| edge.to == node.id)
|
|
.filter_map(|edge| input.nodes.iter().find(|source| source.id == edge.from))
|
|
.collect::<Vec<_>>();
|
|
if sources.len() != 1
|
|
|| sources.iter().any(|source| {
|
|
flow_logic_kind(&source.kind)
|
|
|| matches!(
|
|
source.kind.as_str(),
|
|
"stable_for"
|
|
| "delay"
|
|
| "state_duration"
|
|
| "on_change"
|
|
| "rate_limit"
|
|
| "rolling_stat"
|
|
| "oscillates"
|
|
)
|
|
})
|
|
{
|
|
return Err(AppError::BadRequest(
|
|
"on-change value mode needs one direct source/condition input".into(),
|
|
));
|
|
}
|
|
}
|
|
|
|
// Reject cycles for executable Flows. Drafts intentionally allow unfinished wiring and are never compiled.
|
|
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,
|
|
shared_inputs_override: Option<&[crate::models::FlowSharedInput]>,
|
|
) -> 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(),
|
|
));
|
|
}
|
|
}
|
|
"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(),
|
|
));
|
|
}
|
|
}
|
|
"state_duration" => {
|
|
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);
|
|
if min_seconds > 604800 || max_seconds.is_some_and(|value| value > 604800) {
|
|
return Err(AppError::BadRequest(
|
|
"state duration must be between 0 seconds and 7 days".into(),
|
|
));
|
|
}
|
|
if max_seconds.is_some_and(|value| value < min_seconds) {
|
|
return Err(AppError::BadRequest(
|
|
"state duration maximum must be greater than or equal to minimum".into(),
|
|
));
|
|
}
|
|
if min_seconds == 0 && max_seconds.is_none() {
|
|
return Err(AppError::BadRequest(
|
|
"state duration needs a minimum or maximum duration".into(),
|
|
));
|
|
}
|
|
if condition.inputs.len() != 1 {
|
|
return Err(AppError::BadRequest(
|
|
"state duration block needs exactly one input".into(),
|
|
));
|
|
}
|
|
}
|
|
"on_change" => {
|
|
let mode = flow_string(&condition.config, "mode").unwrap_or_else(|| "result".into());
|
|
if !matches!(mode.as_str(), "result" | "value") {
|
|
return Err(AppError::BadRequest(
|
|
"on-change mode must be result or value".into(),
|
|
));
|
|
}
|
|
if condition.inputs.len() != 1 {
|
|
return Err(AppError::BadRequest(
|
|
"on-change block needs exactly one input".into(),
|
|
));
|
|
}
|
|
}
|
|
"rate_limit" => {
|
|
let max_count = condition
|
|
.config
|
|
.get("max_count")
|
|
.and_then(Value::as_u64)
|
|
.unwrap_or(0);
|
|
let period_seconds = condition
|
|
.config
|
|
.get("period_seconds")
|
|
.and_then(Value::as_u64)
|
|
.unwrap_or(0);
|
|
if !(1..=1000).contains(&max_count) {
|
|
return Err(AppError::BadRequest(
|
|
"rate limit max_count must be between 1 and 1000".into(),
|
|
));
|
|
}
|
|
if !(1..=2678400).contains(&period_seconds) {
|
|
return Err(AppError::BadRequest(
|
|
"rate limit period must be between 1 second and 31 days".into(),
|
|
));
|
|
}
|
|
if condition.inputs.len() != 1 {
|
|
return Err(AppError::BadRequest(
|
|
"rate-limit 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)?;
|
|
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(),
|
|
));
|
|
}
|
|
}
|
|
"shared_input" => {
|
|
let input_id = flow_string(&condition.config, "input_id").ok_or_else(|| {
|
|
AppError::BadRequest("shared Flow input block needs input_id".into())
|
|
})?;
|
|
let shared = if let Some(inputs) = shared_inputs_override {
|
|
inputs.iter().find(|item| item.id == input_id).cloned()
|
|
} else {
|
|
state
|
|
.db
|
|
.load_runtime_settings()?
|
|
.and_then(|settings| {
|
|
settings
|
|
.home_assistant
|
|
.flow_inputs
|
|
.into_iter()
|
|
.find(|item| item.id == input_id)
|
|
})
|
|
}
|
|
.ok_or_else(|| {
|
|
AppError::BadRequest(format!(
|
|
"shared Flow input block references missing input {input_id}"
|
|
))
|
|
})?;
|
|
validate_shared_input_source(&shared.kind, &shared.config, state)?;
|
|
if shared_input_comparison_kind(&shared.kind) {
|
|
let operator = flow_string(&condition.config, "operator").ok_or_else(|| {
|
|
AppError::BadRequest(
|
|
"shared Flow value needs an operator in the Flow block".into(),
|
|
)
|
|
})?;
|
|
let mut config = shared.config.clone();
|
|
let map = config.as_object_mut().ok_or_else(|| {
|
|
AppError::BadRequest("shared Flow input config must be an object".into())
|
|
})?;
|
|
map.insert("operator".into(), Value::String(operator));
|
|
map.insert(
|
|
"value".into(),
|
|
condition
|
|
.config
|
|
.get("value")
|
|
.cloned()
|
|
.unwrap_or(Value::Null),
|
|
);
|
|
let resolved = crate::models::FlowCondition {
|
|
id: condition.id.clone(),
|
|
kind: shared.kind.clone(),
|
|
config,
|
|
inputs: Vec::new(),
|
|
};
|
|
validate_condition(&resolved, state, shared_inputs_override)?;
|
|
} else if flow_string(&condition.config, "operator").is_some()
|
|
|| condition.config.get("value").is_some()
|
|
{
|
|
return Err(AppError::BadRequest(
|
|
"this shared Flow input is already boolean and does not accept a comparison"
|
|
.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,
|
|
shared_inputs_override: Option<&[crate::models::FlowSharedInput]>,
|
|
) -> 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, shared_inputs_override)?;
|
|
}
|
|
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()
|
|
&& flow_louver_position(&action_node.config, "swing_vertical").is_none()
|
|
&& flow_louver_position(&action_node.config, "swing_horizontal").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: generated_flow_name(&flow.id, &action_node.id),
|
|
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 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"
|
|
| "state_duration"
|
|
| "on_change"
|
|
| "rate_limit"
|
|
| "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,
|
|
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() {
|
|
"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);
|
|
}
|
|
item.action.swing_vertical =
|
|
flow_louver_position(&action_node.config, "swing_vertical");
|
|
item.action.swing_horizontal =
|
|
flow_louver_position(&action_node.config, "swing_horizontal");
|
|
engine::validate_command(&item.action)?;
|
|
}
|
|
"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_louver_position(&action_node.config, "swing_vertical");
|
|
item.action.swing_horizontal =
|
|
flow_louver_position(&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()));
|
|
}
|
|
}
|
|
"device_feature_action" => {
|
|
let id = flow_string(&action_node.config, "device_id").ok_or_else(|| {
|
|
AppError::BadRequest("device feature action needs a device".into())
|
|
})?;
|
|
if !devices.iter().any(|d| d.id == id) {
|
|
return Err(AppError::BadRequest(
|
|
"device feature action references a missing device".into(),
|
|
));
|
|
}
|
|
item.action_device_id = id;
|
|
item.action = flow_device_feature_command(&action_node.config)?;
|
|
}
|
|
"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(),
|
|
));
|
|
}
|
|
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 {
|
|
let draft = input.draft;
|
|
crate::models::Flow {
|
|
id,
|
|
name: input.name.trim().into(),
|
|
enabled: if draft { false } else { input.enabled },
|
|
draft,
|
|
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(),
|
|
}
|
|
}
|
|
|
|
fn prepare_draft_flow(mut flow: crate::models::Flow) -> crate::models::Flow {
|
|
flow.enabled = false;
|
|
flow.draft = true;
|
|
flow.compiled_schedule_ids.clear();
|
|
flow.compiled_automation_ids.clear();
|
|
flow.summary = format!("Draft · {} blocks", flow.nodes.len());
|
|
flow
|
|
}
|
|
|
|
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> {
|
|
if input.draft {
|
|
validate_flow_draft_graph(&input)?;
|
|
} else {
|
|
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) = if flow.draft {
|
|
(prepare_draft_flow(flow), vec![], vec![])
|
|
} else {
|
|
compile_flow(&state, flow, None)?
|
|
};
|
|
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> {
|
|
if input.draft {
|
|
validate_flow_draft_graph(&input)?;
|
|
} else {
|
|
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) = if flow.draft {
|
|
(prepare_draft_flow(flow), vec![], vec![])
|
|
} else {
|
|
compile_flow(&state, flow, None)?
|
|
};
|
|
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}")))?;
|
|
let referenced = flow_referenced_shared_input_ids(&flow.nodes);
|
|
let shared_inputs: Vec<_> = state
|
|
.settings
|
|
.read()
|
|
.await
|
|
.home_assistant
|
|
.flow_inputs
|
|
.iter()
|
|
.filter(|item| referenced.contains(&item.id))
|
|
.cloned()
|
|
.collect();
|
|
Ok(Json(json!({
|
|
"format": "gree-controller-flow",
|
|
"version": 1,
|
|
"exported_at": Utc::now(),
|
|
"shared_inputs": shared_inputs,
|
|
"flow": {
|
|
"name": flow.name,
|
|
"enabled": flow.enabled,
|
|
"draft": flow.draft,
|
|
"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(),
|
|
));
|
|
}
|
|
}
|
|
if let Some(version) = document.get("version").and_then(Value::as_u64) {
|
|
if !matches!(version, 1 | 2) {
|
|
return Err(AppError::BadRequest(format!(
|
|
"unsupported Flow import version {version}"
|
|
)));
|
|
}
|
|
}
|
|
let imported_shared_inputs: Vec<crate::models::FlowSharedInput> = document
|
|
.get("shared_inputs")
|
|
.cloned()
|
|
.map(serde_json::from_value)
|
|
.transpose()
|
|
.map_err(|err| AppError::BadRequest(format!("invalid shared Flow inputs: {err}")))?
|
|
.unwrap_or_default();
|
|
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;
|
|
|
|
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 mut next_settings = state.settings.read().await.clone();
|
|
let (flow_inputs, shared_inputs_changed) = prepare_imported_shared_inputs(
|
|
&mut input,
|
|
imported_shared_inputs,
|
|
&next_settings.home_assistant.flow_inputs,
|
|
)?;
|
|
next_settings.home_assistant.flow_inputs = flow_inputs;
|
|
validate_flow_shared_inputs(&mut next_settings.home_assistant, &state)?;
|
|
|
|
if input.draft {
|
|
validate_flow_draft_graph(&input)?;
|
|
} else {
|
|
validate_flow_graph(&input)?;
|
|
}
|
|
|
|
let flow = flow_from_input(Uuid::new_v4().to_string(), input, Utc::now(), 1);
|
|
let (flow, schedules, automations) = if flow.draft {
|
|
(prepare_draft_flow(flow), vec![], vec![])
|
|
} else {
|
|
compile_flow(
|
|
&state,
|
|
flow,
|
|
Some(&next_settings.home_assistant.flow_inputs),
|
|
)?
|
|
};
|
|
|
|
if shared_inputs_changed {
|
|
state.db.replace_flow_outputs_with_runtime_settings(
|
|
&flow,
|
|
&schedules,
|
|
&automations,
|
|
&next_settings,
|
|
)?;
|
|
*state.settings.write().await = next_settings.clone();
|
|
let payload = home_assistant_settings(&next_settings);
|
|
state.broadcast(
|
|
"settings.home_assistant.updated",
|
|
serde_json::to_value(&payload)?,
|
|
);
|
|
} else {
|
|
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,
|
|
"shared_inputs_imported": shared_inputs_changed
|
|
}),
|
|
);
|
|
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" | "device_feature_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()));
|
|
}
|
|
let command = if action.kind == "device_feature_action" {
|
|
flow_device_feature_command(&action.config)?
|
|
} else {
|
|
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_louver_position(&action.config, "swing_vertical");
|
|
command.swing_horizontal = flow_louver_position(&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");
|
|
command
|
|
};
|
|
if zones.iter().any(|z| z.device_id == device_id && !z.enabled)
|
|
&& command.power != Some(true)
|
|
{
|
|
return Ok(Some("zone_disabled".into()));
|
|
}
|
|
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)
|
|
}
|
|
"ha_service_action" => 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,
|
|
draft: false,
|
|
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, None)?;
|
|
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 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!({
|
|
"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})))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod flow_draft_tests {
|
|
use super::*;
|
|
|
|
fn unfinished_input() -> FlowInput {
|
|
FlowInput {
|
|
name: "Unfinished".into(),
|
|
enabled: true,
|
|
draft: true,
|
|
description: String::new(),
|
|
nodes: vec![crate::models::FlowNode {
|
|
id: "condition".into(),
|
|
kind: "constant".into(),
|
|
x: 0.0,
|
|
y: 0.0,
|
|
config: json!({"value": true}),
|
|
}],
|
|
edges: vec![],
|
|
expected_revision: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn draft_graph_accepts_flow_without_action() {
|
|
let input = unfinished_input();
|
|
assert!(validate_flow_draft_graph(&input).is_ok());
|
|
assert!(validate_flow_graph(&input).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn draft_graph_still_rejects_missing_edge_endpoints() {
|
|
let mut input = unfinished_input();
|
|
input.edges.push(crate::models::FlowEdge {
|
|
id: "edge".into(),
|
|
from: "condition".into(),
|
|
to: "missing".into(),
|
|
});
|
|
assert!(validate_flow_draft_graph(&input).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn draft_flow_is_forced_disabled_and_has_no_compiled_outputs() {
|
|
let flow = flow_from_input("draft-id".into(), unfinished_input(), chrono::Utc::now(), 1);
|
|
let flow = prepare_draft_flow(flow);
|
|
assert!(flow.draft);
|
|
assert!(!flow.enabled);
|
|
assert!(flow.compiled_schedule_ids.is_empty());
|
|
assert!(flow.compiled_automation_ids.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn device_feature_action_builds_only_selected_field() {
|
|
let command = flow_device_feature_command(&json!({"feature": "light", "value": true}))
|
|
.expect("valid feature action");
|
|
assert_eq!(command.light, Some(true));
|
|
assert!(command.power.is_none());
|
|
assert!(command.mode.is_none());
|
|
assert!(command.swing_vertical.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn device_feature_action_validates_louver_range() {
|
|
let result = flow_device_feature_command(&json!({"feature": "swing_vertical", "value": 99}));
|
|
assert!(result.is_err());
|
|
}
|
|
}
|