This commit is contained in:
Mateusz Gruszczyński
2026-09-02 10:12:51 +02:00
parent 8180d22cef
commit e50c94b6a3
19 changed files with 323 additions and 108 deletions
+75 -1
View File
@@ -51,6 +51,62 @@ fn flow_condition_kind(kind: &str) -> bool {
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 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"
)
}
fn validate_shared_input_source(kind: &str, config: &Value, state: &AppState) -> Result<(), AppError> {
match kind {
"outdoor_temperature" | "house_mode" | "night_mode" => {}
"device_temperature" => {
let id = flow_string(config, "device_id").ok_or_else(|| AppError::BadRequest("shared device temperature input needs device".into()))?;
if state.db.get_device(&id)?.is_none() { return Err(AppError::BadRequest("shared Flow input references a missing device".into())); }
}
"zone_temperature" => {
let id = flow_string(config, "zone_id").ok_or_else(|| AppError::BadRequest("shared zone temperature input needs zone".into()))?;
if state.db.get_zone(&id)?.is_none() { return Err(AppError::BadRequest("shared Flow input references a missing zone".into())); }
}
"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())); }
}
"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()));
}
}
"device_state" => {
let id = flow_string(config, "device_id").ok_or_else(|| AppError::BadRequest("shared device state input needs device".into()))?;
if state.db.get_device(&id)?.is_none() { return Err(AppError::BadRequest("shared Flow input references a missing 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()));
}
}
"zone_state" => {
let id = flow_string(config, "zone_id").ok_or_else(|| AppError::BadRequest("shared zone state input needs zone".into()))?;
if state.db.get_zone(&id)?.is_none() { return Err(AppError::BadRequest("shared Flow input references a missing 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()));
}
}
"group_state" => {
let id = flow_string(config, "group_id").ok_or_else(|| AppError::BadRequest("shared group state input needs group".into()))?;
if state.db.get_group(&id)?.is_none() { return Err(AppError::BadRequest("shared Flow input references a missing group".into())); }
if flow_string(config, "field").as_deref() != Some("power_enabled") { return Err(AppError::BadRequest("unsupported shared group state field".into())); }
}
"constant" => {
if config.get("value").and_then(Value::as_bool).is_none() { return Err(AppError::BadRequest("shared constant input needs a boolean value".into())); }
}
_ => return Err(AppError::BadRequest(format!("unsupported shared Flow input kind: {kind}"))),
}
Ok(())
}
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())); }
@@ -221,7 +277,25 @@ fn validate_condition(condition: &crate::models::FlowCondition, state: &AppState
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" => {
if flow_string(&condition.config, "input_id").is_none() { return Err(AppError::BadRequest("shared Flow input block needs input_id".into())); }
let input_id = flow_string(&condition.config, "input_id").ok_or_else(|| AppError::BadRequest("shared Flow input block needs input_id".into()))?;
let settings = state.db.load_runtime_settings()?.ok_or_else(|| AppError::BadRequest("runtime settings are unavailable".into()))?;
let shared = settings.home_assistant.flow_inputs.iter().find(|item| item.id == input_id)
.ok_or_else(|| AppError::BadRequest("shared Flow input block references a missing input".into()))?;
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)?;
} 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()));
}
}
_ => {}
}
+10 -7
View File
@@ -120,13 +120,16 @@ fn validate_flow_shared_inputs(settings: &mut RuntimeSettings, state: &AppState)
"night_mode" | "constant") {
return Err(AppError::BadRequest(format!("unsupported shared Flow input kind: {}", item.kind)));
}
let condition = crate::models::FlowCondition {
id: item.id.clone(),
kind: item.kind.clone(),
config: item.config.clone(),
inputs: Vec::new(),
};
validate_condition(&condition, state)?;
if !item.config.is_object() {
return Err(AppError::BadRequest("shared Flow input config must be an object".into()));
}
if item.config.get("operator").is_some() {
return Err(AppError::BadRequest("shared Flow inputs are value sources; operator belongs to the Flow block".into()));
}
if shared_input_comparison_kind(&item.kind) && item.config.get("value").is_some() {
return Err(AppError::BadRequest("shared Flow inputs are value sources; comparison value belongs to the Flow block".into()));
}
validate_shared_input_source(&item.kind, &item.config, state)?;
}
Ok(())
}
+19 -4
View File
@@ -286,16 +286,30 @@ async fn flow_leaf_observation(
overrides: &HashMap<String, Value>,
) -> Result<(bool, Value), AppError> {
let mut resolved_kind = condition.kind.as_str();
let mut resolved_config: Option<&Value> = None;
let mut resolved_config: Option<Value> = None;
if condition.kind == "shared_input" {
let input_id = condition.config.get("input_id").and_then(Value::as_str).unwrap_or("");
let Some(shared) = settings.home_assistant.flow_inputs.iter().find(|item| item.id == input_id) else {
return Ok((false, json!({"error": "missing_shared_input", "input_id": input_id})));
};
resolved_kind = shared.kind.as_str();
resolved_config = Some(&shared.config);
let mut config = shared.config.clone();
if matches!(shared.kind.as_str(),
"outdoor_temperature" | "device_temperature" | "zone_temperature" |
"ha_state" | "ha_numeric" | "ha_attribute" | "house_mode" |
"device_state" | "zone_state" | "group_state") {
let Some(operator) = condition.config.get("operator").and_then(Value::as_str).map(str::trim).filter(|value| !value.is_empty()) else {
return Ok((false, json!({"error": "missing_shared_input_operator", "input_id": input_id})));
};
let Some(map) = config.as_object_mut() else {
return Ok((false, json!({"error": "invalid_shared_input_config", "input_id": input_id})));
};
map.insert("operator".into(), Value::String(operator.to_string()));
map.insert("value".into(), condition.config.get("value").cloned().unwrap_or(Value::Null));
}
resolved_config = Some(config);
}
let c = resolved_config.unwrap_or(&condition.config);
let c = resolved_config.as_ref().unwrap_or(&condition.config);
let override_value = overrides.get(&condition.id).cloned();
let (matched, actual) = match resolved_kind {
"weekday" => {
@@ -497,12 +511,13 @@ pub async fn evaluate_flow_conditions_trace(
};
values.insert(condition.id.clone(), matched);
final_id = Some(condition.id.clone());
let expected = condition.config.get("value").cloned();
trace.push(json!({
"node_id": condition.id,
"kind": condition.kind,
"matched": matched,
"actual": actual,
"expected": condition.config.get("value"),
"expected": expected,
"inputs": condition.inputs
}));
}
+1 -1
View File
@@ -27,7 +27,7 @@ pub struct HomeAssistantSettings {
/// Friendly labels used only by the controller UI/charts; entity_id remains the storage key.
#[serde(default)]
pub sensor_aliases: BTreeMap<String, String>,
/// Reusable Flow condition inputs shared by any visual Flow.
/// Reusable value sources shared by visual Flows. Comparisons belong to Flow nodes.
#[serde(default)]
pub flow_inputs: Vec<FlowSharedInput>,
}