v0.7.0
This commit is contained in:
+235
-33
@@ -371,10 +371,18 @@ async fn patch_device(State(state): State<AppState>, Path(id): Path<String>, Jso
|
||||
}
|
||||
|
||||
async fn delete_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||
if state.db.get_device(&id)?.is_none() { return Err(AppError::NotFound(format!("device {id}"))); }
|
||||
if state.db.list_automations()?.iter().any(|item| {
|
||||
item.trigger_device_id.as_deref() == Some(id.as_str())
|
||||
|| (item.action_group_id.is_none() && item.action_device_id == id)
|
||||
}) {
|
||||
return Err(AppError::BadRequest("device is used by an automation; remove or retarget that automation first".into()));
|
||||
}
|
||||
let removed_zone_ids: std::collections::HashSet<String> = state.db.list_zones()?.into_iter()
|
||||
.filter(|zone| zone.device_id == id)
|
||||
.map(|zone| zone.id)
|
||||
.collect();
|
||||
ensure_zone_removal_safe(&state, &removed_zone_ids)?;
|
||||
if !state.db.delete_device(&id)? { return Err(AppError::NotFound(format!("device {id}"))); }
|
||||
remove_zone_ids_from_groups(&state, &removed_zone_ids)?;
|
||||
state.log("info", "device.deleted", "Device deleted", json!({"device_id": id}));
|
||||
@@ -506,6 +514,13 @@ impl ZoneInput {
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_zone_device_assignment(state: &AppState, device_id: &str, current_zone_id: Option<&str>) -> Result<(), AppError> {
|
||||
if state.db.list_zones()?.iter().any(|zone| zone.device_id == device_id && current_zone_id != Some(zone.id.as_str())) {
|
||||
return Err(AppError::BadRequest("a device can belong to only one thermostat zone".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_zones(State(state): State<AppState>) -> Result<Json<Vec<Zone>>, AppError> { Ok(Json(state.db.list_zones()?)) }
|
||||
async fn get_zone(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Zone>, AppError> {
|
||||
state.db.get_zone(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("zone {id}")))
|
||||
@@ -513,6 +528,7 @@ async fn get_zone(State(state): State<AppState>, Path(id): Path<String>) -> Resu
|
||||
async fn create_zone(State(state): State<AppState>, Json(input): Json<ZoneInput>) -> Result<(StatusCode, Json<Zone>), AppError> {
|
||||
input.validate()?;
|
||||
if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); }
|
||||
validate_zone_device_assignment(&state, &input.device_id, None)?;
|
||||
let mut zone = input.into_zone(Uuid::new_v4().to_string(), Utc::now());
|
||||
let settings = state.settings.read().await.clone();
|
||||
canonicalize_zone_ha_entity(&mut zone, &settings);
|
||||
@@ -524,6 +540,7 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
|
||||
input.validate()?;
|
||||
let existing = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
|
||||
if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); }
|
||||
validate_zone_device_assignment(&state, &input.device_id, Some(&id))?;
|
||||
let mut zone = input.into_zone(id, existing.created_at);
|
||||
zone.device_temperature = existing.device_temperature;
|
||||
zone.external_temperature = existing.external_temperature;
|
||||
@@ -556,7 +573,7 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
|
||||
zone.setpoint = value;
|
||||
zone.manual_setpoint = Some(value);
|
||||
zone.effective_setpoint = Some(value);
|
||||
zone.manual_override_until = Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()));
|
||||
zone.manual_override_until = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
|
||||
}
|
||||
if let Some(value) = patch.mode.as_deref() {
|
||||
match value {
|
||||
@@ -578,7 +595,7 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
|
||||
"comfort" | "sleep" | "away" | "custom" => {
|
||||
zone.manual_preset = Some(value.to_string());
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()));
|
||||
zone.manual_override_until = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
|
||||
}
|
||||
_ => return Err(AppError::BadRequest("unsupported zone preset".into())),
|
||||
}
|
||||
@@ -684,12 +701,31 @@ async fn delete_group(State(state): State<AppState>, Path(id): Path<String>) ->
|
||||
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(())
|
||||
}
|
||||
|
||||
fn remove_zone_ids_from_groups(state: &AppState, zone_ids: &std::collections::HashSet<String>) -> Result<(), AppError> {
|
||||
if zone_ids.is_empty() { return Ok(()); }
|
||||
for mut group in state.db.list_groups()? {
|
||||
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)?);
|
||||
@@ -832,7 +868,7 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
|
||||
} else {
|
||||
zone.manual_preset = Some(input.preset.clone());
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()));
|
||||
zone.manual_override_until = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
|
||||
}
|
||||
zone.updated_at = Utc::now();
|
||||
state.db.save_zone(zone)?;
|
||||
@@ -889,21 +925,28 @@ async fn apply_schedule_template(State(state): State<AppState>, Path(id): Path<S
|
||||
add("Away", weekdays.clone(), "08:00", "16:00", "away");
|
||||
add("Evening", weekdays.clone(), "16:00", "22:30", "comfort");
|
||||
add("Sleep", weekdays, "22:30", "06:30", "sleep");
|
||||
add("Weekend", weekend.clone(), "08:00", "23:00", "comfort");
|
||||
add("Weekend sleep", weekend, "23:00", "08:00", "sleep");
|
||||
add("Weekend", weekend, "08:00", "23:00", "comfort");
|
||||
// Saturday can sleep until the Sunday weekend block starts at 08:00.
|
||||
add("Saturday sleep", vec![6], "23:00", "08:00", "sleep");
|
||||
// Sunday must hand over at 06:30 so it never overlaps Monday morning.
|
||||
add("Sunday sleep", vec![7], "23:00", "06:30", "sleep");
|
||||
}
|
||||
"always" => add("Comfort", all, "00:00", "23:59", "comfort"),
|
||||
"always" => add("Comfort", all, "00:00", "00:00", "comfort"),
|
||||
_ => return Err(AppError::BadRequest("unknown schedule template".into())),
|
||||
}
|
||||
validate_schedule_set(&items)?;
|
||||
state.db.replace_schedules_for_zone(&id, &items)?;
|
||||
refresh_zone_override_boundary(&state, &id)?;
|
||||
state.broadcast("schedule.template_applied", json!({"zone_id": id, "template": input.template, "count": items.len()}));
|
||||
Ok(Json(json!({"zone": zone, "schedules": items})))
|
||||
}
|
||||
|
||||
async fn delete_zone(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||
if !state.db.delete_zone(&id)? { return Err(AppError::NotFound(format!("zone {id}"))); }
|
||||
if state.db.get_zone(&id)?.is_none() { return Err(AppError::NotFound(format!("zone {id}"))); }
|
||||
let mut removed = std::collections::HashSet::new();
|
||||
removed.insert(id.clone());
|
||||
ensure_zone_removal_safe(&state, &removed)?;
|
||||
if !state.db.delete_zone(&id)? { return Err(AppError::NotFound(format!("zone {id}"))); }
|
||||
remove_zone_ids_from_groups(&state, &removed)?;
|
||||
state.broadcast("zone.deleted", json!({"id": id}));
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
@@ -939,6 +982,38 @@ impl ScheduleInput {
|
||||
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(())
|
||||
}
|
||||
|
||||
fn refresh_zone_override_boundary(state: &AppState, zone_id: &str) -> Result<(), AppError> {
|
||||
let Some(mut zone) = state.db.get_zone(zone_id)? else { return Ok(()); };
|
||||
if zone.manual_preset.is_none() && zone.manual_setpoint.is_none() { return Ok(()); }
|
||||
let schedules = state.db.list_schedules()?;
|
||||
zone.manual_override_until = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
|
||||
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}")))
|
||||
@@ -947,7 +1022,9 @@ async fn create_schedule(State(state): State<AppState>, Json(input): Json<Schedu
|
||||
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)?;
|
||||
state.broadcast("schedule.created", serde_json::to_value(&item)?);
|
||||
Ok((StatusCode::CREATED, Json(item)))
|
||||
}
|
||||
@@ -955,13 +1032,19 @@ async fn update_schedule(State(state): State<AppState>, Path(id): Path<String>,
|
||||
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 item = input.into_schedule(id, existing.created_at);
|
||||
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)?;
|
||||
if item.zone_id != old_zone_id { refresh_zone_override_boundary(&state, &item.zone_id)?; }
|
||||
state.broadcast("schedule.updated", serde_json::to_value(&item)?);
|
||||
Ok(Json(item))
|
||||
}
|
||||
async fn delete_schedule(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||
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)?;
|
||||
state.broadcast("schedule.deleted", json!({"id": id}));
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -1023,15 +1106,34 @@ impl AutomationInput {
|
||||
return Err(AppError::BadRequest("group automation mode must be house, cool or heat".into()));
|
||||
}
|
||||
}
|
||||
if self.action.target_temperature.is_some() {
|
||||
return Err(AppError::BadRequest("group automation target temperature is not supported; use a group preset".into()));
|
||||
if self.action.target_temperature.is_some()
|
||||
|| self.action.fan_speed.is_some()
|
||||
|| self.action.swing_vertical.is_some()
|
||||
|| self.action.swing_horizontal.is_some()
|
||||
|| self.action.quiet.is_some()
|
||||
|| self.action.turbo.is_some()
|
||||
|| self.action.light.is_some()
|
||||
|| self.action.air.is_some()
|
||||
|| self.action.xfan.is_some()
|
||||
|| self.action.health.is_some()
|
||||
|| self.action.sleep.is_some()
|
||||
{
|
||||
return Err(AppError::BadRequest("group automations support only power, heat/cool/house mode and a group preset".into()));
|
||||
}
|
||||
if self.action.power.is_none() && self.action.mode.is_none() && self.action_preset.as_deref().map(str::trim).filter(|v| !v.is_empty()).is_none() {
|
||||
return Err(AppError::BadRequest("group automation action cannot be empty".into()));
|
||||
}
|
||||
} else {
|
||||
engine::validate_command(&self.action)?;
|
||||
if self.action.is_empty() {
|
||||
return Err(AppError::BadRequest("automation action cannot be empty".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn into_automation(self, id: String, created_at: chrono::DateTime<Utc>, last_fired_at: Option<chrono::DateTime<Utc>>) -> Automation {
|
||||
Automation { id, name: self.name.trim().into(), enabled: self.enabled,
|
||||
trigger_kind: self.trigger_kind, trigger_device_id: self.trigger_device_id,
|
||||
trigger_kind: self.trigger_kind, trigger_device_id: self.trigger_device_id.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()),
|
||||
threshold: self.threshold, at_time: self.at_time, action_device_id: self.action_device_id.trim().to_string(),
|
||||
action_group_id: self.action_group_id.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()),
|
||||
action_preset: self.action_preset.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()),
|
||||
@@ -1039,17 +1141,30 @@ impl AutomationInput {
|
||||
created_at, updated_at: Utc::now() }
|
||||
}
|
||||
}
|
||||
fn validate_automation_references(state: &AppState, input: &AutomationInput) -> Result<(), AppError> {
|
||||
if matches!(input.trigger_kind.as_str(), "temperature_above" | "temperature_below") {
|
||||
let trigger_id = input.trigger_device_id.as_deref().map(str::trim).unwrap_or_default();
|
||||
if state.db.get_device(trigger_id)?.is_none() {
|
||||
return Err(AppError::BadRequest("automation trigger device does not exist".into()));
|
||||
}
|
||||
}
|
||||
if let Some(group_id) = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
||||
if state.db.get_group(group_id)?.is_none() {
|
||||
return Err(AppError::BadRequest("automation action group does not exist".into()));
|
||||
}
|
||||
} else if state.db.get_device(input.action_device_id.trim())?.is_none() {
|
||||
return Err(AppError::BadRequest("automation action device does not exist".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_automations(State(state): State<AppState>) -> Result<Json<Vec<Automation>>, AppError> { Ok(Json(state.db.list_automations()?)) }
|
||||
async fn get_automation(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Automation>, AppError> {
|
||||
state.db.get_automation(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("automation {id}")))
|
||||
}
|
||||
async fn create_automation(State(state): State<AppState>, Json(input): Json<AutomationInput>) -> Result<(StatusCode, Json<Automation>), AppError> {
|
||||
input.validate()?;
|
||||
if let Some(group_id) = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
||||
if state.db.get_group(group_id)?.is_none() { return Err(AppError::BadRequest("automation action group does not exist".into())); }
|
||||
} else if state.db.get_device(input.action_device_id.trim())?.is_none() {
|
||||
return Err(AppError::BadRequest("automation action device does not exist".into()));
|
||||
}
|
||||
validate_automation_references(&state, &input)?;
|
||||
let item = input.into_automation(Uuid::new_v4().to_string(), Utc::now(), None);
|
||||
state.db.save_automation(&item)?;
|
||||
state.broadcast("automation.created", serde_json::to_value(&item)?);
|
||||
@@ -1058,11 +1173,7 @@ async fn create_automation(State(state): State<AppState>, Json(input): Json<Auto
|
||||
async fn update_automation(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<AutomationInput>) -> Result<Json<Automation>, AppError> {
|
||||
input.validate()?;
|
||||
let existing = state.db.get_automation(&id)?.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?;
|
||||
if let Some(group_id) = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
||||
if state.db.get_group(group_id)?.is_none() { return Err(AppError::BadRequest("automation action group does not exist".into())); }
|
||||
} else if state.db.get_device(input.action_device_id.trim())?.is_none() {
|
||||
return Err(AppError::BadRequest("automation action device does not exist".into()));
|
||||
}
|
||||
validate_automation_references(&state, &input)?;
|
||||
let item = input.into_automation(id, existing.created_at, existing.last_fired_at);
|
||||
state.db.save_automation(&item)?;
|
||||
state.broadcast("automation.updated", serde_json::to_value(&item)?);
|
||||
@@ -1508,29 +1619,120 @@ async fn export_settings(State(state): State<AppState>) -> Result<Json<Configura
|
||||
fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), AppError> {
|
||||
if export.format_version != 1 { return Err(AppError::BadRequest("unsupported configuration export version".into())); }
|
||||
influxdb::validate(&export.settings.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?;
|
||||
if !matches!(export.settings.house_mode.as_str(), "cool" | "heat" | "off") {
|
||||
return Err(AppError::BadRequest("import contains an invalid house mode".into()));
|
||||
}
|
||||
|
||||
let devices: std::collections::HashSet<&str> = export.devices.iter().map(|item| item.id.as_str()).collect();
|
||||
let zones: std::collections::HashSet<&str> = export.zones.iter().map(|item| item.id.as_str()).collect();
|
||||
let schedules: std::collections::HashSet<&str> = export.schedules.iter().map(|item| item.id.as_str()).collect();
|
||||
let automations: std::collections::HashSet<&str> = export.automations.iter().map(|item| item.id.as_str()).collect();
|
||||
if devices.len() != export.devices.len() || zones.len() != export.zones.len()
|
||||
|| schedules.len() != export.schedules.len() || automations.len() != export.automations.len()
|
||||
|| devices.contains("") || zones.contains("") || schedules.contains("") || automations.contains("")
|
||||
{
|
||||
return Err(AppError::BadRequest("import contains duplicate or empty resource IDs".into()));
|
||||
}
|
||||
let device_macs: std::collections::HashSet<&str> = export.devices.iter().map(|item| item.mac.as_str()).collect();
|
||||
if device_macs.len() != export.devices.len() {
|
||||
return Err(AppError::BadRequest("import contains duplicate device MAC addresses".into()));
|
||||
}
|
||||
if export.zones.iter().any(|item| !devices.contains(item.device_id.as_str())) {
|
||||
return Err(AppError::BadRequest("import contains a zone referencing a missing device".into()));
|
||||
}
|
||||
let mut zone_devices = std::collections::HashSet::new();
|
||||
for zone in &export.zones {
|
||||
if !zone_devices.insert(zone.device_id.as_str()) {
|
||||
return Err(AppError::BadRequest("import assigns one device to more than one thermostat zone".into()));
|
||||
}
|
||||
if !matches!(zone.mode.as_str(), "cool" | "heat") {
|
||||
return Err(AppError::BadRequest("import contains an invalid zone mode".into()));
|
||||
}
|
||||
if !matches!(zone.sensor_source.as_str(), "device" | "home_assistant" | "combined") {
|
||||
return Err(AppError::BadRequest("import contains an invalid zone sensor source".into()));
|
||||
}
|
||||
}
|
||||
|
||||
if export.schedules.iter().any(|item| !zones.contains(item.zone_id.as_str())) {
|
||||
return Err(AppError::BadRequest("import contains a schedule referencing a missing zone".into()));
|
||||
}
|
||||
if export.groups.iter().any(|group| group.zone_ids.iter().any(|zone_id| !zones.contains(zone_id.as_str()))) {
|
||||
return Err(AppError::BadRequest("import contains a group referencing a missing zone".into()));
|
||||
for item in &export.schedules {
|
||||
if item.weekdays.is_empty() || item.weekdays.iter().any(|day| !(1..=7).contains(day)) {
|
||||
return Err(AppError::BadRequest("import contains invalid schedule weekdays".into()));
|
||||
}
|
||||
NaiveTime::parse_from_str(&item.start_time, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid schedule start time".into()))?;
|
||||
NaiveTime::parse_from_str(&item.end_time, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid schedule end time".into()))?;
|
||||
if !matches!(item.preset.as_str(), "comfort" | "sleep" | "away" | "custom") {
|
||||
return Err(AppError::BadRequest("import contains an invalid schedule preset".into()));
|
||||
}
|
||||
if item.preset == "custom" && !(8.0..=30.0).contains(&item.setpoint) {
|
||||
return Err(AppError::BadRequest("import contains an invalid schedule setpoint".into()));
|
||||
}
|
||||
}
|
||||
validate_schedule_set(&export.schedules)?;
|
||||
|
||||
if export.groups.iter().any(|group| {
|
||||
let members: std::collections::HashSet<&str> = group.zone_ids.iter().map(String::as_str).collect();
|
||||
group.id.trim().is_empty() || group.zone_ids.is_empty() || members.len() != group.zone_ids.len()
|
||||
|| group.zone_ids.iter().any(|zone_id| !zones.contains(zone_id.as_str()))
|
||||
}) {
|
||||
return Err(AppError::BadRequest("import contains an invalid group, duplicate members or a missing zone reference".into()));
|
||||
}
|
||||
let groups: std::collections::HashSet<&str> = export.groups.iter().map(|item| item.id.as_str()).collect();
|
||||
if export.automations.iter().any(|item| {
|
||||
if let Some(group_id) = item.action_group_id.as_deref().filter(|value| !value.is_empty()) {
|
||||
!groups.contains(group_id)
|
||||
} else {
|
||||
!devices.contains(item.action_device_id.as_str())
|
||||
}
|
||||
}) {
|
||||
return Err(AppError::BadRequest("import contains an automation referencing a missing device or group".into()));
|
||||
if groups.len() != export.groups.len() {
|
||||
return Err(AppError::BadRequest("import contains duplicate group IDs".into()));
|
||||
}
|
||||
if export.automations.iter().any(|item| item.trigger_device_id.as_deref().is_some_and(|id| !devices.contains(id))) {
|
||||
return Err(AppError::BadRequest("import contains an automation trigger referencing a missing device".into()));
|
||||
|
||||
for item in &export.automations {
|
||||
match item.trigger_kind.as_str() {
|
||||
"temperature_above" | "temperature_below" => {
|
||||
let Some(trigger_id) = item.trigger_device_id.as_deref() else {
|
||||
return Err(AppError::BadRequest("import contains a temperature automation without a trigger device".into()));
|
||||
};
|
||||
if !devices.contains(trigger_id) || item.threshold.is_none() {
|
||||
return Err(AppError::BadRequest("import contains an invalid temperature automation trigger".into()));
|
||||
}
|
||||
}
|
||||
"time" => {
|
||||
let at = item.at_time.as_deref().ok_or_else(|| AppError::BadRequest("import contains a time automation without at_time".into()))?;
|
||||
NaiveTime::parse_from_str(at, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid automation time".into()))?;
|
||||
}
|
||||
_ => return Err(AppError::BadRequest("import contains an unsupported automation trigger".into())),
|
||||
}
|
||||
|
||||
if let Some(group_id) = item.action_group_id.as_deref().filter(|value| !value.is_empty()) {
|
||||
if !groups.contains(group_id) {
|
||||
return Err(AppError::BadRequest("import contains an automation referencing a missing group".into()));
|
||||
}
|
||||
if let Some(mode) = item.action.mode.as_deref() {
|
||||
if !matches!(mode, "auto" | "house" | "cool" | "heat") {
|
||||
return Err(AppError::BadRequest("import contains an invalid group automation mode".into()));
|
||||
}
|
||||
}
|
||||
if let Some(preset) = item.action_preset.as_deref() {
|
||||
if !matches!(preset, "auto" | "comfort" | "sleep" | "away") {
|
||||
return Err(AppError::BadRequest("import contains an invalid group automation preset".into()));
|
||||
}
|
||||
}
|
||||
if item.action.target_temperature.is_some() || item.action.fan_speed.is_some()
|
||||
|| item.action.swing_vertical.is_some() || item.action.swing_horizontal.is_some()
|
||||
|| item.action.quiet.is_some() || item.action.turbo.is_some() || item.action.light.is_some()
|
||||
|| item.action.air.is_some() || item.action.xfan.is_some() || item.action.health.is_some() || item.action.sleep.is_some()
|
||||
{
|
||||
return Err(AppError::BadRequest("import contains unsupported fields in a group automation".into()));
|
||||
}
|
||||
if item.action.power.is_none() && item.action.mode.is_none() && item.action_preset.as_deref().filter(|v| !v.is_empty()).is_none() {
|
||||
return Err(AppError::BadRequest("import contains an empty group automation action".into()));
|
||||
}
|
||||
} else {
|
||||
if !devices.contains(item.action_device_id.as_str()) {
|
||||
return Err(AppError::BadRequest("import contains an automation referencing a missing device".into()));
|
||||
}
|
||||
engine::validate_command(&item.action)?;
|
||||
if item.action.is_empty() {
|
||||
return Err(AppError::BadRequest("import contains an empty automation action".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ impl Db {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(queries::DELETE_DEVICE_READINGS, [id])?;
|
||||
tx.execute(queries::DELETE_ZONE_READINGS_BY_DEVICE_ID, [id])?;
|
||||
tx.execute(queries::DELETE_SCHEDULES_BY_DEVICE_ID, [id])?;
|
||||
tx.execute(queries::DELETE_ZONES_BY_DEVICE_ID, [id])?;
|
||||
let changed = tx.execute(queries::DELETE_DEVICE, [id])? > 0;
|
||||
tx.commit()?;
|
||||
|
||||
+210
-21
@@ -316,7 +316,7 @@ fn register_device_failure(state: &AppState, device: &mut Device, error: &str) -
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_command(command: &DeviceCommand) -> Result<(), AppError> {
|
||||
pub(crate) fn validate_command(command: &DeviceCommand) -> Result<(), AppError> {
|
||||
if let Some(value) = command.target_temperature {
|
||||
if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("target temperature must be between 8 and 30 C".into())); }
|
||||
}
|
||||
@@ -377,7 +377,7 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
|
||||
} else {
|
||||
zone.manual_preset = Some(preset.to_string());
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = Some(next_schedule_boundary_utc(&zone.id, &schedules, Local::now()));
|
||||
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &schedules, Local::now());
|
||||
}
|
||||
}
|
||||
zone.updated_at = Utc::now();
|
||||
@@ -390,6 +390,9 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
|
||||
let master_power_enabled = runtime.house_power_enabled;
|
||||
let should_command_power = patch.power.is_some() || activates_group;
|
||||
let desired_power = group.power_enabled;
|
||||
// A zone may intentionally belong to more than one group. Power-off is authoritative:
|
||||
// turning one group on must never briefly wake a member that is still blocked by another group.
|
||||
let group_snapshot = state.db.list_groups()?;
|
||||
let mut failed = Vec::new();
|
||||
if should_command_power && (!desired_power || master_power_enabled) {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
@@ -399,6 +402,10 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
|
||||
if !device.enabled || device.power == desired_power { continue; }
|
||||
if desired_power {
|
||||
if !zone.enabled { continue; }
|
||||
let blocked_by_other_group = group_snapshot.iter().any(|other| {
|
||||
other.id != group.id && !other.power_enabled && other.zone_ids.iter().any(|zone_id| zone_id == &zone.id)
|
||||
});
|
||||
if blocked_by_other_group { continue; }
|
||||
let zone_mode = if zone.inherit_house_mode { runtime.house_mode.as_str() } else { zone.mode.as_str() };
|
||||
if zone_mode == "off" { continue; }
|
||||
}
|
||||
@@ -993,25 +1000,34 @@ fn resolve_zone_target(zone: &Zone, schedule: Option<&Schedule>, mode: &str) ->
|
||||
fn active_schedule_for_zone<'a>(zone: &Zone, schedules: &'a [Schedule], now: DateTime<Local>) -> Option<&'a Schedule> {
|
||||
schedules.iter()
|
||||
.filter(|item| item.enabled && item.zone_id == zone.id && schedule_active(item, now))
|
||||
.last()
|
||||
// Overlaps are rejected by the API, but imported/legacy data may still contain one.
|
||||
// Prefer the most recently edited entry instead of depending on database/name order.
|
||||
.max_by_key(|item| item.updated_at)
|
||||
}
|
||||
|
||||
pub fn next_schedule_boundary_utc(zone_id: &str, schedules: &[Schedule], now: DateTime<Local>) -> DateTime<Utc> {
|
||||
fn minute_floor(now: DateTime<Local>) -> DateTime<Local> {
|
||||
now.with_second(0).and_then(|value| value.with_nanosecond(0)).unwrap_or(now)
|
||||
}
|
||||
|
||||
pub fn next_schedule_boundary_utc(zone_id: &str, schedules: &[Schedule], now: DateTime<Local>) -> Option<DateTime<Utc>> {
|
||||
let current = schedules.iter()
|
||||
.filter(|item| item.enabled && item.zone_id == zone_id && schedule_active(item, now))
|
||||
.last()
|
||||
.max_by_key(|item| item.updated_at)
|
||||
.map(|item| item.id.as_str());
|
||||
for minute in 1..=(48 * 60) {
|
||||
let candidate = now + chrono::Duration::minutes(minute);
|
||||
let base = minute_floor(now);
|
||||
// Eight days cover a complete weekly schedule plus the next transition.
|
||||
for minute in 1..=(8 * 24 * 60) {
|
||||
let candidate = base + chrono::Duration::minutes(minute);
|
||||
let next = schedules.iter()
|
||||
.filter(|item| item.enabled && item.zone_id == zone_id && schedule_active(item, candidate))
|
||||
.last()
|
||||
.max_by_key(|item| item.updated_at)
|
||||
.map(|item| item.id.as_str());
|
||||
if next != current {
|
||||
return candidate.with_timezone(&Utc);
|
||||
return Some(candidate.with_timezone(&Utc));
|
||||
}
|
||||
}
|
||||
(now + chrono::Duration::hours(8)).with_timezone(&Utc)
|
||||
// No schedule transition exists: keep a manual override until the user clears it.
|
||||
None
|
||||
}
|
||||
|
||||
fn schedule_active(item: &Schedule, now: DateTime<Local>) -> bool {
|
||||
@@ -1019,7 +1035,15 @@ fn schedule_active(item: &Schedule, now: DateTime<Local>) -> bool {
|
||||
let Ok(end) = NaiveTime::parse_from_str(&item.end_time, "%H:%M") else { return false; };
|
||||
let time = now.time();
|
||||
let today = now.weekday().number_from_monday();
|
||||
if start <= end {
|
||||
if start == end {
|
||||
// Equal times mean a 24-hour block starting on each selected weekday.
|
||||
if time >= start {
|
||||
item.weekdays.contains(&today)
|
||||
} else {
|
||||
let previous = previous_weekday(now.weekday()).number_from_monday();
|
||||
item.weekdays.contains(&previous)
|
||||
}
|
||||
} else if start < end {
|
||||
item.weekdays.contains(&today) && time >= start && time < end
|
||||
} else if time >= start {
|
||||
item.weekdays.contains(&today)
|
||||
@@ -1031,6 +1055,38 @@ fn schedule_active(item: &Schedule, now: DateTime<Local>) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn schedule_week_mask(item: &Schedule) -> Option<Vec<bool>> {
|
||||
let start = NaiveTime::parse_from_str(&item.start_time, "%H:%M").ok()?;
|
||||
let end = NaiveTime::parse_from_str(&item.end_time, "%H:%M").ok()?;
|
||||
let start_minute = (start.hour() * 60 + start.minute()) as usize;
|
||||
let end_minute = (end.hour() * 60 + end.minute()) as usize;
|
||||
let mut mask = vec![false; 7 * 24 * 60];
|
||||
for weekday in &item.weekdays {
|
||||
if !(1..=7).contains(weekday) { return None; }
|
||||
let day = (*weekday as usize) - 1;
|
||||
let mark = |mask: &mut [bool], day: usize, from: usize, to: usize| {
|
||||
let base = (day % 7) * 24 * 60;
|
||||
for minute in from..to { mask[base + minute] = true; }
|
||||
};
|
||||
if start_minute == end_minute {
|
||||
mark(&mut mask, day, start_minute, 24 * 60);
|
||||
mark(&mut mask, day + 1, 0, end_minute);
|
||||
} else if start_minute < end_minute {
|
||||
mark(&mut mask, day, start_minute, end_minute);
|
||||
} else {
|
||||
mark(&mut mask, day, start_minute, 24 * 60);
|
||||
mark(&mut mask, day + 1, 0, end_minute);
|
||||
}
|
||||
}
|
||||
Some(mask)
|
||||
}
|
||||
|
||||
pub(crate) fn schedules_overlap(a: &Schedule, b: &Schedule) -> bool {
|
||||
if !a.enabled || !b.enabled || a.zone_id != b.zone_id { return false; }
|
||||
let (Some(left), Some(right)) = (schedule_week_mask(a), schedule_week_mask(b)) else { return false; };
|
||||
left.iter().zip(right.iter()).any(|(a, b)| *a && *b)
|
||||
}
|
||||
|
||||
fn previous_weekday(day: Weekday) -> Weekday {
|
||||
match day {
|
||||
Weekday::Mon => Weekday::Sun, Weekday::Tue => Weekday::Mon, Weekday::Wed => Weekday::Tue,
|
||||
@@ -1170,8 +1226,9 @@ fn next_night_mode_events(settings: &NightModeSettings, now: DateTime<Local>, li
|
||||
let Ok(start) = NaiveTime::parse_from_str(&settings.start_time, "%H:%M") else { return Vec::new(); };
|
||||
let Ok(end) = NaiveTime::parse_from_str(&settings.end_time, "%H:%M") else { return Vec::new(); };
|
||||
let mut events = Vec::new();
|
||||
let base = minute_floor(now);
|
||||
for minute in 1..=(48 * 60) {
|
||||
let candidate = now + chrono::Duration::minutes(minute);
|
||||
let candidate = base + chrono::Duration::minutes(minute);
|
||||
let time = candidate.time();
|
||||
let (kind, label) = if time.hour() == start.hour() && time.minute() == start.minute() {
|
||||
let quiet = if settings.force_quiet { " + Quiet" } else { "" };
|
||||
@@ -1195,8 +1252,19 @@ fn next_night_mode_events(settings: &NightModeSettings, now: DateTime<Local>, li
|
||||
|
||||
fn next_time_automation_event(item: &Automation, action_name: &str, now: DateTime<Local>) -> Option<ControlPlanEvent> {
|
||||
let expected = NaiveTime::parse_from_str(item.at_time.as_deref()?, "%H:%M").ok()?;
|
||||
for minute in 1..=(24 * 60) {
|
||||
let candidate = now + chrono::Duration::minutes(minute);
|
||||
let base = minute_floor(now.clone());
|
||||
if time_automation_due(item, now) {
|
||||
return Some(ControlPlanEvent {
|
||||
at: base.with_timezone(&Utc),
|
||||
kind: "automation".into(),
|
||||
label: format!("{} -> {}", item.name, action_name),
|
||||
preset: None,
|
||||
target_temperature: item.action.target_temperature,
|
||||
});
|
||||
}
|
||||
// A local day can last 25 hours at the end of daylight saving time.
|
||||
for minute in 1..=(26 * 60) {
|
||||
let candidate = base + chrono::Duration::minutes(minute);
|
||||
if candidate.hour() == expected.hour() && candidate.minute() == expected.minute() {
|
||||
return Some(ControlPlanEvent {
|
||||
at: candidate.with_timezone(&Utc),
|
||||
@@ -1214,8 +1282,9 @@ fn next_schedule_events(zone: &Zone, schedules: &[Schedule], mode: &str, now: Da
|
||||
if mode == "off" { return Vec::new(); }
|
||||
let mut events = Vec::new();
|
||||
let mut current = active_schedule_for_zone(zone, schedules, now).map(|item| item.id.as_str());
|
||||
let base = minute_floor(now);
|
||||
for minute in 1..=(8 * 24 * 60) {
|
||||
let candidate = now + chrono::Duration::minutes(minute);
|
||||
let candidate = base + chrono::Duration::minutes(minute);
|
||||
let next = active_schedule_for_zone(zone, schedules, candidate);
|
||||
let next_id = next.map(|item| item.id.as_str());
|
||||
if next_id == current { continue; }
|
||||
@@ -1242,6 +1311,8 @@ fn next_schedule_events(zone: &Zone, schedules: &[Schedule], mode: &str, now: Da
|
||||
async fn run_automations(state: &AppState) -> Result<()> {
|
||||
if !state.settings.read().await.house_power_enabled { return Ok(()); }
|
||||
let devices = state.db.list_devices()?;
|
||||
let zones = state.db.list_zones()?;
|
||||
let groups = state.db.list_groups()?;
|
||||
for mut item in state.db.list_automations()? {
|
||||
if !item.enabled || !automation_ready(&item) { continue; }
|
||||
let should_fire = match item.trigger_kind.as_str() {
|
||||
@@ -1249,10 +1320,21 @@ async fn run_automations(state: &AppState) -> Result<()> {
|
||||
.zip(item.threshold).map(|(t, threshold)| t > threshold).unwrap_or(false),
|
||||
"temperature_below" => find_temperature(&devices, item.trigger_device_id.as_deref())
|
||||
.zip(item.threshold).map(|(t, threshold)| t < threshold).unwrap_or(false),
|
||||
"time" => item.at_time.as_deref().map(time_matches).unwrap_or(false),
|
||||
"time" => time_automation_due(&item, Local::now()),
|
||||
_ => false,
|
||||
};
|
||||
if !should_fire { continue; }
|
||||
if item.action_group_id.is_none() && device_blocked_by_disabled_group(&item.action_device_id, &zones, &groups) {
|
||||
// Group power-off is authoritative. Suppress a raw-device automation instead of
|
||||
// waking the unit for one control cycle and immediately switching it off again.
|
||||
item.last_fired_at = Some(Utc::now());
|
||||
item.updated_at = Utc::now();
|
||||
state.db.save_automation(&item)?;
|
||||
state.log("info", "automation.blocked_by_group", &format!("Automation {} suppressed by disabled group", item.name), json!({
|
||||
"automation_id": item.id, "device_id": item.action_device_id
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
let result = if let Some(group_id) = item.action_group_id.as_deref() {
|
||||
let group_mode = item.action.mode.as_deref().map(|mode| if mode == "auto" { "house".to_string() } else { mode.to_string() });
|
||||
control_group(state, group_id, GroupControlPatch {
|
||||
@@ -1272,25 +1354,53 @@ async fn run_automations(state: &AppState) -> Result<()> {
|
||||
"automation_id": item.id, "group_id": item.action_group_id, "device_id": item.action_device_id
|
||||
}));
|
||||
}
|
||||
Err(err) => state.log("error", "automation.error", &err.to_string(), json!({"automation_id": item.id})),
|
||||
Err(err) => {
|
||||
// A failed action is still an execution attempt. Apply the configured cooldown
|
||||
// so an offline/disabled target cannot be hammered on every automation cycle.
|
||||
item.last_fired_at = Some(Utc::now());
|
||||
item.updated_at = Utc::now();
|
||||
state.db.save_automation(&item)?;
|
||||
state.log("error", "automation.error", &err.to_string(), json!({"automation_id": item.id}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
fn device_blocked_by_disabled_group(device_id: &str, zones: &[Zone], groups: &[crate::models::ClimateGroup]) -> bool {
|
||||
let zone_ids: std::collections::HashSet<&str> = zones.iter()
|
||||
.filter(|zone| zone.device_id == device_id)
|
||||
.map(|zone| zone.id.as_str())
|
||||
.collect();
|
||||
if zone_ids.is_empty() { return false; }
|
||||
groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_ids.contains(zone_id.as_str())))
|
||||
}
|
||||
|
||||
fn find_temperature(devices: &[Device], device_id: Option<&str>) -> Option<f64> {
|
||||
let id = device_id?;
|
||||
devices.iter().find(|d| d.id == id)?.current_temperature
|
||||
// Never fire a temperature automation from stale cached data of an offline/disabled unit.
|
||||
devices.iter().find(|d| d.id == id && d.enabled && d.online)?.current_temperature
|
||||
}
|
||||
|
||||
fn automation_ready(item: &Automation) -> bool {
|
||||
item.last_fired_at.map(|last| (Utc::now() - last).num_seconds().max(0) as u64 >= item.cooldown_seconds).unwrap_or(true)
|
||||
}
|
||||
|
||||
fn time_matches(expected: &str) -> bool {
|
||||
fn time_automation_due(item: &Automation, now: DateTime<Local>) -> bool {
|
||||
let Some(expected) = item.at_time.as_deref() else { return false; };
|
||||
let Ok(value) = NaiveTime::parse_from_str(expected, "%H:%M") else { return false; };
|
||||
let now = Local::now().time();
|
||||
now.hour() == value.hour() && now.minute() == value.minute()
|
||||
if now.hour() != value.hour() || now.minute() != value.minute() { return false; }
|
||||
if let Some(last) = item.last_fired_at {
|
||||
let local_last = last.with_timezone(&Local);
|
||||
if local_last.date_naive() == now.date_naive()
|
||||
&& local_last.hour() == now.hour()
|
||||
&& local_last.minute() == now.minute()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1309,6 +1419,85 @@ mod tests {
|
||||
assert!(schedule_active(&item, now));
|
||||
}
|
||||
|
||||
fn test_schedule(id: &str, weekdays: Vec<u32>, start: &str, end: &str) -> Schedule {
|
||||
Schedule {
|
||||
id: id.into(), zone_id: "z".into(), name: id.into(), enabled: true,
|
||||
weekdays, start_time: start.into(), end_time: end.into(), preset: "comfort".into(), setpoint: 21.0,
|
||||
created_at: Utc::now(), updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_schedule_times_mean_a_full_day() {
|
||||
let item = test_schedule("full", vec![1], "06:00", "06:00");
|
||||
let monday_noon = Local.with_ymd_and_hms(2025, 1, 6, 12, 0, 0).single().unwrap();
|
||||
let tuesday_early = Local.with_ymd_and_hms(2025, 1, 7, 5, 59, 0).single().unwrap();
|
||||
let tuesday_after = Local.with_ymd_and_hms(2025, 1, 7, 6, 1, 0).single().unwrap();
|
||||
assert!(schedule_active(&item, monday_noon));
|
||||
assert!(schedule_active(&item, tuesday_early));
|
||||
assert!(!schedule_active(&item, tuesday_after));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_overlap_detection_handles_overnight_ranges() {
|
||||
let daytime = test_schedule("day", vec![1,2,3,4,5,6,7], "06:30", "22:30");
|
||||
let night = test_schedule("night", vec![1,2,3,4,5,6,7], "22:30", "06:30");
|
||||
let conflict = test_schedule("conflict", vec![1], "22:00", "23:00");
|
||||
assert!(!schedules_overlap(&daytime, &night));
|
||||
assert!(schedules_overlap(&night, &conflict));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_schedule_boundary_scans_the_whole_week() {
|
||||
let friday = test_schedule("friday", vec![5], "12:00", "13:00");
|
||||
let monday = Local.with_ymd_and_hms(2025, 1, 6, 10, 0, 30).single().unwrap();
|
||||
let boundary = next_schedule_boundary_utc("z", &[friday], monday).unwrap().with_timezone(&Local);
|
||||
assert_eq!(boundary.weekday(), Weekday::Fri);
|
||||
assert_eq!(boundary.hour(), 12);
|
||||
assert_eq!(boundary.minute(), 0);
|
||||
assert_eq!(boundary.second(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_week_schedule_has_no_manual_override_boundary() {
|
||||
let always = test_schedule("always", vec![1,2,3,4,5,6,7], "00:00", "00:00");
|
||||
let now = Local.with_ymd_and_hms(2025, 1, 6, 10, 0, 30).single().unwrap();
|
||||
assert!(next_schedule_boundary_utc("z", &[always], now).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workday_weekend_handoff_has_no_overlaps() {
|
||||
let schedules = vec![
|
||||
test_schedule("morning", vec![1,2,3,4,5], "06:30", "08:00"),
|
||||
test_schedule("away", vec![1,2,3,4,5], "08:00", "16:00"),
|
||||
test_schedule("evening", vec![1,2,3,4,5], "16:00", "22:30"),
|
||||
test_schedule("sleep", vec![1,2,3,4,5], "22:30", "06:30"),
|
||||
test_schedule("weekend", vec![6,7], "08:00", "23:00"),
|
||||
test_schedule("saturday-sleep", vec![6], "23:00", "08:00"),
|
||||
test_schedule("sunday-sleep", vec![7], "23:00", "06:30"),
|
||||
];
|
||||
for (index, item) in schedules.iter().enumerate() {
|
||||
for other in schedules.iter().skip(index + 1) {
|
||||
assert!(!schedules_overlap(item, other), "{} overlaps {}", item.name, other.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_automation_fires_only_once_in_the_same_minute() {
|
||||
let now = Local.with_ymd_and_hms(2025, 1, 6, 10, 15, 40).single().unwrap();
|
||||
let mut item = Automation {
|
||||
id: "a".into(), name: "at time".into(), enabled: true, trigger_kind: "time".into(),
|
||||
trigger_device_id: None, threshold: None, at_time: Some("10:15".into()),
|
||||
action_device_id: "d".into(), action_group_id: None, action_preset: None,
|
||||
action: DeviceCommand { power: Some(true), ..Default::default() }, cooldown_seconds: 30,
|
||||
last_fired_at: None, created_at: Utc::now(), updated_at: Utc::now(),
|
||||
};
|
||||
assert!(time_automation_due(&item, now.clone()));
|
||||
item.last_fired_at = Some((now.clone() - chrono::Duration::seconds(35)).with_timezone(&Utc));
|
||||
assert!(!time_automation_due(&item, now));
|
||||
}
|
||||
|
||||
fn test_zone(source: &str) -> Zone {
|
||||
Zone {
|
||||
id: "z".into(), name: "Room".into(), device_id: "d".into(), enabled: true,
|
||||
|
||||
@@ -153,6 +153,8 @@ pub const LIST_DEVICES: &str = "SELECT payload FROM devices ORDER BY name COLLAT
|
||||
pub const GET_DEVICE_BY_ID: &str = "SELECT payload FROM devices WHERE id=?1";
|
||||
pub const GET_DEVICE_BY_MAC: &str = "SELECT payload FROM devices WHERE lower(mac)=lower(?1)";
|
||||
pub const DELETE_DEVICE_READINGS: &str = "DELETE FROM readings WHERE device_id=?1";
|
||||
pub const DELETE_SCHEDULES_BY_DEVICE_ID: &str =
|
||||
"DELETE FROM schedules WHERE zone_id IN (SELECT id FROM zones WHERE json_extract(payload, '$.device_id')=?1)";
|
||||
pub const DELETE_ZONES_BY_DEVICE_ID: &str =
|
||||
"DELETE FROM zones WHERE json_extract(payload, '$.device_id')=?1";
|
||||
pub const DELETE_DEVICE: &str = "DELETE FROM devices WHERE id=?1";
|
||||
|
||||
Reference in New Issue
Block a user