#[derive(Debug, Deserialize)] struct DeviceGroupInput { name: String, #[serde(default)] kind: DeviceGroupKind, #[serde(default)] device_ids: Vec, #[serde(default)] energy_source: EnergySourcePreference, #[serde(default)] energy_device_id: Option, #[serde(default)] ha_energy_entity_id: Option, #[serde(default)] ha_energy_unit: Option, #[serde(default)] ha_energy_device_class: Option, #[serde(default)] ha_energy_state_class: Option, #[serde(default)] outdoor_temperature_device_id: Option, } fn normalize_optional(value: Option) -> Option { value .map(|item| item.trim().to_string()) .filter(|item| !item.is_empty()) } fn validate_device_group_input( state: &AppState, input: &DeviceGroupInput, editing_id: Option<&str>, ) -> Result, AppError> { if input.name.trim().is_empty() { return Err(AppError::BadRequest("installation name is required".into())); } let mut device_ids = input .device_ids .iter() .map(|id| id.trim().to_string()) .filter(|id| !id.is_empty()) .collect::>(); device_ids.sort(); device_ids.dedup(); if device_ids.is_empty() { return Err(AppError::BadRequest( "installation must contain at least one device".into(), )); } if input.kind == DeviceGroupKind::Split && device_ids.len() != 1 { return Err(AppError::BadRequest( "split installation must contain exactly one device".into(), )); } for device_id in &device_ids { if state.db.get_device(device_id)?.is_none() { return Err(AppError::BadRequest(format!( "installation references missing device {device_id}" ))); } } for existing in state.db.list_device_groups()? { if editing_id == Some(existing.id.as_str()) { continue; } if let Some(device_id) = device_ids .iter() .find(|id| existing.device_ids.iter().any(|other| other == *id)) { return Err(AppError::BadRequest(format!( "device {device_id} already belongs to installation '{}'", existing.name ))); } } let energy_device_id = normalize_optional(input.energy_device_id.clone()); if let Some(ref id) = energy_device_id { if !device_ids.iter().any(|device_id| device_id == id) { return Err(AppError::BadRequest( "energy source device must belong to this installation".into(), )); } let device = state .db .get_device(id)? .ok_or_else(|| AppError::BadRequest("energy source device does not exist".into()))?; if device.connection_type != ConnectionType::GreeCloud || !device.capabilities.energy_meter { return Err(AppError::BadRequest( "selected device does not expose GREE Cloud energy".into(), )); } } if input.energy_source == EnergySourcePreference::GreeCloud && energy_device_id.is_none() { return Err(AppError::BadRequest( "select a GREE Cloud energy source device".into(), )); } let entity = normalize_optional(input.ha_energy_entity_id.clone()); if input.energy_source == EnergySourcePreference::HomeAssistant && entity.is_none() { return Err(AppError::BadRequest( "select a Home Assistant cumulative energy sensor".into(), )); } if entity.is_some() { if input.ha_energy_device_class.as_deref() != Some("energy") { return Err(AppError::BadRequest( "Home Assistant energy sensor must have device_class=energy".into(), )); } if !matches!( input.ha_energy_state_class.as_deref(), Some("total" | "total_increasing") ) { return Err(AppError::BadRequest( "Home Assistant energy sensor must have state_class=total or total_increasing" .into(), )); } if !matches!( input .ha_energy_unit .as_deref() .map(str::to_ascii_lowercase) .as_deref(), Some("wh" | "kwh") ) { return Err(AppError::BadRequest( "Home Assistant energy sensor must use Wh or kWh".into(), )); } } if let Some(id) = normalize_optional(input.outdoor_temperature_device_id.clone()) { if !device_ids.iter().any(|device_id| device_id == &id) { return Err(AppError::BadRequest( "outdoor temperature source must belong to this installation".into(), )); } } Ok(device_ids) } fn device_group_from_input( id: String, existing: Option, input: DeviceGroupInput, device_ids: Vec, ) -> DeviceGroup { let now = Utc::now(); DeviceGroup { id, name: input.name.trim().to_string(), kind: input.kind, device_ids, energy_source: input.energy_source, energy_device_id: normalize_optional(input.energy_device_id), ha_energy_entity_id: normalize_optional(input.ha_energy_entity_id), ha_energy_unit: normalize_optional(input.ha_energy_unit), ha_energy_device_class: normalize_optional(input.ha_energy_device_class), ha_energy_state_class: normalize_optional(input.ha_energy_state_class), outdoor_temperature_device_id: normalize_optional(input.outdoor_temperature_device_id), created_at: existing.as_ref().map(|item| item.created_at).unwrap_or(now), updated_at: now, } } async fn list_device_groups( State(state): State, ) -> Result>, AppError> { Ok(Json(state.db.list_device_groups()?)) } async fn get_device_group( State(state): State, Path(id): Path, ) -> Result, AppError> { state .db .get_device_group(&id)? .map(Json) .ok_or_else(|| AppError::NotFound(format!("device group {id}"))) } async fn create_device_group( State(state): State, Json(input): Json, ) -> Result<(StatusCode, Json), AppError> { let _guard = state.lock_configuration_operation().await; let device_ids = validate_device_group_input(&state, &input, None)?; let group = device_group_from_input(Uuid::new_v4().to_string(), None, input, device_ids); state.db.save_device_group(&group)?; state.broadcast("device_group.created", serde_json::to_value(&group)?); Ok((StatusCode::CREATED, Json(group))) } async fn update_device_group( State(state): State, Path(id): Path, Json(input): Json, ) -> Result, AppError> { let _guard = state.lock_configuration_operation().await; let existing = state .db .get_device_group(&id)? .ok_or_else(|| AppError::NotFound(format!("device group {id}")))?; let device_ids = validate_device_group_input(&state, &input, Some(&id))?; let group = device_group_from_input(id, Some(existing), input, device_ids); state.db.save_device_group(&group)?; state.broadcast("device_group.updated", serde_json::to_value(&group)?); Ok(Json(group)) } async fn delete_device_group( State(state): State, Path(id): Path, ) -> Result { let _guard = state.lock_configuration_operation().await; if !state.db.delete_device_group(&id)? { return Err(AppError::NotFound(format!("device group {id}"))); } state.broadcast("device_group.deleted", json!({"id": id})); Ok(StatusCode::NO_CONTENT) }