v0.12.0-preety_code
This commit is contained in:
+213
-53
@@ -21,38 +21,75 @@ struct AutomationInput {
|
||||
#[serde(default = "automation_cooldown")]
|
||||
cooldown_seconds: u64,
|
||||
}
|
||||
fn automation_cooldown() -> u64 { 300 }
|
||||
fn automation_cooldown() -> u64 {
|
||||
300
|
||||
}
|
||||
impl AutomationInput {
|
||||
fn validate(&self) -> Result<(), AppError> {
|
||||
if self.name.trim().is_empty() { return Err(AppError::BadRequest("automation name is required".into())); }
|
||||
if self.name.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("automation name is required".into()));
|
||||
}
|
||||
match self.trigger_kind.as_str() {
|
||||
"temperature_above" | "temperature_below" => {
|
||||
if self.trigger_device_id.as_deref().unwrap_or_default().is_empty() || self.threshold.is_none() {
|
||||
return Err(AppError::BadRequest("temperature trigger needs device and threshold".into()));
|
||||
if self
|
||||
.trigger_device_id
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.is_empty()
|
||||
|| self.threshold.is_none()
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"temperature trigger needs device and threshold".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
"time" => {
|
||||
let at = self.at_time.as_deref().ok_or_else(|| AppError::BadRequest("time trigger needs at_time".into()))?;
|
||||
chrono::NaiveTime::parse_from_str(at, "%H:%M").map_err(|_| AppError::BadRequest("invalid automation time".into()))?;
|
||||
let at = self
|
||||
.at_time
|
||||
.as_deref()
|
||||
.ok_or_else(|| AppError::BadRequest("time trigger needs at_time".into()))?;
|
||||
chrono::NaiveTime::parse_from_str(at, "%H:%M")
|
||||
.map_err(|_| AppError::BadRequest("invalid automation time".into()))?;
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::BadRequest(
|
||||
"unsupported automation trigger".into(),
|
||||
))
|
||||
}
|
||||
_ => return Err(AppError::BadRequest("unsupported automation trigger".into())),
|
||||
}
|
||||
let action_group_id = self.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty());
|
||||
let action_group_id = self
|
||||
.action_group_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
if action_group_id.is_none() && self.action_device_id.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("automation action needs a device or group".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"automation action needs a device or group".into(),
|
||||
));
|
||||
}
|
||||
if let Some(preset) = self.action_preset.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
||||
if let Some(preset) = self
|
||||
.action_preset
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
if action_group_id.is_none() {
|
||||
return Err(AppError::BadRequest("automation preset actions require a group target".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"automation preset actions require a group target".into(),
|
||||
));
|
||||
}
|
||||
if !matches!(preset, "auto" | "comfort" | "sleep" | "away") {
|
||||
return Err(AppError::BadRequest("unsupported group automation preset".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"unsupported group automation preset".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if action_group_id.is_some() {
|
||||
if let Some(mode) = self.action.mode.as_deref() {
|
||||
if !matches!(mode, "auto" | "house" | "cool" | "heat") {
|
||||
return Err(AppError::BadRequest("group automation mode must be house, cool or heat".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"group automation mode must be house, cool or heat".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if self.action.target_temperature.is_some()
|
||||
@@ -67,48 +104,121 @@ impl AutomationInput {
|
||||
|| 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()));
|
||||
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()));
|
||||
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()));
|
||||
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.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()),
|
||||
action: self.action, cooldown_seconds: self.cooldown_seconds.max(30), last_fired_at,
|
||||
action_zone_id: None, action_zone_preset: None, action_ha_domain: None, action_ha_service: None, action_ha_entity_id: None, action_ha_data: Value::Null, flow_conditions: vec![], flow_id: None, flow_node_id: None, flow_runtime: Default::default(),
|
||||
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()));
|
||||
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
|
||||
.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()),
|
||||
action: self.action,
|
||||
cooldown_seconds: self.cooldown_seconds.max(30),
|
||||
last_fired_at,
|
||||
action_zone_id: None,
|
||||
action_zone_preset: None,
|
||||
action_ha_domain: None,
|
||||
action_ha_service: None,
|
||||
action_ha_entity_id: None,
|
||||
action_ha_data: Value::Null,
|
||||
flow_conditions: vec![],
|
||||
flow_id: None,
|
||||
flow_node_id: None,
|
||||
flow_runtime: Default::default(),
|
||||
created_at,
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
if let Some(group_id) = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
||||
}
|
||||
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()));
|
||||
return Err(AppError::BadRequest(
|
||||
"automation action group does not exist".into(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
let device_id = input.action_device_id.trim();
|
||||
if state.db.get_device(device_id)?.is_none() {
|
||||
return Err(AppError::BadRequest("automation action device does not exist".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"automation action device does not exist".into(),
|
||||
));
|
||||
}
|
||||
if engine::automation_action_conflicts_with_thermostat(&input.action)
|
||||
&& state.db.list_zones()?.iter().any(|zone| zone.enabled && zone.device_id == device_id)
|
||||
&& state
|
||||
.db
|
||||
.list_zones()?
|
||||
.iter()
|
||||
.any(|zone| zone.enabled && zone.device_id == device_id)
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"direct fan/quiet/sleep automation conflicts with an enabled thermostat zone; use thermostat/group policy instead".into(),
|
||||
@@ -118,47 +228,97 @@ fn validate_automation_references(state: &AppState, input: &AutomationInput) ->
|
||||
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 list_automations(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<Automation>>, AppError> {
|
||||
Ok(Json(state.db.list_automations()?))
|
||||
}
|
||||
async fn create_automation(State(state): State<AppState>, Json(input): Json<AutomationInput>) -> Result<(StatusCode, Json<Automation>), AppError> {
|
||||
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> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _automation_guard = state.lock_automation_operation().await;
|
||||
input.validate()?;
|
||||
let action_group_id = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()).map(str::to_string);
|
||||
let action_group_id = input
|
||||
.action_group_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
let _group_guard = if let Some(group_id) = action_group_id.as_deref() {
|
||||
Some(state.lock_group_operation(group_id).await)
|
||||
} else { None };
|
||||
} else {
|
||||
None
|
||||
};
|
||||
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)?);
|
||||
Ok((StatusCode::CREATED, Json(item)))
|
||||
}
|
||||
async fn update_automation(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<AutomationInput>) -> Result<Json<Automation>, AppError> {
|
||||
async fn update_automation(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(input): Json<AutomationInput>,
|
||||
) -> Result<Json<Automation>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _automation_guard = state.lock_automation_operation().await;
|
||||
input.validate()?;
|
||||
let existing = state.db.get_automation(&id)?.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?;
|
||||
if existing.flow_id.is_some() { return Err(AppError::BadRequest("this automation is generated by Flow; edit it in the Flow editor".into())); }
|
||||
let action_group_id = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()).map(str::to_string);
|
||||
let existing = state
|
||||
.db
|
||||
.get_automation(&id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?;
|
||||
if existing.flow_id.is_some() {
|
||||
return Err(AppError::BadRequest(
|
||||
"this automation is generated by Flow; edit it in the Flow editor".into(),
|
||||
));
|
||||
}
|
||||
let action_group_id = input
|
||||
.action_group_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
let _group_guard = if let Some(group_id) = action_group_id.as_deref() {
|
||||
Some(state.lock_group_operation(group_id).await)
|
||||
} else { None };
|
||||
} else {
|
||||
None
|
||||
};
|
||||
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)?);
|
||||
Ok(Json(item))
|
||||
}
|
||||
async fn delete_automation(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||
async fn delete_automation(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _automation_guard = state.lock_automation_operation().await;
|
||||
let existing = state.db.get_automation(&id)?.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?;
|
||||
if existing.flow_id.is_some() { return Err(AppError::BadRequest("this automation is generated by Flow; delete it from the Flow editor".into())); }
|
||||
if !state.db.delete_automation(&id)? { return Err(AppError::NotFound(format!("automation {id}"))); }
|
||||
let existing = state
|
||||
.db
|
||||
.get_automation(&id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?;
|
||||
if existing.flow_id.is_some() {
|
||||
return Err(AppError::BadRequest(
|
||||
"this automation is generated by Flow; delete it from the Flow editor".into(),
|
||||
));
|
||||
}
|
||||
if !state.db.delete_automation(&id)? {
|
||||
return Err(AppError::NotFound(format!("automation {id}")));
|
||||
}
|
||||
state.broadcast("automation.deleted", json!({"id": id}));
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user