This commit is contained in:
Mateusz Gruszczyński
2026-09-17 08:52:02 +02:00
parent ff4f2b60e7
commit b7846c3b9f
87 changed files with 3783 additions and 1418 deletions
+107 -28
View File
@@ -22,7 +22,9 @@ struct DeviceGroupInput {
}
fn normalize_optional(value: Option<String>) -> Option<String> {
value.map(|item| item.trim().to_string()).filter(|item| !item.is_empty())
value
.map(|item| item.trim().to_string())
.filter(|item| !item.is_empty())
}
fn validate_device_group_input(
@@ -33,63 +35,118 @@ fn validate_device_group_input(
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<_>>();
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()));
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()));
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}")));
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)));
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()));
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()));
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()));
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()));
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()));
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_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 !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()));
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 {
fn device_group_from_input(
id: String,
existing: Option<DeviceGroup>,
input: DeviceGroupInput,
device_ids: Vec<String>,
) -> DeviceGroup {
let now = Utc::now();
DeviceGroup {
id,
@@ -108,15 +165,27 @@ fn device_group_from_input(id: String, existing: Option<DeviceGroup>, input: Dev
}
}
async fn list_device_groups(State(state): State<AppState>) -> Result<Json<Vec<DeviceGroup>>, AppError> {
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 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> {
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);
@@ -125,9 +194,16 @@ async fn create_device_group(State(state): State<AppState>, Json(input): Json<De
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> {
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 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)?;
@@ -135,7 +211,10 @@ async fn update_device_group(State(state): State<AppState>, Path(id): Path<Strin
Ok(Json(group))
}
async fn delete_device_group(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
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}")));