v0.7.4
This commit is contained in:
+76
-33
@@ -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}));
|
||||
|
||||
Reference in New Issue
Block a user