Skip to content

AirConditioner: coalesce rapid control() calls instead of silently dropping them#32

Open
t-jonesy wants to merge 1 commit intodudanov:mainfrom
t-jonesy:pr-control-coalesce
Open

AirConditioner: coalesce rapid control() calls instead of silently dropping them#32
t-jonesy wants to merge 1 commit intodudanov:mainfrom
t-jonesy:pr-control-coalesce

Conversation

@t-jonesy
Copy link
Copy Markdown

@t-jonesy t-jonesy commented May 5, 2026

Problem

AirConditioner::control() short-circuits with if (m_sendControl) return; while a previous control is in flight (m_sendControl is set when a frame is queued and only cleared when the AC's response handler fires, ~1–2 s round-trip). Any control() call arriving inside that window is silently discarded — no log, no error, no retry.

This is fine for slow interactive use (UI clicks seconds apart) but is broken for the common Home Assistant pattern where an automation fires several climate service calls in quick succession. The ESPHome midea wrapper translates each HA set_hvac_mode / set_preset_mode / set_temperature / set_fan_mode into a separate control() call. With four calls within ~100 ms, only the first lands; the other three vanish.

I observed this on a Midea AC controlled by ESPHome 2026.4.4 driving this library — an automation that set mode=cool, preset=none, target=67, fan=high would settle to cool/67/auto/eco instead of cool/67/high/none. Adding 5–8 s delay: blocks between every action in the HA automation is a workaround but is brittle, slow, and shouldn't be the user's responsibility.

Root cause

m_sendControl was conflating two separate concerns:

  1. Stale-diff prevention — don't compute a new Control against m_status/m_mode/m_preset while they're mid-update from a pending response.
  2. Reentrancy guard — don't recurse into control() from inside the response callback chain.

Both are legitimate, but neither argues for dropping the user's intent. The gate solved (1) and (2) by also throwing away the input.

Fix

Replace the silent drop with coalescing. When control() is called while a request is in flight:

  • Merge the new Control's set fields into a m_pendingControl member (latest-writer-wins per field).
  • After the in-flight request's onSuccess / onError fires (i.e. once m_readStatus has refreshed m_mode / m_preset / etc. from the AC's response), m_flushPending() re-invokes control() with the merged struct.

This preserves both original guarantees:

  • The follow-up control() runs only after fresh state is loaded — no stale diff.
  • Coalescing happens by mutating a plain member, not by re-entering control().

It also collapses N rapid calls into at most 2 UART frames (initial + atomic merged follow-up), which is strictly nicer for the protocol.

Testing

Tested on an ESP8266 (ESP12-E) controlling a Midea split AC via ESPHome's `midea` platform with `period: 3s, timeout: 2s, num_attempts: 1`:

Scenario Before After
4 HA service calls in <100 ms, baseline `heat_cool/eco` only first lands; final state wrong (3/4 fields dropped) all 4 settled at `cool/67/high/none` in ~8 s, 3/3 trials
Standard interactive single-field control unchanged unchanged

No behavior change for the existing single-call usage. `library.json` not bumped — leaving versioning to you.

control() previously short-circuited with `if (m_sendControl) return;`
while a previous control was in flight. Any control() call arriving in
that ~1-2s window was silently discarded with no log or error.

This breaks the common Home Assistant pattern where an automation
fires several climate service calls in quick succession. ESPHome's
midea wrapper translates each HA set_hvac_mode / set_preset_mode /
set_temperature / set_fan_mode into a separate control() call. With
four calls within ~100 ms, only the first lands; the others vanish.

The gate was conflating two concerns -- preventing stale-diff against
mid-update internal state, and preventing reentrancy from the response
callback chain -- by also throwing away the user's input.

Replace the silent drop with coalescing: when control() arrives while
a request is in flight, merge its set fields into m_pendingControl
(latest-writer-wins per field). After the in-flight request's
onSuccess / onError fires (by which time m_readStatus has refreshed
m_mode / m_preset / etc. from the AC's response), m_flushPending()
re-invokes control() with the merged struct. The follow-up therefore
diffs against fresh state, preserving both original guarantees while
no longer dropping input.

Coalesces N rapid calls into at most 2 UART frames (initial + atomic
merged follow-up).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant