This commit is contained in:
Mateusz Gruszczyński
2026-09-18 23:57:28 +02:00
parent 00cbe975bb
commit be3708f14d
17 changed files with 424 additions and 83 deletions
+200 -28
View File
@@ -53,6 +53,109 @@ fn flow_u8(config: &Value, key: &str) -> Option<u8> {
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)),
@@ -604,6 +707,7 @@ fn validate_text_comparison(config: &Value) -> Result<(), AppError> {
fn validate_condition(
condition: &crate::models::FlowCondition,
state: &AppState,
shared_inputs_override: Option<&[crate::models::FlowSharedInput]>,
) -> Result<(), AppError> {
match condition.kind.as_str() {
"weekday" => {
@@ -1032,20 +1136,25 @@ fn validate_condition(
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(),
)
})?;
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(|| {
@@ -1072,7 +1181,7 @@ fn validate_condition(
config,
inputs: Vec::new(),
};
validate_condition(&resolved, state)?;
validate_condition(&resolved, state, shared_inputs_override)?;
} else if flow_string(&condition.config, "operator").is_some()
|| condition.config.get("value").is_some()
{
@@ -1104,6 +1213,7 @@ fn validate_flow_comparison(config: &Value) -> Result<(), AppError> {
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();
@@ -1137,7 +1247,7 @@ fn compile_flow(
.iter()
.filter(|condition| flow_condition_kind(&condition.kind))
{
validate_condition(condition, state)?;
validate_condition(condition, state, shared_inputs_override)?;
}
let schedule_leaves: Vec<_> = conditions
.iter()
@@ -1572,7 +1682,7 @@ async fn create_flow(
let (flow, schedules, automations) = if flow.draft {
(prepare_draft_flow(flow), vec![], vec![])
} else {
compile_flow(&state, flow)?
compile_flow(&state, flow, None)?
};
state
.db
@@ -1629,7 +1739,7 @@ async fn update_flow(
let (flow, schedules, automations) = if flow.draft {
(prepare_draft_flow(flow), vec![], vec![])
} else {
compile_flow(&state, flow)?
compile_flow(&state, flow, None)?
};
state
.db
@@ -1689,10 +1799,22 @@ async fn export_flow(
.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,
@@ -1715,28 +1837,74 @@ async fn import_flow(
));
}
}
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 _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)?
compile_flow(
&state,
flow,
Some(&next_settings.home_assistant.flow_inputs),
)?
};
state
.db
.replace_flow_outputs(&flow, &schedules, &automations)?;
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())
@@ -1748,7 +1916,11 @@ async fn import_flow(
"info",
"flow.imported",
&format!("Imported Flow {}", flow.name),
json!({"flow_id": flow.id, "revision": flow.revision}),
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();
@@ -1896,7 +2068,7 @@ async fn simulate_flow(
created_at: Utc::now(),
updated_at: Utc::now(),
};
let (compiled, schedules, automations) = compile_flow(&state, preview)?;
let (compiled, schedules, automations) = compile_flow(&state, preview, None)?;
let devices = state.db.list_devices()?;
let mut actions = Vec::new();
for action in input
+44
View File
@@ -45,6 +45,50 @@ impl Db {
Ok(())
}
pub fn replace_flow_outputs_with_runtime_settings(
&self,
flow: &Flow,
schedules: &[Schedule],
automations: &[Automation],
settings: &RuntimeSettings,
) -> Result<()> {
let mut conn = self.lock()?;
let tx = conn.transaction()?;
tx.execute(queries::DELETE_SCHEDULES_BY_FLOW_ID, [flow.id.as_str()])?;
tx.execute(queries::DELETE_AUTOMATIONS_BY_FLOW_ID, [flow.id.as_str()])?;
for schedule in schedules {
let payload = Self::to_json(schedule)?;
tx.execute(
queries::UPSERT_SCHEDULE,
params![
schedule.id,
schedule.zone_id,
payload,
schedule.updated_at.to_rfc3339()
],
)?;
}
for item in automations {
let payload = Self::to_json(item)?;
tx.execute(
queries::UPSERT_AUTOMATION,
params![item.id, payload, item.updated_at.to_rfc3339()],
)?;
}
let flow_payload = Self::to_json(flow)?;
tx.execute(
queries::UPSERT_FLOW,
params![flow.id, flow_payload, flow.updated_at.to_rfc3339()],
)?;
let settings_payload = Self::to_json(settings)?;
tx.execute(
queries::UPSERT_RUNTIME_SETTINGS,
params![settings_payload, Utc::now().to_rfc3339()],
)?;
tx.commit()?;
Ok(())
}
pub fn delete_flow(&self, id: &str) -> Result<bool> {
let mut conn = self.lock()?;
let tx = conn.transaction()?;