This commit is contained in:
Mateusz Gruszczyński
2026-09-17 21:46:10 +02:00
parent cafbf6e5fa
commit 7d9e6c8189
20 changed files with 281 additions and 71 deletions
+114 -17
View File
@@ -63,6 +63,66 @@ fn flow_louver_position(config: &Value, key: &str) -> Option<u8> {
}
}
fn flow_device_feature_command(config: &Value) -> Result<DeviceCommand, AppError> {
let feature = flow_string(config, "feature")
.ok_or_else(|| AppError::BadRequest("device feature action needs a feature".into()))?;
let mut command = DeviceCommand::default();
match feature.as_str() {
"power" => {
command.power = Some(flow_bool(config, "value").ok_or_else(|| {
AppError::BadRequest("device feature power needs a boolean value".into())
})?);
}
"mode" => {
command.mode = Some(flow_string(config, "value").ok_or_else(|| {
AppError::BadRequest("device feature mode needs a value".into())
})?);
}
"target_temperature" => {
command.target_temperature = Some(flow_f64(config, "value").ok_or_else(|| {
AppError::BadRequest("device feature target temperature needs a numeric value".into())
})?);
}
"fan_speed" => {
command.fan_speed = Some(flow_u8(config, "value").ok_or_else(|| {
AppError::BadRequest("device feature fan speed needs an integer value".into())
})?);
}
"swing_vertical" => {
command.swing_vertical = Some(flow_louver_position(config, "value").ok_or_else(|| {
AppError::BadRequest("device feature vertical louver needs a position".into())
})?);
}
"swing_horizontal" => {
command.swing_horizontal = Some(flow_louver_position(config, "value").ok_or_else(|| {
AppError::BadRequest("device feature horizontal louver needs a position".into())
})?);
}
"quiet" | "turbo" | "light" | "air" | "xfan" | "health" | "sleep" => {
let value = flow_bool(config, "value").ok_or_else(|| {
AppError::BadRequest("device feature action needs an on/off value".into())
})?;
match feature.as_str() {
"quiet" => command.quiet = Some(value),
"turbo" => command.turbo = Some(value),
"light" => command.light = Some(value),
"air" => command.air = Some(value),
"xfan" => command.xfan = Some(value),
"health" => command.health = Some(value),
"sleep" => command.sleep = Some(value),
_ => unreachable!(),
}
}
_ => {
return Err(AppError::BadRequest(format!(
"unsupported device feature action: {feature}"
)))
}
}
engine::validate_command(&command)?;
Ok(command)
}
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]))
@@ -104,7 +164,11 @@ fn flow_logic_kind(kind: &str) -> bool {
fn flow_action_kind(kind: &str) -> bool {
matches!(
kind,
"zone_thermostat" | "device_action" | "group_action" | "ha_service_action"
"zone_thermostat"
| "device_action"
| "device_feature_action"
| "group_action"
| "ha_service_action"
)
}
@@ -1332,6 +1396,18 @@ fn compile_flow(
return Err(AppError::BadRequest("device action cannot be empty".into()));
}
}
"device_feature_action" => {
let id = flow_string(&action_node.config, "device_id").ok_or_else(|| {
AppError::BadRequest("device feature action needs a device".into())
})?;
if !devices.iter().any(|d| d.id == id) {
return Err(AppError::BadRequest(
"device feature action references a missing device".into(),
));
}
item.action_device_id = id;
item.action = flow_device_feature_command(&action_node.config)?;
}
"ha_service_action" => {
let domain = flow_string(&action_node.config, "domain").ok_or_else(|| {
AppError::BadRequest("Home Assistant action needs a domain".into())
@@ -1708,7 +1784,7 @@ fn dry_run_block_reason(
}
Ok(None)
}
"device_action" => {
"device_action" | "device_feature_action" => {
let Some(device_id) = flow_string(&action.config, "device_id") else {
return Ok(Some("missing_device".into()));
};
@@ -1737,25 +1813,30 @@ fn dry_run_block_reason(
}) {
return Ok(Some("temporary_quick_thermostat".into()));
}
let command = if action.kind == "device_feature_action" {
flow_device_feature_command(&action.config)?
} else {
let mut command = DeviceCommand::default();
command.power = flow_bool(&action.config, "power");
command.mode = flow_string(&action.config, "mode");
command.target_temperature = flow_f64(&action.config, "target_temperature");
command.fan_speed = flow_u8(&action.config, "fan_speed");
command.swing_vertical = flow_louver_position(&action.config, "swing_vertical");
command.swing_horizontal = flow_louver_position(&action.config, "swing_horizontal");
command.quiet = flow_bool(&action.config, "quiet");
command.turbo = flow_bool(&action.config, "turbo");
command.light = flow_bool(&action.config, "light");
command.air = flow_bool(&action.config, "air");
command.xfan = flow_bool(&action.config, "xfan");
command.health = flow_bool(&action.config, "health");
command.sleep = flow_bool(&action.config, "sleep");
command
};
if zones.iter().any(|z| z.device_id == device_id && !z.enabled)
&& flow_bool(&action.config, "power") != Some(true)
&& command.power != Some(true)
{
return Ok(Some("zone_disabled".into()));
}
let mut command = DeviceCommand::default();
command.power = flow_bool(&action.config, "power");
command.mode = flow_string(&action.config, "mode");
command.target_temperature = flow_f64(&action.config, "target_temperature");
command.fan_speed = flow_u8(&action.config, "fan_speed");
command.swing_vertical = flow_louver_position(&action.config, "swing_vertical");
command.swing_horizontal = flow_louver_position(&action.config, "swing_horizontal");
command.quiet = flow_bool(&action.config, "quiet");
command.turbo = flow_bool(&action.config, "turbo");
command.light = flow_bool(&action.config, "light");
command.air = flow_bool(&action.config, "air");
command.xfan = flow_bool(&action.config, "xfan");
command.health = flow_bool(&action.config, "health");
command.sleep = flow_bool(&action.config, "sleep");
if zones.iter().any(|z| z.device_id == device_id)
&& engine::automation_action_conflicts_with_thermostat(&command)
{
@@ -1950,4 +2031,20 @@ mod flow_draft_tests {
assert!(flow.compiled_schedule_ids.is_empty());
assert!(flow.compiled_automation_ids.is_empty());
}
#[test]
fn device_feature_action_builds_only_selected_field() {
let command = flow_device_feature_command(&json!({"feature": "light", "value": true}))
.expect("valid feature action");
assert_eq!(command.light, Some(true));
assert!(command.power.is_none());
assert!(command.mode.is_none());
assert!(command.swing_vertical.is_none());
}
#[test]
fn device_feature_action_validates_louver_range() {
let result = flow_device_feature_command(&json!({"feature": "swing_vertical", "value": 99}));
assert!(result.is_err());
}
}