This commit is contained in:
Mateusz Gruszczyński committed 2026-09-23 13:41:34 +02:00
1 parent abfbf0dd68
commit aa1cb05b2f
24 files changed
+750 -71

No files matched your search

+110 -10
View File
@@ -66,6 +66,43 @@ async fn run_automations(state: &AppState) -> Result<()> {
let Some(latest_item) = state.db.get_automation(&item.id)? else { continue; };
if latest_item.updated_at != item.updated_at { continue; }
item = latest_item;
let mut effective_action = item.action.clone();
let mut effective_ha_data = item.action_ha_data.clone();
let mut dynamic_value = None::<f64>;
if let Some(source_id) = item.flow_value_source_id.as_deref() {
let Some(value) = flow_trace_numeric_output(&trigger_trace, source_id) else {
state.log("warn", "flow.value_missing", &format!("Flow automation {} has no numeric value from {}", item.name, source_id), json!({
"automation_id": item.id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id, "source_node_id": source_id
}));
continue;
};
dynamic_value = Some(value);
let target = item.flow_value_target.as_deref().unwrap_or("target_temperature");
if item.action_ha_domain.is_some() {
let Some(map) = effective_ha_data.as_object_mut() else {
state.log("warn", "flow.value_target_error", &format!("Flow automation {} has invalid Home Assistant service data", item.name), json!({
"automation_id": item.id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id
}));
continue;
};
map.insert(target.to_string(), json!(value));
} else if matches!(target, "target_temperature" | "setpoint") {
effective_action.target_temperature = Some(value);
} else {
state.log("warn", "flow.value_target_error", &format!("Flow automation {} has unsupported dynamic target {}", item.name, target), json!({
"automation_id": item.id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id, "target": target
}));
continue;
}
if item.action_ha_domain.is_none() {
if let Err(err) = validate_command(&effective_action) {
state.log("warn", "flow.value_target_error", &format!("Flow automation {} produced an invalid action: {}", item.name, err), json!({
"automation_id": item.id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id, "value": value
}));
continue;
}
}
}
// Group membership and zone ownership may have changed after the cycle snapshot but
// before we acquired the automation lock. Reload them inside this serialized window so
// same-cycle conflict arbitration claims the actual current target set.
@@ -74,7 +111,7 @@ async fn run_automations(state: &AppState) -> Result<()> {
if item.action_group_id.is_none() && item.action_zone_id.is_none()
&& device_blocked_by_disabled_zone(&item.action_device_id, &zones)
&& item.action.power != Some(true)
&& effective_action.power != Some(true)
{
state.log("info", "automation.blocked_by_zone", &format!("Automation {} suppressed by disabled zone", item.name), json!({
"automation_id": item.id, "device_id": item.action_device_id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id
@@ -101,7 +138,7 @@ async fn run_automations(state: &AppState) -> Result<()> {
}
if item.action_group_id.is_none() && item.action_zone_id.is_none()
&& device_has_thermostat_zone(&item.action_device_id, &zones)
&& automation_action_conflicts_with_thermostat(&item.action)
&& automation_action_conflicts_with_thermostat(&effective_action)
{
// Fan/quiet/sleep are outputs continuously managed by the thermostat. Unlike
// power/mode/target they cannot be translated into durable zone state, so a direct
@@ -139,22 +176,22 @@ async fn run_automations(state: &AppState) -> Result<()> {
let result: Result<bool, AppError> = if let (Some(domain), Some(service)) = (item.action_ha_domain.as_deref(), item.action_ha_service.as_deref()) {
let settings = state.settings.read().await.clone();
match home_assistant::call_service(&state.http, &settings.home_assistant, domain, service, item.action_ha_entity_id.as_deref(), &item.action_ha_data).await {
match home_assistant::call_service(&state.http, &settings.home_assistant, domain, service, item.action_ha_entity_id.as_deref(), &effective_ha_data).await {
Ok(_) => Ok(true),
Err(err) => Err(AppError::BadRequest(format!("Home Assistant service action failed: {err}"))),
}
} else if let Some(zone_id) = item.action_zone_id.as_deref() {
apply_flow_zone_action(state, zone_id, item.action_zone_preset.as_deref(), &item.action).await
apply_flow_zone_action(state, zone_id, item.action_zone_preset.as_deref(), &effective_action).await
} else if let Some(group_id) = item.action_group_id.as_deref() {
let group_mode = item.action.mode.as_deref().map(|mode| if mode == "auto" { "house".to_string() } else { mode.to_string() });
let group_mode = effective_action.mode.as_deref().map(|mode| if mode == "auto" { "house".to_string() } else { mode.to_string() });
control_group(state, group_id, GroupControlPatch {
power: item.action.power,
power: effective_action.power,
mode: group_mode,
preset: item.action_preset.clone(),
setpoint: item.action.target_temperature,
setpoint: effective_action.target_temperature,
}, "automation.group").await.map(|value| !value.get("suppressed").and_then(Value::as_bool).unwrap_or(false))
} else {
match apply_automatic_device_action(state, &item.action_device_id, item.action.clone()).await {
match apply_automatic_device_action(state, &item.action_device_id, effective_action.clone()).await {
Ok(Some(_)) => Ok(true),
Ok(None) => {
state.log("info", "automation.blocked_by_fresh_ownership", &format!("Automation {} was suppressed after ownership changed", item.name), json!({
@@ -179,7 +216,7 @@ async fn run_automations(state: &AppState) -> Result<()> {
// automation.updated also invalidates the materialized control plan.
state.broadcast("automation.updated", serde_json::to_value(&item)?);
state.log("info", "automation.fired", &format!("Automation {} fired", item.name), json!({
"automation_id": item.id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id, "group_id": item.action_group_id, "device_id": item.action_device_id
"automation_id": item.id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id, "group_id": item.action_group_id, "device_id": item.action_device_id, "dynamic_value": dynamic_value
}));
}
Ok(false) => {
@@ -494,6 +531,49 @@ fn flow_rolling_stat_update(
}
}
fn flow_heating_curve_output(config: &Value, input: f64) -> Option<f64> {
let mut points = config
.get("points")?
.as_array()?
.iter()
.filter_map(|point| {
Some((
point.get("x")?.as_f64()?,
point.get("y")?.as_f64()?,
))
})
.collect::<Vec<_>>();
if points.len() < 2 || !input.is_finite() {
return None;
}
points.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
if input <= points[0].0 {
return Some(points[0].1);
}
if input >= points[points.len() - 1].0 {
return Some(points[points.len() - 1].1);
}
for pair in points.windows(2) {
let (x1, y1) = pair[0];
let (x2, y2) = pair[1];
if input < x1 || input > x2 || (x2 - x1).abs() < f64::EPSILON {
continue;
}
let ratio = (input - x1) / (x2 - x1);
return Some(y1 + (y2 - y1) * ratio);
}
None
}
fn flow_trace_numeric_output(trace: &[Value], source_id: &str) -> Option<f64> {
trace
.iter()
.find(|item| item.get("node_id").and_then(Value::as_str) == Some(source_id))
.and_then(|item| item.get("actual"))
.and_then(|actual| actual.get("output"))
.and_then(Value::as_f64)
}
async fn flow_leaf_observation(
state: &AppState,
devices: &[Device],
@@ -789,6 +869,26 @@ pub async fn evaluate_flow_conditions_trace(
} else { false };
(input && available, json!({"input": input, "used": used, "max_count": max_count, "period_seconds": period_seconds, "remaining": max_count.saturating_sub(used)}))
}
"heating_curve" => {
let predecessors_match = condition.inputs.iter().all(|id| values.get(id).copied().unwrap_or(false));
let source = condition.config.get("source").and_then(Value::as_str).unwrap_or("");
let sample = if let Some(value) = overrides.get(&condition.id).and_then(Value::as_f64) {
Some(value)
} else {
match source {
"outdoor_temperature" => outdoor_temperature,
"device_temperature" => condition.config.get("device_id").and_then(Value::as_str).and_then(|id| find_temperature(devices, Some(id))),
"zone_temperature" => condition.config.get("zone_id").and_then(Value::as_str).and_then(|id| zones.iter().find(|zone| zone.id == id).and_then(|zone| zone.current_temperature)),
"ha_numeric" => {
let entity = condition.config.get("entity_id").and_then(Value::as_str).unwrap_or("");
match home_assistant::read_state(&state.http, &settings.home_assistant, Some(entity)).await { Ok(raw) => raw.parse::<f64>().ok(), Err(_) => None }
}
_ => None,
}
};
let output = sample.and_then(|value| flow_heating_curve_output(&condition.config, value));
(predecessors_match && output.is_some(), json!({"input": sample, "output": output, "source": source}))
}
"rolling_stat" => {
let predecessors_match = condition.inputs.iter().all(|id| values.get(id).copied().unwrap_or(false));
let source = condition.config.get("source").and_then(Value::as_str).unwrap_or("");
@@ -905,7 +1005,7 @@ fn flow_condition_kind_runtime(kind: &str) -> bool {
matches!(kind,
"weekday" | "time_range" | "date_range" | "cron_trigger" | "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"
"device_state" | "zone_state" | "group_state" | "night_mode" | "constant" | "shared_input" | "heating_curve"
)
}