This commit is contained in:
Mateusz Gruszczyński
2026-08-23 23:20:00 +02:00
parent 8bb12a1782
commit b23578a0c8
34 changed files with 2300 additions and 447 deletions
+184 -22
View File
@@ -47,6 +47,9 @@ pub fn router(state: AppState) -> Router {
.route("/api/zones", get(list_zones).post(create_zone))
.route("/api/zones/:id", get(get_zone).put(update_zone).delete(delete_zone))
.route("/api/zones/:id/control", post(update_zone_control))
.route("/api/zones/:id/schedule-template", post(apply_schedule_template))
.route("/api/house/control", post(update_house_control))
.route("/api/house/preset", post(update_house_preset))
.route("/api/schedules", get(list_schedules).post(create_schedule))
.route("/api/schedules/:id", get(get_schedule).put(update_schedule).delete(delete_schedule))
.route("/api/automations", get(list_automations).post(create_automation))
@@ -155,6 +158,7 @@ async fn build_bootstrap(state: &AppState) -> Result<Value, AppError> {
"automations": state.db.list_automations()?,
"access_tokens": state.db.list_api_tokens()?,
"settings": public_settings(&settings),
"outdoor_temperature": *state.outdoor_temperature.read().await,
"system": {
"version": env!("CARGO_PKG_VERSION"),
"uptime_seconds": state.started.elapsed().as_secs(),
@@ -324,14 +328,34 @@ struct ZoneInput {
enabled: bool,
#[serde(default = "cool")]
mode: String,
#[serde(default = "yes")]
inherit_house_mode: bool,
#[serde(default = "setpoint")]
setpoint: f64,
#[serde(default = "cool_comfort")]
cool_comfort_setpoint: f64,
#[serde(default = "cool_sleep")]
cool_sleep_setpoint: f64,
#[serde(default = "cool_away")]
cool_away_setpoint: f64,
#[serde(default = "heat_comfort")]
heat_comfort_setpoint: f64,
#[serde(default = "heat_sleep")]
heat_sleep_setpoint: f64,
#[serde(default = "heat_away")]
heat_away_setpoint: f64,
#[serde(default = "hysteresis")]
hysteresis: f64,
#[serde(default = "cycle")]
min_on_seconds: u64,
#[serde(default = "cycle")]
min_off_seconds: u64,
#[serde(default = "min_adjust")]
min_adjust_seconds: u64,
#[serde(default = "standby_offset")]
standby_offset_c: f64,
#[serde(default = "yes")]
smart_fan: bool,
#[serde(default = "device_source")]
sensor_source: String,
#[serde(default)]
@@ -344,8 +368,16 @@ struct ZoneInput {
fn yes() -> bool { true }
fn cool() -> String { "cool".into() }
fn setpoint() -> f64 { 24.0 }
fn cool_comfort() -> f64 { 23.0 }
fn cool_sleep() -> f64 { 24.5 }
fn cool_away() -> f64 { 27.0 }
fn heat_comfort() -> f64 { 21.0 }
fn heat_sleep() -> f64 { 19.0 }
fn heat_away() -> f64 { 17.0 }
fn hysteresis() -> f64 { 0.6 }
fn cycle() -> u64 { 180 }
fn min_adjust() -> u64 { 120 }
fn standby_offset() -> f64 { 2.0 }
fn external_sensor_weight() -> f64 { 0.4 }
fn max_sensor_difference() -> f64 { 3.0 }
fn device_source() -> String { "device".into() }
@@ -353,8 +385,12 @@ fn device_source() -> String { "device".into() }
impl ZoneInput {
fn validate(&self) -> Result<(), AppError> {
if self.name.trim().is_empty() { return Err(AppError::BadRequest("zone name is required".into())); }
if !(8.0..=30.0).contains(&self.setpoint) { return Err(AppError::BadRequest("zone setpoint must be between 8 and 30 C".into())); }
for value in [self.setpoint, self.cool_comfort_setpoint, self.cool_sleep_setpoint, self.cool_away_setpoint,
self.heat_comfort_setpoint, self.heat_sleep_setpoint, self.heat_away_setpoint] {
if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("zone temperatures must be between 8 and 30 C".into())); }
}
if !(0.1..=5.0).contains(&self.hysteresis) { return Err(AppError::BadRequest("hysteresis must be between 0.1 and 5 C".into())); }
if !(0.5..=8.0).contains(&self.standby_offset_c) { return Err(AppError::BadRequest("standby offset must be between 0.5 and 8 C".into())); }
if !matches!(self.mode.as_str(), "cool" | "heat") { return Err(AppError::BadRequest("zone mode must be cool or heat".into())); }
if !matches!(self.sensor_source.as_str(), "device" | "home_assistant" | "combined") { return Err(AppError::BadRequest("unsupported sensor source".into())); }
if !(0.0..=1.0).contains(&self.external_sensor_weight) { return Err(AppError::BadRequest("external sensor weight must be between 0 and 1".into())); }
@@ -367,11 +403,17 @@ impl ZoneInput {
fn into_zone(self, id: String, created_at: chrono::DateTime<Utc>) -> Zone {
Zone {
id, name: self.name.trim().into(), device_id: self.device_id, enabled: self.enabled,
mode: self.mode, setpoint: self.setpoint, hysteresis: self.hysteresis,
min_on_seconds: self.min_on_seconds, min_off_seconds: self.min_off_seconds,
mode: self.mode, inherit_house_mode: self.inherit_house_mode, setpoint: self.setpoint, profile_version: 1,
cool_comfort_setpoint: self.cool_comfort_setpoint, cool_sleep_setpoint: self.cool_sleep_setpoint,
cool_away_setpoint: self.cool_away_setpoint, heat_comfort_setpoint: self.heat_comfort_setpoint,
heat_sleep_setpoint: self.heat_sleep_setpoint, heat_away_setpoint: self.heat_away_setpoint,
hysteresis: self.hysteresis, min_on_seconds: self.min_on_seconds, min_off_seconds: self.min_off_seconds,
min_adjust_seconds: self.min_adjust_seconds, standby_offset_c: self.standby_offset_c, smart_fan: self.smart_fan,
sensor_source: self.sensor_source, ha_entity_id: self.ha_entity_id.filter(|v| !v.trim().is_empty()),
external_sensor_weight: self.external_sensor_weight, max_sensor_difference: self.max_sensor_difference,
device_temperature: None, external_temperature: None, current_temperature: None, control_temperature_source: "device".into(),
active_preset: "comfort".into(), manual_preset: None, manual_override_until: None,
effective_mode: String::new(), effective_setpoint: None, device_setpoint: None,
demand: false, last_action_at: None,
created_at, updated_at: Utc::now(),
}
@@ -399,6 +441,12 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
zone.external_temperature = existing.external_temperature;
zone.current_temperature = existing.current_temperature;
zone.control_temperature_source = existing.control_temperature_source;
zone.active_preset = existing.active_preset;
zone.manual_preset = existing.manual_preset;
zone.manual_override_until = existing.manual_override_until;
zone.effective_mode = existing.effective_mode;
zone.effective_setpoint = existing.effective_setpoint;
zone.device_setpoint = existing.device_setpoint;
zone.demand = existing.demand;
zone.last_action_at = existing.last_action_at;
state.db.save_zone(&zone)?;
@@ -407,36 +455,139 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
}
async fn update_zone_control(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<ZoneControlPatch>) -> Result<Json<Zone>, AppError> {
let mut zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
let schedules = state.db.list_schedules()?;
if let Some(value) = patch.setpoint {
if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("zone setpoint must be between 8 and 30 C".into())); }
zone.setpoint = (value * 2.0).round() / 2.0;
zone.manual_preset = Some("custom".into());
zone.manual_override_until = Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()));
}
if let Some(value) = patch.mode.as_deref() {
if !matches!(value, "cool" | "heat") { return Err(AppError::BadRequest("zone mode must be cool or heat".into())); }
zone.mode = value.to_string();
match value {
"house" | "auto" => zone.inherit_house_mode = true,
"cool" | "heat" => {
zone.inherit_house_mode = false;
zone.mode = value.to_string();
}
_ => return Err(AppError::BadRequest("zone mode must be house, cool or heat".into())),
}
}
if let Some(value) = patch.preset.as_deref() {
match value {
"auto" => {
zone.manual_preset = None;
zone.manual_override_until = None;
}
"comfort" | "sleep" | "away" | "custom" => {
zone.manual_preset = Some(value.to_string());
zone.manual_override_until = Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()));
}
_ => return Err(AppError::BadRequest("unsupported zone preset".into())),
}
}
if patch.clear_override.unwrap_or(false) {
zone.manual_preset = None;
zone.manual_override_until = None;
}
if let Some(value) = patch.enabled { zone.enabled = value; }
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
// Quick zone controls also update the paired climate unit immediately. Power is
// intentionally left unchanged; the zone engine still owns ON/OFF demand.
if patch.setpoint.is_some() || patch.mode.is_some() {
let command = DeviceCommand {
mode: patch.mode.as_ref().map(|_| zone.mode.clone()),
target_temperature: patch.setpoint.map(|_| zone.setpoint),
..Default::default()
};
if let Err(err) = engine::send_command(&state, &zone.device_id, command).await {
state.log("warn", "zone.quick_control_device_error", &format!("{}: {err}", zone.name), json!({"zone_id": zone.id, "device_id": zone.device_id}));
}
}
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
state.log("info", "zone.quick_control", &format!("Quick control updated for {}", zone.name), json!({"zone_id": zone.id, "setpoint": zone.setpoint, "mode": zone.mode, "enabled": zone.enabled}));
state.log("info", "zone.quick_control", &format!("Quick control updated for {}", zone.name), json!({
"zone_id": zone.id, "setpoint": zone.setpoint, "mode": zone.mode,
"inherit_house_mode": zone.inherit_house_mode, "preset": zone.manual_preset,
"override_until": zone.manual_override_until, "enabled": zone.enabled
}));
Ok(Json(zone))
}
#[derive(Debug, Deserialize)]
struct HouseControlPatch { mode: String }
async fn update_house_control(State(state): State<AppState>, Json(input): Json<HouseControlPatch>) -> Result<Json<Value>, AppError> {
if !matches!(input.mode.as_str(), "cool" | "heat" | "off") {
return Err(AppError::BadRequest("house mode must be cool, heat or off".into()));
}
let mut settings = state.settings.write().await;
settings.house_mode = input.mode;
state.db.save_runtime_settings(&settings)?;
let payload = public_settings(&settings);
state.broadcast("settings.updated", payload.clone());
state.log("info", "house.mode", &format!("House mode set to {}", settings.house_mode), json!({"mode": settings.house_mode}));
Ok(Json(payload))
}
#[derive(Debug, Deserialize)]
struct HousePresetPatch { preset: String }
async fn update_house_preset(State(state): State<AppState>, Json(input): Json<HousePresetPatch>) -> Result<Json<Value>, AppError> {
if !matches!(input.preset.as_str(), "auto" | "comfort" | "sleep" | "away") {
return Err(AppError::BadRequest("house preset must be auto, comfort, sleep or away".into()));
}
let schedules = state.db.list_schedules()?;
let mut zones = state.db.list_zones()?;
for zone in &mut zones {
if input.preset == "auto" {
zone.manual_preset = None;
zone.manual_override_until = None;
} else {
zone.manual_preset = Some(input.preset.clone());
zone.manual_override_until = Some(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)?);
}
state.log("info", "house.preset", &format!("House preset set to {}", input.preset), json!({"preset": input.preset}));
Ok(Json(json!({"preset": input.preset, "zones": zones})))
}
#[derive(Debug, Deserialize)]
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> {
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(),
});
};
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");
add("Sleep", all, "22:30", "06:30", "sleep");
}
"child" => {
add("Comfort", all.clone(), "06:30", "20:30", "comfort");
add("Sleep", all, "20:30", "06:30", "sleep");
}
"bedroom" => {
add("Comfort", all.clone(), "06:30", "22:00", "comfort");
add("Sleep", all, "22:00", "06:30", "sleep");
}
"workday" => {
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");
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");
}
"always" => add("Comfort", all, "00:00", "23:59", "comfort"),
_ => return Err(AppError::BadRequest("unknown schedule template".into())),
}
state.db.replace_schedules_for_zone(&id, &items)?;
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}"))); }
state.broadcast("zone.deleted", json!({"id": id}));
@@ -452,21 +603,25 @@ struct ScheduleInput {
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 !(8.0..=30.0).contains(&self.setpoint) { return Err(AppError::BadRequest("schedule setpoint must be between 8 and 30 C".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,
setpoint: self.setpoint, created_at, updated_at: Utc::now() }
preset: self.preset, setpoint: self.setpoint, created_at, updated_at: Utc::now() }
}
}
async fn list_schedules(State(state): State<AppState>) -> Result<Json<Vec<Schedule>>, AppError> { Ok(Json(state.db.list_schedules()?)) }
@@ -591,6 +746,8 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
input.poll_interval_seconds = input.poll_interval_seconds.clamp(2, 3600);
input.zone_interval_seconds = input.zone_interval_seconds.clamp(2, 3600);
input.discovery_timeout_ms = input.discovery_timeout_ms.clamp(300, 30_000);
if !matches!(input.house_mode.as_str(), "cool" | "heat" | "off") { return Err(AppError::BadRequest("house mode must be cool, heat or off".into())); }
if input.control_strategy != "setpoint" { input.control_strategy = "setpoint".into(); }
if !(input.discovery_broadcast.eq_ignore_ascii_case("auto")
|| input.discovery_broadcast.to_ascii_lowercase().starts_with("auto:")) {
input.discovery_broadcast.parse::<std::net::SocketAddr>()
@@ -674,11 +831,16 @@ fn public_settings(settings: &RuntimeSettings) -> Value {
"zone_interval_seconds": settings.zone_interval_seconds,
"discovery_timeout_ms": settings.discovery_timeout_ms,
"discovery_broadcast": settings.discovery_broadcast,
"house_mode": settings.house_mode,
"control_strategy": settings.control_strategy,
"outdoor_assist_enabled": settings.outdoor_assist_enabled,
"home_assistant": {
"url": settings.home_assistant.url,
"token": "",
"token_configured": !settings.home_assistant.token.trim().is_empty(),
"default_entity_id": settings.home_assistant.default_entity_id,
"outdoor_entity_id": settings.home_assistant.outdoor_entity_id,
"allow_invalid_tls": settings.home_assistant.allow_invalid_tls,
}
})
}