This commit is contained in:
Mateusz Gruszczyński
2026-08-27 22:58:13 +02:00
parent b93a1f2d92
commit 6572ff368b
19 changed files with 398 additions and 51 deletions
+64 -9
View File
@@ -85,7 +85,7 @@ pub fn router(state: AppState) -> Router {
.route("/api/integrations/home-assistant/house/control", post(update_house_control))
.route("/api/integrations/home-assistant/house/preset", post(update_house_preset))
.route("/api/integrations/home-assistant/house/power", post(update_house_power))
.route("/api/integrations/home-assistant/zones/:id/control", post(update_zone_control))
.route("/api/integrations/home-assistant/zones/:id/control", post(update_home_assistant_zone_control))
.route_layer(middleware::from_fn_with_state(state.clone(), home_assistant_auth));
let app = Router::new()
@@ -487,6 +487,10 @@ struct ZoneInput {
external_sensor_weight: f64,
#[serde(default = "max_sensor_difference")]
max_sensor_difference: f64,
#[serde(default = "sensor_stale_after")]
sensor_stale_after_seconds: u64,
#[serde(default)]
revision: Option<u64>,
}
fn yes() -> bool { true }
fn cool() -> String { "cool".into() }
@@ -503,6 +507,7 @@ 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 sensor_stale_after() -> u64 { 300 }
fn device_source() -> String { "device".into() }
impl ZoneInput {
@@ -533,10 +538,12 @@ impl ZoneInput {
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,
external_sensor_weight: self.external_sensor_weight, max_sensor_difference: self.max_sensor_difference, sensor_stale_after_seconds: self.sensor_stale_after_seconds,
device_temperature: None, external_temperature: None, current_temperature: None, control_temperature_source: "device".into(),
active_preset: "comfort".into(), manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None, local_thermostat_resume_at: None,
device_manual_override: false, device_manual_override_since: None, device_manual_override_until: None, device_manual_override_fields: Vec::new(), device_manual_override_baseline: None,
revision: 1, control_owner: "automation".into(), control_source: "automation".into(), control_since: Some(Utc::now()), control_resume_at: None, control_reason: "zone created".into(),
last_power_change_at: None, last_mode_change_at: None, lockout_until: None, lockout_reason: None,
effective_mode: String::new(), effective_setpoint: None, device_setpoint: None,
demand: false, demand_since: None, target_alerted_at: None, last_action_at: None,
created_at, updated_at: Utc::now(),
@@ -568,10 +575,30 @@ async fn create_zone(State(state): State<AppState>, Json(input): Json<ZoneInput>
}
async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<ZoneInput>) -> Result<Json<Zone>, AppError> {
input.validate()?;
let existing = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
let _zone_guard = state.lock_zone_operation(&id).await;
let mut existing = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
if let Some(expected) = input.revision {
if expected != existing.revision {
return Err(AppError::Conflict(format!("zone {id} changed; expected revision {expected}, current revision {}", existing.revision)));
}
}
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 device_changed = existing.device_id != input.device_id;
// Serialize a normal zone edit with polling/manual-takeover detection for its device.
// Device reassignment uses ensure_device_stopped_for_detach below, which acquires the
// old device lock itself while this zone lock is held.
let _device_guard = if !device_changed { Some(state.lock_device_operation(&existing.device_id).await) } else { None };
if !device_changed {
// Polling may have updated takeover/runtime state while we were waiting for the
// device lock. Re-read under both locks before building the replacement Zone.
existing = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
if let Some(expected) = input.revision {
if expected != existing.revision {
return Err(AppError::Conflict(format!("zone {id} changed; expected revision {expected}, current revision {}", existing.revision)));
}
}
}
let mut zone = input.into_zone(id, existing.created_at);
if !device_changed {
zone.device_temperature = existing.device_temperature;
@@ -589,6 +616,16 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
zone.device_manual_override_until = existing.device_manual_override_until;
zone.device_manual_override_fields = existing.device_manual_override_fields;
zone.device_manual_override_baseline = existing.device_manual_override_baseline;
zone.revision = existing.revision.saturating_add(1);
zone.control_owner = existing.control_owner;
zone.control_source = existing.control_source;
zone.control_since = existing.control_since;
zone.control_resume_at = existing.control_resume_at;
zone.control_reason = existing.control_reason;
zone.last_power_change_at = existing.last_power_change_at;
zone.last_mode_change_at = existing.last_mode_change_at;
zone.lockout_until = existing.lockout_until;
zone.lockout_reason = existing.lockout_reason;
zone.effective_mode = existing.effective_mode;
zone.effective_setpoint = existing.effective_setpoint;
zone.device_setpoint = existing.device_setpoint;
@@ -600,23 +637,26 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
// A new physical unit starts with a clean ownership/runtime state. Never transfer
// demand, sensor cache or remote-control takeover from the previous device.
ensure_device_stopped_for_detach(&state, &existing.device_id, "zone.device_reassigned").await?;
zone.revision = existing.revision.saturating_add(1);
}
let settings = state.settings.read().await.clone();
canonicalize_zone_ha_entity(&mut zone, &settings);
let power_off_device = !device_changed && existing.enabled && !zone.enabled;
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
drop(_device_guard);
if power_off_device {
power_off_zone_device(&state, &zone, "zone.disabled").await;
}
Ok(Json(zone))
}
async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControlPatch) -> Result<Zone, AppError> {
async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControlPatch, source: &str) -> Result<Zone, AppError> {
// Serialize quick-thermostat changes with the same device lock used by GREE polling and
// manual-takeover detection. Without this, a poll that started just before a Web/HA
// thermostat action could save an older zone snapshot afterwards and resurrect a false
// "physical/pilot" takeover.
let _zone_guard = state.lock_zone_operation(id).await;
let device_id = state.db.get_zone(id)?
.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?
.device_id;
@@ -631,6 +671,9 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
let resume_device_automation = resume_device_takeover
|| patch.power.is_some() || patch.setpoint.is_some() || patch.mode.is_some()
|| patch.preset.is_some() || patch.enabled.is_some();
if resume_device_automation || resume_local_thermostat {
zone.control_source = if source.contains("home_assistant") { "home_assistant_thermostat".into() } else { "web_thermostat".into() };
}
if resume_local_thermostat {
engine::reset_local_thermostat_override(&mut zone);
@@ -696,8 +739,13 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
if !value { engine::reset_local_thermostat_override(&mut zone); }
}
let device_override_cleared = if resume_device_automation { engine::reset_device_manual_override(&mut zone) } else { false };
let house_mode = state.settings.read().await.house_mode.clone();
let runtime = state.settings.read().await.clone();
let house_mode = runtime.house_mode.clone();
let blocked_by_group = zone.local_thermostat_power != Some(true)
&& state.db.list_groups()?.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id));
engine::refresh_control_ownership(&mut zone, runtime.house_power_enabled, blocked_by_group);
engine::refresh_zone_runtime_target(&mut zone, &schedules, &house_mode);
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)?);
@@ -731,7 +779,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
}
async fn update_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).await?))
Ok(Json(apply_zone_control_patch(&state, &id, patch, "web.zone_thermostat").await?))
}
@@ -1254,6 +1302,10 @@ async fn apply_schedule_template(State(state): State<AppState>, Path(id): Path<S
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 delete_zone(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
let zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
let mut removed = std::collections::HashSet::new();
@@ -1319,9 +1371,12 @@ fn validate_schedule_conflicts(state: &AppState, item: &Schedule, exclude_id: Op
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(()); }
if zone.manual_preset.is_none() && zone.manual_setpoint.is_none() && !zone.device_manual_override { return Ok(()); }
let schedules = state.db.list_schedules()?;
zone.manual_override_until = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
let boundary = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
if zone.manual_preset.is_some() || zone.manual_setpoint.is_some() { zone.manual_override_until = boundary; }
if zone.device_manual_override { zone.device_manual_override_until = boundary; zone.control_resume_at = boundary; }
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)?);
@@ -2134,7 +2189,7 @@ struct HaTestRequest { entity_id: Option<String> }
async fn test_home_assistant(State(state): State<AppState>, Json(input): Json<HaTestRequest>) -> Result<Json<Value>, AppError> {
let settings = state.settings.read().await.clone();
let resolved_entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, input.entity_id.as_deref());
let temperature = home_assistant::read_temperature(&state.http, &settings.home_assistant, resolved_entity_id.as_deref())
let temperature = home_assistant::read_temperature(&state.http, &settings.home_assistant, resolved_entity_id.as_deref(), Some(300))
.await.map_err(|e| AppError::Device(e.to_string()))?;
Ok(Json(json!({"ok": true, "temperature_c": temperature, "entity_id": resolved_entity_id})))
}