This commit is contained in:
Mateusz Gruszczyński
2026-09-01 22:20:10 +02:00
parent 1cb62c8d0b
commit 16e0d94564
47 changed files with 2565 additions and 117 deletions
+4
View File
@@ -87,6 +87,7 @@ impl AutomationInput {
action_group_id: self.action_group_id.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()),
action_preset: self.action_preset.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()),
action: self.action, cooldown_seconds: self.cooldown_seconds.max(30), last_fired_at,
action_zone_id: None, action_zone_preset: None, flow_conditions: vec![], flow_id: None, flow_node_id: None,
created_at, updated_at: Utc::now() }
}
}
@@ -140,6 +141,7 @@ async fn update_automation(State(state): State<AppState>, Path(id): Path<String>
let _automation_guard = state.lock_automation_operation().await;
input.validate()?;
let existing = state.db.get_automation(&id)?.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?;
if existing.flow_id.is_some() { return Err(AppError::BadRequest("this automation is generated by Flow; edit it in the Flow editor".into())); }
let action_group_id = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()).map(str::to_string);
let _group_guard = if let Some(group_id) = action_group_id.as_deref() {
Some(state.lock_group_operation(group_id).await)
@@ -153,6 +155,8 @@ async fn update_automation(State(state): State<AppState>, Path(id): Path<String>
async fn delete_automation(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 existing = state.db.get_automation(&id)?.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?;
if existing.flow_id.is_some() { return Err(AppError::BadRequest("this automation is generated by Flow; delete it from the Flow editor".into())); }
if !state.db.delete_automation(&id)? { return Err(AppError::NotFound(format!("automation {id}"))); }
state.broadcast("automation.deleted", json!({"id": id}));
Ok(StatusCode::NO_CONTENT)
+561
View File
@@ -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})))
}
+1 -1
View File
@@ -256,7 +256,7 @@ async fn apply_schedule_template(State(state): State<AppState>, Path(id): Path<S
items.push(Schedule {
id: Uuid::new_v4().to_string(), zone_id: id.clone(), name: name.into(), enabled: true,
weekdays: days, start_time: start.into(), end_time: end.into(), preset: preset.into(),
setpoint: zone.setpoint, created_at: Utc::now(), updated_at: Utc::now(),
setpoint: zone.setpoint, created_at: Utc::now(), updated_at: Utc::now(), flow_id: None, flow_node_id: None,
});
};
let all = vec![1,2,3,4,5,6,7];
+3 -1
View File
@@ -25,7 +25,7 @@ impl ScheduleInput {
fn into_schedule(self, id: String, created_at: chrono::DateTime<Utc>) -> Schedule {
Schedule { id, zone_id: self.zone_id, name: self.name.trim().into(), enabled: self.enabled,
weekdays: self.weekdays, start_time: self.start_time, end_time: self.end_time,
preset: self.preset, setpoint: self.setpoint, created_at, updated_at: Utc::now() }
preset: self.preset, setpoint: self.setpoint, created_at, updated_at: Utc::now(), flow_id: None, flow_node_id: None }
}
}
fn validate_schedule_set(items: &[Schedule]) -> Result<(), AppError> {
@@ -119,6 +119,7 @@ async fn update_schedule(State(state): State<AppState>, Path(id): Path<String>,
let _cycle_guard = state.lock_zone_control_cycle().await;
input.validate()?;
let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?;
if existing.flow_id.is_some() { return Err(AppError::BadRequest("this schedule is generated by Flow; edit it in the Flow editor".into())); }
if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); }
let old_zone_id = existing.zone_id.clone();
let item = input.into_schedule(id.clone(), existing.created_at);
@@ -135,6 +136,7 @@ async fn delete_schedule(State(state): State<AppState>, Path(id): Path<String>)
let _schedule_guard = state.lock_schedule_operation().await;
let _cycle_guard = state.lock_zone_control_cycle().await;
let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?;
if existing.flow_id.is_some() { return Err(AppError::BadRequest("this schedule is generated by Flow; delete it from the Flow editor".into())); }
if !state.db.delete_schedule(&id)? { return Err(AppError::NotFound(format!("schedule {id}"))); }
refresh_zone_override_boundary(&state, &existing.zone_id).await?;
state.broadcast("schedule.deleted", json!({"id": id}));
+36 -9
View File
@@ -154,7 +154,7 @@ async fn export_settings(State(state): State<AppState>) -> Result<Json<Configura
}
fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), AppError> {
if export.format_version != 1 { return Err(AppError::BadRequest("unsupported configuration export version".into())); }
if !matches!(export.format_version, 1 | 2) { return Err(AppError::BadRequest("unsupported configuration export version".into())); }
influxdb::validate(&export.settings.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?;
if !matches!(export.settings.house_mode.as_str(), "cool" | "heat" | "off") {
return Err(AppError::BadRequest("import contains an invalid house mode".into()));
@@ -164,9 +164,10 @@ fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), App
let zones: std::collections::HashSet<&str> = export.zones.iter().map(|item| item.id.as_str()).collect();
let schedules: std::collections::HashSet<&str> = export.schedules.iter().map(|item| item.id.as_str()).collect();
let automations: std::collections::HashSet<&str> = export.automations.iter().map(|item| item.id.as_str()).collect();
let flows: std::collections::HashSet<&str> = export.flows.iter().map(|item| item.id.as_str()).collect();
if devices.len() != export.devices.len() || zones.len() != export.zones.len()
|| schedules.len() != export.schedules.len() || automations.len() != export.automations.len()
|| devices.contains("") || zones.contains("") || schedules.contains("") || automations.contains("")
|| schedules.len() != export.schedules.len() || automations.len() != export.automations.len() || flows.len() != export.flows.len()
|| devices.contains("") || zones.contains("") || schedules.contains("") || automations.contains("") || flows.contains("")
{
return Err(AppError::BadRequest("import contains duplicate or empty resource IDs".into()));
}
@@ -194,6 +195,9 @@ fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), App
return Err(AppError::BadRequest("import contains a schedule referencing a missing zone".into()));
}
for item in &export.schedules {
if item.flow_id.as_deref().is_some_and(|flow_id| !flows.contains(flow_id)) {
return Err(AppError::BadRequest("import contains a Flow-generated schedule referencing a missing Flow".into()));
}
if item.weekdays.is_empty() || item.weekdays.iter().any(|day| !(1..=7).contains(day)) {
return Err(AppError::BadRequest("import contains invalid schedule weekdays".into()));
}
@@ -234,10 +238,27 @@ fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), App
let at = item.at_time.as_deref().ok_or_else(|| AppError::BadRequest("import contains a time automation without at_time".into()))?;
NaiveTime::parse_from_str(at, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid automation time".into()))?;
}
"flow" => {
if item.flow_id.as_deref().filter(|id| flows.contains(*id)).is_none() || item.flow_conditions.is_empty() {
return Err(AppError::BadRequest("import contains an invalid Flow-generated automation".into()));
}
}
_ => return Err(AppError::BadRequest("import contains an unsupported automation trigger".into())),
}
if let Some(group_id) = item.action_group_id.as_deref().filter(|value| !value.is_empty()) {
if let Some(zone_id) = item.action_zone_id.as_deref().filter(|value| !value.is_empty()) {
if !zones.contains(zone_id) { return Err(AppError::BadRequest("import contains a Flow automation referencing a missing zone".into())); }
if let Some(preset) = item.action_zone_preset.as_deref() {
if !matches!(preset, "auto" | "custom" | "comfort" | "sleep" | "away") {
return Err(AppError::BadRequest("import contains an invalid Flow thermostat preset".into()));
}
}
if item.action_zone_preset.as_deref() == Some("custom")
&& item.action.target_temperature.is_some_and(|value| !(8.0..=30.0).contains(&value))
{
return Err(AppError::BadRequest("import contains an invalid Flow thermostat target".into()));
}
} else if let Some(group_id) = item.action_group_id.as_deref().filter(|value| !value.is_empty()) {
if !groups.contains(group_id) {
return Err(AppError::BadRequest("import contains an automation referencing a missing group".into()));
}
@@ -246,17 +267,23 @@ fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), App
return Err(AppError::BadRequest("import contains an invalid group automation mode".into()));
}
}
let flow_custom_group = item.flow_id.is_some() && item.action_preset.as_deref() == Some("custom");
if let Some(preset) = item.action_preset.as_deref() {
if !matches!(preset, "auto" | "comfort" | "sleep" | "away") {
if !matches!(preset, "auto" | "comfort" | "sleep" | "away") && !(flow_custom_group && preset == "custom") {
return Err(AppError::BadRequest("import contains an invalid group automation preset".into()));
}
}
if item.action.target_temperature.is_some() || item.action.fan_speed.is_some()
|| item.action.swing_vertical.is_some() || item.action.swing_horizontal.is_some()
if flow_custom_group {
let Some(target) = item.action.target_temperature else { return Err(AppError::BadRequest("import contains a Flow custom group preset without a target".into())); };
if !(8.0..=30.0).contains(&target) { return Err(AppError::BadRequest("import contains an invalid Flow group target".into())); }
} else if item.action.target_temperature.is_some() {
return Err(AppError::BadRequest("import contains unsupported target temperature in a group automation".into()));
}
if item.action.fan_speed.is_some() || item.action.swing_vertical.is_some() || item.action.swing_horizontal.is_some()
|| item.action.quiet.is_some() || item.action.turbo.is_some() || item.action.light.is_some()
|| item.action.air.is_some() || item.action.xfan.is_some() || item.action.health.is_some() || item.action.sleep.is_some()
{
return Err(AppError::BadRequest("import contains unsupported fields in a group automation".into()));
return Err(AppError::BadRequest("import contains unsupported device fields in a group automation".into()));
}
if item.action.power.is_none() && item.action.mode.is_none() && item.action_preset.as_deref().filter(|v| !v.is_empty()).is_none() {
return Err(AppError::BadRequest("import contains an empty group automation action".into()));
@@ -361,7 +388,7 @@ async fn import_settings(State(state): State<AppState>, Json(mut export): Json<C
// Configuration replacement is the broadest mutation in the application. Serialize it
// against every structural editor and every live owner that can write zone/device state.
// Global lock order: configuration -> automation -> house -> schedule -> zones -> devices.
// Global lock order: configuration -> automation -> house -> schedule -> thermostat-cycle -> zones -> devices.
let _configuration_guard = state.lock_configuration_operation().await;
let _automation_guard = state.lock_automation_operation().await;
let _house_guard = state.lock_house_operation().await;
+1
View File
@@ -26,6 +26,7 @@ async fn build_bootstrap(state: &AppState) -> Result<Value, AppError> {
"groups": state.db.list_groups()?,
"schedules": state.db.list_schedules()?,
"automations": state.db.list_automations()?,
"flows": state.db.list_flows()?,
"access_tokens": state.db.list_api_tokens()?,
"settings": public_settings(&settings),
"outdoor_temperature": *state.outdoor_temperature.read().await,