This commit is contained in:
Mateusz Gruszczyński
2026-08-25 18:54:35 +02:00
parent a8b80bdba9
commit 3ee93b7225
18 changed files with 560 additions and 93 deletions
+235 -33
View File
@@ -371,10 +371,18 @@ async fn patch_device(State(state): State<AppState>, Path(id): Path<String>, Jso
}
async fn delete_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
if state.db.get_device(&id)?.is_none() { return Err(AppError::NotFound(format!("device {id}"))); }
if state.db.list_automations()?.iter().any(|item| {
item.trigger_device_id.as_deref() == Some(id.as_str())
|| (item.action_group_id.is_none() && item.action_device_id == id)
}) {
return Err(AppError::BadRequest("device is used by an automation; remove or retarget that automation first".into()));
}
let removed_zone_ids: std::collections::HashSet<String> = state.db.list_zones()?.into_iter()
.filter(|zone| zone.device_id == id)
.map(|zone| zone.id)
.collect();
ensure_zone_removal_safe(&state, &removed_zone_ids)?;
if !state.db.delete_device(&id)? { return Err(AppError::NotFound(format!("device {id}"))); }
remove_zone_ids_from_groups(&state, &removed_zone_ids)?;
state.log("info", "device.deleted", "Device deleted", json!({"device_id": id}));
@@ -506,6 +514,13 @@ impl ZoneInput {
}
}
fn validate_zone_device_assignment(state: &AppState, device_id: &str, current_zone_id: Option<&str>) -> Result<(), AppError> {
if state.db.list_zones()?.iter().any(|zone| zone.device_id == device_id && current_zone_id != Some(zone.id.as_str())) {
return Err(AppError::BadRequest("a device can belong to only one thermostat zone".into()));
}
Ok(())
}
async fn list_zones(State(state): State<AppState>) -> Result<Json<Vec<Zone>>, AppError> { Ok(Json(state.db.list_zones()?)) }
async fn get_zone(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Zone>, AppError> {
state.db.get_zone(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("zone {id}")))
@@ -513,6 +528,7 @@ async fn get_zone(State(state): State<AppState>, Path(id): Path<String>) -> Resu
async fn create_zone(State(state): State<AppState>, Json(input): Json<ZoneInput>) -> Result<(StatusCode, Json<Zone>), AppError> {
input.validate()?;
if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); }
validate_zone_device_assignment(&state, &input.device_id, None)?;
let mut zone = input.into_zone(Uuid::new_v4().to_string(), Utc::now());
let settings = state.settings.read().await.clone();
canonicalize_zone_ha_entity(&mut zone, &settings);
@@ -524,6 +540,7 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
input.validate()?;
let existing = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); }
validate_zone_device_assignment(&state, &input.device_id, Some(&id))?;
let mut zone = input.into_zone(id, existing.created_at);
zone.device_temperature = existing.device_temperature;
zone.external_temperature = existing.external_temperature;
@@ -556,7 +573,7 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
zone.setpoint = value;
zone.manual_setpoint = Some(value);
zone.effective_setpoint = Some(value);
zone.manual_override_until = Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()));
zone.manual_override_until = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
}
if let Some(value) = patch.mode.as_deref() {
match value {
@@ -578,7 +595,7 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
"comfort" | "sleep" | "away" | "custom" => {
zone.manual_preset = Some(value.to_string());
zone.manual_setpoint = None;
zone.manual_override_until = Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()));
zone.manual_override_until = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
}
_ => return Err(AppError::BadRequest("unsupported zone preset".into())),
}
@@ -684,12 +701,31 @@ async fn delete_group(State(state): State<AppState>, Path(id): Path<String>) ->
Ok(StatusCode::NO_CONTENT)
}
fn ensure_zone_removal_safe(state: &AppState, zone_ids: &std::collections::HashSet<String>) -> Result<(), AppError> {
if zone_ids.is_empty() { return Ok(()); }
let automated_groups: std::collections::HashSet<String> = state.db.list_automations()?.into_iter()
.filter_map(|item| item.action_group_id)
.collect();
for group in state.db.list_groups()? {
let remaining = group.zone_ids.iter().filter(|zone_id| !zone_ids.contains(*zone_id)).count();
if remaining == 0 && group.zone_ids.iter().any(|zone_id| zone_ids.contains(zone_id)) && automated_groups.contains(&group.id) {
return Err(AppError::BadRequest(format!("cannot remove the last zone from group '{}' while an automation targets that group", group.name)));
}
}
Ok(())
}
fn remove_zone_ids_from_groups(state: &AppState, zone_ids: &std::collections::HashSet<String>) -> Result<(), AppError> {
if zone_ids.is_empty() { return Ok(()); }
for mut group in state.db.list_groups()? {
let before = group.zone_ids.len();
group.zone_ids.retain(|zone_id| !zone_ids.contains(zone_id));
if group.zone_ids.len() == before { continue; }
if group.zone_ids.is_empty() {
state.db.delete_group(&group.id)?;
state.broadcast("group.deleted", json!({"id": group.id}));
continue;
}
group.updated_at = Utc::now();
state.db.save_group(&group)?;
state.broadcast("group.updated", serde_json::to_value(&group)?);
@@ -832,7 +868,7 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
} else {
zone.manual_preset = Some(input.preset.clone());
zone.manual_setpoint = None;
zone.manual_override_until = Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()));
zone.manual_override_until = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
}
zone.updated_at = Utc::now();
state.db.save_zone(zone)?;
@@ -889,21 +925,28 @@ async fn apply_schedule_template(State(state): State<AppState>, Path(id): Path<S
add("Away", weekdays.clone(), "08:00", "16:00", "away");
add("Evening", weekdays.clone(), "16:00", "22:30", "comfort");
add("Sleep", weekdays, "22:30", "06:30", "sleep");
add("Weekend", weekend.clone(), "08:00", "23:00", "comfort");
add("Weekend sleep", weekend, "23:00", "08:00", "sleep");
add("Weekend", weekend, "08:00", "23:00", "comfort");
// Saturday can sleep until the Sunday weekend block starts at 08:00.
add("Saturday sleep", vec![6], "23:00", "08:00", "sleep");
// Sunday must hand over at 06:30 so it never overlaps Monday morning.
add("Sunday sleep", vec![7], "23:00", "06:30", "sleep");
}
"always" => add("Comfort", all, "00:00", "23:59", "comfort"),
"always" => add("Comfort", all, "00:00", "00:00", "comfort"),
_ => return Err(AppError::BadRequest("unknown schedule template".into())),
}
validate_schedule_set(&items)?;
state.db.replace_schedules_for_zone(&id, &items)?;
refresh_zone_override_boundary(&state, &id)?;
state.broadcast("schedule.template_applied", json!({"zone_id": id, "template": input.template, "count": items.len()}));
Ok(Json(json!({"zone": zone, "schedules": items})))
}
async fn delete_zone(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
if !state.db.delete_zone(&id)? { return Err(AppError::NotFound(format!("zone {id}"))); }
if state.db.get_zone(&id)?.is_none() { return Err(AppError::NotFound(format!("zone {id}"))); }
let mut removed = std::collections::HashSet::new();
removed.insert(id.clone());
ensure_zone_removal_safe(&state, &removed)?;
if !state.db.delete_zone(&id)? { return Err(AppError::NotFound(format!("zone {id}"))); }
remove_zone_ids_from_groups(&state, &removed)?;
state.broadcast("zone.deleted", json!({"id": id}));
Ok(StatusCode::NO_CONTENT)
@@ -939,6 +982,38 @@ impl ScheduleInput {
preset: self.preset, setpoint: self.setpoint, created_at, updated_at: Utc::now() }
}
}
fn validate_schedule_set(items: &[Schedule]) -> Result<(), AppError> {
for (index, item) in items.iter().enumerate() {
for other in items.iter().skip(index + 1) {
if engine::schedules_overlap(item, other) {
return Err(AppError::BadRequest(format!("schedule '{}' overlaps with '{}' for the same zone", item.name, other.name)));
}
}
}
Ok(())
}
fn validate_schedule_conflicts(state: &AppState, item: &Schedule, exclude_id: Option<&str>) -> Result<(), AppError> {
for existing in state.db.list_schedules()? {
if exclude_id == Some(existing.id.as_str()) { continue; }
if engine::schedules_overlap(item, &existing) {
return Err(AppError::BadRequest(format!("schedule overlaps with '{}'", existing.name)));
}
}
Ok(())
}
fn refresh_zone_override_boundary(state: &AppState, zone_id: &str) -> Result<(), AppError> {
let Some(mut zone) = state.db.get_zone(zone_id)? else { return Ok(()); };
if zone.manual_preset.is_none() && zone.manual_setpoint.is_none() { return Ok(()); }
let schedules = state.db.list_schedules()?;
zone.manual_override_until = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
Ok(())
}
async fn list_schedules(State(state): State<AppState>) -> Result<Json<Vec<Schedule>>, AppError> { Ok(Json(state.db.list_schedules()?)) }
async fn get_schedule(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Schedule>, AppError> {
state.db.get_schedule(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("schedule {id}")))
@@ -947,7 +1022,9 @@ async fn create_schedule(State(state): State<AppState>, Json(input): Json<Schedu
input.validate()?;
if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); }
let item = input.into_schedule(Uuid::new_v4().to_string(), Utc::now());
validate_schedule_conflicts(&state, &item, None)?;
state.db.save_schedule(&item)?;
refresh_zone_override_boundary(&state, &item.zone_id)?;
state.broadcast("schedule.created", serde_json::to_value(&item)?);
Ok((StatusCode::CREATED, Json(item)))
}
@@ -955,13 +1032,19 @@ async fn update_schedule(State(state): State<AppState>, Path(id): Path<String>,
input.validate()?;
let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?;
if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); }
let item = input.into_schedule(id, existing.created_at);
let old_zone_id = existing.zone_id.clone();
let item = input.into_schedule(id.clone(), existing.created_at);
validate_schedule_conflicts(&state, &item, Some(&id))?;
state.db.save_schedule(&item)?;
refresh_zone_override_boundary(&state, &old_zone_id)?;
if item.zone_id != old_zone_id { refresh_zone_override_boundary(&state, &item.zone_id)?; }
state.broadcast("schedule.updated", serde_json::to_value(&item)?);
Ok(Json(item))
}
async fn delete_schedule(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?;
if !state.db.delete_schedule(&id)? { return Err(AppError::NotFound(format!("schedule {id}"))); }
refresh_zone_override_boundary(&state, &existing.zone_id)?;
state.broadcast("schedule.deleted", json!({"id": id}));
Ok(StatusCode::NO_CONTENT)
}
@@ -1023,15 +1106,34 @@ impl AutomationInput {
return Err(AppError::BadRequest("group automation mode must be house, cool or heat".into()));
}
}
if self.action.target_temperature.is_some() {
return Err(AppError::BadRequest("group automation target temperature is not supported; use a group preset".into()));
if self.action.target_temperature.is_some()
|| self.action.fan_speed.is_some()
|| self.action.swing_vertical.is_some()
|| self.action.swing_horizontal.is_some()
|| self.action.quiet.is_some()
|| self.action.turbo.is_some()
|| self.action.light.is_some()
|| self.action.air.is_some()
|| self.action.xfan.is_some()
|| self.action.health.is_some()
|| self.action.sleep.is_some()
{
return Err(AppError::BadRequest("group automations support only power, heat/cool/house mode and a group preset".into()));
}
if self.action.power.is_none() && self.action.mode.is_none() && self.action_preset.as_deref().map(str::trim).filter(|v| !v.is_empty()).is_none() {
return Err(AppError::BadRequest("group automation action cannot be empty".into()));
}
} else {
engine::validate_command(&self.action)?;
if self.action.is_empty() {
return Err(AppError::BadRequest("automation action cannot be empty".into()));
}
}
Ok(())
}
fn into_automation(self, id: String, created_at: chrono::DateTime<Utc>, last_fired_at: Option<chrono::DateTime<Utc>>) -> Automation {
Automation { id, name: self.name.trim().into(), enabled: self.enabled,
trigger_kind: self.trigger_kind, trigger_device_id: self.trigger_device_id,
trigger_kind: self.trigger_kind, trigger_device_id: self.trigger_device_id.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()),
threshold: self.threshold, at_time: self.at_time, action_device_id: self.action_device_id.trim().to_string(),
action_group_id: self.action_group_id.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()),
action_preset: self.action_preset.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()),
@@ -1039,17 +1141,30 @@ impl AutomationInput {
created_at, updated_at: Utc::now() }
}
}
fn validate_automation_references(state: &AppState, input: &AutomationInput) -> Result<(), AppError> {
if matches!(input.trigger_kind.as_str(), "temperature_above" | "temperature_below") {
let trigger_id = input.trigger_device_id.as_deref().map(str::trim).unwrap_or_default();
if state.db.get_device(trigger_id)?.is_none() {
return Err(AppError::BadRequest("automation trigger device does not exist".into()));
}
}
if let Some(group_id) = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
if state.db.get_group(group_id)?.is_none() {
return Err(AppError::BadRequest("automation action group does not exist".into()));
}
} else if state.db.get_device(input.action_device_id.trim())?.is_none() {
return Err(AppError::BadRequest("automation action device does not exist".into()));
}
Ok(())
}
async fn list_automations(State(state): State<AppState>) -> Result<Json<Vec<Automation>>, AppError> { Ok(Json(state.db.list_automations()?)) }
async fn get_automation(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Automation>, AppError> {
state.db.get_automation(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("automation {id}")))
}
async fn create_automation(State(state): State<AppState>, Json(input): Json<AutomationInput>) -> Result<(StatusCode, Json<Automation>), AppError> {
input.validate()?;
if let Some(group_id) = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
if state.db.get_group(group_id)?.is_none() { return Err(AppError::BadRequest("automation action group does not exist".into())); }
} else if state.db.get_device(input.action_device_id.trim())?.is_none() {
return Err(AppError::BadRequest("automation action device does not exist".into()));
}
validate_automation_references(&state, &input)?;
let item = input.into_automation(Uuid::new_v4().to_string(), Utc::now(), None);
state.db.save_automation(&item)?;
state.broadcast("automation.created", serde_json::to_value(&item)?);
@@ -1058,11 +1173,7 @@ async fn create_automation(State(state): State<AppState>, Json(input): Json<Auto
async fn update_automation(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<AutomationInput>) -> Result<Json<Automation>, AppError> {
input.validate()?;
let existing = state.db.get_automation(&id)?.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?;
if let Some(group_id) = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
if state.db.get_group(group_id)?.is_none() { return Err(AppError::BadRequest("automation action group does not exist".into())); }
} else if state.db.get_device(input.action_device_id.trim())?.is_none() {
return Err(AppError::BadRequest("automation action device does not exist".into()));
}
validate_automation_references(&state, &input)?;
let item = input.into_automation(id, existing.created_at, existing.last_fired_at);
state.db.save_automation(&item)?;
state.broadcast("automation.updated", serde_json::to_value(&item)?);
@@ -1508,29 +1619,120 @@ async fn export_settings(State(state): State<AppState>) -> Result<Json<Configura
fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), AppError> {
if export.format_version != 1 { return Err(AppError::BadRequest("unsupported configuration export version".into())); }
influxdb::validate(&export.settings.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?;
if !matches!(export.settings.house_mode.as_str(), "cool" | "heat" | "off") {
return Err(AppError::BadRequest("import contains an invalid house mode".into()));
}
let devices: std::collections::HashSet<&str> = export.devices.iter().map(|item| item.id.as_str()).collect();
let zones: std::collections::HashSet<&str> = export.zones.iter().map(|item| item.id.as_str()).collect();
let schedules: std::collections::HashSet<&str> = export.schedules.iter().map(|item| item.id.as_str()).collect();
let automations: std::collections::HashSet<&str> = export.automations.iter().map(|item| item.id.as_str()).collect();
if devices.len() != export.devices.len() || zones.len() != export.zones.len()
|| schedules.len() != export.schedules.len() || automations.len() != export.automations.len()
|| devices.contains("") || zones.contains("") || schedules.contains("") || automations.contains("")
{
return Err(AppError::BadRequest("import contains duplicate or empty resource IDs".into()));
}
let device_macs: std::collections::HashSet<&str> = export.devices.iter().map(|item| item.mac.as_str()).collect();
if device_macs.len() != export.devices.len() {
return Err(AppError::BadRequest("import contains duplicate device MAC addresses".into()));
}
if export.zones.iter().any(|item| !devices.contains(item.device_id.as_str())) {
return Err(AppError::BadRequest("import contains a zone referencing a missing device".into()));
}
let mut zone_devices = std::collections::HashSet::new();
for zone in &export.zones {
if !zone_devices.insert(zone.device_id.as_str()) {
return Err(AppError::BadRequest("import assigns one device to more than one thermostat zone".into()));
}
if !matches!(zone.mode.as_str(), "cool" | "heat") {
return Err(AppError::BadRequest("import contains an invalid zone mode".into()));
}
if !matches!(zone.sensor_source.as_str(), "device" | "home_assistant" | "combined") {
return Err(AppError::BadRequest("import contains an invalid zone sensor source".into()));
}
}
if export.schedules.iter().any(|item| !zones.contains(item.zone_id.as_str())) {
return Err(AppError::BadRequest("import contains a schedule referencing a missing zone".into()));
}
if export.groups.iter().any(|group| group.zone_ids.iter().any(|zone_id| !zones.contains(zone_id.as_str()))) {
return Err(AppError::BadRequest("import contains a group referencing a missing zone".into()));
for item in &export.schedules {
if item.weekdays.is_empty() || item.weekdays.iter().any(|day| !(1..=7).contains(day)) {
return Err(AppError::BadRequest("import contains invalid schedule weekdays".into()));
}
NaiveTime::parse_from_str(&item.start_time, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid schedule start time".into()))?;
NaiveTime::parse_from_str(&item.end_time, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid schedule end time".into()))?;
if !matches!(item.preset.as_str(), "comfort" | "sleep" | "away" | "custom") {
return Err(AppError::BadRequest("import contains an invalid schedule preset".into()));
}
if item.preset == "custom" && !(8.0..=30.0).contains(&item.setpoint) {
return Err(AppError::BadRequest("import contains an invalid schedule setpoint".into()));
}
}
validate_schedule_set(&export.schedules)?;
if export.groups.iter().any(|group| {
let members: std::collections::HashSet<&str> = group.zone_ids.iter().map(String::as_str).collect();
group.id.trim().is_empty() || group.zone_ids.is_empty() || members.len() != group.zone_ids.len()
|| group.zone_ids.iter().any(|zone_id| !zones.contains(zone_id.as_str()))
}) {
return Err(AppError::BadRequest("import contains an invalid group, duplicate members or a missing zone reference".into()));
}
let groups: std::collections::HashSet<&str> = export.groups.iter().map(|item| item.id.as_str()).collect();
if export.automations.iter().any(|item| {
if let Some(group_id) = item.action_group_id.as_deref().filter(|value| !value.is_empty()) {
!groups.contains(group_id)
} else {
!devices.contains(item.action_device_id.as_str())
}
}) {
return Err(AppError::BadRequest("import contains an automation referencing a missing device or group".into()));
if groups.len() != export.groups.len() {
return Err(AppError::BadRequest("import contains duplicate group IDs".into()));
}
if export.automations.iter().any(|item| item.trigger_device_id.as_deref().is_some_and(|id| !devices.contains(id))) {
return Err(AppError::BadRequest("import contains an automation trigger referencing a missing device".into()));
for item in &export.automations {
match item.trigger_kind.as_str() {
"temperature_above" | "temperature_below" => {
let Some(trigger_id) = item.trigger_device_id.as_deref() else {
return Err(AppError::BadRequest("import contains a temperature automation without a trigger device".into()));
};
if !devices.contains(trigger_id) || item.threshold.is_none() {
return Err(AppError::BadRequest("import contains an invalid temperature automation trigger".into()));
}
}
"time" => {
let at = item.at_time.as_deref().ok_or_else(|| AppError::BadRequest("import contains a time automation without at_time".into()))?;
NaiveTime::parse_from_str(at, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid automation time".into()))?;
}
_ => return Err(AppError::BadRequest("import contains an unsupported automation trigger".into())),
}
if let Some(group_id) = item.action_group_id.as_deref().filter(|value| !value.is_empty()) {
if !groups.contains(group_id) {
return Err(AppError::BadRequest("import contains an automation referencing a missing group".into()));
}
if let Some(mode) = item.action.mode.as_deref() {
if !matches!(mode, "auto" | "house" | "cool" | "heat") {
return Err(AppError::BadRequest("import contains an invalid group automation mode".into()));
}
}
if let Some(preset) = item.action_preset.as_deref() {
if !matches!(preset, "auto" | "comfort" | "sleep" | "away") {
return Err(AppError::BadRequest("import contains an invalid group automation preset".into()));
}
}
if item.action.target_temperature.is_some() || item.action.fan_speed.is_some()
|| item.action.swing_vertical.is_some() || item.action.swing_horizontal.is_some()
|| item.action.quiet.is_some() || item.action.turbo.is_some() || item.action.light.is_some()
|| item.action.air.is_some() || item.action.xfan.is_some() || item.action.health.is_some() || item.action.sleep.is_some()
{
return Err(AppError::BadRequest("import contains unsupported fields in a group automation".into()));
}
if item.action.power.is_none() && item.action.mode.is_none() && item.action_preset.as_deref().filter(|v| !v.is_empty()).is_none() {
return Err(AppError::BadRequest("import contains an empty group automation action".into()));
}
} else {
if !devices.contains(item.action_device_id.as_str()) {
return Err(AppError::BadRequest("import contains an automation referencing a missing device".into()));
}
engine::validate_command(&item.action)?;
if item.action.is_empty() {
return Err(AppError::BadRequest("import contains an empty automation action".into()));
}
}
}
Ok(())
}