Files
gree-controller/src/api/automations.rs
T

325 lines
11 KiB
Rust

#[derive(Debug, Deserialize)]
struct AutomationInput {
name: String,
#[serde(default = "yes")]
enabled: bool,
trigger_kind: String,
#[serde(default)]
trigger_device_id: Option<String>,
#[serde(default)]
threshold: Option<f64>,
#[serde(default)]
at_time: Option<String>,
#[serde(default)]
action_device_id: String,
#[serde(default)]
action_group_id: Option<String>,
#[serde(default)]
action_preset: Option<String>,
#[serde(default)]
action: DeviceCommand,
#[serde(default = "automation_cooldown")]
cooldown_seconds: u64,
}
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()));
}
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(),
));
}
}
"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()))?;
}
_ => {
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());
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(),
));
}
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(),
));
}
if !matches!(preset, "auto" | "comfort" | "sleep" | "away") {
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(),
));
}
}
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
.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(),
));
}
}
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 {
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(),
));
}
if engine::automation_action_conflicts_with_thermostat(&input.action)
&& 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(),
));
}
}
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> {
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 _group_guard = if let Some(group_id) = action_group_id.as_deref() {
Some(state.lock_group_operation(group_id).await)
} 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> {
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 _group_guard = if let Some(group_id) = action_group_id.as_deref() {
Some(state.lock_group_operation(group_id).await)
} 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> {
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}")));
}
state.broadcast("automation.deleted", json!({"id": id}));
Ok(StatusCode::NO_CONTENT)
}