This commit is contained in:
Mateusz Gruszczyński
2026-09-02 08:39:20 +02:00
parent 16e0d94564
commit a161d8785d
26 changed files with 712 additions and 164 deletions
+11 -3
View File
@@ -36,11 +36,16 @@ fn flow_f64(config: &Value, key: &str) -> Option<f64> { config.get(key).and_then
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 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" | "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"
"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") }
@@ -215,6 +220,9 @@ fn validate_condition(condition: &crate::models::FlowCondition, state: &AppState
"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" => {
if flow_string(&condition.config, "input_id").is_none() { return Err(AppError::BadRequest("shared Flow input block needs input_id".into())); }
}
_ => {}
}
Ok(())
@@ -271,7 +279,7 @@ fn compile_flow(state: &AppState, mut flow: crate::models::Flow) -> Result<(crat
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,
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()),
};
@@ -283,7 +291,7 @@ fn compile_flow(state: &AppState, mut flow: crate::models::Flow) -> Result<(crat
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,
id, name: generated_flow_name(&flow.id, &action_node.id), enabled: flow.enabled,
trigger_kind: "flow".into(), trigger_device_id: None, threshold: None, at_time: None,
action_device_id: String::new(), action_group_id: None, action_preset: None, action: DeviceCommand::default(), cooldown_seconds: 60,
last_fired_at, action_zone_id: None, action_zone_preset: None, flow_conditions: conditions,
+1
View File
@@ -57,6 +57,7 @@ fn public_settings(settings: &RuntimeSettings) -> Value {
"sensor_stale_after_seconds": settings.home_assistant.sensor_stale_after_seconds,
"allow_invalid_tls": settings.home_assistant.allow_invalid_tls,
"sensor_aliases": settings.home_assistant.sensor_aliases,
"flow_inputs": settings.home_assistant.flow_inputs,
}
})
}
+34
View File
@@ -43,6 +43,7 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
let compressor_settings_changed = input.compressor_protection_enabled != old.compressor_protection_enabled
|| input.compressor_protection_seconds != old.compressor_protection_seconds;
normalize_sensor_aliases(&mut input);
validate_flow_shared_inputs(&mut input, &state)?;
canonicalize_home_assistant_entities(&mut input);
validate_night_mode(&mut input)?;
input.influxdb.history_threshold_days = input.influxdb.history_threshold_days.clamp(1, 3650);
@@ -97,6 +98,39 @@ fn normalize_sensor_aliases(settings: &mut RuntimeSettings) {
.collect();
}
fn validate_flow_shared_inputs(settings: &mut RuntimeSettings, state: &AppState) -> Result<(), AppError> {
let mut ids = std::collections::HashSet::new();
if settings.home_assistant.flow_inputs.len() > 128 {
return Err(AppError::BadRequest("too many shared Flow inputs (max 128)".into()));
}
for item in &mut settings.home_assistant.flow_inputs {
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("shared Flow input requires id and name".into()));
}
if !ids.insert(item.id.clone()) {
return Err(AppError::BadRequest("shared Flow input IDs must be unique".into()));
}
if !matches!(item.kind.as_str(),
"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") {
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)?;
}
Ok(())
}
fn canonicalize_home_assistant_entities(settings: &mut RuntimeSettings) {
let default_entity = settings.home_assistant.default_entity_id.clone();
if let Some(entity_id) = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&default_entity)) {
+1
View File
@@ -86,6 +86,7 @@ impl Config {
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
.unwrap_or(false),
sensor_aliases: Default::default(),
flow_inputs: Vec::new(),
},
}
}
+13 -3
View File
@@ -285,9 +285,19 @@ async fn flow_leaf_observation(
settings: &crate::models::RuntimeSettings,
overrides: &HashMap<String, Value>,
) -> Result<(bool, Value), AppError> {
let c = &condition.config;
let mut resolved_kind = condition.kind.as_str();
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 c = resolved_config.unwrap_or(&condition.config);
let override_value = overrides.get(&condition.id).cloned();
let (matched, actual) = match condition.kind.as_str() {
let (matched, actual) = match resolved_kind {
"weekday" => {
let actual = override_value.unwrap_or_else(|| json!(now.weekday().number_from_monday()));
let day = actual.as_u64().unwrap_or(now.weekday().number_from_monday() as u64);
@@ -508,7 +518,7 @@ fn flow_condition_kind_runtime(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"
"device_state" | "zone_state" | "group_state" | "night_mode" | "constant" | "shared_input"
)
}
+1
View File
@@ -96,6 +96,7 @@ mod tests {
sensor_stale_after_seconds: 300,
allow_invalid_tls: false,
sensor_aliases,
flow_inputs: Vec::new(),
}
}
+12
View File
@@ -1,3 +1,12 @@
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlowSharedInput {
pub id: String,
pub name: String,
pub kind: String,
#[serde(default)]
pub config: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HomeAssistantSettings {
#[serde(default)]
@@ -18,6 +27,9 @@ 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.
#[serde(default)]
pub flow_inputs: Vec<FlowSharedInput>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]