#[derive(Debug, Deserialize)] struct GroupInput { name: String, #[serde(default)] zone_ids: Vec, #[serde(default)] power_enabled: Option, } fn normalize_group_zone_ids(zone_ids: Vec) -> Vec { let mut values: Vec = zone_ids .into_iter() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .collect(); values.sort(); values.dedup(); values } fn validate_group_input(state: &AppState, input: &GroupInput) -> Result, AppError> { if input.name.trim().is_empty() { return Err(AppError::BadRequest("group name is required".into())); } 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(), )); } 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}" ))); } } Ok(zone_ids) } async fn list_groups(State(state): State) -> Result>, AppError> { Ok(Json(state.db.list_groups()?)) } async fn get_group( State(state): State, Path(id): Path, ) -> Result, AppError> { state .db .get_group(&id)? .map(Json) .ok_or_else(|| AppError::NotFound(format!("group {id}"))) } async fn create_group( State(state): State, Json(input): Json, ) -> Result<(StatusCode, Json), 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; let _cycle_guard = state.lock_zone_control_cycle().await; let zone_ids = validate_group_input(&state, &input)?; let now = Utc::now(); let group = ClimateGroup { id: Uuid::new_v4().to_string(), name: input.name.trim().to_string(), zone_ids, power_enabled: input.power_enabled.unwrap_or(true), created_at: now, updated_at: now, }; state.db.save_group(&group)?; state.broadcast("group.created", serde_json::to_value(&group)?); state.wake_zone_control(); Ok((StatusCode::CREATED, Json(group))) } async fn update_group( State(state): State, Path(id): Path, Json(input): Json, ) -> Result, 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. 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; 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, name: input.name.trim().to_string(), zone_ids, power_enabled: input.power_enabled.unwrap_or(existing.power_enabled), created_at: existing.created_at, updated_at: Utc::now(), }; state.db.save_group(&group)?; state.broadcast("group.updated", serde_json::to_value(&group)?); state.wake_zone_control(); Ok(Json(group)) } async fn delete_group( State(state): State, Path(id): Path, ) -> Result { 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.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, ) -> Result<(), AppError> { if zone_ids.is_empty() { return Ok(()); } let automated_groups: std::collections::HashSet = 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) { 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, ) -> Result<(), AppError> { if zone_ids.is_empty() { return Ok(()); } let mut group_ids: Vec = 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 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.is_empty() { state.db.delete_group(&group.id)?; state.broadcast("group.deleted", json!({"id": group.id})); continue; } group.updated_at = Utc::now(); state.db.save_group(&group)?; state.broadcast("group.updated", serde_json::to_value(&group)?); } Ok(()) } async fn update_group_control( State(state): State, Path(id): Path, Json(patch): Json, ) -> Result, 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() }; if !matches!(current, "house" | "cool" | "heat") { return "mixed".into(); } if let Some(previous) = value { if previous != current { return "mixed".into(); } } else { value = Some(current); } } value.unwrap_or("mixed").to_string() } fn home_assistant_group_preset(zones: &[&Zone]) -> String { let mut value: Option<&str> = None; for zone in zones { let current = zone.manual_preset.as_deref().unwrap_or("auto"); if !matches!(current, "auto" | "comfort" | "sleep" | "away" | "custom") { return "mixed".into(); } if let Some(previous) = value { if previous != current { return "mixed".into(); } } else { value = Some(current); } } value.unwrap_or("mixed").to_string() } fn home_assistant_group_custom_setpoint(zones: &[&Zone]) -> Option { let mut value: Option = None; for zone in zones { 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; } } else { value = Some(current); } } value } async fn list_home_assistant_groups( State(state): State, ) -> Result>, AppError> { let groups = state.db.list_groups()?; let zones = state.db.list_zones()?; let devices = state.db.list_devices()?; let plan = engine::get_control_plan_snapshot(&state).await?; let settings = state.settings.read().await.clone(); let mut output = Vec::with_capacity(groups.len()); for group in groups { let members = zones .iter() .filter(|zone| group.zone_ids.iter().any(|zone_id| zone_id == &zone.id)) .collect::>(); let planned_members = plan .plan .zones .iter() .filter(|zone| { group .zone_ids .iter() .any(|zone_id| zone_id == &zone.zone_id) }) .collect::>(); let zone_names = members .iter() .map(|zone| zone.name.clone()) .collect::>(); let member_device_ids = members .iter() .map(|zone| zone.device_id.as_str()) .collect::>(); let online_devices = devices .iter() .filter(|device| member_device_ids.contains(device.id.as_str()) && device.online) .count(); let current_temperatures = planned_members .iter() .filter_map(|zone| zone.current_temperature) .collect::>(); let current_temperature = if current_temperatures.is_empty() { None } else { Some(current_temperatures.iter().sum::() / current_temperatures.len() as f64) }; let mut next_events = Vec::new(); for zone in &planned_members { for event in &zone.next_events { let mut event = event.clone(); event.label = format!("{}: {}", zone.zone_name, event.label); next_events.push(event); } } 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::>(); output.push(json!({ "id": group.id, "name": group.name, "zone_ids": group.zone_ids, "zone_names": zone_names, "power_enabled": group.power_enabled, "effective_power": group.power_enabled, "mode": home_assistant_group_mode(&members), "preset": home_assistant_group_preset(&members), "custom_setpoint": home_assistant_group_custom_setpoint(&members), "house_mode": settings.house_mode, "zone_count": members.len(), "enabled_zones": members.iter().filter(|zone| zone.enabled).count(), "active_zones": planned_members.iter().filter(|zone| zone.effective_enabled).count(), "demanding_zones": planned_members.iter().filter(|zone| zone.demand).count(), "device_count": member_device_ids.len(), "online_devices": online_devices, "current_temperature": current_temperature, "members": member_states, "next_events": next_events, })); } Ok(Json(output)) } async fn update_home_assistant_group_control( State(state): State, Path(id): Path, Json(patch): Json, ) -> Result, AppError> { Ok(Json( engine::control_group(&state, &id, patch, "home_assistant.group_control").await?, )) }