v0.11.6
This commit is contained in:
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user