v0.12.0-preety_code

This commit is contained in:
Mateusz Gruszczyński
2026-09-03 15:33:37 +02:00
parent be67932b47
commit 07be4b85d9
87 changed files with 11691 additions and 3172 deletions
+179 -62
View File
@@ -1,21 +1,36 @@
#[derive(Debug, Deserialize)]
struct HouseControlPatch { mode: String }
struct HouseControlPatch {
mode: String,
}
async fn rearm_house_automation_compressor_queues(state: &AppState) -> Result<(), AppError> {
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
let mut zone_ids: Vec<String> = state
.db
.list_zones()?
.into_iter()
.map(|zone| zone.id)
.collect();
zone_ids.sort();
zone_ids.dedup();
for zone_id in zone_ids {
let _zone_guard = state.lock_zone_operation(&zone_id).await;
let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; };
let Some(mut zone) = state.db.get_zone(&zone_id)? else {
continue;
};
let scoped_manual = zone.device_manual_override
|| zone.local_thermostat_power.is_some()
|| zone.control_source.starts_with("group:")
|| engine::temporary_quick_thermostat_is_active(&zone, Utc::now());
if scoped_manual { continue; }
if zone.compressor_pending_action.is_none() && zone.compressor_cancelled_action.is_none()
&& zone.lockout_until.is_none() && zone.lockout_reason.is_none() { continue; }
if scoped_manual {
continue;
}
if zone.compressor_pending_action.is_none()
&& zone.compressor_cancelled_action.is_none()
&& zone.lockout_until.is_none()
&& zone.lockout_reason.is_none()
{
continue;
}
engine::rearm_compressor_queue(&mut zone);
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now();
@@ -25,15 +40,24 @@ async fn rearm_house_automation_compressor_queues(state: &AppState) -> Result<()
Ok(())
}
async fn set_all_thermostat_power_state(state: &AppState, power: bool) -> Result<usize, AppError> {
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
let mut zone_ids: Vec<String> = state
.db
.list_zones()?
.into_iter()
.map(|zone| zone.id)
.collect();
zone_ids.sort();
zone_ids.dedup();
let mut changed = 0usize;
for zone_id in zone_ids {
let _zone_guard = state.lock_zone_operation(&zone_id).await;
let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; };
let Some(mut zone) = state.db.get_zone(&zone_id)? else {
continue;
};
engine::rearm_compressor_queue(&mut zone);
if engine::set_house_bulk_thermostat_power(&mut zone, power) { changed += 1; }
if engine::set_house_bulk_thermostat_power(&mut zone, power) {
changed += 1;
}
engine::refresh_control_ownership(&mut zone);
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now();
@@ -43,10 +67,16 @@ async fn set_all_thermostat_power_state(state: &AppState, power: bool) -> Result
Ok(changed)
}
async fn command_all_enabled_devices_power(state: &AppState, power: bool, source: &str) -> Result<Vec<Value>, AppError> {
async fn command_all_enabled_devices_power(
state: &AppState,
power: bool,
source: &str,
) -> Result<Vec<Value>, AppError> {
let mut failed = Vec::new();
for device in state.db.list_devices()? {
if !device.enabled { continue; }
if !device.enabled {
continue;
}
// The per-zone thermostat power state is persisted before these physical commands.
// OFF is immediate; ON still respects compressor protection.
let result = if power {
@@ -55,12 +85,17 @@ async fn command_all_enabled_devices_power(state: &AppState, power: bool, source
engine::force_house_power_off_device(state, &device.id, source).await
};
if let Err(err) = result {
state.log("error", "house.power_all_error", &err.to_string(), json!({
"device_id": device.id,
"device_name": device.name,
"power": power,
"source": source,
}));
state.log(
"error",
"house.power_all_error",
&err.to_string(),
json!({
"device_id": device.id,
"device_name": device.name,
"power": power,
"source": source,
}),
);
failed.push(json!({
"device_id": device.id,
"device_name": device.name,
@@ -71,14 +106,19 @@ async fn command_all_enabled_devices_power(state: &AppState, power: bool, source
Ok(failed)
}
async fn update_house_control(State(state): State<AppState>, Json(input): Json<HouseControlPatch>) -> Result<Json<Value>, AppError> {
async fn update_house_control(
State(state): State<AppState>,
Json(input): Json<HouseControlPatch>,
) -> Result<Json<Value>, AppError> {
let _house_guard = state.lock_house_operation().await;
// Serialize the ownership/configuration transition against an already-running thermostat
// cycle. Otherwise a cycle that captured the previous house mode could send one stale
// climate command after this interactive change.
let cycle_guard = state.lock_zone_control_cycle().await;
if !matches!(input.mode.as_str(), "cool" | "heat" | "off") {
return Err(AppError::BadRequest("house mode must be cool, heat or off".into()));
return Err(AppError::BadRequest(
"house mode must be cool, heat or off".into(),
));
}
let mode = input.mode;
let activate_all = mode != "off";
@@ -101,14 +141,24 @@ async fn update_house_control(State(state): State<AppState>, Json(input): Json<H
} else {
state.wake_zone_control();
}
state.log("info", "house.mode", &format!("House mode set to {}", mode), json!({"mode": mode}));
state.log(
"info",
"house.mode",
&format!("House mode set to {}", mode),
json!({"mode": mode}),
);
Ok(Json(payload))
}
#[derive(Debug, Deserialize)]
struct HousePowerPatch { power: bool }
struct HousePowerPatch {
power: bool,
}
async fn update_house_power(State(state): State<AppState>, Json(input): Json<HousePowerPatch>) -> Result<Json<Value>, AppError> {
async fn update_house_power(
State(state): State<AppState>,
Json(input): Json<HousePowerPatch>,
) -> Result<Json<Value>, AppError> {
let _house_guard = state.lock_house_operation().await;
let _cycle_guard = state.lock_zone_control_cycle().await;
@@ -125,17 +175,22 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
let devices = state.db.list_devices()?;
let groups = state.db.list_groups()?;
state.log("info", "house.power_all", if input.power {
"Whole-house ON sent; local OFF state released and house thermostat intent armed"
} else {
"Whole-house OFF sent; all thermostats left locally OFF until explicitly re-enabled"
}, json!({
"power": input.power,
"failed": failed.len(),
"changed_zones": changed_zones,
"one_shot": true,
"persistent_global_gate": false,
}));
state.log(
"info",
"house.power_all",
if input.power {
"Whole-house ON sent; local OFF state released and house thermostat intent armed"
} else {
"Whole-house OFF sent; all thermostats left locally OFF until explicitly re-enabled"
},
json!({
"power": input.power,
"failed": failed.len(),
"changed_zones": changed_zones,
"one_shot": true,
"persistent_global_gate": false,
}),
);
Ok(Json(json!({
"power": input.power,
"one_shot": true,
@@ -146,13 +201,20 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
}
#[derive(Debug, Deserialize)]
struct HousePresetPatch { preset: String }
struct HousePresetPatch {
preset: String,
}
async fn update_house_preset(State(state): State<AppState>, Json(input): Json<HousePresetPatch>) -> Result<Json<Value>, AppError> {
async fn update_house_preset(
State(state): State<AppState>,
Json(input): Json<HousePresetPatch>,
) -> Result<Json<Value>, AppError> {
let _house_guard = state.lock_house_operation().await;
let cycle_guard = state.lock_zone_control_cycle().await;
if !matches!(input.preset.as_str(), "auto" | "comfort" | "sleep" | "away") {
return Err(AppError::BadRequest("house preset must be auto, comfort, sleep or away".into()));
return Err(AppError::BadRequest(
"house preset must be auto, comfort, sleep or away".into(),
));
}
// A house profile applies to free house-controlled zones. Explicit local/group/direct
@@ -160,7 +222,12 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
rearm_house_automation_compressor_queues(&state).await?;
let schedules = state.db.list_schedules()?;
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
let mut zone_ids: Vec<String> = state
.db
.list_zones()?
.into_iter()
.map(|zone| zone.id)
.collect();
zone_ids.sort();
zone_ids.dedup();
let mut _zone_guards = Vec::with_capacity(zone_ids.len());
@@ -169,9 +236,13 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
}
let mut zones = Vec::with_capacity(zone_ids.len());
for zone_id in &zone_ids {
let Some(zone_snapshot) = state.db.get_zone(zone_id)? else { continue; };
let Some(zone_snapshot) = state.db.get_zone(zone_id)? else {
continue;
};
let _device_guard = state.lock_device_operation(&zone_snapshot.device_id).await;
let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; };
let Some(mut zone) = state.db.get_zone(zone_id)? else {
continue;
};
let scoped_manual = zone.device_manual_override
|| zone.local_thermostat_power.is_some()
|| zone.control_source.starts_with("group:")
@@ -188,7 +259,8 @@ 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 = 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)?;
@@ -205,14 +277,24 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
// same arbitration cycle and no member is left waiting behind the periodic interval.
let mut failed: Vec<Value> = Vec::new();
if let Err(err) = engine::run_zone_control_now(&state).await {
state.log("error", "house.immediate_control_error", &err.to_string(), json!({"source":"house_preset"}));
state.log(
"error",
"house.immediate_control_error",
&err.to_string(),
json!({"source":"house_preset"}),
);
failed.push(json!({"scope":"thermostat_cycle","error":err.to_string()}));
}
let devices = state.db.list_devices()?;
state.log("info", "house.preset", &format!("House preset set to {}", input.preset), json!({
"preset": input.preset,
"failed": failed.len(),
}));
state.log(
"info",
"house.preset",
&format!("House preset set to {}", input.preset),
json!({
"preset": input.preset,
"failed": failed.len(),
}),
);
Ok(Json(json!({
"preset": input.preset,
"zones": zones,
@@ -222,22 +304,41 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
}
#[derive(Debug, Deserialize)]
struct ScheduleTemplateRequest { template: String }
struct ScheduleTemplateRequest {
template: String,
}
async fn apply_schedule_template(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<ScheduleTemplateRequest>) -> Result<Json<Value>, AppError> {
async fn apply_schedule_template(
State(state): State<AppState>,
Path(id): Path<String>,
Json(input): Json<ScheduleTemplateRequest>,
) -> Result<Json<Value>, 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 zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
let zone = state
.db
.get_zone(&id)?
.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
let mut items: Vec<Schedule> = Vec::new();
let mut add = |name: &str, days: Vec<u32>, start: &str, end: &str, preset: &str| {
items.push(Schedule {
id: Uuid::new_v4().to_string(), zone_id: id.clone(), name: name.into(), enabled: true,
weekdays: days, start_time: start.into(), end_time: end.into(), preset: preset.into(),
setpoint: zone.setpoint, created_at: Utc::now(), updated_at: Utc::now(), flow_id: None, flow_node_id: None,
id: Uuid::new_v4().to_string(),
zone_id: id.clone(),
name: name.into(),
enabled: true,
weekdays: days,
start_time: start.into(),
end_time: end.into(),
preset: preset.into(),
setpoint: zone.setpoint,
created_at: Utc::now(),
updated_at: Utc::now(),
flow_id: None,
flow_node_id: None,
});
};
let all = vec![1,2,3,4,5,6,7];
let all = vec![1, 2, 3, 4, 5, 6, 7];
match input.template.as_str() {
"family" => {
add("Comfort", all.clone(), "06:30", "22:30", "comfort");
@@ -252,8 +353,8 @@ async fn apply_schedule_template(State(state): State<AppState>, Path(id): Path<S
add("Sleep", all, "22:00", "06:30", "sleep");
}
"workday" => {
let weekdays = vec![1,2,3,4,5];
let weekend = vec![6,7];
let weekdays = vec![1, 2, 3, 4, 5];
let weekend = vec![6, 7];
add("Morning", weekdays.clone(), "06:30", "08:00", "comfort");
add("Away", weekdays.clone(), "08:00", "16:00", "away");
add("Evening", weekdays.clone(), "16:00", "22:30", "comfort");
@@ -270,28 +371,45 @@ async fn apply_schedule_template(State(state): State<AppState>, Path(id): Path<S
validate_schedule_set(&items)?;
state.db.replace_schedules_for_zone(&id, &items)?;
refresh_zone_override_boundary(&state, &id).await?;
state.broadcast("schedule.template_applied", json!({"zone_id": id, "template": input.template, "count": items.len()}));
state.broadcast(
"schedule.template_applied",
json!({"zone_id": id, "template": input.template, "count": items.len()}),
);
state.wake_zone_control();
Ok(Json(json!({"zone": zone, "schedules": items})))
}
async fn update_home_assistant_zone_control(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<ZoneControlPatch>) -> Result<Json<Zone>, AppError> {
Ok(Json(apply_zone_control_patch(&state, &id, patch, "home_assistant.zone_thermostat").await?))
async fn update_home_assistant_zone_control(
State(state): State<AppState>,
Path(id): Path<String>,
Json(patch): Json<ZoneControlPatch>,
) -> Result<Json<Zone>, AppError> {
Ok(Json(
apply_zone_control_patch(&state, &id, patch, "home_assistant.zone_thermostat").await?,
))
}
async fn delete_zone(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
async fn delete_zone(
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 _schedule_guard = state.lock_schedule_operation().await;
let _cycle_guard = state.lock_zone_control_cycle().await;
let zone_guard = state.lock_zone_operation(&id).await;
let zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
let zone = state
.db
.get_zone(&id)?
.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
let mut removed = std::collections::HashSet::new();
removed.insert(id.clone());
ensure_zone_removal_safe(&state, &removed)?;
ensure_device_stopped_for_detach(&state, &zone.device_id, "zone.deleted").await?;
if !state.db.delete_zone(&id)? { return Err(AppError::NotFound(format!("zone {id}"))); }
if !state.db.delete_zone(&id)? {
return Err(AppError::NotFound(format!("zone {id}")));
}
// Group control locks group first and zone second. Release the zone lock before taking
// group locks so deletion cannot form the inverse zone -> group lock order.
drop(zone_guard);
@@ -299,4 +417,3 @@ async fn delete_zone(State(state): State<AppState>, Path(id): Path<String>) -> R
state.broadcast("zone.deleted", json!({"id": id}));
Ok(StatusCode::NO_CONTENT)
}