This commit is contained in:
Mateusz Gruszczyński
2026-09-03 10:26:18 +02:00
parent 8f6169fbe3
commit 4c9f8ff403
31 changed files with 673 additions and 71 deletions
+1
View File
@@ -68,6 +68,7 @@ pub fn router(state: AppState) -> Router {
.route("/api/devices/:id", get(get_device).patch(patch_device).delete(delete_device))
.route("/api/devices/:id/bind", post(bind_device))
.route("/api/devices/:id/poll", post(poll_device))
.route("/api/devices/:id/probe", post(probe_device))
.route("/api/devices/:id/command", post(command_device))
.route("/api/zones", get(list_zones).post(create_zone))
.route("/api/zones/:id", get(get_zone).put(update_zone).delete(delete_zone))
+33 -6
View File
@@ -208,14 +208,41 @@ async fn poll_device(State(state): State<AppState>, Path(id): Path<String>) -> R
Ok(Json(engine::poll_one(&state, &id).await?))
}
async fn command_device(State(state): State<AppState>, Path(id): Path<String>, Json(command): Json<DeviceCommand>) -> Result<Json<Device>, AppError> {
Ok(Json(engine::send_manual_command(&state, &id, command, "device.manual_control").await?))
async fn probe_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Value>, AppError> {
let device = state.db.get_device(&id)?.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
let response_time_ms = state.gree.probe(&device).await.map_err(|err| AppError::Device(err.to_string()))?;
Ok(Json(json!({
"device_id": device.id,
"response_time_ms": response_time_ms,
"ok": true
})))
}
#[derive(Debug, Deserialize)]
struct ManualDeviceCommandRequest {
#[serde(flatten)]
command: DeviceCommand,
#[serde(default)]
manual_override: bool,
}
async fn command_device(State(state): State<AppState>, Path(id): Path<String>, Json(request): Json<ManualDeviceCommandRequest>) -> Result<Json<Device>, AppError> {
Ok(Json(engine::send_manual_command(
&state,
&id,
request.command,
"device.manual_control",
request.manual_override,
).await?))
}
async fn command_home_assistant_device(State(state): State<AppState>, Path(id): Path<String>, Json(command): Json<DeviceCommand>) -> Result<Json<Device>, AppError> {
if state.db.list_zones()?.iter().any(|zone| zone.device_id == id && !zone.enabled) {
return Err(AppError::BadRequest("device belongs to a disabled thermostat zone; use Manual control in the web UI for explicit direct operation".into()));
}
Ok(Json(engine::send_manual_command(&state, &id, command, "home_assistant.device_manual_control").await?))
Ok(Json(engine::send_manual_command(
&state,
&id,
command,
"home_assistant.device_manual_control",
false,
).await?))
}
+16 -4
View File
@@ -236,14 +236,26 @@ async fn detect_external_device_control(state: &AppState, before: &Device, after
Ok(())
}
pub async fn send_manual_command(state: &AppState, device_id: &str, command: DeviceCommand, source: &str) -> Result<Device, AppError> {
// Keep zone -> device lock ordering consistent with Quick Thermostat/full-zone edits.
// A device belongs to at most one thermostat zone, but keep this generic for legacy data.
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id).map(|zone| zone.id).collect();
pub async fn send_manual_command(state: &AppState, device_id: &str, command: DeviceCommand, source: &str, allow_disabled_zone: bool) -> Result<Device, AppError> {
// Stabilize device <-> zone membership while validating the manual-control safety gate.
// Lock order remains configuration -> zone(s) -> device.
let _configuration_guard = state.lock_configuration_operation().await;
let zones: Vec<Zone> = state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id).collect();
let mut zone_ids: Vec<String> = zones.iter().map(|zone| zone.id.clone()).collect();
zone_ids.sort();
zone_ids.dedup();
let mut _zone_guards = Vec::new();
for zone_id in &zone_ids { _zone_guards.push(state.lock_zone_operation(zone_id).await); }
if !allow_disabled_zone {
// Re-read after taking the zone lock(s): a quick thermostat action may have changed
// enabled state while this request was waiting, even though membership is stable.
if let Some(zone) = state.db.list_zones()?.into_iter().find(|zone| zone.device_id == device_id && !zone.enabled) {
return Err(AppError::BadRequest(format!(
"device belongs to disabled thermostat zone '{}'; explicit manual_override=true is required for direct control",
zone.name
)));
}
}
// Keep the device lock until the zone takeover marker is persisted. Otherwise a poll
// could observe our own just-sent command before the controller records manual ownership.
let _device_guard = state.lock_device_operation(device_id).await;
+13
View File
@@ -1,4 +1,17 @@
impl GreeClient {
/// Measure a minimal GREE round-trip without mutating persisted/live device state.
/// Diagnostics must not alter online/error counters, ownership, readings or capabilities.
pub async fn probe(&self, device: &Device) -> Result<u64> {
if device.simulated { return Ok(0); }
let key = device.key.as_deref().filter(|value| !value.is_empty())
.ok_or_else(|| anyhow!("device is not bound"))?;
let started = Instant::now();
let response = self.status_request(device, key, &["Pow"]).await?;
let mut snapshot = device.clone();
self.apply_status(&mut snapshot, &response)?;
Ok(started.elapsed().as_millis().min(u64::MAX as u128) as u64)
}
pub async fn poll(&self, device: &mut Device) -> Result<()> {
let key = device.key.clone().ok_or_else(|| anyhow!("device is not bound"))?;
let full_cols = [