This commit is contained in:
Mateusz Gruszczyński
2026-09-14 23:10:17 +02:00
parent 4bb9c8621a
commit 0a2fb8c6c7
47 changed files with 1337 additions and 372 deletions
+145
View File
@@ -0,0 +1,145 @@
#[derive(Debug, Deserialize)]
struct DeviceGroupInput {
name: String,
#[serde(default)]
kind: DeviceGroupKind,
#[serde(default)]
device_ids: Vec<String>,
#[serde(default)]
energy_source: EnergySourcePreference,
#[serde(default)]
energy_device_id: Option<String>,
#[serde(default)]
ha_energy_entity_id: Option<String>,
#[serde(default)]
ha_energy_unit: Option<String>,
#[serde(default)]
ha_energy_device_class: Option<String>,
#[serde(default)]
ha_energy_state_class: Option<String>,
#[serde(default)]
outdoor_temperature_device_id: Option<String>,
}
fn normalize_optional(value: Option<String>) -> Option<String> {
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<Vec<String>, 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::<Vec<_>>();
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<DeviceGroup>, input: DeviceGroupInput, device_ids: Vec<String>) -> 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<AppState>) -> Result<Json<Vec<DeviceGroup>>, AppError> {
Ok(Json(state.db.list_device_groups()?))
}
async fn get_device_group(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<DeviceGroup>, 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<AppState>, Json(input): Json<DeviceGroupInput>) -> Result<(StatusCode, Json<DeviceGroup>), 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<AppState>, Path(id): Path<String>, Json(input): Json<DeviceGroupInput>) -> Result<Json<DeviceGroup>, 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<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
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)
}