145 lines
8.0 KiB
Rust
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)
|
|
}
|
|
|