225 lines
7.8 KiB
Rust
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)
|
|
}
|