This commit is contained in:
Mateusz Gruszczyński
2026-08-27 22:00:13 +02:00
parent 4cc4378588
commit b93a1f2d92
16 changed files with 196 additions and 64 deletions
+37 -37
View File
@@ -149,6 +149,7 @@ async fn send_command_locked_inner(
// 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();
@@ -264,21 +265,19 @@ async fn send_command_locked_inner(
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).await;
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 {
if confirmed_state && confirmed_requested_state {
// A verified full status snapshot supersedes any older unsettled expectation for
// this device, so it must not mask a later real remote change.
clear_pending_controller_command(state, device_id).await;
} else if !command_manual_control_fields(&applied_command).is_empty() {
remember_controller_command(state, device_id, &applied_command).await;
}
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;
}
state.log("info", "device.command", &format!("Updated {}", device.name), json!({
@@ -476,42 +475,41 @@ fn command_manual_control_fields(command: &DeviceCommand) -> Vec<String> {
fields
}
fn merge_device_command(base: &mut DeviceCommand, update: &DeviceCommand) {
if update.power.is_some() { base.power = update.power; }
if update.mode.is_some() { base.mode = update.mode.clone(); }
if update.target_temperature.is_some() { base.target_temperature = update.target_temperature; }
if update.fan_speed.is_some() { base.fan_speed = update.fan_speed; }
if update.swing_vertical.is_some() { base.swing_vertical = update.swing_vertical; }
if update.swing_horizontal.is_some() { base.swing_horizontal = update.swing_horizontal; }
if update.quiet.is_some() { base.quiet = update.quiet; }
if update.turbo.is_some() { base.turbo = update.turbo; }
if update.light.is_some() { base.light = update.light; }
if update.air.is_some() { base.air = update.air; }
if update.xfan.is_some() { base.xfan = update.xfan; }
if update.health.is_some() { base.health = update.health; }
if update.sleep.is_some() { base.sleep = update.sleep; }
fn command_baseline_from_device(command: &DeviceCommand, device: &Device) -> DeviceCommand {
DeviceCommand {
power: command.power.map(|_| device.power),
mode: command.mode.as_ref().map(|_| device.mode.clone()),
target_temperature: command.target_temperature.map(|_| device.target_temperature),
fan_speed: command.fan_speed.map(|_| device.fan_speed),
quiet: command.quiet.map(|_| device.quiet),
sleep: command.sleep.map(|_| device.sleep),
..Default::default()
}
}
async fn remember_controller_command(state: &AppState, device_id: &str, command: &DeviceCommand) {
async fn remember_controller_command(state: &AppState, device_id: &str, command: &DeviceCommand, baseline_device: &Device) {
let poll_seconds = state.settings.read().await.poll_interval_seconds.max(2);
let ttl = Duration::from_secs(poll_seconds.saturating_mul(2).saturating_add(5).min(120));
let mut pending = state.pending_controller_commands.lock().await;
let expires_at = Instant::now() + ttl;
let baseline = command_baseline_from_device(command, baseline_device);
if let Some(existing) = pending.get_mut(device_id) {
merge_device_command(&mut existing.command, command);
existing.commands.push(command.clone());
existing.baselines.push(baseline);
// The history only spans one settling window; cap it defensively so a noisy device
// cannot grow this allocation without bound.
if existing.commands.len() > 8 { existing.commands.remove(0); }
if existing.baselines.len() > 8 { existing.baselines.remove(0); }
existing.expires_at = expires_at;
} else {
pending.insert(device_id.to_string(), PendingControllerCommand {
command: command.clone(),
commands: vec![command.clone()],
baselines: vec![baseline],
expires_at,
});
}
}
async fn clear_pending_controller_command(state: &AppState, device_id: &str) {
state.pending_controller_commands.lock().await.remove(device_id);
}
fn command_field_matches_device(command: &DeviceCommand, field: &str, device: &Device) -> bool {
match field {
"power" => command.power.map(|value| value == device.power).unwrap_or(false),
@@ -542,14 +540,16 @@ async fn suppress_expected_controller_changes(
}
let Some(expected) = pending.get(&device.id).cloned() else { return fields; };
let filtered = fields.into_iter()
.filter(|field| !command_field_matches_device(&expected.command, field, device))
.filter(|field| {
let matches_recent_controller_state = expected.commands.iter()
.chain(expected.baselines.iter())
.any(|command| command_field_matches_device(command, field, device));
!matches_recent_controller_state
})
.collect();
let expected_fields = command_manual_control_fields(&expected.command);
if !expected_fields.is_empty()
&& expected_fields.iter().all(|field| command_field_matches_device(&expected.command, field, device))
{
pending.remove(&device.id);
}
// Do not clear the settling guard merely because one poll matched the requested state.
// A later status packet can still briefly roll back to the pre-command snapshot. The
// bounded TTL is what ends this ambiguity window.
filtered
}