v0.7.13
This commit is contained in:
+37
-37
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user