v0.14.21
This commit is contained in:
+107
-28
@@ -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}")));
|
||||
|
||||
Reference in New Issue
Block a user