Skip to content

vNext: VRF gateways: ask all sub-device list forms, hold sent values until confirmed - #522

Merged
RobHofmann merged 3 commits into
5.0-devfrom
fix/vrf-series
Sep 24, 2026
Merged

RobHofmann merged 3 commits into
5.0-devfrom
fix/vrf-series

Conversation

@RobHofmann

@RobHofmann RobHofmann commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

Port of the VRF (multi-split) fixes that landed on master in #507, #454 and #508, released in 4.0.7. I checked each one against 5.0 first. Two of the four parts were already covered here, so this PR only fills the two real gaps.

Master PR Part 5.0 before this PR
#507 Ask every sub-device list form and join the results Gap: one hybrid form, never confirmed on hardware
#507 Fetch sub-lists after the scan, gateways side by side, subCnt > 0 Partly: after the scan, but one gateway after the other. subCnt == 1 already worked
#454 One HA device per indoor unit Already fine: devices are keyed by the sub-unit MAC
#508 Hold sent values while the gateway reports its old cache Gap: the read right after a command showed the old value again
#508 Hide props the unit does not support Already fine: empty values are dropped from polling

1. Sub-device list forms

Symptom. A gateway whose firmware does not answer the one form 5.0 sent gives no indoor units, or only some of them.

Cause. 5.0 sent one hybrid request: envelope subList with i: 0, and the reply read with the device key. That matches none of the forms seen on real modules. There was a TODO saying it needed real-world data.

Change. The forms below come from #507, captured on real gateways by @meirlo, @Ilya-Draigor and @vellad1. They cover GR-Gcloud V3.2.M and the older W06 V1.1.0.0.

Form Envelope t, i Key, request and reply
device-key pack, 0 the bound device key
generic-key subList, 1 the generic key
subDev (older W06 modules) pack, 0 the bound device key
  • V1 gateways get all three forms. The lists are joined by MAC, in the order the units were first seen. V2 gets the device-key form, as on master.
  • The generic-key form uses the generic key both ways. Its reply comes in the generic key, and @meirlo's probe on a GR-Gcloud V3.2.M (Fix VRF sub-device discovery: defer + concurrent subList, robust parsing #507) showed the gateway ignores its request pack: device, generic and random keys all got the same answer. So no response_cipher is needed in the transport. The same change for master is Send the generic-key subList request with the generic key #527.
  • A form without a usable answer is skipped. If no form answers, one warning is logged and discovery of the other devices goes on.
  • The scan replies are processed with asyncio.gather, so a slow gateway does not hold up the others.
  • The old hybrid form is no longer sent.
  • A unit without a MAC in the list is left out. Before, it became a device with an empty MAC.

Measured on master (#507). On a GR-Gcloud V3.2.M the forms gave: device key 4, generic key 3, subDev 4, joined 4.

2. Stale state after a command

Symptom. You set 25 °C on a VRF indoor unit, and the UI jumps back to 21 °C for a moment.

Cause. The gateway caches the state of its units. For a few seconds after a command it answers a status request from that cache. push_device_status() reads straight after the command, so it gets the old state.

Change.

  • For a VRF sub-unit, DeviceState.hold() keeps the sent values. While a prop is held, get() returns the sent value.
  • A hold ends when the device reports the sent value. The check uses the value the device reported, never the held one.
  • A hold also ends after HELD_VALUE_TTL (8 s). Then the reported value wins again, so a rejected command is not shown forever.
  • pending (changes not sent yet) and held (sent, not confirmed yet) are separate. has_pending_updates compares with the held value first. Without that, going back to the value the stale cache still shows would be skipped as "no change".
  • The coordinator polls again every 2 s while values are held. It stops when the device confirms, or with the first poll after the hold ends. So a rejected command shows the real value within about 8 s, not at the next scan interval.
    • This uses async_call_later and async_refresh(). async_request_refresh() would not work, because its debouncer pushes a second request within its cooldown back to 10 s.
    • The poll is cancelled in async_shutdown().
  • Only sub-units are held (GreeDevice.is_sub_unit: the device MAC differs from the controller MAC). The stale cache was only seen on VRF gateways. For a standalone unit a hold has a cost, raised by @p-monteiro: a unit that corrects a value it cannot take (for example an unsupported swing mode) reports its own value, and a hold would keep the refused value on screen for up to 8 s. Standalone units behave as before, with no hold and no extra poll.
  • To learn whether a standalone unit or the MQTT transport caches too, every device logs at debug level when the read right after a command does not report the sent value, with the transport and whether it is a sub-unit. If one of them does cache, extending the hold is one condition.
  • Held values show in diagnostics as state_held.

The overlay works like the one on master, with two differences. First, master held values for every unit until #525 scoped it to sub-units the same way. Second, master polls once after 2 s, and here the poll repeats until the hold ends. With a single poll, a rejected command stayed visible until the next scan interval (up to 60 s).

Testing

  • Real unit, sub-device list: _get_sub_devices_list() called directly against my standalone unit, which answers no form: result [], one warning, no crash, no command sent.
  • Real unit, a standalone AC on my dev instance: it binds as before ("Status requests are limited to 29 columns", "Bound to device", no errors). set_temperature 22 shows 22 on the first read, with no hold, no extra poll and no debug line about an unconfirmed value. After that I set it back to 21.
  • pytest: 249 passed (227 on 5.0-dev), in a python:3.14-slim container.
    • tests/test_discovery_vrf.py:
      • union of 4 and 3 units
      • a gateway that answers only the generic key form, with subCnt=1
      • each form is sent with its own key: device, generic, device
      • a gateway that answers only subDev
      • a gateway that answers no form
      • gateways handled side by side
      • V2
    • tests/test_device_state.py and tests/test_device.py, with a fake clock:
      • stale read
      • confirmation
      • TTL expiry
      • rejected command
      • a new hold resets the TTL
      • pending against held
      • beeper
      • remove()
      • a sub-unit behind a stale gateway is held, a standalone unit is never held, and a value the unit corrects shows at once
  • FakeVrfGateway answers each form on its own, with its own subset of units. The base fake can report stale state for N reads after a command, or ignore a command.
  • Ruff check and format are clean. Mypy reports nothing new in the changed files.
  • Pylint does not run outside the devcontainer, because it cannot load pylint_home_assistant.

Notes

  • No real VRF hardware was used here. The forms and their keys follow Fix VRF sub-device discovery: defer + concurrent subList, robust parsing #507, where they were captured on real gateways. The fakes model that description.
  • The follow-up poll in the coordinator has no automated test, because the suite does not cover the HA layer. A standalone unit never schedules it.
  • User ID: Fix VRF sub-device discovery: defer + concurrent subList, robust parsing #507 sends uid: 0 for the subDev form. This PR keeps the existing user_id argument, which is 0 for local discovery. If a W06 module needs uid: 0 when discovery runs together with the cloud, this needs a change.
  • Log noise: each form that gets no answer logs the transport's per-attempt warnings. A gateway that answers only some forms shows those on every discovery. I left the transport logging as it is.

Documentation

  • docs/protocol.md: new "VRF gateways" section with the forms, the key per form, the join, the stale cache and the hold.
  • docs/architecture.md: discovery, state model and coordinator.
  • docs/development.md: the new fake options.
  • docs/troubleshooting.md: two user entries, for missing indoor units and for a value that jumps back.

…onfirmed

Port of the VRF fixes that landed on master in #507 and #508. #454 needs no
port: 5.0 already gives each indoor unit its own device.

Sub-device list: a gateway answers one or more of three request forms,
depending on its WiFi module firmware (device key, generic key, subDev).
The old single hybrid form was never confirmed on hardware. V1 gateways
now get all three forms and the lists are joined by MAC; V2 gets the
device key form. The generic key form is encrypted with the device key
but answered with the generic key, so request_json() takes an optional
response_cipher. Gateways from a scan are handled side by side. On the
GR-Gcloud V3.2.M gateway from #507 the forms gave 4, 3 and 4 units,
joined 4.

Stale state: a gateway answers from a cache for a few seconds after a
command, so the read right after it showed the old value again. The
device state now holds sent values until the device reports them, for at
most 8 s. The coordinator polls again every 2 s while values are held. A
standalone unit confirms on the first read and gets no extra poll.

Suite: 247 passed (227 before).
The stale cache was only seen on VRF gateways. For a standalone unit a hold
has a cost: when the unit corrects a value it cannot take, for example an
unsupported swing mode, it reports its own value, and the hold kept the
refused value on screen for up to 8 s. Standalone units now behave as they
did before the hold.

To find out whether a standalone unit or the MQTT transport caches too,
every device now logs at debug level when the read right after a command
does not report the sent value, with the transport and whether it is a
sub-unit.

Suite: 249 passed.
RobHofmann added a commit that referenced this pull request Sep 23, 2026
#508 re-applies the commanded values over the read-back for up to 8 s, so a
VRF gateway's stale cache does not revert the UI. It did that for every
unit. A standalone unit is read directly, and when it corrects a value it
cannot take (for example an unsupported swing mode), it reports its own
value. The hold then kept the refused value on screen for up to 8 s,
where before the UI showed the corrected value at once.

The hold and the 2 s follow-up read now only apply when the device is a
sub-unit (sub MAC differs from the gateway MAC). Standalone units behave
as before #508. The same change went into the 5.0 port (#522).
The generic-key form is answered with the generic key, but its request used
the bound device key, so transport.request_json() needed a separate
response_cipher for this one case. A probe on a GR-Gcloud V3.2.M gateway
(PR 507) showed the gateway ignores the request pack of this form: device,
generic and random keys all got the same answer, 3 units, readable with the
generic key. The request now uses the generic key too, and response_cipher
is removed again. The form stays in the union, because it adds units on some
gateways.
@RobHofmann
RobHofmann merged commit 0871330 into 5.0-dev Sep 24, 2026
4 checks passed
@RobHofmann
RobHofmann deleted the fix/vrf-series branch September 24, 2026 10:08
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