async fn send_command_locked( state: &AppState, device_id: &str, command: DeviceCommand, ) -> Result { send_command_locked_inner(state, device_id, command, true, true).await } async fn send_command_locked_forced( state: &AppState, device_id: &str, command: DeviceCommand, ) -> Result { send_command_locked_inner(state, device_id, command, false, true).await } async fn send_command_locked_inner( state: &AppState, device_id: &str, command: DeviceCommand, dedupe_against_cache: bool, track_controller_command: bool, ) -> Result { validate_command(&command)?; let mut device = state .db .get_device(device_id)? .ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?; if !device.enabled { return Err(AppError::BadRequest("device is disabled".into())); } // Routine control avoids redundant frames. Explicit safety transitions (global/group OFF, // detach) may bypass cache de-duplication so stale state cannot leave a unit powered. let command = if dedupe_against_cache && device.online && device.communication_failures == 0 { command.changed_from(&device) } else { command }; if command.is_empty() { return Ok(device); } let controller_command_baseline = device.clone(); let suppress_beep = state.settings.read().await.suppress_device_beep; let response_started = Instant::now(); let mut applied_command = command.clone(); let mut confirmed_state = false; let mut confirmed_requested_state = true; if device.simulated { applied_command.apply(&mut device); confirmed_state = true; device.online = true; device.response_time_ms = Some(0); device.last_seen = Some(Utc::now()); device.last_error = None; state.db.save_device(&device)?; } else { if device.key.as_deref().unwrap_or_default().is_empty() { match state.gree.bind(&device).await { Ok(bound) => { device.key = Some(bound.key); device.protocol_version = bound.protocol_version; device.communication_failures = 0; state.db.save_device(&device)?; state.log("info", "device.bound", &format!("Bound {} using protocol V{}", device.name, device.protocol_version), json!({"device_id": device.id, "protocol_version": device.protocol_version})); } Err(err) => { register_device_failure(state, &mut device, &err.to_string())?; return Err(AppError::Device(err.to_string())); } } } match state.gree.command(&device, &command, suppress_beep).await { Ok(result) => applied_command = result, Err(first_err) => { // A lost command ACK does not mean the command was lost. Read the device // first and avoid sending the same frame (and another beep) when the requested // state is already present. Only rebind when the verification read also fails. let mut observed = device.clone(); let retry_result = match state.gree.poll(&mut observed).await { Ok(()) if command.changed_from(&observed).is_empty() => { device = observed; confirmed_state = true; tracing::debug!(device=%device.id, "GREE command ACK was uncertain, but status confirms the requested state"); Ok(command.clone()) } Ok(()) => { device = observed; let remaining = command.changed_from(&device); if remaining.is_empty() { Ok(command.clone()) } else { state.gree.command(&device, &remaining, suppress_beep).await } } Err(_) => match state.gree.bind(&device).await { Ok(bound) => { device.key = Some(bound.key); device.protocol_version = bound.protocol_version; state.db.save_device(&device)?; state.gree.command(&device, &command, suppress_beep).await } Err(_) => Err(first_err), }, }; match retry_result { Ok(result) => applied_command = result, Err(err) => { register_device_failure(state, &mut device, &err.to_string())?; return Err(AppError::Device(err.to_string())); } } } } if command.quiet.is_some() && applied_command.quiet.is_none() { device.supports_quiet = Some(false); } if command.sleep.is_some() && applied_command.sleep.is_none() { device.supports_sleep = Some(false); } // A command ACK confirms transport/acceptance, but several GREE firmwares keep // returning the pre-command status for a short settling window. Publishing that first // stale read makes Home Assistant visibly bounce ON -> OFF -> ON. Verify a few times // with bounded backoff and only publish a differing state after the settling window. if !confirmed_state { let verification_delays_ms = [0_u64, 150, 350, 650]; let mut last_verification_error: Option = None; for delay_ms in verification_delays_ms { if delay_ms > 0 { sleep(Duration::from_millis(delay_ms)).await; } let mut observed = device.clone(); match state.gree.poll(&mut observed).await { Ok(()) => { let requested_matches = applied_command.changed_from(&observed).is_empty(); device = observed; confirmed_state = true; confirmed_requested_state = requested_matches; last_verification_error = None; if requested_matches { break; } } Err(err) => { last_verification_error = Some(err.to_string()); } } } if confirmed_state && !confirmed_requested_state { tracing::debug!(device=%device.id, command=?applied_command, "GREE command acknowledged but status still differs after settling window"); } else if !confirmed_state { let error = last_verification_error.unwrap_or_else(|| "status verification failed".into()); record_poll_failure( &mut device, &format!("command accepted but status verification failed: {error}"), ); state.log( "warn", "device.command_unconfirmed", &format!( "Command accepted by {}, but resulting state could not be verified", device.name ), json!({ "device_id": device.id, "error": error }), ); } } if confirmed_state { device.response_time_ms = Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64); } state.db.save_device(&device)?; if !dedupe_against_cache && confirmed_state && !confirmed_requested_state { if track_controller_command && !command_manual_control_fields(&applied_command).is_empty() { remember_controller_command( state, device_id, &applied_command, &controller_command_baseline, ) .await; } state.broadcast( "device.updated", serde_json::to_value(&device).unwrap_or_default(), ); return Err(AppError::Device( "device did not confirm the requested forced state change".into(), )); } } if track_controller_command && !command_manual_control_fields(&applied_command).is_empty() { // Keep a bounded settling history even after the requested state has already been // observed once. Several GREE modules can briefly publish an older snapshot again // and then return to the controller-requested state. Without this guard that normal // firmware bounce can be misclassified as a physical/pilot takeover. remember_controller_command( state, device_id, &applied_command, &controller_command_baseline, ) .await; } record_device_transition_timestamps(state, &controller_command_baseline, &device)?; state.log( "info", "device.command", &format!("Updated {}", device.name), json!({ "device_id": device.id, "command": applied_command, "confirmed": confirmed_state, }), ); state.broadcast( "device.updated", serde_json::to_value(&device).unwrap_or_default(), ); Ok(device) } fn record_device_transition_timestamps( state: &AppState, before: &Device, after: &Device, ) -> Result<(), AppError> { let power_changed = before.power != after.power; let mode_changed = before.mode != after.mode; if !power_changed && !mode_changed { return Ok(()); } // This function is often called while the device lock is held, so acquiring a zone lock // here would invert the global zone -> device order. Merge only these timestamp fields // with a DB compare-and-swap instead of saving a stale whole-zone snapshot. for zone in state.db.merge_zone_device_transition_timestamps( &after.id, power_changed, mode_changed, Utc::now(), )? { state.broadcast("zone.updated", serde_json::to_value(&zone)?); } Ok(()) }