This commit is contained in:
Mateusz Gruszczyński
2026-08-25 23:59:04 +02:00
parent 9fa0a5399e
commit b5a4265148
11 changed files with 598 additions and 193 deletions
+76 -33
View File
@@ -252,6 +252,14 @@ async fn discover(State(state): State<AppState>, Json(request): Json<DiscoveryRe
let existing = state.db.get_device_by_mac(&item.mac)?;
let is_new = existing.is_none();
let mut merged = merge_discovered(existing, item);
let _device_guard = state.lock_device_operation(&merged.id).await;
// A poll/command may have updated the same known device between discovery and
// acquiring its operation lock. Re-merge against the freshest persisted state.
if !is_new {
if let Some(current) = state.db.get_device(&merged.id)? {
merged = merge_discovered(Some(current), merged);
}
}
// Bind right after discovery. GREE modules can have a short bind window;
// bind() also refreshes it with a direct scan before the handshake.
if !merged.simulated && merged.key.as_deref().unwrap_or_default().is_empty() {
@@ -346,6 +354,7 @@ async fn get_device(State(state): State<AppState>, Path(id): Path<String>) -> Re
}
async fn patch_device(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<DevicePatch>) -> Result<Json<Device>, AppError> {
let _device_guard = state.lock_device_operation(&id).await;
let mut device = state.db.get_device(&id)?.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
if let Some(v) = patch.name { if !v.trim().is_empty() { device.name = v.trim().to_string(); } }
if let Some(v) = patch.ip { v.parse::<IpAddr>().map_err(|_| AppError::BadRequest("invalid IP address".into()))?; device.ip = v; }
@@ -385,6 +394,7 @@ async fn delete_device(State(state): State<AppState>, Path(id): Path<String>) ->
.map(|zone| zone.id)
.collect();
ensure_zone_removal_safe(&state, &removed_zone_ids)?;
ensure_device_stopped_for_detach(&state, &id, "device.deleted").await?;
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}));
@@ -393,6 +403,7 @@ async fn delete_device(State(state): State<AppState>, Path(id): Path<String>) ->
}
async fn bind_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Device>, AppError> {
let _device_guard = state.lock_device_operation(&id).await;
let mut device = state.db.get_device(&id)?.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
if device.simulated { return Ok(Json(device)); }
let bound = state.gree.bind(&device).await.map_err(|e| AppError::Device(e.to_string()))?;
@@ -551,29 +562,36 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
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 device_changed = existing.device_id != input.device_id;
let mut zone = input.into_zone(id, existing.created_at);
zone.device_temperature = existing.device_temperature;
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_setpoint = existing.manual_setpoint;
zone.manual_override_until = existing.manual_override_until;
zone.device_manual_override = existing.device_manual_override;
zone.device_manual_override_since = existing.device_manual_override_since;
zone.device_manual_override_until = existing.device_manual_override_until;
zone.device_manual_override_fields = existing.device_manual_override_fields;
zone.effective_mode = existing.effective_mode;
zone.effective_setpoint = existing.effective_setpoint;
zone.device_setpoint = existing.device_setpoint;
zone.demand = existing.demand;
zone.demand_since = existing.demand_since;
zone.target_alerted_at = existing.target_alerted_at;
zone.last_action_at = existing.last_action_at;
if !device_changed {
zone.device_temperature = existing.device_temperature;
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_setpoint = existing.manual_setpoint;
zone.manual_override_until = existing.manual_override_until;
zone.device_manual_override = existing.device_manual_override;
zone.device_manual_override_since = existing.device_manual_override_since;
zone.device_manual_override_until = existing.device_manual_override_until;
zone.device_manual_override_fields = existing.device_manual_override_fields;
zone.effective_mode = existing.effective_mode;
zone.effective_setpoint = existing.effective_setpoint;
zone.device_setpoint = existing.device_setpoint;
zone.demand = existing.demand;
zone.demand_since = existing.demand_since;
zone.target_alerted_at = existing.target_alerted_at;
zone.last_action_at = existing.last_action_at;
} else {
// 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?;
}
let settings = state.settings.read().await.clone();
canonicalize_zone_ha_entity(&mut zone, &settings);
let power_off_device = existing.enabled && !zone.enabled;
let power_off_device = !device_changed && existing.enabled && !zone.enabled;
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
if power_off_device {
@@ -581,6 +599,7 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
}
Ok(Json(zone))
}
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 was_enabled = zone.enabled;
@@ -645,10 +664,25 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
Ok(Json(zone))
}
async fn ensure_device_stopped_for_detach(state: &AppState, device_id: &str, source: &str) -> Result<(), AppError> {
let Some(device) = state.db.get_device(device_id)? else { return Ok(()); };
if !device.enabled {
return Err(AppError::BadRequest("cannot safely detach a technically disabled device; enable it so the controller can confirm it is powered off first".into()));
}
// Force one OFF transition even when the cached state already says OFF. A remote change
// may not have been polled yet and detaching must not leave a running unit without owner.
engine::force_power_off_device(state, device_id).await?;
state.log("info", "zone.detach_power_off", &format!("Powered off {} before detaching thermostat ownership", device.name), json!({
"device_id": device.id, "source": source
}));
Ok(())
}
async fn power_off_zone_device(state: &AppState, zone: &Zone, source: &str) {
let Ok(Some(device)) = state.db.get_device(&zone.device_id) else { return; };
if !device.enabled || !device.power { return; }
if let Err(err) = engine::send_command(state, &device.id, DeviceCommand { power: Some(false), ..Default::default() }).await {
if !device.enabled { return; }
if let Err(err) = engine::force_power_off_device(state, &device.id).await {
state.log("error", "zone.disable_power_error", &err.to_string(), json!({
"zone_id": zone.id,
"device_id": device.id,
@@ -926,12 +960,19 @@ async fn command_all_enabled_devices_power(state: &AppState, power: bool, source
std::collections::HashSet::new()
};
for device in state.db.list_devices()? {
if !device.enabled || device.power == power { continue; }
if !device.enabled { continue; }
// Whole-house ON only operates thermostat-managed, enabled zones. Devices with
// a disabled zone (or no zone at all) remain manual/technical Devices controls.
if power && !enabled_zone_devices.contains(&device.id) { continue; }
let command = DeviceCommand { power: Some(power), ..Default::default() };
if let Err(err) = engine::send_command(state, &device.id, command).await {
// Do not trust the pre-loop power snapshot for deciding whether to send. The engine
// reloads state under the per-device lock and turns an already-matching command into
// a no-op. This closes the polling/command race without extra UDP frames.
let result = if power {
engine::send_command(state, &device.id, DeviceCommand { power: Some(true), ..Default::default() }).await
} else {
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,
@@ -983,11 +1024,8 @@ async fn update_house_control(State(state): State<AppState>, Json(input): Json<H
struct HousePowerPatch { power: bool }
async fn update_house_power(State(state): State<AppState>, Json(input): Json<HousePowerPatch>) -> Result<Json<Value>, AppError> {
// Whole-house power is independent from the thermostat mode. Turning it off is
// authoritative, while house mode `off` remains a separate "do not control" state.
if !input.power {
engine::clear_all_device_manual_overrides(&state, "house_power_off")?;
}
// Whole-house power is independent from the thermostat mode. Publish/persist the master
// first so the regulator becomes passive before the one-shot OFF cascade starts.
{
let mut settings = state.settings.write().await;
if settings.house_power_enabled != input.power {
@@ -998,9 +1036,13 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
}
}
// Global power is a true cascade across group gates. Disabled thermostat zones stay
// disabled and are therefore not powered by the ON direction of this command.
// Global power is a true cascade across group gates. OFF clears the current takeover
// markers once, then each enabled device is re-cleared atomically with its OFF command.
// A later pilot action is therefore not erased by subsequent controller cycles.
set_all_groups_power(&state, input.power)?;
if !input.power {
engine::clear_all_device_manual_overrides(&state, "house_power_off")?;
}
let failed = command_all_enabled_devices_power(&state, input.power, "house_power").await?;
let devices = state.db.list_devices()?;
@@ -1124,10 +1166,11 @@ async fn apply_schedule_template(State(state): State<AppState>, Path(id): Path<S
}
async fn delete_zone(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
if state.db.get_zone(&id)?.is_none() { return Err(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}"))); }
remove_zone_ids_from_groups(&state, &removed)?;
state.broadcast("zone.deleted", json!({"id": id}));