Files
gree-controller/src/api/schedules.rs
T
2026-09-01 11:10:28 +02:00

145 lines
8.0 KiB
Rust

#[derive(Debug, Deserialize)]
struct ScheduleInput {
zone_id: String,
name: String,
#[serde(default = "yes")]
enabled: bool,
weekdays: Vec<u32>,
start_time: String,
end_time: String,
#[serde(default = "schedule_preset")]
preset: String,
setpoint: f64,
}
fn schedule_preset() -> String { "custom".into() }
impl ScheduleInput {
fn validate(&self) -> Result<(), AppError> {
if self.name.trim().is_empty() { return Err(AppError::BadRequest("schedule name is required".into())); }
if self.weekdays.is_empty() || self.weekdays.iter().any(|v| !(1..=7).contains(v)) { return Err(AppError::BadRequest("weekdays must contain numbers 1..7".into())); }
chrono::NaiveTime::parse_from_str(&self.start_time, "%H:%M").map_err(|_| AppError::BadRequest("invalid start time".into()))?;
chrono::NaiveTime::parse_from_str(&self.end_time, "%H:%M").map_err(|_| AppError::BadRequest("invalid end time".into()))?;
if !matches!(self.preset.as_str(), "comfort" | "sleep" | "away" | "custom") { return Err(AppError::BadRequest("unsupported schedule preset".into())); }
if self.preset == "custom" && !(8.0..=30.0).contains(&self.setpoint) { return Err(AppError::BadRequest("schedule setpoint must be between 8 and 30 C".into())); }
Ok(())
}
fn into_schedule(self, id: String, created_at: chrono::DateTime<Utc>) -> Schedule {
Schedule { id, zone_id: self.zone_id, name: self.name.trim().into(), enabled: self.enabled,
weekdays: self.weekdays, start_time: self.start_time, end_time: self.end_time,
preset: self.preset, setpoint: self.setpoint, created_at, updated_at: Utc::now() }
}
}
fn validate_schedule_set(items: &[Schedule]) -> Result<(), AppError> {
for (index, item) in items.iter().enumerate() {
for other in items.iter().skip(index + 1) {
if engine::schedules_overlap(item, other) {
return Err(AppError::BadRequest(format!("schedule '{}' overlaps with '{}' for the same zone", item.name, other.name)));
}
}
}
Ok(())
}
fn validate_schedule_conflicts(state: &AppState, item: &Schedule, exclude_id: Option<&str>) -> Result<(), AppError> {
for existing in state.db.list_schedules()? {
if exclude_id == Some(existing.id.as_str()) { continue; }
if engine::schedules_overlap(item, &existing) {
return Err(AppError::BadRequest(format!("schedule overlaps with '{}'", existing.name)));
}
}
Ok(())
}
async fn refresh_zone_override_boundary(state: &AppState, zone_id: &str) -> Result<(), AppError> {
let _zone_guard = state.lock_zone_operation(zone_id).await;
let device_id = state.db.get_zone(zone_id)?.map(|zone| zone.device_id);
let _device_guard = if let Some(device_id) = device_id.as_deref() {
Some(state.lock_device_operation(device_id).await)
} else {
None
};
let Some(mut zone) = state.db.get_zone(zone_id)? else { return Ok(()); };
let has_temporary_schedule_boundary = zone.temporary_quick_thermostat.as_ref()
.map(|session| session.finish_kind == "schedule_boundary")
.unwrap_or(false);
if zone.manual_preset.is_none() && zone.manual_setpoint.is_none() && !zone.device_manual_override && !has_temporary_schedule_boundary { return Ok(()); }
let schedules = state.db.list_schedules()?;
let boundary = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
// An active Temporary Quick Thermostat explicitly owns its target until its own finish
// rule. Editing/applying schedules must not arm the generic quick-setpoint boundary and
// accidentally clear that target at the next schedule transition.
let temporary_owns_zone = engine::temporary_quick_thermostat_is_active(&zone, Utc::now());
if (zone.manual_preset.is_some() || zone.manual_setpoint.is_some()) && !temporary_owns_zone {
zone.manual_override_until = boundary;
}
// Editing schedules must never arm an expiry for direct device takeover. Manual device
// control is intentionally persistent until the user explicitly resumes automation (or
// whole-house OFF performs the global safety reset).
if zone.device_manual_override {
zone.device_manual_override_until = None;
zone.control_resume_at = None;
}
if has_temporary_schedule_boundary {
let reference = zone.temporary_quick_thermostat.as_ref()
.filter(|session| session.activated_at.is_none())
.map(|session| session.started_at.with_timezone(&chrono::Local))
.unwrap_or_else(chrono::Local::now);
let refreshed = engine::next_schedule_boundary_utc(&zone.id, &schedules, reference).or(Some(Utc::now()));
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
session.expires_at = refreshed;
}
}
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
Ok(())
}
async fn list_schedules(State(state): State<AppState>) -> Result<Json<Vec<Schedule>>, AppError> { Ok(Json(state.db.list_schedules()?)) }
async fn get_schedule(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Schedule>, AppError> {
state.db.get_schedule(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("schedule {id}")))
}
async fn create_schedule(State(state): State<AppState>, Json(input): Json<ScheduleInput>) -> Result<(StatusCode, Json<Schedule>), AppError> {
let _configuration_guard = state.lock_configuration_operation().await;
let _schedule_guard = state.lock_schedule_operation().await;
let _cycle_guard = state.lock_zone_control_cycle().await;
input.validate()?;
if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); }
let item = input.into_schedule(Uuid::new_v4().to_string(), Utc::now());
validate_schedule_conflicts(&state, &item, None)?;
state.db.save_schedule(&item)?;
refresh_zone_override_boundary(&state, &item.zone_id).await?;
state.broadcast("schedule.created", serde_json::to_value(&item)?);
state.wake_zone_control();
Ok((StatusCode::CREATED, Json(item)))
}
async fn update_schedule(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<ScheduleInput>) -> Result<Json<Schedule>, AppError> {
let _configuration_guard = state.lock_configuration_operation().await;
let _schedule_guard = state.lock_schedule_operation().await;
let _cycle_guard = state.lock_zone_control_cycle().await;
input.validate()?;
let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?;
if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); }
let old_zone_id = existing.zone_id.clone();
let item = input.into_schedule(id.clone(), existing.created_at);
validate_schedule_conflicts(&state, &item, Some(&id))?;
state.db.save_schedule(&item)?;
refresh_zone_override_boundary(&state, &old_zone_id).await?;
if item.zone_id != old_zone_id { refresh_zone_override_boundary(&state, &item.zone_id).await?; }
state.broadcast("schedule.updated", serde_json::to_value(&item)?);
state.wake_zone_control();
Ok(Json(item))
}
async fn delete_schedule(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
let _configuration_guard = state.lock_configuration_operation().await;
let _schedule_guard = state.lock_schedule_operation().await;
let _cycle_guard = state.lock_zone_control_cycle().await;
let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?;
if !state.db.delete_schedule(&id)? { return Err(AppError::NotFound(format!("schedule {id}"))); }
refresh_zone_override_boundary(&state, &existing.zone_id).await?;
state.broadcast("schedule.deleted", json!({"id": id}));
state.wake_zone_control();
Ok(StatusCode::NO_CONTENT)
}