v0.15.20
This commit is contained in:
1 parent
abfbf0dd68
commit
aa1cb05b2f
24 files changed
+750
-71
No files matched your search
+110
-10
@@ -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"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user