Skip to content

vNext: Support Gree zone controllers - #552

Open
Twintop wants to merge 18 commits into
RobHofmann:5.0-devfrom
Twintop:zone-controller-support
Open

Twintop wants to merge 18 commits into
RobHofmann:5.0-devfrom
Twintop:zone-controller-support

Conversation

@Twintop

@Twintop Twintop commented Oct 10, 2026 •

Copy link
Copy Markdown

Context

This adds support for Gree zone controllers. It's part of porting the changes I made for my own zone controller (Gree LE60-13/GH) over to this project, and #547, #548 and #549 got things ready for it. It also picks up the DeviceType idea @p-monteiro suggested in #549. Two more PRs build on this one: a per mode memory for the zone target temperatures, and the same memory for every device. Background and the full plan: https://github.com/Twintop/ha-gree-zone-controller/blob/main/upstream/README.md

What a zone controller is

A zone controller drives the dampers of one ducted unit. Seen: LE60-13/GH with a ME31-00/C13 WiFi module, firmware 362001062617+U-W05SAV1.27.bin (the Braemar LE60 uses the same module). It answers the scan like a VRF gateway, with subCnt above zero, but its sub-units aren't indoor units:

  • Sub-unit 00 is the ducted unit. It answers Pow, Mod, WdSpd, AllErr and TemUnit, and StTem and StFahFlg always as 0. Its Pow is the power of the whole system.
  • Sub-units 01 to 08 are the zones. A zone answers Pow (the damper), StTem and StFahFlg (its target temperature), TemUnit, and a copy of Mod and WdSpd of the ducted unit.
  • No sub-unit answers SetTem, TemRec, TemUn or Add0.5.
  • Every sub-unit answers the info request with the name of the controller (GR-ZCntrlr_5000_02_..._EC), and the sub-device list calls every unit zone. The name and the 00 suffix of the ducted unit are fixed in the WiFi module firmware (checked v1.21, v1.27 and v1.28), not taken from the units.
  • StTem + 16 and StFahFlg work like SetTem and TemRec: in °C the bit is the half degree (the wall panel only shows whole degrees), in °F it's the same table. The unit is TemUnit (0 °C, 1 °F), not TemUn. Written to the ducted unit, the whole controller and the wall panel switch.

Symptoms on 5.0-dev

  • All sub-units are named zone, and the gateway device is labelled "VRF gateway".
  • The ducted unit numbers its modes 1 Cool, 2 Heat, 3 Dry, 4 Fan, 5 Auto. Read with the normal numbers, Fan shows as Heat, and Auto raises ValueError from OperationMode(5).
  • The ducted unit has no Tur column. Turbo is fan speed 6, so turbo is not offered, and fan speed 6 raises ValueError from FanSpeed(6).
  • Every zone gets a climate entity with the mode of the ducted unit, and no target temperature, because StTem is not polled.
  • Every fan change logs Property Quiet is unsupported on this device at error level. This happens on any unit without Quiet.
  • Not zone specific: after a device with a "connection failed" repair issue is removed, the next setup fails with RuntimeError: dictionary changed size during iteration, because cleanup_device_connection_issues() deletes from registry.issues while it loops over it.
  • Not zone specific: with debug logging on, the cloud login is logged in clear text: the login response with the session token and email, and the entry data with the email, password and token when an entry is set up or added through the UI. Entry diagnostics include the token and email.
  • With cloud and local discovery, only the AC unit is offered. The cloud lists the gateway and every sub-unit with one key, _filter_duplicate_devices_complete() keeps only the MAC ending in 00, and a local device is only offered when the cloud lists it too.

Change

Protocol layer:

  • DeviceType for every device, set after the info fetch: ac_unit (a standalone unit), vrf_unit (an indoor unit behind a VRF gateway), zone_controller (the ducted unit of a zone controller) and zone. A sub-unit whose name starts with ZONE_CONTROLLER_NAME_PREFIX belongs to a zone controller, and ZONE_CONTROLLER_AC_UNIT_SUFFIX tells the ducted unit from the zones. Nothing is added to the config entry, so there is no migration. The type is in the device diagnostics.
  • The target temperature, its bit and the unit are column groups, like the sensors: TARGET_TEMPERATURE_1/_2 (SetTem, StTem), TARGET_TEMPERATURE_BIT_1/_2 (TemRec, StFahFlg) and TARGET_TEMPERATURE_UNIT_1/_2 (TemUn, TemUnit). The new columns are in the default poll, and the first poll drops what a device doesn't answer, like any other column. _remove_unsupported_props() then keeps the first answered column of each group and saves it on the device, and the getters and setters use only that one. 0 is a valid value in all three groups (StTem 0 is 16 °C), so they use missing_value=-1. Units that answer SetTem, TemRec and TemUn keep using them.
  • invalidate_missing_property_group() returns the column it keeps, and the sensor groups are saved on the device the same way.
  • The bit is only sent to a device that answers one. That's no change in behaviour: on 5.0-dev, DeviceState.set() already dropped TemRec for a unit that doesn't report it. Only the is unsupported error log is gone.
  • The type is only used where the columns can't tell: the mode numbers and turbo of the zone controller, which entities a sub-unit gets, and the StTem/StFahFlg of the ducted unit, which are always 0 (the same as a zone at 16 °C), so it drops them.
  • operation_mode and set_operation_mode() convert the zone controller mode numbers.
  • Turbo is fan speed 6 on the ducted unit: feature_turbo, set_feature_turbo() and supports_property(FEAT_TURBO_MODE) handle that. Leaving Cool or Heat drops turbo to High.
  • A zone uses target_temperature and set_target_temperature() like any other device: StTem with an offset of 16 (TARGET_TEMPERATURE_OFFSETS), and the same SetTem/TemRec table in °C and °F. set_target_temperature() rounds °C to the nearest half degree and °F to whole degrees. A zone write sends StTem and StFahFlg together, because the controller keeps the old bit when only StTem comes in.
  • Discovery names the sub-units AC unit and Zone 1 to Zone 8, from the controller name and the MAC suffix.
  • Cloud discovery: a listed device that other devices point to with their pmac is a gateway. It's left out, and all its sub-units are kept. Devices without such a link go through the key check as before. A sub-unit's pmac is used as its MQTT controller MAC only when it's also the first 12 characters of its own MAC (the gateway, as on the zone controller). Otherwise the MAC rules stay as they are, and gree_extract_macs() is unchanged.
  • Cloud login: GreeCloudApi.login() only logs r, msg, uid and alive of the response, also in its errors. GreeDiscoveredDevice leaves the key and the account email out of its repr, which discovery logs.

Home Assistant layer:

  • A zone gets no climate entity. It gets a switch with the name of the zone, and a Target Temperature number that follows the controller's unit: 16 to 30 °C in half degrees, or 61 to 86 °F in whole degrees. Home Assistant shows it in your own unit.
  • The Temperature Units select shows up on the AC unit of a zone controller and switches the whole controller, wall panel included. Zones don't get their own.
  • The zone switch is the optional feature Zone Switch, so it can be left out for a zone that must always stay open. Turning such a zone off is refused by the controller or turns the whole system off. The feature is only offered on zones, and zones do not show the climate options.
  • GreeEntityDescription.device_filter limits an entity to some kinds of device when the props alone can't tell.
  • The gateway device of a zone controller is labelled "Zone controller", from the type of a bound sub-unit. With no bound sub-unit, an existing device keeps its label and a new one gets the VRF gateway label (_controller_label()).
  • Device options: when several sub-units are set up in one go, the next form only takes the mode lists and features of the one before, not its name, and never zone. A zone always starts with Zone Switch ticked, and the features default is filtered against what the device supports.
  • The climate entity only sets Quiet and Turbo on units that have them.
  • Number entities pass their value to the device as it is, and the device rounds. The humidity number keeps its int().
  • cleanup_device_connection_issues() loops over a copy of the issues.
  • TO_REDACT (device key, password, token, email) is used for the entry data in the setup log, the new-entry logs and the entry diagnostics. Lines that logged the email or the entry title log the user id or entry id instead.
  • en.json, icons.json, docs/protocol.md, docs/entities.md, docs/configuration.md, docs/architecture.md, docs/troubleshooting.md, docs/actions.md and supported-devices.md are updated.

Testing

  • pytest: 290 passed (255 on 5.0-dev, plus 35 new). tests/fakes/zone.py is a fake controller that behaves like the real one: subDev form only, no cid, per unit columns, mode, fan and unit mirrored from the ducted unit to the zones, and an empty result above the column limit.
  • Ruff 0.17.0 passes. Mypy (run with Home Assistant 2026.10 installed, using the repo's mypy.ini) and Pylint show nothing new compared with 5.0-dev.
  • Real device, LE60-13/GH on Home Assistant 2026.10:
    • Entities and states are right (no target temperature or swing on the AC unit, turbo offered). The gateway shows as "Zone controller" with the AC unit and zones linked. A zone target temperature, a zone switch, the fan speed and Heat with Turbo all reach the controller.
    • After the first poll, the AC unit polls only Pow, Mod, WdSpd and TemUnit, and each zone keeps StTem, StFahFlg and TemUnit.
    • Half degrees: a zone set to 18.5 °C goes out as StTem 2 with StFahFlg 1, and the controller keeps it.
    • °F: the Temperature Units select switches the controller and the wall panel to °F, and a zone set to 21 °C in Home Assistant goes out as 70 °F (StTem 5, StFahFlg 1). Values set on the panel (64, 66 and 67 °F) read back right.
    • A zone set to 16 °C (StTem 0) keeps its target temperature after a restart.
    • Cloud-only entry added through the UI: the bind over MQTT gets no reply, while the Gree+ app controls it over the cloud. The ME31-00/C13 firmware has no MQTT client, so zone controllers are controlled locally (noted in the docs).
    • Cloud and local entry added through the UI: the AC unit and the four zones are offered with the names from the Gree app, all of them bind locally with the key from the cloud, and a zone target temperature reaches the controller. The debug log of the flow has no token, password or email in it.
    • An offline VRF gateway in the same entry no longer breaks setup, and removing it again cleans up its repair issue and its device.
    • No errors in the log.
  • probe_device_limits() measured 29 columns. The module answers 30 with an empty result, and requests far above that (about 40 columns and more) made it reboot.

A Gree zone controller (seen: LE60-13/GH with a ME31-00/C13 WiFi module)
drives the dampers of one ducted unit. It answers like a VRF gateway, but
its sub-units are the ducted unit (MAC suffix 00) and up to eight zones
(01 to 08), and they behave differently from VRF indoor units.

Protocol layer:
- GreeDevice detects the role of a sub-unit after the info fetch: it
  reports the name of the controller (GR-ZCntrlr...), and the MAC suffix
  tells the ducted unit from a zone.
- The ducted unit numbers its modes 1 Cool, 2 Heat, 3 Dry, 4 Fan, 5 Auto.
  Before this, Fan showed as Heat and Auto raised. operation_mode and
  set_operation_mode convert for every unit with a zone role.
- It has no Tur column. Turbo is fan speed 6, only in Cool and Heat.
- A zone has a target temperature, StTem, in degrees Celsius minus 16.
  Only zones poll it, so other units send the same columns as before.
- Discovery names the sub-units "AC unit" and "Zone 1" to "Zone 8"
  instead of "zone" for every one of them.

Home Assistant layer:
- A zone gets no climate entity. It gets a switch with the name of the
  zone and a Target Temperature number.
- The switch is the optional feature Zone Switch, so it can be left out
  for a zone that must always stay open. It is only offered on zones, and
  zones do not show the climate options.
- GreeEntityDescription.device_filter limits an entity to some kinds of
  device when the props alone cannot tell.

Tested against a real LE60-13/GH (firmware 362001062617 V1.27) and against
a new fake controller in tests/fakes/zone.py.
ZoneRole only knew the sub-units of a zone controller. It is now DeviceType
and every device gets one after the info fetch: ac_unit (a standalone
unit), vrf_unit (an indoor unit behind a VRF gateway), zone_controller (the
ducted unit, sub-unit 00 of a zone controller) and zone. Only the two zone
controller types change behaviour, so standalone and VRF units work as
before. The type is in the device diagnostics. Suggested by @p-monteiro in
RobHofmann#549.

The controller device of a zone controller was labelled "VRF gateway". It
is now "Zone controller", taken from the type of a bound sub-unit. With no
bound sub-unit the registry keeps what it has. The lookup moved into a
small helper, so reconcile_vrf_controllers stays simple.
Every fan mode change called set_feature_quiet and set_feature_turbo, also
on a unit without those columns. The state then logged "Property Quiet is
unsupported on this device" at error level on every change. Seen on the
ducted unit of a zone controller, but any unit without Quiet or Turbo did
it. Units that have them work as before.

@RobHofmann RobHofmann left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, and for taking in the DeviceType idea. I ran the PR in my dev instance against my standalone test unit (encryption v1). The start is clean: same 29-column probe, Device type: ac_unit, and the poll still sends the same 20 columns, without StTem. A fan change still sends Quiet and Tur as before. pytest gives 272 passed. I could not test the zone parts myself, because I have no zone controller.

One blocker and one question.

1. translation_key=UNDEFINED breaks setup when no sub-unit is bound (blocker)

In reconcile_vrf_controllers(), when the controller device already exists and _controller_info() finds no bound sub-unit, model and translation_key stay UNDEFINED and go into async_get_or_create(). That works for model, but translation_key is typed str | None, and Home Assistant does this:

if translation_key:
    name = self._resolve_translated_name(config_entry, translation_key, translation_placeholders)

UNDEFINED is truthy, so the name lookup falls back to the key itself and then calls .format() on it. I ran Home Assistant's own _resolve_translated_name() with UNDEFINED in my dev container:

'vrf_controller' -> 'vrf_controller'
<UndefinedType._singleton: 0> -> RAISES AttributeError 'UndefinedType' object has no attribute 'format'

The call in async_setup_entry() has no try around it. So when a local VRF gateway is offline at Home Assistant start, I expect the whole entry to fail, including standalone units in the same entry, and without a retry because it is not ConfigEntryNotReady. That also hits plain VRF users, not only zone controllers. I did not run that full scenario end to end; the crash itself is reproduced. Mypy flags the same line (helpers.py:475, arg-type), and it is the only new mypy error in the changed files.

A simple fix: only pass model, translation_key and translation_placeholders when the type is known, for example by building the kwargs and leaving those keys out when they are UNDEFINED. A test with an existing controller device and no bound sub-unit would cover it.

2. Zone Switch default in a new setup (question)

async_step_device_options() uses the options of the previous sub-unit of the same controller as defaults (_options_by_controller), and the features default is defaults.get(CONF_FEATURES, valid_features). If the AC unit is configured first, its feature list has no zone, so as far as I can read it, Zone 1 opens with Zone Switch unticked. The other way round, the AC unit could get zone as a default that is not in its options. Did you see this on your controller, or did you tick it by hand? If it is real, filtering the copied defaults against valid_features would solve both cases.

When an existing controller device had no bound sub-unit at start, for
example a VRF gateway that is offline, reconcile_vrf_controllers passed
UNDEFINED as translation_key. Home Assistant types it str | None and checks
it with "if translation_key:", so it tried to format UNDEFINED and the
whole entry failed with AttributeError, with no retry. Found in review.

The model and the name arguments are now left out when the type is not
known, so the registry keeps what it has. A new device still gets the VRF
label. The decision moved into _controller_label.

Reproduced on Home Assistant 2026.10 with a VRF device whose gateway never
answers: before, the entry stayed in setup_error with every entity
unavailable; after, the entry loads and the other devices work.
When the device options step starts from the options of the device of the
same controller set up before, it copied all of them. So each sub-unit's
form showed the name of the device before it, and the features came from
a device of another kind: a zone after the AC unit started with Zone
Switch off, and the AC unit after a zone got a "zone" default it cannot
take. Found in review.

- Only the mode and feature lists are copied now, the same lists the
  copy by model already used. The name stays the device's own.
- The Zone Switch is never copied, because only an always open zone goes
  without it: a zone starts with it on, any other device without it.
- The feature default is filtered against the features the device has.
cleanup_device_connection_issues deleted issues from the issue registry
while it looped over registry.issues. After a device that had a "connection
failed" issue was removed from the config, the next setup deleted that
issue and then failed with "RuntimeError: dictionary changed size during
iteration", so the entry stayed in setup_error. A second stale issue was
never reached.

Found while testing the controller label fix: a VRF device whose gateway
never answers was removed from the YAML again, and the entry failed at the
next start. A manual reload worked, because the issue was already gone.
@Twintop

Twintop commented Oct 10, 2026

Copy link
Copy Markdown
Author

Thanks for testing it on your standalone unit. Both points are fixed, plus a bug I found while testing.

1. translation_key=UNDEFINED (blocker)

Confirmed end to end on Home Assistant 2026.10: with a VRF device whose gateway never answers in the same entry as the zone controller, the entry failed on every start, all its entities were unavailable, and there was no retry.

Fixed in 5bfa6cb. The new _controller_label() leaves out the model and name when no sub-unit is bound and the device already exists, so it keeps its label. A new device gets the VRF gateway label as before. The entry now loads.

Mypy missed it on my side because I had only tested in my Home Assistant instance, not with a local one in the dev environment, so every Home Assistant type was Any. I now run it against a local install with the repo's mypy.ini, and there are no new errors compared with 5.0-dev.

Question: A test for reconcile_vrf_controllers() needs Home Assistant in the test environment, and the suite only imports aiogree today. If you like, I can add pytest-homeassistant-custom-component in a separate PR, with this case as the first test.

2. Zone Switch default (question)

It is real. I had ticked it by hand. My sub-units report no ModelType, so the form falls back to the previous sub-unit's options, and that also copied its name. Fixed in d6abdcd:

  • only the mode lists and features are copied, the same keys as the model path;
  • zone is never copied, so a zone always starts with Zone Switch ticked and other units never get it;
  • the features default is filtered against valid_features.

3. Bug found while testing (already on 5.0-dev)

cleanup_device_connection_issues() deleted repair issues while looping over registry.issues. After a device with a "connection failed" issue was removed, the next start failed with RuntimeError: dictionary changed size during iteration. Fixed in d666fe1 by looping over a copy. Checked on my install: the entry loads, and both the issue and the stale gateway device are removed.

@Twintop
Twintop requested a review from RobHofmann October 10, 2026 12:47
Comment thread custom_components/gree_custom/aiogree/api.py Outdated
Comment thread custom_components/gree_custom/aiogree/device.py Outdated
The name prefix, the MAC suffix of the ducted unit and the two model ids
in the sub-device list were separate constants, so another model with
other values meant changing the code that uses them. Asked in review: are
all zone controllers 5000/5001?

The values come from the WiFi module, not from the units: they are fixed
in its firmware (v1.21, v1.27 and v1.28 checked). Every sub-unit answers
the info request with the name and model id (3017) of the controller, so
detection keeps using the name and only discovery uses the model ids.

ZONE_CONTROLLER_MODELS now has one entry per model. The entry with the
longest matching name prefix is used, so the order does not matter and a
more specific entry wins over a general one. The table has a general
GR-ZCntrlr entry, and a GR-ZCntrlr_5000_ entry for the LE60-13/GH as an
example of a more specific one.
Asked in review. A zone now reads and sets its target through
target_temperature and set_target_temperature, like every other device,
and the zone number uses them. The zone-only properties are gone.

The column stays StTem, in whole degrees Celsius minus 16, clamped to 16
to 30. target_temperature is None until a zone is read, so its number
shows unknown and not 0. A unit gives a value as before.
The wall panel can show Fahrenheit, and the controller has its own
columns for it: TemUnit for the unit (not TemUn) and StFahFlg for the
Fahrenheit bit of a zone. Checked on a LE60-13/GH by switching TemUnit
over WiFi and setting zones on the panel and over WiFi: StTem + 16 and
StFahFlg follow the same table as SetTem and TemRec (64 F is 2 and 0,
66 F is 3 and 0, 67 F is 3 and 1, 70 F is 5 and 1). When only StTem is
sent, the controller keeps the old bit, so both are always sent.

- The sub-units of a zone controller poll TemUnit, zones also StFahFlg
  (ZONE_CONTROLLER_PROPS). Other units send the same columns as before.
- target_temperature_unit uses TemUnit on a zone controller, so the
  Temperature Units select works on its ducted unit and switches the
  whole controller, the wall panel included. Zones get no select.
- The zone number follows the unit: 16 to 30 C or 61 to 86 F. Home
  Assistant shows it in the user's unit.
- A number value is rounded, not cut, before it is sent: Home Assistant
  converts 21 C to 69.8 F, which must go out as 70.
Asked in review: GR-ZCntrlr is a zone controller whatever the model ids
are, so why the table? Detection already went by the name and the MAC
suffix only. The model ids were only used by discovery, to name the
sub-units, and discovery has the name of the controller too: the scan
reply of the gateway.

ZONE_CONTROLLER_MODELS and the model ids are gone. Two constants are left,
ZONE_CONTROLLER_NAME_PREFIX and ZONE_CONTROLLER_AC_UNIT_SUFFIX, and
discovery names the sub-units from the controller name and the MAC
suffix. A test gives the sub-units other model ids and still gets AC unit
and Zone 1 to Zone 4.
Asked in review: StTem, StFahFlg and TemUnit were used because a device
is a zone controller, but other units may answer them too. The sub-units
of the zone controller answer none of SetTem, TemRec, TemUn and Add0.5,
and other units do not answer the new columns. So the columns now come
from what a device answers, like for the features:

- StTem, StFahFlg and TemUnit are in the default poll. The first poll
  drops them on a unit that does not answer them, like any other column,
  so after it every other unit sends the same columns as before. The
  type-based add_polled() is gone.
- A device uses TemUn when it answers it, and TemUnit only without it.
- A zone sends its bit only when it answers StFahFlg.

The type is still used where the columns cannot tell: the mode numbers
and turbo of a zone controller, and which entities a sub-unit gets. The
ducted unit answers StTem too, always with 0, so only a zone uses StTem
as its target temperature.
Asked in review: 25.5 C is valid for units with half degrees, but the
number entity rounded every value to a whole number. The rounding now
happens in set_target_temperature, as it already did for units: Celsius
to the nearest half degree, Fahrenheit to whole degrees. The number
entity passes the value on as it is. The humidity number keeps its int().

A zone in Celsius takes half degrees too: StFahFlg is the half degree,
like TemRec. Checked on a LE60-13/GH: StTem 2 with StFahFlg 1 was still
there after a minute and after using the wall panel, which shows only
whole degrees (18). So a zone now uses the same SetTem/TemRec table in
both units, and its number steps by 0.5 in Celsius and 1 in Fahrenheit.
@p-monteiro

p-monteiro commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Thanks for the changes; it's better now.
Some more changes:

Treat the props like the sensors:

TARGET_TEMPERATURE_1 = "SetTem"
TARGET_TEMPERATURE_2 = "StTem"

The same for the temperature bit and temperature unit

Then call invalidate_missing_property_group() in the same place (in _remove_unsupported_props()) for all 3 groups (target, bit, unit).

This should give a valid _props_to_poll with only one of them. It should also remove StTem from the zone controller, as a value of 0 is invalid for temperature. If 0 is a valid value for temperature because of the MIN_TEMP_C - offset, set the missing_value for it to something like -1. None values are removed in invalidate_missing_properties() before that.

Then, in the device properties, do your logic based on which props are supported.
Usually, (SetTem, TemRec) or (StTem, StFahFlg) are always set at the same time, if they are supported.

In the near future, we will gate half-degrees by a feature option that relies on the device responding to bit or Add0.5, so this keeps it ready for that.

Check what we do for temperature sensors on the device, which matches the case of one property being provided by multiple fields.
Now that you're touching that code, it would be nice to change invalidate_missing_property_group() to return the preferred GreeProp that survived the invalidation, if any, or None.
Then, when we call it, we can save the props in device.py for each:

self._indoor_temp_sensor_prop = invalidate_missing_property_group(...)
self._target_temp_prop = invalidate_missing_property_group(...)
(...)

So that we don't need to check support in the device properties and can directly get/set the correct ones. And adjust the target temperature and temperature units accordingly to respond to the correct prop and logic.

@p-monteiro

Copy link
Copy Markdown
Contributor

Don't ask how I know this, but only mids 5000/5001 answer to those props.
Unfortunately, some units return an empty mid column and no name (my case), so we can't assume features based on that.
But I don't think this invalidates my previous comment; just work with props as suggested and base the assumptions on what answers or not.

Asked in review: invalidate_missing_property_group() now returns the
property it keeps, or None when none of them has a value. The device
saves it for the indoor temperature, the outdoor temperature and the
humidity, and reads only that column, instead of looking for the one
that is supported every time. Nothing changes for any unit.
Asked in review: the target temperature, its bit and the unit each have
two columns, now numbered like the sensors: TARGET_TEMPERATURE_1 and _2
(SetTem, StTem), TARGET_TEMPERATURE_BIT_1 and _2 (TemRec, StFahFlg) and
TARGET_TEMPERATURE_UNIT_1 and _2 (TemUn, TemUnit). The ZONE_ names are
gone. _remove_unsupported_props() keeps the first column of each group
that the device answers and saves it, and the device reads and writes
only that one, without checking the device type.

0 is a valid value in all three groups: StTem 0 is 16 C, the bits are 0
or 1, and unit 0 is Celsius. So they use missing_value=-1, and only a
column that the device does not answer is dropped. With 0, a zone at
16 C would lose its target temperature, and a unit with TemRec 0 its
bit.

The ducted unit of a zone controller answers StTem and StFahFlg with 0,
the same as a zone at 16 C. Only the type tells them apart, so the
ducted unit drops both, and polls two columns less.

The bit is only sent to a device that answers one. Before, TemRec always
went out with SetTem. A unit that does not report TemRec gets whole
degrees.

The offset of StTem is in TARGET_TEMPERATURE_OFFSETS. The send order has
the new columns next to the old ones: unit, bit, temperature. The docs
said the default poll has 30 props; it has 33 since StTem, StFahFlg and
TemUnit were added.
@Twintop

Twintop commented Oct 14, 2026

Copy link
Copy Markdown
Author

That's a much cleaner way to do it, thanks for your guidance!

Done in two commits:

  • b922a26: invalidate_missing_property_group() now returns the prop it keeps (or None), and the device saves it for the indoor/outdoor temperature and humidity groups, so those getters just read the saved prop.
  • a788528: the target temperature, bit and unit are groups now, numbered like the sensors: TARGET_TEMPERATURE_1/2 (SetTem/StTem), TARGET_TEMPERATURE_BIT_1/2 (TemRec/StFahFlg) and TARGET_TEMPERATURE_UNIT_1/2 (TemUn/TemUnit). All three get invalidated in _remove_unsupported_props(), the survivors are saved on the device, and the target temperature and unit getters/setters use whichever ones were saved. No more type checks or supports() calls in there, so it's ready for gating half degrees on the bit.

A few things I ran into:

  • 0 is valid in all three groups. StTem 0 is 16 °C (and 0 with the bit is 61 °F), the bits are 0/1, and unit 0 is °C. So they use missing_value=-1 like you suggested. With the default 0, a zone sitting at 16 °C at startup would lose its target temperature, and a normal unit with TemRec 0 would lose its bit. I checked it on the real controller: set a zone to 16 °C, restarted HA, and it kept its target temperature.
  • The ducted unit's StTem. Since 0 is valid, its StTem/StFahFlg (always 0) look exactly like a zone at 16 °C, so the answers alone can't tell them apart. That's the one spot where the type still decides a column: the ducted unit drops both in _remove_unsupported_props(). Its poll is now just Pow, Mod, WdSpd and TemUnit. Other than that, the type is only used for the mode numbers, turbo as fan speed 6, and which entities a sub-unit gets.
  • One behaviour change: the bit only goes out to a device that answers it. On 5.0-dev, TemRec was always sent with SetTem, even to a unit that doesn't report it, so a unit like that now gets whole degrees. Units that answer TemRec work as before. Happy to keep always sending TemRec for SetTem units if you'd rather.

Tests cover each group keeping the old columns when a unit answers both and the new ones when it doesn't, a zone at 16 °C, the ducted unit dropping its two, and a unit without TemRec getting SetTem alone.

And thanks for the mid info, that matches the firmware: 5000/5001 are hardcoded in the module's sub-device list. Since 6d4c77b nothing uses the mids (detection is name + suffix), and the temperature columns now only depend on what answers, so an empty mid doesn't matter there.

@p-monteiro

p-monteiro commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Code looks clean. Can you test it end-to-end in a cloud-only entry added via UI?
If possible, please paste the log of the cloud discovery here; we'd like to have some cloud discovery examples, and Zone controllers seem like a good thing to have examples of.

Asked in review: test a cloud-only entry. Cloud discovery offers only the
AC unit, and the bind over MQTT gets no reply, while the Gree+ app can
control the controller over the cloud. The firmware of the ME31-00/C13
WiFi module has no MQTT client; it only uses the older Gree TCP cloud.
entities.md and supported-devices.md now say that a zone controller is
local only.

Also from review: has_target_temperature_bit matters in Fahrenheit too.
Without the bit, a Fahrenheit target moves in steps of about 2.
@Twintop

Twintop commented Oct 14, 2026

Copy link
Copy Markdown
Author

Tested it end to end with a cloud-only entry added through the UI (Gree Cloud account only, Australia region). Short version: cloud discovery only offers the AC unit, and cloud control doesn't work for this controller at all, because its WiFi module doesn't speak MQTT. The debug log of the whole flow is at the bottom.

Discovery. The account lists 6 devices: the gateway (12-char MAC, empty pmac, mid 50), the AC unit (…00, mid 5000) and the four zones (…01 to …04, mid 5001). Every sub-unit has pmac = the gateway MAC, so pmac looks like exactly what the TODO in gree_extract_macs() is asking about. All 6 share one key, so _filter_duplicate_devices_complete() keeps only …00 and drops the gateway and all four zones (the Filtering out non-responsive device(s) line in the log).

Even if the zones made it through, gree_extract_macs() only treats a 14-char MAC ending in 00 as a sub-unit, so …01 would become its own controller. Using pmac for the controller MAC, and dropping a device only when it's another device's pmac, would handle both. Happy to put that in a separate small PR with this list as a test fixture.

Control. The AC unit got to the bind step, but the bind over MQTT never got a reply (v1 twice, then v2, 10 s each), so the flow stopped with "Unable to bind the device". The controller was online in Gree+ and I could control it from the app on cellular only, so it's definitely connected to Gree's cloud. The catch is the ME31-00/C13 firmware: its cloud code only talks to Gree's older TCP cloud (dis.gree.com, then a data server socket), and there's no MQTT client in it at all. So with the MQTT transport, this controller is local only. I've noted that in supported-devices.md and the zone controller section of entities.md (e21a986).

One more thing I noticed: with debug logging on, GreeCloudApi.login() logs the whole login response, decrypted and parsed, including the session token and the account email in clear text. troubleshooting.md asks users to post the debug log from this exact flow, so it might be worth redacting those two lines like the device list. They're redacted in the log below.

And thanks for the docstring catch, that's fixed in e21a986 too: has_target_temperature_bit now says it's needed for every whole degree in °F, not only half degrees in °C.

Debug log of the cloud-only setup flow

Redacted: the login response, the Gree user and home ids, the home name, the account email, the device key, location, SSID and bind time in the device list, and the encrypted payloads.

2026-10-14 21:11:10.372 DEBUG (MainThread) [custom_components.gree_custom.aiogree.cloud_api] Sending request to https://augrih.gree.com/App/UserLoginV2
2026-10-14 21:11:10.771 DEBUG (MainThread) [custom_components.gree_custom.aiogree.cloud_api] Login response (decrypted): <redacted: session token and account details>
2026-10-14 21:11:10.771 DEBUG (MainThread) [custom_components.gree_custom.aiogree.cloud_api] Login response (parsed): <redacted: session token and account details>
2026-10-14 21:11:10.771 INFO (MainThread) [custom_components.gree_custom.aiogree.cloud_api] Successfully logged in as user <uid>
2026-10-14 21:11:11.053 DEBUG (MainThread) [custom_components.gree_custom.aiogree.transport_mqtt] Connected to MQTT broker Australia:1984
2026-10-14 21:11:11.053 DEBUG (MainThread) [custom_components.gree_custom.aiogree.cloud_api] Sending request to https://augrih.gree.com/App/GetHomes
2026-10-14 21:11:11.088 DEBUG (MainThread) [custom_components.gree_custom.aiogree.cloud_api] {'home': [{'id': <home id>, 'name': <home name>, 'ownerId': <uid>, 'ctime': <removed>, 'noteName': '', 'province': '', 'city': '', 'district': '', 'adminStatus': 2}], 'r': 200}
2026-10-14 21:11:11.088 INFO (MainThread) [custom_components.gree_custom.aiogree.cloud_api] Found 1 homes
2026-10-14 21:11:11.089 DEBUG (MainThread) [custom_components.gree_custom.aiogree.cloud_api] Sending request to https://augrih.gree.com/App/GetDevsInRoomsOfHomeV2
2026-10-14 21:11:11.155 INFO (MainThread) [custom_components.gree_custom.aiogree.cloud_api] Found 6 devices in home <home id>
2026-10-14 21:11:11.155 DEBUG (MainThread) [custom_components.gree_custom.aiogree.cloud_api] Raw cloud device list: [{'mac': '9424b8bc5a5f00', 'pmac': '9424b8bc5a5f', 'name': 'Zone Controller', 'catalog': '', 'brand': 'gree', 'mid': '5000', 'subdivCode': '', 'vender': '2', 'key': '<redacted>', 'barCode': '', 'longitude': '<removed>', 'latitude': '<removed>', 'altitude': '<removed>', 'city': '', 'bindTime': '<removed>', 'selfLearning': 0, 'ssid': '<removed>', 'autoRepair': 0, 'authorize': '', 'thirdpartyId': '', 'ver': 'V1.2.1', 'prodModel': '', 'hid': '', 'institutionCodeSN': '', 'devNoteName': '', 'isHidden': 0, 'devExt': '', 'regionalControl': '', 'homeSort': 0}, {'mac': '9424b8bc5a5f02', 'pmac': '9424b8bc5a5f', 'name': 'Zone2', 'catalog': '', 'brand': 'gree', 'mid': '5001', 'subdivCode': '', 'vender': '2', 'key': '<redacted>', 'barCode': '', 'longitude': '<removed>', 'latitude': '<removed>', 'altitude': '<removed>', 'city': '', 'bindTime': '<removed>', 'selfLearning': 0, 'ssid': '<removed>', 'autoRepair': 0, 'authorize': '', 'thirdpartyId': '', 'ver': 'V1.2.1', 'prodModel': '', 'hid': '', 'institutionCodeSN': '', 'devNoteName': '', 'isHidden': 0, 'devExt': '', 'regionalControl': '', 'homeSort': 1}, {'mac': '9424b8bc5a5f03', 'pmac': '9424b8bc5a5f', 'name': 'Zone3', 'catalog': '', 'brand': 'gree', 'mid': '5001', 'subdivCode': '', 'vender': '2', 'key': '<redacted>', 'barCode': '', 'longitude': '<removed>', 'latitude': '<removed>', 'altitude': '<removed>', 'city': '', 'bindTime': '<removed>', 'selfLearning': 0, 'ssid': '<removed>', 'autoRepair': 0, 'authorize': '', 'thirdpartyId': '', 'ver': 'V1.2.1', 'prodModel': '', 'hid': '', 'institutionCodeSN': '', 'devNoteName': '', 'isHidden': 0, 'devExt': '', 'regionalControl': '', 'homeSort': 2}, {'mac': '9424b8bc5a5f04', 'pmac': '9424b8bc5a5f', 'name': 'Zone4', 'catalog': '', 'brand': 'gree', 'mid': '5001', 'subdivCode': '', 'vender': '2', 'key': '<redacted>', 'barCode': '', 'longitude': '<removed>', 'latitude': '<removed>', 'altitude': '<removed>', 'city': '', 'bindTime': '<removed>', 'selfLearning': 0, 'ssid': '<removed>', 'autoRepair': 0, 'authorize': '', 'thirdpartyId': '', 'ver': 'V1.2.1', 'prodModel': '', 'hid': '', 'institutionCodeSN': '', 'devNoteName': '', 'isHidden': 0, 'devExt': '', 'regionalControl': '', 'homeSort': 3}, {'mac': '9424b8bc5a5f', 'pmac': '', 'name': 'Zone Controller', 'catalog': '', 'brand': 'gree', 'mid': '50', 'subdivCode': '', 'vender': '2', 'key': '<redacted>', 'barCode': '', 'longitude': '<removed>', 'latitude': '<removed>', 'altitude': '<removed>', 'city': '', 'bindTime': '<removed>', 'selfLearning': 0, 'ssid': '<removed>', 'autoRepair': 0, 'authorize': '', 'thirdpartyId': '', 'ver': 'V1.2.1', 'prodModel': '', 'hid': '', 'institutionCodeSN': '', 'devNoteName': '', 'isHidden': 0, 'devExt': '', 'regionalControl': '', 'homeSort': 4}, {'mac': '9424b8bc5a5f01', 'pmac': '9424b8bc5a5f', 'name': 'Zone1', 'catalog': '', 'brand': 'gree', 'mid': '5001', 'subdivCode': '', 'vender': '2', 'key': '<redacted>', 'barCode': '', 'longitude': '<removed>', 'latitude': '<removed>', 'altitude': '<removed>', 'city': '', 'bindTime': '<removed>', 'selfLearning': 0, 'ssid': '<removed>', 'autoRepair': 0, 'authorize': '', 'thirdpartyId': '', 'ver': 'V1.2.1', 'prodModel': '', 'hid': '', 'institutionCodeSN': '', 'devNoteName': '', 'isHidden': 0, 'devExt': '', 'regionalControl': '', 'homeSort': 5}]
2026-10-14 21:11:11.155 DEBUG (MainThread) [custom_components.gree_custom.aiogree.cloud_api] Filtering out non-responsive device(s) without '00': ['9424b8bc5a5f02', '9424b8bc5a5f03', '9424b8bc5a5f04', '9424b8bc5a5f', '9424b8bc5a5f01']
2026-10-14 21:11:11.155 INFO (MainThread) [custom_components.gree_custom.aiogree.cloud_api] Filtered out 5 duplicate device(s)
2026-10-14 21:11:11.155 INFO (MainThread) [custom_components.gree_custom.aiogree.cloud_api] Found total of 1 devices across all homes
2026-10-14 21:11:11.155 INFO (MainThread) [custom_components.gree_custom.config_flow] Discovered 1 devices from the cloud account: <email>
2026-10-14 21:11:11.155 DEBUG (MainThread) [custom_components.gree_custom.aiogree.api] Merged devices: 1
2026-10-14 21:11:11.156 DEBUG (MainThread) [custom_components.gree_custom.aiogree.cloud_api] HTTP session closed
2026-10-14 21:12:39.859 INFO (MainThread) [custom_components.gree_custom.aiogree.device] [9424b8bc5a5f00] Initializing the GREE Device API
2026-10-14 21:12:39.859 ERROR (MainThread) [custom_components.gree_custom.aiogree.device] No controller MAC provided for local transport
2026-10-14 21:12:39.859 INFO (MainThread) [custom_components.gree_custom.aiogree.device_api_client] [9424b8bc5a5f:MQTT(<uid>, AU)] Starting binding procedure
2026-10-14 21:12:39.901 DEBUG (MainThread) [custom_components.gree_custom.aiogree.transport_mqtt] Subscribed to topic: response/9424b8bc5a5f/#
2026-10-14 21:12:39.935 DEBUG (MainThread) [custom_components.gree_custom.aiogree.transport_mqtt] Subscribed to topic: status/9424b8bc5a5f/#
2026-10-14 21:12:39.964 DEBUG (MainThread) [custom_components.gree_custom.aiogree.transport_mqtt] Subscribed to topic: connect/9424b8bc5a5f
2026-10-14 21:12:39.964 INFO (MainThread) [custom_components.gree_custom.aiogree.api] [MQTT(<uid>, AU)] Trying to perform binding. Prefer provided version (1) and key (<redacted>)
2026-10-14 21:12:39.964 DEBUG (MainThread) [custom_components.gree_custom.aiogree.api] [MQTT(<uid>, AU)] Requesting bind to device with encryption key v1
2026-10-14 21:12:39.964 DEBUG (MainThread) [custom_components.gree_custom.aiogree.api] Bind Pack: {'t': 'bind', 'uid': <uid>, 'mac': '9424b8bc5a5f'}
2026-10-14 21:12:39.964 DEBUG (MainThread) [custom_components.gree_custom.aiogree.api] Payload: {'cid': 'app', 'i': 1, 't': 'pack', 'pack': {'t': 'bind', 'uid': <uid>, 'mac': '9424b8bc5a5f'}, 'tcid': '9424b8bc5a5f', 'uid': <uid>}
2026-10-14 21:12:39.965 DEBUG (MainThread) [custom_components.gree_custom.aiogree.cipher] Encrypting data (V1): {"t": "bind", "uid": <uid>, "mac": "9424b8bc5a5f"}
2026-10-14 21:12:39.965 DEBUG (MainThread) [custom_components.gree_custom.aiogree.cipher] Encrypted data (V1): <ciphertext omitted>
2026-10-14 21:12:39.965 DEBUG (MainThread) [custom_components.gree_custom.aiogree.helpers] Before Encryption(payload=137, pack=52) | After Encryption(payload=88, pack=175)
2026-10-14 21:12:50.000 ERROR (MainThread) [custom_components.gree_custom.aiogree.api] [MQTT(<uid>, AU)] Error in bind request using encryption key with version 1
2026-10-14 21:12:50.002 DEBUG (MainThread) [custom_components.gree_custom.aiogree.api] [MQTT(<uid>, AU)] Requesting bind to device with encryption key v1
2026-10-14 21:12:50.002 DEBUG (MainThread) [custom_components.gree_custom.aiogree.api] Bind Pack: {'t': 'bind', 'uid': <uid>, 'mac': '9424b8bc5a5f'}
2026-10-14 21:12:50.002 DEBUG (MainThread) [custom_components.gree_custom.aiogree.api] Payload: {'cid': 'app', 'i': 1, 't': 'pack', 'pack': {'t': 'bind', 'uid': <uid>, 'mac': '9424b8bc5a5f'}, 'tcid': '9424b8bc5a5f', 'uid': <uid>}
2026-10-14 21:12:50.002 DEBUG (MainThread) [custom_components.gree_custom.aiogree.cipher] Encrypting data (V1): {"t": "bind", "uid": <uid>, "mac": "9424b8bc5a5f"}
2026-10-14 21:12:50.003 DEBUG (MainThread) [custom_components.gree_custom.aiogree.cipher] Encrypted data (V1): <ciphertext omitted>
2026-10-14 21:12:50.003 DEBUG (MainThread) [custom_components.gree_custom.aiogree.helpers] Before Encryption(payload=137, pack=52) | After Encryption(payload=88, pack=175)
2026-10-14 21:13:00.036 ERROR (MainThread) [custom_components.gree_custom.aiogree.api] [MQTT(<uid>, AU)] Error in bind request using encryption key with version 1
2026-10-14 21:13:00.038 DEBUG (MainThread) [custom_components.gree_custom.aiogree.api] [MQTT(<uid>, AU)] Requesting bind to device with encryption key v2
2026-10-14 21:13:00.038 DEBUG (MainThread) [custom_components.gree_custom.aiogree.api] Bind Pack: {'t': 'bind', 'uid': <uid>, 'mac': '9424b8bc5a5f', 'cid': '9424b8bc5a5f'}
2026-10-14 21:13:00.038 DEBUG (MainThread) [custom_components.gree_custom.aiogree.api] Payload: {'cid': 'app', 'i': 1, 't': 'pack', 'pack': {'t': 'bind', 'uid': <uid>, 'mac': '9424b8bc5a5f', 'cid': '9424b8bc5a5f'}, 'tcid': '9424b8bc5a5f', 'uid': <uid>}
2026-10-14 21:13:00.038 DEBUG (MainThread) [custom_components.gree_custom.aiogree.cipher] Encrypting data (V2): {"t": "bind", "uid": <uid>, "mac": "9424b8bc5a5f", "cid": "9424b8bc5a5f"}
2026-10-14 21:13:00.038 DEBUG (MainThread) [custom_components.gree_custom.aiogree.cipher] Encrypted data (V2): <ciphertext omitted>
2026-10-14 21:13:00.038 DEBUG (MainThread) [custom_components.gree_custom.aiogree.helpers] Before Encryption(payload=160, pack=75) | After Encryption(payload=100, pack=222)
2026-10-14 21:13:10.067 ERROR (MainThread) [custom_components.gree_custom.aiogree.api] [MQTT(<uid>, AU)] Error in bind request using encryption key with version 2
2026-10-14 21:13:10.068 ERROR (MainThread) [custom_components.gree_custom.aiogree.device_api_client] Error while binding
2026-10-14 21:13:10.096 DEBUG (MainThread) [custom_components.gree_custom.aiogree.transport_mqtt] Unsubscribed from topic: response/9424b8bc5a5f/#
2026-10-14 21:13:10.121 DEBUG (MainThread) [custom_components.gree_custom.aiogree.transport_mqtt] Unsubscribed from topic: status/9424b8bc5a5f/#
2026-10-14 21:13:10.146 DEBUG (MainThread) [custom_components.gree_custom.aiogree.transport_mqtt] Unsubscribed from topic: connect/9424b8bc5a5f
2026-10-14 21:13:10.147 DEBUG (MainThread) [custom_components.gree_custom.aiogree.transport_mqtt] MQTT receive loop cancelled
2026-10-14 21:13:10.147 DEBUG (MainThread) [custom_components.gree_custom.aiogree.transport_mqtt] Disconnected from MQTT broker
2026-10-14 21:13:10.147 WARNING (MainThread) [custom_components.gree_custom.aiogree.device] [9424b8bc5a5f00] Failed binding via MQTT(<uid>, AU)
2026-10-14 21:13:10.149 ERROR (MainThread) [custom_components.gree_custom.config_flow] Error while binding

@p-monteiro

Copy link
Copy Markdown
Contributor

Thanks. That makes sense, and good catch with the sensitive info
If you don't mind, you can add redaction for the cloud info in the login log, or leave it and we'll do it after.

About the response processing of gree_extract_macs(), as far as we know, for VRF, they are controlled through their 14-char MAC and not the gateway MAC in pmac. This is why we need more debugging from people with VRF, so if the pmac can also work as a cloud controller for the 14-char MAC, it would simplify things a bit there.

But since we don't support TCP for cloud, there's not much we can do about Zone controllers anyway.

If you add the entry with cloud+local discovery, do the zones work well?
Mainly, does gree_merge_discovered_devices() work well and present you with the zones correctly with local preference?

Found while testing a cloud setup for review: with debug logging on, the
cloud login was logged in clear text in several places, and users are
asked to post their debug logs.

- GreeCloudApi.login() logged the whole login response, with the session
  token, the email and the user name, and its errors repeated it. Only r,
  msg, uid and alive are logged now (LOGIN_LOG_KEYS).
- Setting up an entry logged its data with only the device key redacted.
  For a cloud entry that includes the email, the password and the token.
  Adding an entry through the UI logged the same. Both now redact
  TO_REDACT: the device key, the password, the token and the email.
- Entry diagnostics redacted the password, but not the token or the
  email. They use TO_REDACT too.
- Some lines logged the email, or the entry title, which holds it. They
  log the user id or the entry id now.
- Discovery logs every device with repr(), which held the device key and
  the email. GreeDiscoveredDevice leaves both out of its repr.

troubleshooting.md and actions.md say what is redacted.
Asked in review: with cloud and local discovery, are the zones offered?
They were not. The cloud lists a zone controller as the gateway and its
sub-units (AC unit 00, zones 01 to 04), all with one key, and each
sub-unit has the gateway MAC as pmac. _filter_duplicate_devices_complete()
kept only the MAC ending in 00 of a shared key, so the zones were
dropped. With both discovery methods, a local device is only offered when
the cloud lists it too, so the zones were dropped there as well.

A listed device that other devices point to with their pmac is a gateway
now: it is left out, and all its sub-units are kept. Devices without
such a link go through the key check as before.

Checked on a LE60-13/GH with both discovery methods: the AC unit and the
four zones are offered with the names from the Gree app, and all of them
are controlled locally. The tests use the cloud list of that controller.
@Twintop

Twintop commented Oct 14, 2026

Copy link
Copy Markdown
Author

Both are in now.

Redaction (e33b6f7). I went a bit further than the login lines, because testing turned up a few more spots:

  • login() only logs r, msg, uid and alive of the response now, also in its error messages.
  • Setting up an entry logged the entry data with only the device key redacted, so for a cloud entry it logged the email, the password and the token. The new-entry log of the UI flow did the same (the YAML import one already redacted the password and token). Both use a shared TO_REDACT now: device key, password, token and email.
  • Entry diagnostics redacted the password, but not the token or email. They use TO_REDACT too.
  • A few lines logged the email, or the entry title, which holds it. They log the user id or entry id now.
  • GreeDiscoveredDevice's repr, which discovery logs for every device, included the device key and the account email. Both are repr=False now.

Cloud + local (3f55495). No, the zones didn't show up at first. With both methods, async_step_local_add() only keeps the local devices that the cloud also lists, and the duplicate filter had already cut the cloud list down to …00. So gree_merge_discovered_devices() only ever saw the AC unit.

The filter now treats a listed device that other devices point to with their pmac as a gateway: it's dropped, and all its sub-units are kept. Anything without that link goes through the key check like before, so the existing cases don't change (including the fake's pmac = own MAC). I left gree_extract_macs() alone, given what you said about VRF and the 14-char MAC.

Tested again on the real controller with both methods: the picker offered all 5 (the AC unit as "Zone Controller" and the zones as "Zone1" to "Zone4", the names from the app, all marked Local). Every device bound locally with the key from the cloud, the gateway device showed up as "Zone controller" with all five linked, and a zone setpoint went out fine. So the merge does what it should: cloud names, local connection. The tests use this controller's cloud list as a fixture.

If this doesn't align with what you're looking for I can revert the last commit that added it, however, it made most of the functionality largely unusable if a user chose both Cloud and Local as sources for data. Without it I'd imagine a user with multiple Gree devices (including a Zone Controller like I have) would end up pulling in Cloud data only and be stuck with either/or.

@p-monteiro

Copy link
Copy Markdown
Contributor

I think that is correct; I just need to test against my devices to make sure it is ok.

Using cloud+local should do what it implies: Look for devices in the cloud, look for the same devices locally, and offer them through a single GreeDiscoveredDevice computed in gree_merge_discovered_devices(). The integration logic then uses that to decide if the device is controlled through UDP or MQTT based on the parameters that are filled. If it can be controlled by both, it prefers local, which can be overridden in the config.

If we confirm with someone that cloud discovery of VRF devices returns the subunits with the pmac of the gateway and MQTT allows us to control the subunits through that controller, we can simplify the creation of the GreeDiscoveredDevice

@p-monteiro

Copy link
Copy Markdown
Contributor

My AC units are properly discovered in the cloud and locally and can be controlled as before.
Thanks for all the work.

@Twintop

Twintop commented Oct 14, 2026

Copy link
Copy Markdown
Author

Glad to help! Thanks for taking the time to review and provide feedback.

I've got the last 2 branches ready to go to (1) add the memory in for these Zone Controllers and then (2) extend it for every other device. I'll put the first one up once this gets merged. 😄

@p-monteiro

p-monteiro commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

About memory, I think it's better to have both in one PR since the logic should be similar across all devices.

I'll give you this in advance if you haven't done it yet:

  • Save target temperature by device and mode: This should account for temperature units, so that if a user changes the device units between modes and comes back to a previous mode, the temperature is correctly restored in the new unit. Maybe save the props (SetTem, Bit) instead of the temperature value since they are stable across both temperature units (?)
  • Apply the previous temperature for a mode when the mode of device changes, or keep it if no memory. This is where things are different between device types: normal units can directly know when the mode changes, while VRF units and Zones are bound to the mode of their controller. I'm not even sure which device is the VRF main controller, while for zones you already know how.
  • Have the memory behaviour be an optional setting (defaulted to true) in the device options.

@RobHofmann RobHofmann left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the rework, and for the extra fixes along the way (the repair issue loop and the cloud login redaction). I ran 3f55495 in my dev instance against my standalone test unit (encryption v1). I have no zone controller, so the zone parts rest on your tests and the pytest fakes.

Both points from my earlier review are solved. My harness for the offline gateway (an existing controller device with no bound sub-unit) now runs through reconcile_vrf_controllers() without an exception.

What I checked on my test unit

  • Bind: the probe still ends on 29 columns, then Device type: ac_unit and Bound to device.
  • Poll: the first poll also asks for StTem, StFahFlg and TemUnit. After that, every poll sends the same 20 columns as on 5.0-dev, with SetTem, TemRec and TemUn.
  • 21 °C goes out as SetTem 21, TemRec 0. A fan change still sends Quiet and Tur, which this unit has.
  • The Temperature Units select sends TemUn 1. After that, 21 °C in Home Assistant goes out as 70 °F (SetTem 21, TemRec 1), and the unit confirms it.
  • No errors in the log.
  • pytest: 288 passed.

1. Cloud sub-units get their own MAC as controller (should fix)

_filter_duplicate_devices_complete() now keeps the sub-units of a listed gateway. But gree_discover_devices_cloud() still takes the controller MAC from gree_extract_macs(dev.mac), and that only splits a MAC that ends in 00. So a zone such as 9424b8bc5a5f01 gets 9424b8bc5a5f01 as its MQTT controller MAC, not the gateway 9424b8bc5a5f.

For your zones this hardly matters, because the module has no MQTT client. It does matter for a cloud VRF account whose sub-units carry pmac. Those units used to be filtered out, and now they are offered with the wrong controller. I have no cloud VRF listing to confirm that such sub-units have pmac, so this is from reading the code. A simple fix: use dev.pmac as the controller MAC when it is set and differs from dev.mac. That also answers the TODO in gree_extract_macs().

2. The TemRec point in the description (small)

The description and docs/protocol.md say that on 5.0-dev TemRec always went out with SetTem, and that a unit without it now gets whole degrees. As far as I can read, that was already the case. set_target_temperature() goes through DeviceState.update(), and set() drops a prop that the unit did not answer, with the is unsupported error. So the behaviour is the same; only the error line is gone. It would help to say that, so nobody looks for a change in behaviour that is not there.

3. Probe cost (just a note)

The default poll grows from 30 to 33 columns. A firmware with a limit of 30 to 32 columns used to pass the first probe, and now does a few more probe steps at bind. The result is still right, so I am fine with this.

Point 1 is the only one I would like to see changed before merging. The rest can go as it is.

@p-monteiro

Copy link
Copy Markdown
Contributor

@RobHofmann about point 1: We don't know if pmac is the controller MAC, and if it is, we don't know if MQTT answers to it as a controller MAC. We only know that for subunits like 9424b8bc5a5f01 the controller that replies is 9424b8bc5a5f and not what is on pmac.It might also answer to pmac, but that needs confirmation from someone with VRF and pmac field filled.

…note

Asked in review: since cloud discovery keeps the sub-units of a gateway,
a zone such as 9424b8bc5a5f01 got itself as its MQTT controller, because
gree_extract_macs() only splits a MAC that ends in 00.

The pmac of a sub-unit is now its controller, but only when it is also
the first 12 characters of the sub-unit MAC. Then both readings agree:
pmac names the gateway, and the start of the MAC is the controller known
to answer. What pmac holds for a VRF is not known yet, so anything else
keeps the old rules, and gree_extract_macs() is left as it is.

Also from review: docs/protocol.md read as if TemRec used to go out to a
unit that does not answer it. It did not: DeviceState.set() already
dropped such a column. Only the "is unsupported" error is gone.
@Twintop

Twintop commented Oct 19, 2026

Copy link
Copy Markdown
Author

Thanks for testing it again on your unit, @RobHofmann, and for the thorough check!

1. Cloud sub-units and their controller (c0d0eea). Good catch, that one came in with the filter change. I went with a narrower version of your fix, because of Pedro's point: a sub-unit's pmac is only used as its MQTT controller when it's also the first 12 characters of the sub-unit's own MAC. Then both readings agree: pmac names the gateway, and the start of the MAC is the controller we know answers (what gree_extract_macs() already does for …00). That covers the zone controller (9424b8bc5a5f01 → 9424b8bc5a5f).

Anything else keeps the current rules until someone with a cloud VRF can share a listing. I left gree_extract_macs() itself alone, since it also handles YAML keys, local discovery and the 4.x migration, and has the "only 00 splits" test. Two new tests: the zone controller's cloud list gives the gateway as the controller for all five sub-units, and a pmac that isn't the start of the MAC is ignored.

@p-monteiro that should match what you described: …01 ends up on 9424b8bc5a5f, without relying on pmac meaning the same thing for a VRF.

2. The TemRec note. You're right, I misread that. DeviceState.set() already dropped TemRec for a unit that doesn't report it, so nothing changed in behaviour; only the is unsupported error line is gone. Fixed in docs/protocol.md and in the PR description.

3. Probe cost. Noted, thanks.

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.

3 participants