Files
gree-controller/src/api/groups.rs
T
2026-09-04 11:00:15 +02:00

395 lines
13 KiB
Rust

#[derive(Debug, Deserialize)]
struct GroupInput {
name: String,
#[serde(default)]
zone_ids: Vec<String>,
#[serde(default)]
power_enabled: Option<bool>,
}
fn normalize_group_zone_ids(zone_ids: Vec<String>) -> Vec<String> {
let mut values: Vec<String> = 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<Vec<String>, 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<AppState>) -> Result<Json<Vec<ClimateGroup>>, AppError> {
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 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;
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<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.
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<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.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()
.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<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 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<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()
};
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<f64> {
let mut value: Option<f64> = 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<AppState>,
) -> Result<Json<Vec<Value>>, 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::<Vec<_>>();
let planned_members = plan
.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()
.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::<Vec<_>>();
let current_temperature = if current_temperatures.is_empty() {
None
} else {
Some(current_temperatures.iter().sum::<f64>() / 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::<Vec<_>>();
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<AppState>,
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?,
))
}