Files
gree-controller/src/api/device_groups.rs
T
2026-09-17 08:52:02 +02:00

225 lines
7.8 KiB
Rust

#[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)
}