v0.12.0-preety_code
This commit is contained in:
+168
-61
@@ -8,7 +8,8 @@ struct GroupInput {
|
||||
}
|
||||
|
||||
fn normalize_group_zone_ids(zone_ids: Vec<String>) -> Vec<String> {
|
||||
let mut values: Vec<String> = zone_ids.into_iter()
|
||||
let mut values: Vec<String> = zone_ids
|
||||
.into_iter()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect();
|
||||
@@ -23,11 +24,15 @@ fn validate_group_input(state: &AppState, input: &GroupInput) -> Result<Vec<Stri
|
||||
}
|
||||
let zone_ids = normalize_group_zone_ids(input.zone_ids.clone());
|
||||
if zone_ids.is_empty() {
|
||||
return Err(AppError::BadRequest("group must contain at least one zone".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"group must contain at least one zone".into(),
|
||||
));
|
||||
}
|
||||
for zone_id in &zone_ids {
|
||||
if state.db.get_zone(zone_id)?.is_none() {
|
||||
return Err(AppError::BadRequest(format!("group references missing zone {zone_id}")));
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"group references missing zone {zone_id}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(zone_ids)
|
||||
@@ -37,11 +42,21 @@ async fn list_groups(State(state): State<AppState>) -> Result<Json<Vec<ClimateGr
|
||||
Ok(Json(state.db.list_groups()?))
|
||||
}
|
||||
|
||||
async fn get_group(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<ClimateGroup>, AppError> {
|
||||
state.db.get_group(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("group {id}")))
|
||||
async fn get_group(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ClimateGroup>, AppError> {
|
||||
state
|
||||
.db
|
||||
.get_group(&id)?
|
||||
.map(Json)
|
||||
.ok_or_else(|| AppError::NotFound(format!("group {id}")))
|
||||
}
|
||||
|
||||
async fn create_group(State(state): State<AppState>, Json(input): Json<GroupInput>) -> Result<(StatusCode, Json<ClimateGroup>), AppError> {
|
||||
async fn create_group(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<GroupInput>,
|
||||
) -> Result<(StatusCode, Json<ClimateGroup>), AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _reference_guard = state.lock_automation_operation().await;
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
@@ -62,7 +77,11 @@ async fn create_group(State(state): State<AppState>, Json(input): Json<GroupInpu
|
||||
Ok((StatusCode::CREATED, Json(group)))
|
||||
}
|
||||
|
||||
async fn update_group(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<GroupInput>) -> Result<Json<ClimateGroup>, AppError> {
|
||||
async fn update_group(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(input): Json<GroupInput>,
|
||||
) -> Result<Json<ClimateGroup>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
// Membership changes alter the target set of group automations, so serialize them with
|
||||
// automation execution/reference validation before taking the group lock.
|
||||
@@ -70,7 +89,10 @@ async fn update_group(State(state): State<AppState>, Path(id): Path<String>, Jso
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
let _group_guard = state.lock_group_operation(&id).await;
|
||||
let existing = state.db.get_group(&id)?.ok_or_else(|| AppError::NotFound(format!("group {id}")))?;
|
||||
let existing = state
|
||||
.db
|
||||
.get_group(&id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("group {id}")))?;
|
||||
let zone_ids = validate_group_input(&state, &input)?;
|
||||
let group = ClimateGroup {
|
||||
id,
|
||||
@@ -86,46 +108,90 @@ async fn update_group(State(state): State<AppState>, Path(id): Path<String>, Jso
|
||||
Ok(Json(group))
|
||||
}
|
||||
|
||||
async fn delete_group(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||
async fn delete_group(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _automation_guard = state.lock_automation_operation().await;
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
let _group_guard = state.lock_group_operation(&id).await;
|
||||
if state.db.list_automations()?.iter().any(|item| item.action_group_id.as_deref() == Some(id.as_str())) {
|
||||
return Err(AppError::BadRequest("group is used by an automation; remove or retarget that automation first".into()));
|
||||
if state
|
||||
.db
|
||||
.list_automations()?
|
||||
.iter()
|
||||
.any(|item| item.action_group_id.as_deref() == Some(id.as_str()))
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"group is used by an automation; remove or retarget that automation first".into(),
|
||||
));
|
||||
}
|
||||
if !state.db.delete_group(&id)? {
|
||||
return Err(AppError::NotFound(format!("group {id}")));
|
||||
}
|
||||
if !state.db.delete_group(&id)? { return Err(AppError::NotFound(format!("group {id}"))); }
|
||||
state.broadcast("group.deleted", json!({"id": id}));
|
||||
state.wake_zone_control();
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
fn ensure_zone_removal_safe(state: &AppState, zone_ids: &std::collections::HashSet<String>) -> Result<(), AppError> {
|
||||
if zone_ids.is_empty() { return Ok(()); }
|
||||
let automated_groups: std::collections::HashSet<String> = state.db.list_automations()?.into_iter()
|
||||
fn ensure_zone_removal_safe(
|
||||
state: &AppState,
|
||||
zone_ids: &std::collections::HashSet<String>,
|
||||
) -> Result<(), AppError> {
|
||||
if zone_ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let automated_groups: std::collections::HashSet<String> = state
|
||||
.db
|
||||
.list_automations()?
|
||||
.into_iter()
|
||||
.filter_map(|item| item.action_group_id)
|
||||
.collect();
|
||||
for group in state.db.list_groups()? {
|
||||
let remaining = group.zone_ids.iter().filter(|zone_id| !zone_ids.contains(*zone_id)).count();
|
||||
if remaining == 0 && group.zone_ids.iter().any(|zone_id| zone_ids.contains(zone_id)) && automated_groups.contains(&group.id) {
|
||||
let remaining = group
|
||||
.zone_ids
|
||||
.iter()
|
||||
.filter(|zone_id| !zone_ids.contains(*zone_id))
|
||||
.count();
|
||||
if remaining == 0
|
||||
&& group
|
||||
.zone_ids
|
||||
.iter()
|
||||
.any(|zone_id| zone_ids.contains(zone_id))
|
||||
&& automated_groups.contains(&group.id)
|
||||
{
|
||||
return Err(AppError::BadRequest(format!("cannot remove the last zone from group '{}' while an automation targets that group", group.name)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_zone_ids_from_groups_locked(state: &AppState, zone_ids: &std::collections::HashSet<String>) -> Result<(), AppError> {
|
||||
if zone_ids.is_empty() { return Ok(()); }
|
||||
let mut group_ids: Vec<String> = state.db.list_groups()?.into_iter().map(|group| group.id).collect();
|
||||
async fn remove_zone_ids_from_groups_locked(
|
||||
state: &AppState,
|
||||
zone_ids: &std::collections::HashSet<String>,
|
||||
) -> Result<(), AppError> {
|
||||
if zone_ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut group_ids: Vec<String> = state
|
||||
.db
|
||||
.list_groups()?
|
||||
.into_iter()
|
||||
.map(|group| group.id)
|
||||
.collect();
|
||||
group_ids.sort();
|
||||
group_ids.dedup();
|
||||
for group_id in group_ids {
|
||||
let _group_guard = state.lock_group_operation(&group_id).await;
|
||||
let Some(mut group) = state.db.get_group(&group_id)? else { continue; };
|
||||
let Some(mut group) = state.db.get_group(&group_id)? else {
|
||||
continue;
|
||||
};
|
||||
let before = group.zone_ids.len();
|
||||
group.zone_ids.retain(|zone_id| !zone_ids.contains(zone_id));
|
||||
if group.zone_ids.len() == before { continue; }
|
||||
if group.zone_ids.len() == before {
|
||||
continue;
|
||||
}
|
||||
if group.zone_ids.is_empty() {
|
||||
state.db.delete_group(&group.id)?;
|
||||
state.broadcast("group.deleted", json!({"id": group.id}));
|
||||
@@ -138,19 +204,31 @@ async fn remove_zone_ids_from_groups_locked(state: &AppState, zone_ids: &std::co
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_group_control(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<GroupControlPatch>) -> Result<Json<Value>, AppError> {
|
||||
Ok(Json(engine::control_group(&state, &id, patch, "group.quick_control").await?))
|
||||
async fn update_group_control(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(patch): Json<GroupControlPatch>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
Ok(Json(
|
||||
engine::control_group(&state, &id, patch, "group.quick_control").await?,
|
||||
))
|
||||
}
|
||||
|
||||
fn home_assistant_group_mode(zones: &[&Zone]) -> String {
|
||||
let mut value: Option<&str> = None;
|
||||
for zone in zones {
|
||||
let current = if zone.inherit_house_mode { "house" } else { zone.mode.as_str() };
|
||||
let current = if zone.inherit_house_mode {
|
||||
"house"
|
||||
} else {
|
||||
zone.mode.as_str()
|
||||
};
|
||||
if !matches!(current, "house" | "cool" | "heat") {
|
||||
return "mixed".into();
|
||||
}
|
||||
if let Some(previous) = value {
|
||||
if previous != current { return "mixed".into(); }
|
||||
if previous != current {
|
||||
return "mixed".into();
|
||||
}
|
||||
} else {
|
||||
value = Some(current);
|
||||
}
|
||||
@@ -166,7 +244,9 @@ fn home_assistant_group_preset(zones: &[&Zone]) -> String {
|
||||
return "mixed".into();
|
||||
}
|
||||
if let Some(previous) = value {
|
||||
if previous != current { return "mixed".into(); }
|
||||
if previous != current {
|
||||
return "mixed".into();
|
||||
}
|
||||
} else {
|
||||
value = Some(current);
|
||||
}
|
||||
@@ -174,14 +254,17 @@ fn home_assistant_group_preset(zones: &[&Zone]) -> String {
|
||||
value.unwrap_or("mixed").to_string()
|
||||
}
|
||||
|
||||
|
||||
fn home_assistant_group_custom_setpoint(zones: &[&Zone]) -> Option<f64> {
|
||||
let mut value: Option<f64> = None;
|
||||
for zone in zones {
|
||||
if zone.manual_preset.as_deref() != Some("custom") { return None; }
|
||||
if zone.manual_preset.as_deref() != Some("custom") {
|
||||
return None;
|
||||
}
|
||||
let current = zone.manual_setpoint.or(zone.effective_setpoint)?;
|
||||
if let Some(previous) = value {
|
||||
if (previous - current).abs() > 0.05 { return None; }
|
||||
if (previous - current).abs() > 0.05 {
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
value = Some(current);
|
||||
}
|
||||
@@ -189,7 +272,9 @@ fn home_assistant_group_custom_setpoint(zones: &[&Zone]) -> Option<f64> {
|
||||
value
|
||||
}
|
||||
|
||||
async fn list_home_assistant_groups(State(state): State<AppState>) -> Result<Json<Vec<Value>>, AppError> {
|
||||
async fn list_home_assistant_groups(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<Value>>, AppError> {
|
||||
let groups = state.db.list_groups()?;
|
||||
let zones = state.db.list_zones()?;
|
||||
let devices = state.db.list_devices()?;
|
||||
@@ -198,19 +283,35 @@ async fn list_home_assistant_groups(State(state): State<AppState>) -> Result<Jso
|
||||
let mut output = Vec::with_capacity(groups.len());
|
||||
|
||||
for group in groups {
|
||||
let members = zones.iter()
|
||||
let members = zones
|
||||
.iter()
|
||||
.filter(|zone| group.zone_ids.iter().any(|zone_id| zone_id == &zone.id))
|
||||
.collect::<Vec<_>>();
|
||||
let planned_members = plan.zones.iter()
|
||||
.filter(|zone| group.zone_ids.iter().any(|zone_id| zone_id == &zone.zone_id))
|
||||
let planned_members = plan
|
||||
.zones
|
||||
.iter()
|
||||
.filter(|zone| {
|
||||
group
|
||||
.zone_ids
|
||||
.iter()
|
||||
.any(|zone_id| zone_id == &zone.zone_id)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let zone_names = members.iter().map(|zone| zone.name.clone()).collect::<Vec<_>>();
|
||||
let member_device_ids = members.iter().map(|zone| zone.device_id.as_str()).collect::<std::collections::HashSet<_>>();
|
||||
let online_devices = devices.iter()
|
||||
let zone_names = members
|
||||
.iter()
|
||||
.map(|zone| zone.name.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let member_device_ids = members
|
||||
.iter()
|
||||
.map(|zone| zone.device_id.as_str())
|
||||
.collect::<std::collections::HashSet<_>>();
|
||||
let online_devices = devices
|
||||
.iter()
|
||||
.filter(|device| member_device_ids.contains(device.id.as_str()) && device.online)
|
||||
.count();
|
||||
let current_temperatures = planned_members.iter()
|
||||
let current_temperatures = planned_members
|
||||
.iter()
|
||||
.filter_map(|zone| zone.current_temperature)
|
||||
.collect::<Vec<_>>();
|
||||
let current_temperature = if current_temperatures.is_empty() {
|
||||
@@ -228,27 +329,32 @@ async fn list_home_assistant_groups(State(state): State<AppState>) -> Result<Jso
|
||||
}
|
||||
next_events.sort_by_key(|event| event.at);
|
||||
next_events.truncate(8);
|
||||
let member_states = planned_members.iter().map(|zone| json!({
|
||||
"zone_id": zone.zone_id,
|
||||
"zone_name": zone.zone_name,
|
||||
"device_id": zone.device_id,
|
||||
"device_name": zone.device_name,
|
||||
"enabled": zone.enabled,
|
||||
"effective_enabled": zone.effective_enabled,
|
||||
"mode": zone.mode,
|
||||
"configured_mode": zone.configured_mode,
|
||||
"inherit_house_mode": zone.inherit_house_mode,
|
||||
"preset": zone.preset,
|
||||
"current_temperature": zone.current_temperature,
|
||||
"target_temperature": zone.target_temperature,
|
||||
"demand": zone.demand,
|
||||
"control_source": zone.control_source,
|
||||
"current_schedule": zone.current_schedule_name,
|
||||
"local_thermostat_power": zone.local_thermostat_power,
|
||||
"local_thermostat_resume_at": zone.local_thermostat_resume_at,
|
||||
"device_manual_override": zone.device_manual_override,
|
||||
"device_manual_override_until": zone.device_manual_override_until,
|
||||
})).collect::<Vec<_>>();
|
||||
let member_states = planned_members
|
||||
.iter()
|
||||
.map(|zone| {
|
||||
json!({
|
||||
"zone_id": zone.zone_id,
|
||||
"zone_name": zone.zone_name,
|
||||
"device_id": zone.device_id,
|
||||
"device_name": zone.device_name,
|
||||
"enabled": zone.enabled,
|
||||
"effective_enabled": zone.effective_enabled,
|
||||
"mode": zone.mode,
|
||||
"configured_mode": zone.configured_mode,
|
||||
"inherit_house_mode": zone.inherit_house_mode,
|
||||
"preset": zone.preset,
|
||||
"current_temperature": zone.current_temperature,
|
||||
"target_temperature": zone.target_temperature,
|
||||
"demand": zone.demand,
|
||||
"control_source": zone.control_source,
|
||||
"current_schedule": zone.current_schedule_name,
|
||||
"local_thermostat_power": zone.local_thermostat_power,
|
||||
"local_thermostat_resume_at": zone.local_thermostat_resume_at,
|
||||
"device_manual_override": zone.device_manual_override,
|
||||
"device_manual_override_until": zone.device_manual_override_until,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
output.push(json!({
|
||||
"id": group.id,
|
||||
@@ -281,6 +387,7 @@ async fn update_home_assistant_group_control(
|
||||
Path(id): Path<String>,
|
||||
Json(patch): Json<GroupControlPatch>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
Ok(Json(engine::control_group(&state, &id, patch, "home_assistant.group_control").await?))
|
||||
Ok(Json(
|
||||
engine::control_group(&state, &id, patch, "home_assistant.group_control").await?,
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user