#[derive(Debug, Deserialize)] struct ScheduleInput { zone_id: String, name: String, #[serde(default = "yes")] enabled: bool, weekdays: Vec, 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) -> 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) -> Result>, AppError> { Ok(Json(state.db.list_schedules()?)) } async fn get_schedule(State(state): State, Path(id): Path) -> Result, AppError> { state.db.get_schedule(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("schedule {id}"))) } async fn create_schedule(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), 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, Path(id): Path, Json(input): Json) -> Result, 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, Path(id): Path) -> Result { 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) }