From 43f488115d75628a39b1ec3acb6efb40aff6609e Mon Sep 17 00:00:00 2001 From: Twintop Date: Fri, 9 Oct 2026 20:35:38 +1100 Subject: [PATCH 01/18] Support Gree zone controllers 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. --- custom_components/gree_custom/aiogree/api.py | 75 ++++- .../gree_custom/aiogree/device.py | 87 +++++- .../gree_custom/aiogree/device_state.py | 10 + custom_components/gree_custom/climate.py | 13 +- .../gree_custom/config_schema.py | 12 +- custom_components/gree_custom/const.py | 13 + custom_components/gree_custom/entity.py | 2 + custom_components/gree_custom/icons.json | 9 + custom_components/gree_custom/number.py | 40 ++- .../gree_custom/platform_helpers.py | 3 + custom_components/gree_custom/switch.py | 13 +- .../gree_custom/translations/en.json | 7 +- docs/configuration.md | 2 +- docs/entities.md | 12 + docs/protocol.md | 14 + supported-devices.md | 1 + tests/fakes/zone.py | 130 +++++++++ tests/test_zone_controller.py | 270 ++++++++++++++++++ 18 files changed, 694 insertions(+), 19 deletions(-) create mode 100644 tests/fakes/zone.py create mode 100644 tests/test_zone_controller.py diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index 3b7ff5e..a5e4a99 100755 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -94,12 +94,20 @@ class GreeProp(StrEnum): # If set to 1 the unit will beep on every command (available on newer firmwares) BEEPER_NEW = "BuzzerCtrl" + # ZONE CONTROLLER + # target temperature of one zone, in whole degrees Celsius minus 16 + ZONE_TARGET_TEMPERATURE = "StTem" + PROP_KEY_TO_ENUM = {prop.value: prop for prop in GreeProp} -# Props every device is polled for by default; the beeper is only written, never read +# Props every device is polled for by default. The beeper is only written, +# never read. The zone target temperature is only polled on zones, see +# `GreeDevice.zone_role`. POLLED_PROPS: tuple[GreeProp, ...] = tuple( - p for p in GreeProp if p not in (GreeProp.BEEPER, GreeProp.BEEPER_NEW) + p + for p in GreeProp + if p not in (GreeProp.BEEPER, GreeProp.BEEPER_NEW, GreeProp.ZONE_TARGET_TEMPERATURE) ) @@ -471,6 +479,43 @@ class HumidityControlMode(IntEnum): smart_dry = 2 # This is only available in cool operation mode +@unique +class ZoneRole(StrEnum): + """What a sub-unit of a zone controller is. + + A zone controller (seen: LE60-13/GH with a ME31-00/C13 WiFi module) is a + gateway like a VRF gateway. Sub-unit `00` is the ducted unit and `01` to + `08` are the zones. See docs/protocol.md. + """ + + NONE = "" + AC_UNIT = "ac_unit" + ZONE = "zone" + + +# A zone controller and its sub-units report a name that starts with this. +ZONE_CONTROLLER_NAME_PREFIX = "GR-ZCntrlr" +# The model ids of the sub-units in the sub-device list. +ZONE_CONTROLLER_MID_AC_UNIT = "5000" +ZONE_CONTROLLER_MID_ZONE = "5001" + +# The ducted unit behind a zone controller numbers the modes in its own way. +ZONE_CONTROLLER_MODES: dict[int, OperationMode] = { + 1: OperationMode.cool, + 2: OperationMode.heat, + 3: OperationMode.dry, + 4: OperationMode.fan, + 5: OperationMode.auto, +} +ZONE_CONTROLLER_MODES_TO_RAW: dict[OperationMode, int] = { + mode: raw for raw, mode in ZONE_CONTROLLER_MODES.items() +} +# It has no turbo column. Turbo is one more fan speed. +ZONE_CONTROLLER_FAN_TURBO = 6 +# A zone stores its target temperature as degrees Celsius minus this. +ZONE_TEMPERATURE_OFFSET = 16 + + class GreeCommand(StrEnum): """Enumeration of Gree commands.""" @@ -1281,6 +1326,25 @@ def _parse_sub_devices_list( return sub_devs +def _zone_controller_sub_unit_name(sub_mac: str, sub_dev: dict[str, Any]) -> str | None: + """Name a sub-unit of a zone controller. + + A zone controller calls every sub-unit "zone", so the name says nothing. + The model id tells the ducted unit from a zone, and the last two + characters of the MAC are the zone number. + + Returns: + The name, or None if the unit is not part of a zone controller + + """ + mid = sub_dev.get("mid") + if mid == ZONE_CONTROLLER_MID_AC_UNIT: + return "AC unit" + if mid == ZONE_CONTROLLER_MID_ZONE: + return f"Zone {int(sub_mac[-2:], 16)}" + return None + + async def _get_sub_devices_list( mac_addr_controller: str, uid: int, @@ -1383,11 +1447,13 @@ async def _get_sub_devices_list( discovered_subdevices: list[GreeDiscoveredDevice] = [] for sub_mac, sub_dev in merged.items(): new_dev: GreeDiscoveredDevice + zone_name = _zone_controller_sub_unit_name(sub_mac, sub_dev) if parent_device: # TODO: get real-data from VRF discovery to check the result list fields new_dev = replace( parent_device, - name=sub_dev.get( + name=zone_name + or sub_dev.get( "name", f"{sub_mac[-5:]} VRF at {parent_device.name}", ), @@ -1397,7 +1463,8 @@ async def _get_sub_devices_list( ) else: new_dev = GreeDiscoveredDevice( - name=sub_dev.get( + name=zone_name + or sub_dev.get( "name", f"{sub_mac[-5:]} VRF at {mac_addr_controller[-5:]}", ), diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index ad61ded..4a287ff 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -8,6 +8,11 @@ from packaging.version import Version from .api import ( + ZONE_CONTROLLER_FAN_TURBO, + ZONE_CONTROLLER_MODES, + ZONE_CONTROLLER_MODES_TO_RAW, + ZONE_CONTROLLER_NAME_PREFIX, + ZONE_TEMPERATURE_OFFSET, EncryptionVersion, FanSpeed, GreeProp, @@ -19,6 +24,7 @@ StatusResult, TemperatureUnits, VerticalSwingMode, + ZoneRole, extract_fw_version, ) from .cloud_api import FirmwareInfoResponse, GreeRegion, gree_get_latest_firmware_info @@ -26,8 +32,10 @@ DEFAULT_DEVICE_USERID, MAX_HUM_COOL_P, MAX_HUM_DRY_P, + MAX_TEMP_C, MIN_HUM_COOL_P, MIN_HUM_DRY_P, + MIN_TEMP_C, ) from .device_api_client import DeviceApiClient from .device_state import DeviceState @@ -96,6 +104,8 @@ def __init__( self._firmware_code: str | None = None self._firmware_protocol_version: str = "" + self._zone_role: ZoneRole = ZoneRole.NONE + self._state = DeviceState( device_id=self.unique_id, capabilities=self._capabilities ) @@ -175,6 +185,7 @@ async def bind_with_transport( # Fetch initial information after sucessful bind await self.fetch_device_info() + self._detect_zone_role() await self.fetch_device_status(first_fetch=True) self._remove_unsupported_props() return @@ -185,6 +196,28 @@ def _device_pushed_status(self, status: dict[str, str]) -> None: _LOGGER.debug("[%s] Got data pushed from the device", self.unique_id) self._state.process_new_state(status) + def _detect_zone_role(self) -> None: + """Find out if this is a sub-unit of a zone controller, and which one. + + A sub-unit answers the info request with the name of the zone + controller. Sub-unit `00` is the ducted unit, the others are zones. + A zone also gets its target temperature polled. + """ + name = self._state.info.get(InfoProp.DEVICE_NAME, "") + if not self.is_sub_unit or not name.startswith(ZONE_CONTROLLER_NAME_PREFIX): + self._zone_role = ZoneRole.NONE + return + + self._zone_role = ( + ZoneRole.AC_UNIT if self._mac_addr.endswith("00") else ZoneRole.ZONE + ) + if self._zone_role is ZoneRole.ZONE: + self._state.add_polled(GreeProp.ZONE_TARGET_TEMPERATURE) + + _LOGGER.debug( + "[%s] Zone controller sub-unit: %s", self.unique_id, self._zone_role + ) + async def unbind_device(self) -> None: """Properly disconnect the device from transport.""" if not self._api.bound: @@ -450,6 +483,10 @@ async def set_props(self, values: Mapping[str, int]) -> None: def supports_property(self, property: GreeProp) -> bool: """Return True if the device endpoint supports the property.""" + # A zone controller has turbo as a fan speed, not as its own column. + if property is GreeProp.FEAT_TURBO_MODE and self._zone_role is ZoneRole.AC_UNIT: + return self._state.supports(GreeProp.FAN_SPEED) + # We consider a property as unsupported if it is not present in the raw state list # This assumes that the full state is fetched at least once before this method is called return self._state.supports(property) @@ -591,6 +628,24 @@ def is_sub_unit(self) -> bool: """Return True if the device is an indoor unit behind a VRF gateway.""" return self.mac_address != self.mac_address_controller + @property + def zone_role(self) -> ZoneRole: + """Return what this device is in a zone controller, if it is part of one.""" + return self._zone_role + + @property + def zone_target_temperature(self) -> int | None: + """Return the target temperature of a zone in degrees Celsius.""" + raw = self._state.get(GreeProp.ZONE_TARGET_TEMPERATURE) + return None if raw is None else raw + ZONE_TEMPERATURE_OFFSET + + def set_zone_target_temperature(self, value: int) -> None: + """Set the target temperature of a zone in degrees Celsius.""" + value = max(MIN_TEMP_C, min(MAX_TEMP_C, int(value))) + self._state.set( + GreeProp.ZONE_TARGET_TEMPERATURE, value - ZONE_TEMPERATURE_OFFSET + ) + @property def has_held_values(self) -> bool: """Return True if sent values still wait for the device to confirm them.""" @@ -682,6 +737,11 @@ def set_power_mode(self, value: bool) -> None: @property def operation_mode(self) -> OperationMode: """Return the current operation mode.""" + if self._zone_role is not ZoneRole.NONE: + return ZONE_CONTROLLER_MODES.get( + self._state.get(GreeProp.OP_MODE) or 0, OperationMode.auto + ) + return OperationMode( self._state.get(GreeProp.OP_MODE) or OperationMode.auto.value ) @@ -706,12 +766,26 @@ def set_operation_mode(self, mode: OperationMode) -> None: if mode != OperationMode.heat and self.feature_smart_heat: self.set_feature_smart_heat(False) + if self._zone_role is not ZoneRole.NONE: + # Turbo is a fan speed here, and only Cool and Heat have it. + if self.feature_turbo and mode not in ( + OperationMode.cool, + OperationMode.heat, + ): + self.set_feature_turbo(False) + self._state.set(GreeProp.OP_MODE, ZONE_CONTROLLER_MODES_TO_RAW[mode]) + return + self._state.set(GreeProp.OP_MODE, mode) @property def fan_speed(self) -> FanSpeed: """Return the current fan speed.""" - return FanSpeed(self._state.get(GreeProp.FAN_SPEED) or FanSpeed.auto.value) + raw = self._state.get(GreeProp.FAN_SPEED) or FanSpeed.auto.value + if self._zone_role is not ZoneRole.NONE and raw == ZONE_CONTROLLER_FAN_TURBO: + # Turbo is reported through `feature_turbo`. + return FanSpeed.high + return FanSpeed(raw) def set_fan_speed(self, speed: FanSpeed) -> None: """Set the device fan speed mode. @@ -928,6 +1002,8 @@ def set_feature_quiet(self, value: bool) -> None: @property def feature_turbo(self) -> bool: """Return the turbo mode state.""" + if self._zone_role is not ZoneRole.NONE: + return self._state.get(GreeProp.FAN_SPEED) == ZONE_CONTROLLER_FAN_TURBO return self._state.get_bool(GreeProp.FEAT_TURBO_MODE) def set_feature_turbo(self, value: bool) -> None: @@ -945,6 +1021,15 @@ def set_feature_turbo(self, value: bool) -> None: "Turbo mode is only available under Cool or Heat modes" ) + if self._zone_role is not ZoneRole.NONE: + # A zone controller has no turbo column. Turbo is fan speed 6, and + # leaving it goes back to the highest normal speed. + if value: + self._state.set(GreeProp.FAN_SPEED, ZONE_CONTROLLER_FAN_TURBO) + elif self.feature_turbo: + self._state.set(GreeProp.FAN_SPEED, FanSpeed.high) + return + self._state.set_bool(GreeProp.FEAT_TURBO_MODE, value) if value and (self.feature_energy_saving or self.feature_smart_heat): diff --git a/custom_components/gree_custom/aiogree/device_state.py b/custom_components/gree_custom/aiogree/device_state.py index 07288e0..d584e40 100755 --- a/custom_components/gree_custom/aiogree/device_state.py +++ b/custom_components/gree_custom/aiogree/device_state.py @@ -234,6 +234,16 @@ def supports(self, prop: GreeProp) -> bool: GreeProp.BEEPER_NEW, ) + def add_polled(self, prop: GreeProp) -> None: + """Start polling a prop that is not in the default poll. + + This is for props that only some kinds of device have, such as the + target temperature of a zone. Call it before the first status request. + After that the list of polled props only shrinks. + """ + if prop not in self._props_to_poll: + self._props_to_poll = (*self._props_to_poll, prop) + def remove(self, prop: GreeProp) -> None: """Remove a property from being polled.""" self._props_to_poll = tuple(p for p in self._props_to_poll if p != prop) diff --git a/custom_components/gree_custom/climate.py b/custom_components/gree_custom/climate.py index 99c21e4..ac4f2c3 100755 --- a/custom_components/gree_custom/climate.py +++ b/custom_components/gree_custom/climate.py @@ -34,7 +34,13 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util.unit_conversion import TemperatureConverter -from .aiogree.api import FanSpeed, GreeProp, HorizontalSwingMode, VerticalSwingMode +from .aiogree.api import ( + FanSpeed, + GreeProp, + HorizontalSwingMode, + VerticalSwingMode, + ZoneRole, +) from .aiogree.const import MAX_TEMP_C, MAX_TEMP_F, MIN_TEMP_C, MIN_TEMP_F from .aiogree.errors import GreeTurboUnavailable from .const import ( @@ -84,6 +90,11 @@ async def async_setup_entry( entities: list[GreeClimate] = [] for coordinator in entry.runtime_data.values(): + # A zone has no mode of its own. Its switch and target temperature + # come from the switch and number platforms. + if coordinator.device.zone_role is ZoneRole.ZONE: + continue + options: dict[str, Any] = coordinator.device_config.get(CONF_DEVICE_OPTIONS, {}) hvac_modes: list[HVACMode] = [ diff --git a/custom_components/gree_custom/config_schema.py b/custom_components/gree_custom/config_schema.py index eb36e25..aaf1ce2 100644 --- a/custom_components/gree_custom/config_schema.py +++ b/custom_components/gree_custom/config_schema.py @@ -46,7 +46,7 @@ TextSelectorType, ) -from .aiogree.api import GreeDiscoveredDevice, GreeProp +from .aiogree.api import GreeDiscoveredDevice, GreeProp, ZoneRole from .aiogree.cipher import EncryptionVersion from .aiogree.cloud_api import GreeRegion from .aiogree.device import GreeDevice @@ -98,6 +98,7 @@ GATTR_FEAT_QUIET_MODE, GATTR_FEAT_TURBO, MIN_SCAN_INTERVAL, + ZONE_ONLY_FEATURES, ) from .helpers import get_entity_ids_from_unique_ids @@ -610,7 +611,10 @@ def setup_device_options_schema( # noqa: C901 } ) - if device.supports_property(GreeProp.OP_MODE): + # A zone has no climate entity, so the climate options mean nothing there. + is_zone = device.zone_role is ZoneRole.ZONE + + if device.supports_property(GreeProp.OP_MODE) and not is_zone: schema.update( { probatio.Optional( @@ -633,7 +637,7 @@ def setup_device_options_schema( # noqa: C901 } valid_fan_modes: list[str] = [] for prop, modes in fan_mapping.items(): - if device.supports_property(prop): + if device.supports_property(prop) and not is_zone: valid_fan_modes.extend(modes) if valid_fan_modes: @@ -688,6 +692,8 @@ def setup_device_options_schema( # noqa: C901 valid_features = [] for feat, props in ATTR_FEATURES_TO_PROP_MAP.items(): + if feat in ZONE_ONLY_FEATURES and not is_zone: + continue if all(device.supports_property(p) for p in props): valid_features.append(feat) diff --git a/custom_components/gree_custom/const.py b/custom_components/gree_custom/const.py index 7e3c089..5fda04b 100755 --- a/custom_components/gree_custom/const.py +++ b/custom_components/gree_custom/const.py @@ -100,6 +100,12 @@ # Humidity Control Target. Sets humidity target for humidity control GATTR_FEAT_HUMIDITY_TARGET = "humidity_control_target" +# Zone controller. Opens and closes one zone. It is a feature, so it can be left +# out for a zone that must always stay open. +GATTR_ZONE = "zone" +# Zone controller. The target temperature of one zone. +GATTR_ZONE_TARGET_TEMPERATURE = "zone_target_temperature" + GATTR_TEMP_UNITS = "temperature_units" GATTR_INDOOR_TEMPERATURE = "indoor_temperature" GATTR_OUTDOOR_TEMPERATURE = "outdoor_temperature" @@ -130,8 +136,13 @@ GATTR_ANTI_DIRECT_BLOW: [GreeProp.FEAT_ANTI_DIRECT_BLOW], GATTR_FEAT_ENERGY_SAVING: [GreeProp.FEAT_ENERGY_SAVING], GATTR_FEAT_HUMIDITY: [GreeProp.FEATURE_HUMIDITY_CONTROL], + GATTR_ZONE: [GreeProp.POWER], } +# Features that only a zone has. Every unit has a Pow column, so the props +# alone cannot tell. +ZONE_ONLY_FEATURES: list[str] = [GATTR_ZONE] + ATTR_SENSORS_TO_PROP_MAP: dict[str, list[GreeProp]] = { GATTR_INDOOR_TEMPERATURE: [ GreeProp.SENSOR_INDOOR_TEMPERATURE_1, @@ -148,6 +159,7 @@ CONF_TO_PROP_FEATURE_MAP: dict[str, list[GreeProp]] = { GATTR_TEMP_UNITS: [GreeProp.TARGET_TEMPERATURE_UNIT], + GATTR_ZONE_TARGET_TEMPERATURE: [GreeProp.ZONE_TARGET_TEMPERATURE], # SENSORS **ATTR_SENSORS_TO_PROP_MAP, # FEATURES @@ -228,6 +240,7 @@ GATTR_FEAT_ENERGY_SAVING, GATTR_FEAT_SENSOR_LIGHT, GATTR_FAULTS, + GATTR_ZONE, ] UNITS_GREE_TO_HA = { diff --git a/custom_components/gree_custom/entity.py b/custom_components/gree_custom/entity.py index 0a07cec..7a40c92 100755 --- a/custom_components/gree_custom/entity.py +++ b/custom_components/gree_custom/entity.py @@ -84,3 +84,5 @@ class GreeEntityDescription(EntityDescription, frozen_or_thawed=True): additional_available_func: Callable[[GreeDevice], bool] = lambda _: True # True if the support can be evaluated with the device API only, false if support must be explicit in device config auto_device_support: bool = False + # Use this to create the entity only on some kinds of device, when the props alone cannot tell + device_filter: Callable[[GreeDevice], bool] = lambda _: True diff --git a/custom_components/gree_custom/icons.json b/custom_components/gree_custom/icons.json index 6cda525..00351ef 100755 --- a/custom_components/gree_custom/icons.json +++ b/custom_components/gree_custom/icons.json @@ -56,6 +56,9 @@ "number": { "humidity_control_target": { "default": "mdi:water-percent" + }, + "zone_target_temperature": { + "default": "mdi:home-thermometer" } }, "switch": { @@ -94,6 +97,12 @@ }, "beeper": { "default": "mdi:volume-high" + }, + "zone": { + "default": "mdi:hvac", + "state": { + "off": "mdi:hvac-off" + } } } }, diff --git a/custom_components/gree_custom/number.py b/custom_components/gree_custom/number.py index 533950a..b25c03d 100755 --- a/custom_components/gree_custom/number.py +++ b/custom_components/gree_custom/number.py @@ -10,15 +10,26 @@ NumberEntityDescription, NumberMode, ) -from homeassistant.const import PERCENTAGE +from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .aiogree.api import HumidityControlMode, OperationMode -from .aiogree.const import MAX_HUM_COOL_P, MAX_HUM_DRY_P, MIN_HUM_COOL_P, MIN_HUM_DRY_P +from .aiogree.api import HumidityControlMode, OperationMode, ZoneRole +from .aiogree.const import ( + MAX_HUM_COOL_P, + MAX_HUM_DRY_P, + MAX_TEMP_C, + MIN_HUM_COOL_P, + MIN_HUM_DRY_P, + MIN_TEMP_C, +) from .aiogree.device import GreeDevice -from .const import GATTR_FEAT_HUMIDITY, GATTR_FEAT_HUMIDITY_TARGET +from .const import ( + GATTR_FEAT_HUMIDITY, + GATTR_FEAT_HUMIDITY_TARGET, + GATTR_ZONE_TARGET_TEMPERATURE, +) from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription from .platform_helpers import supported_descriptions @@ -31,7 +42,7 @@ class GreeNumberDescription( ): """Description of a Gree number.""" - value_func: Callable[[GreeDevice], int] + value_func: Callable[[GreeDevice], int | None] set_func: Callable[[GreeDevice, int], None] min_func: Callable[[GreeDevice], int] | None = None max_func: Callable[[GreeDevice], int] | None = None @@ -66,7 +77,22 @@ class GreeNumberDescription( else MAX_HUM_DRY_P ), updates_device=True, - ) + ), + GreeNumberDescription( + key=GATTR_ZONE_TARGET_TEMPERATURE, + translation_key=GATTR_ZONE_TARGET_TEMPERATURE, + auto_device_support=True, + device_filter=lambda device: device.zone_role is ZoneRole.ZONE, + device_class=NumberDeviceClass.TEMPERATURE, + mode=NumberMode.BOX, + native_step=1, + native_min_value=MIN_TEMP_C, + native_max_value=MAX_TEMP_C, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_func=lambda device: device.zone_target_temperature, + set_func=lambda device, value: device.set_zone_target_temperature(value), + updates_device=True, + ), ] @@ -149,7 +175,7 @@ def native_max_value(self) -> float: @property @override - def native_value(self) -> float: + def native_value(self) -> float | None: """Return the state of the sensor.""" return self.entity_description.value_func(self.device) diff --git a/custom_components/gree_custom/platform_helpers.py b/custom_components/gree_custom/platform_helpers.py index fe4617e..76e27c4 100755 --- a/custom_components/gree_custom/platform_helpers.py +++ b/custom_components/gree_custom/platform_helpers.py @@ -46,6 +46,9 @@ def supported_descriptions( for description in descriptions: feature = entity_feature_key(description) + if not description.device_filter(device): + continue + if ( configured_features is not None and not description.auto_device_support diff --git a/custom_components/gree_custom/switch.py b/custom_components/gree_custom/switch.py index 052a966..71f4bd1 100755 --- a/custom_components/gree_custom/switch.py +++ b/custom_components/gree_custom/switch.py @@ -15,7 +15,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .aiogree.api import HumidityControlMode, OperationMode, SleepMode +from .aiogree.api import HumidityControlMode, OperationMode, SleepMode, ZoneRole from .aiogree.device import GreeDevice from .aiogree.errors import GreeContinuousDryUnavailable from .const import ( @@ -32,6 +32,7 @@ GATTR_FEAT_SLEEP_MODE, GATTR_FEAT_SMART_HEAT_8C, GATTR_FEAT_XFAN, + GATTR_ZONE, ) from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription @@ -169,6 +170,16 @@ class GreeSwitchDescription( entity_category=EntityCategory.CONFIG, updates_device=False, # Local entity ), + GreeSwitchDescription( + # Opening and closing is what a zone does, so the switch has the name + # of the zone. + key=GATTR_ZONE, + translation_key=GATTR_ZONE, + name=None, + device_filter=lambda device: device.zone_role is ZoneRole.ZONE, + value_func=lambda device, _: device.power_mode, + set_func=lambda device, _, value: device.set_power_mode(value), + ), ] diff --git a/custom_components/gree_custom/translations/en.json b/custom_components/gree_custom/translations/en.json index c172cf1..5e0a367 100644 --- a/custom_components/gree_custom/translations/en.json +++ b/custom_components/gree_custom/translations/en.json @@ -115,6 +115,7 @@ "restore_states": "Restore Entities" }, "data_description": { + "features": "On a zone of a zone controller, leave out Zone Switch if the zone must always stay open.", "external_temperature_sensor": "If set will replace the built-in HVAC temperature sensor for the Climate entity data", "external_humidity_sensor": "If set will replace the built-in HVAC humidity sensor for the Climate entity data", "restore_states": "If checked, when the integration is started the device will be overridden by previous states of the integration instead of using the current device state.", @@ -206,7 +207,8 @@ "powersave": "Energy Saving", "light_sensor": "Display Auto Brightness", "faults": "Fault Detection", - "humidity_control": "Humidity Control" + "humidity_control": "Humidity Control", + "zone": "Zone Switch" } } }, @@ -283,6 +285,9 @@ }, "humidity_control_target": { "name": "Humidity Control Target" + }, + "zone_target_temperature": { + "name": "Target Temperature" } }, "select": { diff --git a/docs/configuration.md b/docs/configuration.md index 355c6b1..f073386 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -90,7 +90,7 @@ One page per device. Gree devices do not report reliably which features they hav | Fan Speeds | all the unit supports | Auto, Low, Medium-Low, Medium, Medium-High, High, and Turbo and Quiet when the unit has them. | | Vertical Swing Modes | all | The vertical positions and swing ranges. | | Horizontal Swing Modes | all | The horizontal positions and swing ranges. Only shown when the unit has horizontal swing. | -| Device Features and Modes | all the unit supports | Which switches and selects to create. See [entities.md](entities.md). | +| Device Features and Modes | all the unit supports | Which switches and selects to create. See [entities.md](entities.md). On a zone of a zone controller, leave out **Zone Switch** if the zone must always stay open. | | Temperature Step | 1 | The step of the target temperature, 0.5 to 5 in steps of 0.5. In Fahrenheit the step is rounded to a whole degree. | | External Temperature Sensor | none | A Home Assistant sensor that replaces the unit's own room temperature in the climate entity. | | External Humidity Sensor | none | A Home Assistant sensor that replaces the unit's own humidity in the climate entity. | diff --git a/docs/entities.md b/docs/entities.md index bdbf2c4..6c72f03 100644 --- a/docs/entities.md +++ b/docs/entities.md @@ -39,6 +39,18 @@ Turbo and Quiet are not fan speeds on the unit but separate features. The integr The unit has its own temperature scale, Celsius or Fahrenheit, which you change with the **Temperature Units** select. The climate entity follows the unit, and Home Assistant converts to your display units. Fahrenheit values come from the unit's own lookup table, not from a formula. +## Zone controller + +A zone controller (for example the Gree LE60-13/GH) has one ducted unit and up to eight zones behind one WiFi module. Discovery finds the ducted unit as **AC unit** and the zones as **Zone 1**, **Zone 2** and so on. Add all of them. + +- The **AC unit** gets the climate entity. Its HVAC mode and fan mode work as above. Turbo is a fan speed of this unit, in Cool and Heat only. It has no target temperature and no room temperature: the zones have their own target temperature, and the controller does not report the room temperatures of the zones. Turning the AC unit off turns the whole system off. The zones keep their own setting and get it back when the system is turned on again. +- Each **zone** gets no climate entity, because a zone has no mode of its own. It gets: + +| Entity | What it does | +|---|---| +| Switch with the name of the zone | Opens and closes the zone. It is the **Zone Switch** feature, so you can leave it out. Do that for a zone that must always stay open: turning such a zone off is refused by the controller or turns the whole system off. | +| Target Temperature | The target temperature of the zone, 16 to 30 degrees C. | + ## Sensors Created when the unit has the sensor. Nothing to enable. diff --git a/docs/protocol.md b/docs/protocol.md index 9b3469e..9adeaa9 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -84,6 +84,20 @@ A gateway keeps a cached copy of the state of every indoor unit. For a few secon - If a standalone unit or the MQTT transport turns out to cache as well, the UI shows the old value for a moment after a command, as it did before the hold existed. For every device, `push_device_status()` 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. That line is how to find out, before the hold is extended. - While a hold is open after a command, the coordinator polls again every 2 s (`FOLLOW_UP_REFRESH_DELAY`), so the UI shows the confirmed value soon instead of at the next scan interval. The polls stop when the device confirms, or with the first poll after the hold ends. That poll shows what the device really reports, so a rejected command is visible within about 8 s. A standalone unit is never held, so it gets no extra poll. +## Zone controllers + +A zone controller (seen: LE60-13/GH with a ME31-00/C13 WiFi module, firmware `362001062617+U-W05SAV1.27.bin`) drives the dampers of a ducted unit. It answers the scan like a VRF gateway, with `subCnt` above zero, so discovery handles it the same way. Its sub-units are not indoor units, though. + +- The scan reply has no `cid`, see [Discovery and network](#discovery-and-network). +- It answers only the `subDev` form of the sub-device list. Every unit in the list is called `zone`. The ducted unit has model id `5000` and MAC suffix `00`. The zones have model id `5001` and suffixes `01` to `08`. Discovery names them `AC unit` and `Zone 1`, `Zone 2` and so on. +- A sub-unit answers the info request with the name of the controller, which starts with `GR-ZCntrlr`. `GreeDevice._detect_zone_role()` uses that and the MAC suffix to set `zone_role` after the info fetch. A zone also gets `StTem` added to its poll. +- The ducted unit answers `Pow`, `Mod`, `WdSpd` and `AllErr`, nothing more. `Pow` of the ducted unit is the power of the whole system. The zones keep their own `Pow` while it is off, and get it back when it is turned on. +- `Mod` uses its own numbers: 1 Cool, 2 Heat, 3 Dry, 4 Fan, 5 Auto (`ZONE_CONTROLLER_MODES`). Read with the normal numbers, Fan shows as Heat and Auto raises. `operation_mode` and `set_operation_mode()` convert for every unit with a zone role. +- There is no `Tur` column. Turbo is `WdSpd` 6, only in Cool and Heat. Dry forces `WdSpd` 1. +- A zone answers `Pow` (the damper), `StTem` (its target temperature) and a copy of `Mod` and `WdSpd` of the ducted unit. `StTem` is the temperature in degrees Celsius minus 16 (`ZONE_TEMPERATURE_OFFSET`), so 0 to 14 for 16 to 30 degrees. The controller itself also answers `StTem1` to `StTem8`. +- The full list of columns is a table in the WiFi module firmware. There is no column for the room temperature of a zone, in any released firmware, so it cannot be shown. +- The status column limit measured by `probe_device_limits()` is 29. Requests far above the limit (about 40 columns and more) made the module reboot. + ## MAC addresses MACs are written in lower case with no separators. Talking to a device needs two MACs: the device MAC (the unit to control) and the controller MAC (the unit that manages it). diff --git a/supported-devices.md b/supported-devices.md index 0c5092b..b3a8565 100644 --- a/supported-devices.md +++ b/supported-devices.md @@ -20,6 +20,7 @@ Tested on the following hardware: - Gree KFR-35GW/(35549)FNhAc-B1: ECB encryption, basic functions work; Wi‑Fi setup may require Bluetooth/Gree+ app pairing. - Gree MC31-00/F Central Air Conditioner Remote Control Module - Gree GWH18AGD-K6DNA1D/I (encryption version 1) +- Gree LE60-13/GH zone controller with ME31-00/C13 WiFi module (firmware 362001062617 V1.27, encryption version 1): AC unit and zones, see [Zone controller](docs/entities.md#zone-controller) ## Kolin - Kolin KAG-100WCINV (encryption version 2) diff --git a/tests/fakes/zone.py b/tests/fakes/zone.py new file mode 100644 index 0000000..de83aeb --- /dev/null +++ b/tests/fakes/zone.py @@ -0,0 +1,130 @@ +"""A fake Gree zone controller: one ducted unit and its zones behind one WiFi module. + +Seen on a real LE60-13/GH zone controller with a ME31-00/C13 WiFi module, +firmware `362001062617+U-W05SAV1.27.bin` (see docs/protocol.md): + +- It is a gateway. It only answers the subDev form of the sub-device list and + calls every unit "zone". The ducted unit has model id 5000 and MAC suffix + `00`. The zones have model id 5001 and suffixes `01` to `08`. +- Each sub-unit answers only its own columns. The ducted unit has `Pow`, `Mod`, + `WdSpd` and `AllErr`. A zone has `Pow`, `Mod`, `WdSpd` and `StTem`. `Mod` + and `WdSpd` of a zone follow the ducted unit. +- `Mod` is 1 Cool, 2 Heat, 3 Dry, 4 Fan, 5 Auto. Turbo is `WdSpd` 6. +- `StTem` is the zone target temperature in degrees Celsius minus 16. +- Every sub-unit answers the info columns with the name of the controller. +- A status request with too many columns gets an empty result. +""" + +from typing import Any, override + +from aiogree.api import SubListForm + +from .vrf import FakeVrfGateway + +ZONE_CONTROLLER_MAC = "9424b8c0ffee" +ZONE_CONTROLLER_NAME = "GR-ZCntrlr_5000_02_ffee_EC" + + +class FakeZoneController(FakeVrfGateway): + """A zone controller with a ducted unit and `zones` zones.""" + + def __init__( + self, + mac: str = ZONE_CONTROLLER_MAC, + name: str = ZONE_CONTROLLER_NAME, + *, + zones: int = 4, + **kwargs: Any, + ) -> None: + """Set up the controller. + + Args: + mac: MAC of the controller. + name: Name in the scan reply and in the info columns. + zones: How many zones the controller has. + kwargs: Anything FakeVrfGateway accepts. + + """ + kwargs.setdefault("forms", {SubListForm.SUB_DEV: None}) + kwargs.setdefault("max_columns", 25) + super().__init__(mac=mac, name=name, sub_count=zones + 1, **kwargs) + + self.ac_unit = f"{mac}00" + self.zone_macs = [f"{mac}{index:02d}" for index in range(1, zones + 1)] + self.units: dict[str, dict[str, Any]] = { + mac: {"Pow": 0, "Mod": 4, "WdSpd": 5}, + self.ac_unit: {"Pow": 1, "Mod": 4, "WdSpd": 5, "AllErr": 0}, + } + for zone in self.zone_macs: + self.units[zone] = {"Pow": 1, "Mod": 4, "WdSpd": 5, "StTem": 2} + self.info: dict[str, Any] = { + "name": name, + "mid": "3017", + "hid": "362001062617+U-W05SAV1.27.bin", + "host": "au.dis.gree.com", + "ver": "V1.2.1", + } + + @override + def build_scan_info(self) -> dict[str, Any]: + """Answer the scan as the real controller does. Note: there is no cid.""" + return { + "t": "dev", + "bc": "gree", + "catalog": "gree", + "mid": "50", + "lock": 0, + "model": "gree", + "name": self.name, + "series": "gree", + "vender": "2", + "ver": "V1.2.1", + "brand": "gree", + "mac": self.mac, + "subCnt": self.sub_count, + } + + @override + def sub_devices(self, form: SubListForm) -> list[dict[str, str]]: + """Return the ducted unit and the zones, each called "zone".""" + units = [{"mac": self.ac_unit, "mid": "5000", "name": "zone"}] + units += [ + {"mac": zone, "mid": "5001", "name": "zone"} for zone in self.zone_macs + ] + return units + + @override + def build_status(self, pack: dict[str, Any]) -> dict[str, Any]: + """Answer the columns the addressed unit has, and the info columns.""" + target: str = pack.get("mac", self.mac) + cols: list[str] = list(pack.get("cols", [])) + + if self.max_columns is not None and len(cols) > self.max_columns: + return {"t": "dat", "mac": target, "r": 200, "cols": [], "dat": []} + + values = {**self.info, **self.units.get(target, {})} + answered = [col for col in cols if col in values] + return { + "t": "dat", + "mac": target, + "r": 200, + "cols": answered, + "dat": [values[col] for col in answered], + } + + @override + def build_command_result(self, pack: dict[str, Any]) -> dict[str, Any]: + """Apply a command to the addressed unit. The zones follow the ducted unit.""" + target: str = pack.get("sub", self.mac) + options = list(pack.get("opt", [])) + values = list(pack.get("p", [])) + + unit = self.units.get(target, {}) + for option, value in zip(options, values, strict=False): + if option in unit: + unit[option] = value + if target == self.ac_unit and option in ("Mod", "WdSpd"): + for other in (self.mac, *self.zone_macs): + self.units[other][option] = value + + return {"t": "res", "mac": target, "r": 200, "opt": options, "p": values} diff --git a/tests/test_zone_controller.py b/tests/test_zone_controller.py new file mode 100644 index 0000000..ccbc173 --- /dev/null +++ b/tests/test_zone_controller.py @@ -0,0 +1,270 @@ +"""Tests for a zone controller: a ducted unit and its zones behind one WiFi module. + +A zone controller is a gateway like a VRF gateway, but its sub-units are not +indoor units. Sub-unit `00` is the ducted unit, which numbers its modes in its +own way and has turbo as a fan speed. The other sub-units are zones, with an +on/off state and a target temperature. See docs/protocol.md. +""" + +# pylint: disable=redefined-outer-name +# A test takes a fixture as an argument with the same name. That is the +# pytest pattern, not shadowing. + +from collections.abc import AsyncIterator + +from aiogree.api import FanSpeed, GreeProp, OperationMode, ZoneRole +from aiogree.device import GreeDevice +from aiogree.errors import GreeTurboUnavailable +from aiogree.transport_udp import GreeUdpTransport +import pytest + +from .conftest import DISCOVERY_PORT, DiscoverOne, RecordingHandler +from .fakes.device import FakeGreeDevice +from .fakes.vrf import FakeVrfGateway +from .fakes.zone import ZONE_CONTROLLER_MAC, FakeZoneController + + +@pytest.fixture +async def controller() -> AsyncIterator[FakeZoneController]: + """Start a fake zone controller with four zones.""" + fake = FakeZoneController() + await fake.start() + try: + yield fake + finally: + fake.close() + + +async def bind_unit( + gateway: FakeGreeDevice, suffix: str +) -> tuple[GreeDevice, GreeUdpTransport]: + """Bind the sub-unit with this MAC suffix behind the gateway.""" + transport = GreeUdpTransport(gateway.host, gateway.port, max_retries=1, timeout=0.3) + device = GreeDevice(name=f"Unit {suffix}", mac_addr=f"{gateway.mac}{suffix}") + await device.bind_with_transport( + local_controller_mac=gateway.mac, local_transport=transport + ) + return device, transport + + +@pytest.fixture +async def ac_unit(controller: FakeZoneController) -> AsyncIterator[GreeDevice]: + """Bind the ducted unit of the zone controller.""" + device, transport = await bind_unit(controller, "00") + try: + yield device + finally: + await transport.disconnect() + + +@pytest.fixture +async def zone(controller: FakeZoneController) -> AsyncIterator[GreeDevice]: + """Bind zone 1 of the zone controller.""" + device, transport = await bind_unit(controller, "01") + try: + yield device + finally: + await transport.disconnect() + + +# +# Discovery +# + + +async def test_discovery_names_the_ducted_unit_and_the_zones( + loopback_ip: str, discover_one: DiscoverOne, gree_logs: RecordingHandler +) -> None: + """Every unit says "zone". Discovery names them from the model id and MAC.""" + controller = FakeZoneController() + await controller.start(loopback_ip, DISCOVERY_PORT) + try: + found = await discover_one(controller.host, timeout=1) + finally: + controller.close() + + assert [(d.name, d.mac) for d in found] == [ + ("AC unit", f"{ZONE_CONTROLLER_MAC}00"), + ("Zone 1", f"{ZONE_CONTROLLER_MAC}01"), + ("Zone 2", f"{ZONE_CONTROLLER_MAC}02"), + ("Zone 3", f"{ZONE_CONTROLLER_MAC}03"), + ("Zone 4", f"{ZONE_CONTROLLER_MAC}04"), + ] + assert {d.mac_controller_local for d in found} == {ZONE_CONTROLLER_MAC} + # The two subList forms get no answer, which the transport warns about. + # Only the warnings about the sub-device list itself would be a problem. + assert not any("sub-devices" in line for line in gree_logs.warnings()) + + +# +# The ducted unit +# + + +async def test_the_ducted_unit_is_recognised(ac_unit: GreeDevice) -> None: + """The name in the info columns and the 00 suffix make it the ducted unit.""" + assert ac_unit.zone_role is ZoneRole.AC_UNIT + assert not ac_unit.supports_property(GreeProp.ZONE_TARGET_TEMPERATURE) + + +@pytest.mark.parametrize( + ("raw", "mode"), + [ + (1, OperationMode.cool), + (2, OperationMode.heat), + (3, OperationMode.dry), + (4, OperationMode.fan), + (5, OperationMode.auto), + ], +) +async def test_the_ducted_unit_reads_its_own_mode_numbers( + controller: FakeZoneController, ac_unit: GreeDevice, raw: int, mode: OperationMode +) -> None: + """Mode 4 is Fan, not Heat, and mode 5 (Auto) does not raise.""" + controller.units[controller.ac_unit]["Mod"] = raw + + await ac_unit.fetch_device_status() + + assert ac_unit.operation_mode is mode + + +async def test_the_ducted_unit_is_sent_its_own_mode_numbers( + controller: FakeZoneController, ac_unit: GreeDevice +) -> None: + """Heat goes out as 2, and the zones follow the ducted unit.""" + ac_unit.set_operation_mode(OperationMode.heat) + + await ac_unit.push_device_status() + + assert controller.units[controller.ac_unit]["Mod"] == 2 + assert controller.units[controller.zone_macs[0]]["Mod"] == 2 + assert ac_unit.operation_mode is OperationMode.heat + + +async def test_turbo_is_a_fan_speed( + controller: FakeZoneController, ac_unit: GreeDevice +) -> None: + """There is no turbo column. Turbo is fan speed 6.""" + controller.units[controller.ac_unit]["Mod"] = 1 + await ac_unit.fetch_device_status() + assert ac_unit.supports_property(GreeProp.FEAT_TURBO_MODE) + + ac_unit.set_feature_turbo(True) + await ac_unit.push_device_status() + + assert controller.units[controller.ac_unit]["WdSpd"] == 6 + assert ac_unit.feature_turbo is True + assert ac_unit.fan_speed is FanSpeed.high + + ac_unit.set_feature_turbo(False) + await ac_unit.push_device_status() + + assert controller.units[controller.ac_unit]["WdSpd"] == FanSpeed.high.value + assert ac_unit.feature_turbo is False + + +async def test_turbo_is_refused_outside_cool_and_heat(ac_unit: GreeDevice) -> None: + """The fake starts in Fan mode, where the panel offers no turbo either.""" + assert ac_unit.operation_mode is OperationMode.fan + + with pytest.raises(GreeTurboUnavailable): + ac_unit.set_feature_turbo(True) + + +async def test_leaving_cool_ends_turbo( + controller: FakeZoneController, ac_unit: GreeDevice +) -> None: + """A mode without turbo gets the highest normal fan speed instead.""" + controller.units[controller.ac_unit].update({"Mod": 1, "WdSpd": 6}) + await ac_unit.fetch_device_status() + + ac_unit.set_operation_mode(OperationMode.fan) + await ac_unit.push_device_status() + + assert controller.units[controller.ac_unit]["Mod"] == 4 + assert controller.units[controller.ac_unit]["WdSpd"] == FanSpeed.high.value + + +# +# Zones +# + + +async def test_a_zone_is_recognised_and_polls_its_target_temperature( + controller: FakeZoneController, zone: GreeDevice +) -> None: + """A zone asks for StTem and reads it as degrees Celsius minus 16.""" + assert zone.zone_role is ZoneRole.ZONE + assert zone.supports_property(GreeProp.ZONE_TARGET_TEMPERATURE) + assert zone.zone_target_temperature == 18 + + controller.units[controller.zone_macs[0]]["StTem"] = 5 + await zone.fetch_device_status() + + assert zone.zone_target_temperature == 21 + + +async def test_a_zone_target_temperature_is_sent_minus_16( + controller: FakeZoneController, zone: GreeDevice +) -> None: + """19 degrees goes out as 3, and values outside 16 to 30 are clamped.""" + zone.set_zone_target_temperature(19) + await zone.push_device_status() + + assert controller.units[controller.zone_macs[0]]["StTem"] == 3 + + zone.set_zone_target_temperature(35) + await zone.push_device_status() + + assert controller.units[controller.zone_macs[0]]["StTem"] == 14 + + +async def test_a_zone_is_opened_and_closed_with_its_power( + controller: FakeZoneController, zone: GreeDevice +) -> None: + """A zone damper is the Pow column of the zone.""" + zone.set_power_mode(False) + await zone.push_device_status() + + assert controller.units[controller.zone_macs[0]]["Pow"] == 0 + assert controller.units[controller.ac_unit]["Pow"] == 1 + + +# +# Other devices are not affected +# + + +async def test_a_normal_unit_never_asks_for_the_zone_column() -> None: + """Only zones poll StTem. A normal unit sends the same columns as before.""" + unit = FakeGreeDevice() + await unit.start() + transport = GreeUdpTransport(unit.host, unit.port, max_retries=1, timeout=0.3) + try: + device = GreeDevice(name="Zolderkamer", mac_addr=unit.mac) + await device.bind_with_transport( + local_controller_mac=unit.mac, local_transport=transport + ) + await device.fetch_device_status() + finally: + await transport.disconnect() + unit.close() + + asked = {col for pack in unit.packs for col in pack.get("cols", [])} + assert GreeProp.ZONE_TARGET_TEMPERATURE.value not in asked + assert device.zone_role is ZoneRole.NONE + + +async def test_a_vrf_indoor_unit_keeps_the_normal_mode_numbers() -> None: + """A VRF gateway has another name, so mode 4 still means Heat there.""" + gateway = FakeVrfGateway() + gateway.values.update({"Mod": 4, "name": gateway.name}) + await gateway.start() + try: + device, transport = await bind_unit(gateway, "01") + await transport.disconnect() + finally: + gateway.close() + + assert device.zone_role is ZoneRole.NONE + assert device.operation_mode is OperationMode.heat From d54829cd4d4a6097717551139c8fef827412a684 Mon Sep 17 00:00:00 2001 From: Twintop Date: Sat, 10 Oct 2026 13:53:53 +1100 Subject: [PATCH 02/18] Give every device a type, and label a zone controller as one 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 #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. --- custom_components/gree_custom/aiogree/api.py | 22 +++-- .../gree_custom/aiogree/device.py | 64 ++++++++------- custom_components/gree_custom/climate.py | 4 +- .../gree_custom/config_schema.py | 4 +- custom_components/gree_custom/const.py | 4 + custom_components/gree_custom/helpers.py | 81 +++++++++++++++---- custom_components/gree_custom/number.py | 4 +- custom_components/gree_custom/switch.py | 4 +- .../gree_custom/translations/en.json | 3 + docs/architecture.md | 1 + docs/entities.md | 2 +- docs/protocol.md | 2 +- tests/test_zone_controller.py | 20 +++-- 13 files changed, 148 insertions(+), 67 deletions(-) diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index a5e4a99..35e8b2e 100755 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -103,7 +103,7 @@ class GreeProp(StrEnum): # Props every device is polled for by default. The beeper is only written, # never read. The zone target temperature is only polled on zones, see -# `GreeDevice.zone_role`. +# `GreeDevice.device_type`. POLLED_PROPS: tuple[GreeProp, ...] = tuple( p for p in GreeProp @@ -480,19 +480,29 @@ class HumidityControlMode(IntEnum): @unique -class ZoneRole(StrEnum): - """What a sub-unit of a zone controller is. +class DeviceType(StrEnum): + """What kind of device a unit is. Known after the first bind. A zone controller (seen: LE60-13/GH with a ME31-00/C13 WiFi module) is a - gateway like a VRF gateway. Sub-unit `00` is the ducted unit and `01` to - `08` are the zones. See docs/protocol.md. + gateway like a VRF gateway. Its sub-unit `00` is the ducted unit, which has + the mode and the fan. Sub-units `01` to `08` are the zones. See + docs/protocol.md. """ - NONE = "" + # A standalone unit AC_UNIT = "ac_unit" + # An indoor unit behind a VRF gateway + VRF_UNIT = "vrf_unit" + # Sub-unit 00 of a zone controller: the ducted unit + ZONE_CONTROLLER = "zone_controller" + # Sub-unit 01 to 08 of a zone controller ZONE = "zone" +# The sub-units of a zone controller. They number the modes in their own way. +ZONE_CONTROLLER_TYPES = frozenset({DeviceType.ZONE_CONTROLLER, DeviceType.ZONE}) + + # A zone controller and its sub-units report a name that starts with this. ZONE_CONTROLLER_NAME_PREFIX = "GR-ZCntrlr" # The model ids of the sub-units in the sub-device list. diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index 4a287ff..e5d3565 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -12,7 +12,9 @@ ZONE_CONTROLLER_MODES, ZONE_CONTROLLER_MODES_TO_RAW, ZONE_CONTROLLER_NAME_PREFIX, + ZONE_CONTROLLER_TYPES, ZONE_TEMPERATURE_OFFSET, + DeviceType, EncryptionVersion, FanSpeed, GreeProp, @@ -24,7 +26,6 @@ StatusResult, TemperatureUnits, VerticalSwingMode, - ZoneRole, extract_fw_version, ) from .cloud_api import FirmwareInfoResponse, GreeRegion, gree_get_latest_firmware_info @@ -104,7 +105,7 @@ def __init__( self._firmware_code: str | None = None self._firmware_protocol_version: str = "" - self._zone_role: ZoneRole = ZoneRole.NONE + self._device_type: DeviceType = DeviceType.AC_UNIT self._state = DeviceState( device_id=self.unique_id, capabilities=self._capabilities @@ -185,7 +186,7 @@ async def bind_with_transport( # Fetch initial information after sucessful bind await self.fetch_device_info() - self._detect_zone_role() + self._detect_device_type() await self.fetch_device_status(first_fetch=True) self._remove_unsupported_props() return @@ -196,27 +197,27 @@ def _device_pushed_status(self, status: dict[str, str]) -> None: _LOGGER.debug("[%s] Got data pushed from the device", self.unique_id) self._state.process_new_state(status) - def _detect_zone_role(self) -> None: - """Find out if this is a sub-unit of a zone controller, and which one. + def _detect_device_type(self) -> None: + """Find out what kind of device this is. - A sub-unit answers the info request with the name of the zone - controller. Sub-unit `00` is the ducted unit, the others are zones. - A zone also gets its target temperature polled. + A unit that is not a sub-unit is a standalone unit. A sub-unit of a + zone controller answers the info request with the name of the zone + controller: sub-unit `00` is the ducted unit, the others are zones. + Every other sub-unit is a VRF indoor unit. A zone also gets its + target temperature polled. """ name = self._state.info.get(InfoProp.DEVICE_NAME, "") - if not self.is_sub_unit or not name.startswith(ZONE_CONTROLLER_NAME_PREFIX): - self._zone_role = ZoneRole.NONE - return - - self._zone_role = ( - ZoneRole.AC_UNIT if self._mac_addr.endswith("00") else ZoneRole.ZONE - ) - if self._zone_role is ZoneRole.ZONE: + if not self.is_sub_unit: + self._device_type = DeviceType.AC_UNIT + elif not name.startswith(ZONE_CONTROLLER_NAME_PREFIX): + self._device_type = DeviceType.VRF_UNIT + elif self._mac_addr.endswith("00"): + self._device_type = DeviceType.ZONE_CONTROLLER + else: + self._device_type = DeviceType.ZONE self._state.add_polled(GreeProp.ZONE_TARGET_TEMPERATURE) - _LOGGER.debug( - "[%s] Zone controller sub-unit: %s", self.unique_id, self._zone_role - ) + _LOGGER.debug("[%s] Device type: %s", self.unique_id, self._device_type) async def unbind_device(self) -> None: """Properly disconnect the device from transport.""" @@ -433,6 +434,7 @@ def gather_diagnostics(self) -> dict[str, Any]: "transport": str(self._api.transport), "mac": self.mac_address, "mac_controller": self.mac_address_controller, + "device_type": self._device_type.value, "name": self.name, "fw": self.firmware_version, "is_bound": self._api.bound, @@ -484,7 +486,10 @@ async def set_props(self, values: Mapping[str, int]) -> None: def supports_property(self, property: GreeProp) -> bool: """Return True if the device endpoint supports the property.""" # A zone controller has turbo as a fan speed, not as its own column. - if property is GreeProp.FEAT_TURBO_MODE and self._zone_role is ZoneRole.AC_UNIT: + if ( + property is GreeProp.FEAT_TURBO_MODE + and self._device_type is DeviceType.ZONE_CONTROLLER + ): return self._state.supports(GreeProp.FAN_SPEED) # We consider a property as unsupported if it is not present in the raw state list @@ -629,9 +634,9 @@ def is_sub_unit(self) -> bool: return self.mac_address != self.mac_address_controller @property - def zone_role(self) -> ZoneRole: - """Return what this device is in a zone controller, if it is part of one.""" - return self._zone_role + def device_type(self) -> DeviceType: + """Return what kind of device this is. Known after the first bind.""" + return self._device_type @property def zone_target_temperature(self) -> int | None: @@ -737,7 +742,7 @@ def set_power_mode(self, value: bool) -> None: @property def operation_mode(self) -> OperationMode: """Return the current operation mode.""" - if self._zone_role is not ZoneRole.NONE: + if self._device_type in ZONE_CONTROLLER_TYPES: return ZONE_CONTROLLER_MODES.get( self._state.get(GreeProp.OP_MODE) or 0, OperationMode.auto ) @@ -766,7 +771,7 @@ def set_operation_mode(self, mode: OperationMode) -> None: if mode != OperationMode.heat and self.feature_smart_heat: self.set_feature_smart_heat(False) - if self._zone_role is not ZoneRole.NONE: + if self._device_type in ZONE_CONTROLLER_TYPES: # Turbo is a fan speed here, and only Cool and Heat have it. if self.feature_turbo and mode not in ( OperationMode.cool, @@ -782,7 +787,10 @@ def set_operation_mode(self, mode: OperationMode) -> None: def fan_speed(self) -> FanSpeed: """Return the current fan speed.""" raw = self._state.get(GreeProp.FAN_SPEED) or FanSpeed.auto.value - if self._zone_role is not ZoneRole.NONE and raw == ZONE_CONTROLLER_FAN_TURBO: + if ( + self._device_type in ZONE_CONTROLLER_TYPES + and raw == ZONE_CONTROLLER_FAN_TURBO + ): # Turbo is reported through `feature_turbo`. return FanSpeed.high return FanSpeed(raw) @@ -1002,7 +1010,7 @@ def set_feature_quiet(self, value: bool) -> None: @property def feature_turbo(self) -> bool: """Return the turbo mode state.""" - if self._zone_role is not ZoneRole.NONE: + if self._device_type in ZONE_CONTROLLER_TYPES: return self._state.get(GreeProp.FAN_SPEED) == ZONE_CONTROLLER_FAN_TURBO return self._state.get_bool(GreeProp.FEAT_TURBO_MODE) @@ -1021,7 +1029,7 @@ def set_feature_turbo(self, value: bool) -> None: "Turbo mode is only available under Cool or Heat modes" ) - if self._zone_role is not ZoneRole.NONE: + if self._device_type in ZONE_CONTROLLER_TYPES: # A zone controller has no turbo column. Turbo is fan speed 6, and # leaving it goes back to the highest normal speed. if value: diff --git a/custom_components/gree_custom/climate.py b/custom_components/gree_custom/climate.py index ac4f2c3..d4607d0 100755 --- a/custom_components/gree_custom/climate.py +++ b/custom_components/gree_custom/climate.py @@ -35,11 +35,11 @@ from homeassistant.util.unit_conversion import TemperatureConverter from .aiogree.api import ( + DeviceType, FanSpeed, GreeProp, HorizontalSwingMode, VerticalSwingMode, - ZoneRole, ) from .aiogree.const import MAX_TEMP_C, MAX_TEMP_F, MIN_TEMP_C, MIN_TEMP_F from .aiogree.errors import GreeTurboUnavailable @@ -92,7 +92,7 @@ async def async_setup_entry( for coordinator in entry.runtime_data.values(): # A zone has no mode of its own. Its switch and target temperature # come from the switch and number platforms. - if coordinator.device.zone_role is ZoneRole.ZONE: + if coordinator.device.device_type is DeviceType.ZONE: continue options: dict[str, Any] = coordinator.device_config.get(CONF_DEVICE_OPTIONS, {}) diff --git a/custom_components/gree_custom/config_schema.py b/custom_components/gree_custom/config_schema.py index aaf1ce2..e928d21 100644 --- a/custom_components/gree_custom/config_schema.py +++ b/custom_components/gree_custom/config_schema.py @@ -46,7 +46,7 @@ TextSelectorType, ) -from .aiogree.api import GreeDiscoveredDevice, GreeProp, ZoneRole +from .aiogree.api import DeviceType, GreeDiscoveredDevice, GreeProp from .aiogree.cipher import EncryptionVersion from .aiogree.cloud_api import GreeRegion from .aiogree.device import GreeDevice @@ -612,7 +612,7 @@ def setup_device_options_schema( # noqa: C901 ) # A zone has no climate entity, so the climate options mean nothing there. - is_zone = device.zone_role is ZoneRole.ZONE + is_zone = device.device_type is DeviceType.ZONE if device.supports_property(GreeProp.OP_MODE) and not is_zone: schema.update( diff --git a/custom_components/gree_custom/const.py b/custom_components/gree_custom/const.py index 5fda04b..b82502c 100755 --- a/custom_components/gree_custom/const.py +++ b/custom_components/gree_custom/const.py @@ -17,6 +17,10 @@ # Device identifier prefix of the VRF controller (gateway) device VRF_CONTROLLER_ID_PREFIX = "controller_" VRF_CONTROLLER_TRANSLATION_KEY = "vrf_controller" +VRF_CONTROLLER_MODEL = "VRF gateway" +# A zone controller gets the same kind of controller device +ZONE_CONTROLLER_TRANSLATION_KEY = "zone_controller" +ZONE_CONTROLLER_MODEL = "Zone controller" CURRENT_CONF_VERSION = 3 diff --git a/custom_components/gree_custom/helpers.py b/custom_components/gree_custom/helpers.py index c18de13..5f4d770 100755 --- a/custom_components/gree_custom/helpers.py +++ b/custom_components/gree_custom/helpers.py @@ -3,7 +3,7 @@ from collections.abc import Mapping from ipaddress import IPv4Address, IPv4Network, ip_address, ip_network import logging -from typing import Any +from typing import Any, NamedTuple from homeassistant.components import network from homeassistant.config_entries import ConfigEntry @@ -13,7 +13,11 @@ from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import UNDEFINED, UndefinedType -from .aiogree.api import GreeDiscoveredDevice, gree_discover_devices +from .aiogree.api import ( + ZONE_CONTROLLER_TYPES, + GreeDiscoveredDevice, + gree_discover_devices, +) from .aiogree.device import GreeDevice from .aiogree.transport_udp import GreeUdpTransport from .const import ( @@ -35,7 +39,10 @@ DOMAIN, MAX_UNICAST_SCAN_HOSTS, VRF_CONTROLLER_ID_PREFIX, + VRF_CONTROLLER_MODEL, VRF_CONTROLLER_TRANSLATION_KEY, + ZONE_CONTROLLER_MODEL, + ZONE_CONTROLLER_TRANSLATION_KEY, ) _LOGGER = logging.getLogger(__name__) @@ -377,6 +384,46 @@ def get_vrf_controller_mac(device_entry: dr.DeviceEntry) -> str | None: ) +class _ControllerInfo(NamedTuple): + """What a controller device shows, taken from one of its sub-units.""" + + sw_version: str | UndefinedType | None + hw_version: str | UndefinedType | None + model: str | UndefinedType + translation_key: str | UndefinedType + + +def _controller_info(entry: ConfigEntry, sub_macs: set[str]) -> _ControllerInfo: + """Take the firmware and the kind of controller from a bound sub-unit. + + The firmware belongs to the WiFi module of the gateway. A zone controller + is a gateway too, with zones instead of indoor units, and only a bound + sub-unit knows its type. Without a bound sub-unit everything is UNDEFINED, + so the registry keeps what it has. + """ + for sub_mac in sorted(sub_macs): + coordinator = entry.runtime_data.get(sub_mac) + if coordinator is None or not coordinator.device.is_bound: + continue + + device = coordinator.device + if device.device_type in ZONE_CONTROLLER_TYPES: + return _ControllerInfo( + device.firmware_version, + device.firmware_code, + ZONE_CONTROLLER_MODEL, + ZONE_CONTROLLER_TRANSLATION_KEY, + ) + return _ControllerInfo( + device.firmware_version, + device.firmware_code, + VRF_CONTROLLER_MODEL, + VRF_CONTROLLER_TRANSLATION_KEY, + ) + + return _ControllerInfo(UNDEFINED, UNDEFINED, UNDEFINED, UNDEFINED) + + def reconcile_vrf_controllers( hass: HomeAssistant, entry: ConfigEntry, @@ -398,34 +445,34 @@ def reconcile_vrf_controllers( entry_devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) # Remove the controllers that have no sub-unit anymore + existing_controllers: set[str] = set() for device_entry in entry_devices: controller_mac = get_vrf_controller_mac(device_entry) - if controller_mac is not None and controller_mac not in sub_units: + if controller_mac is None: + continue + if controller_mac not in sub_units: _LOGGER.debug("Removing VRF controller device %s", controller_mac) device_registry.async_remove_device(device_entry.id) + else: + existing_controllers.add(controller_mac) controller_ids: dict[str, str] = {} for controller_mac, sub_macs in sub_units.items(): - # The firmware belongs to the WiFi module of the gateway, so take it - # from a bound sub-unit. Without one, keep what the registry has. - sw_version: str | UndefinedType | None = UNDEFINED - hw_version: str | UndefinedType | None = UNDEFINED - for sub_mac in sorted(sub_macs): - coordinator = entry.runtime_data.get(sub_mac) - if coordinator is not None and coordinator.device.is_bound: - sw_version = coordinator.device.firmware_version - hw_version = coordinator.device.firmware_code - break + info = _controller_info(entry, sub_macs) + model, translation_key = info.model, info.translation_key + if model is UNDEFINED and controller_mac not in existing_controllers: + model = VRF_CONTROLLER_MODEL + translation_key = VRF_CONTROLLER_TRANSLATION_KEY controller = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, f"{VRF_CONTROLLER_ID_PREFIX}{controller_mac}")}, connections={(dr.CONNECTION_NETWORK_MAC, controller_mac)}, manufacturer="Gree", - model="VRF gateway", - sw_version=sw_version, - hw_version=hw_version, - translation_key=VRF_CONTROLLER_TRANSLATION_KEY, + model=model, + sw_version=info.sw_version, + hw_version=info.hw_version, + translation_key=translation_key, translation_placeholders={"mac": controller_mac[-5:]}, ) controller_ids[controller_mac] = controller.id diff --git a/custom_components/gree_custom/number.py b/custom_components/gree_custom/number.py index b25c03d..e1ca1b7 100755 --- a/custom_components/gree_custom/number.py +++ b/custom_components/gree_custom/number.py @@ -15,7 +15,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .aiogree.api import HumidityControlMode, OperationMode, ZoneRole +from .aiogree.api import DeviceType, HumidityControlMode, OperationMode from .aiogree.const import ( MAX_HUM_COOL_P, MAX_HUM_DRY_P, @@ -82,7 +82,7 @@ class GreeNumberDescription( key=GATTR_ZONE_TARGET_TEMPERATURE, translation_key=GATTR_ZONE_TARGET_TEMPERATURE, auto_device_support=True, - device_filter=lambda device: device.zone_role is ZoneRole.ZONE, + device_filter=lambda device: device.device_type is DeviceType.ZONE, device_class=NumberDeviceClass.TEMPERATURE, mode=NumberMode.BOX, native_step=1, diff --git a/custom_components/gree_custom/switch.py b/custom_components/gree_custom/switch.py index 71f4bd1..0cecfb4 100755 --- a/custom_components/gree_custom/switch.py +++ b/custom_components/gree_custom/switch.py @@ -15,7 +15,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .aiogree.api import HumidityControlMode, OperationMode, SleepMode, ZoneRole +from .aiogree.api import DeviceType, HumidityControlMode, OperationMode, SleepMode from .aiogree.device import GreeDevice from .aiogree.errors import GreeContinuousDryUnavailable from .const import ( @@ -176,7 +176,7 @@ class GreeSwitchDescription( key=GATTR_ZONE, translation_key=GATTR_ZONE, name=None, - device_filter=lambda device: device.zone_role is ZoneRole.ZONE, + device_filter=lambda device: device.device_type is DeviceType.ZONE, value_func=lambda device, _: device.power_mode, set_func=lambda device, _, value: device.set_power_mode(value), ), diff --git a/custom_components/gree_custom/translations/en.json b/custom_components/gree_custom/translations/en.json index 5e0a367..0eba2ca 100644 --- a/custom_components/gree_custom/translations/en.json +++ b/custom_components/gree_custom/translations/en.json @@ -215,6 +215,9 @@ "device": { "vrf_controller": { "name": "VRF gateway {mac}" + }, + "zone_controller": { + "name": "Zone controller {mac}" } }, "entity": { diff --git a/docs/architecture.md b/docs/architecture.md index 7498585..9b82e5d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -81,6 +81,7 @@ The indoor units behind one local VRF gateway are grouped under a controller dev - A sub-unit is a device whose `connection.local.mac_controller_local` is set and differs from its own MAC. The runtime `mac_address_controller` is not used, because for MQTT it comes from the cloud MAC. - Every local controller MAC with at least one sub-unit gets one controller device. Its identifier is `(gree_custom, "controller_")`, its model is `VRF gateway`, and its name comes from the `vrf_controller` device translation. +- A zone controller gets the same controller device, with model `Zone controller` and the `zone_controller` translation. The `device_type` of a bound sub-unit decides which. With no bound sub-unit the registry keeps the last known model and name, and a new device gets the VRF label. - The controller has no entities. Its `connections` hold the gateway MAC. Discovery never returns the gateway itself as a device, so no other device of this integration has that MAC. - Its `sw_version` and `hw_version` come from the first bound sub-unit, because the firmware belongs to the WiFi module of the gateway. With no bound sub-unit the registry keeps the last known values. - A cloud-only VRF has no local controller MAC, so it gets no controller device yet. diff --git a/docs/entities.md b/docs/entities.md index 6c72f03..6cc657f 100644 --- a/docs/entities.md +++ b/docs/entities.md @@ -6,7 +6,7 @@ Every device gets one climate entity and a set of sensors, switches, selects and 2. **What you enabled.** The **Device Features and Modes** option decides which optional switches and selects are created. See [configuration.md](configuration.md#device-features). 3. **The current mode.** Some features only exist in some HVAC modes. The entity then shows as unavailable in the other modes. -A VRF system also gets a **VRF gateway** device for its WiFi gateway. It has no entities. Its device page lists the indoor units under **Connected devices**. You cannot delete it. Delete its indoor units, and the gateway device goes away with the last one. You can rename it and give it an area. A VRF that is only added through the cloud gets no gateway device yet. +A VRF system also gets a **VRF gateway** device for its WiFi gateway. It has no entities. Its device page lists the indoor units under **Connected devices**. You cannot delete it. Delete its indoor units, and the gateway device goes away with the last one. You can rename it and give it an area. A VRF that is only added through the cloud gets no gateway device yet. A zone controller gets the same kind of device, called **Zone controller**, with its AC unit and zones under **Connected devices**. Home Assistant chooses the entity IDs, not the integration. By default it builds them from the device name and the translated entity name. A device named `Living Room AC` in an English Home Assistant then gets `climate.living_room_ac` and `switch.living_room_ac_x_fan`. That default depends on your language and on your Home Assistant version, and you can change any ID yourself. Copy the IDs from the device page instead of guessing them. The examples below use `climate.your_ac`. diff --git a/docs/protocol.md b/docs/protocol.md index 9adeaa9..2b79ff9 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -90,7 +90,7 @@ A zone controller (seen: LE60-13/GH with a ME31-00/C13 WiFi module, firmware `36 - The scan reply has no `cid`, see [Discovery and network](#discovery-and-network). - It answers only the `subDev` form of the sub-device list. Every unit in the list is called `zone`. The ducted unit has model id `5000` and MAC suffix `00`. The zones have model id `5001` and suffixes `01` to `08`. Discovery names them `AC unit` and `Zone 1`, `Zone 2` and so on. -- A sub-unit answers the info request with the name of the controller, which starts with `GR-ZCntrlr`. `GreeDevice._detect_zone_role()` uses that and the MAC suffix to set `zone_role` after the info fetch. A zone also gets `StTem` added to its poll. +- A sub-unit answers the info request with the name of the controller, which starts with `GR-ZCntrlr`. After the info fetch, `GreeDevice._detect_device_type()` sets `device_type` for every device: `ac_unit` for a unit that is not a sub-unit, `zone_controller` for sub-unit `00` of a zone controller, `zone` for its other sub-units, and `vrf_unit` for any other sub-unit. Only the two zone controller types (`ZONE_CONTROLLER_TYPES`) change behaviour. A zone also gets `StTem` added to its poll. - The ducted unit answers `Pow`, `Mod`, `WdSpd` and `AllErr`, nothing more. `Pow` of the ducted unit is the power of the whole system. The zones keep their own `Pow` while it is off, and get it back when it is turned on. - `Mod` uses its own numbers: 1 Cool, 2 Heat, 3 Dry, 4 Fan, 5 Auto (`ZONE_CONTROLLER_MODES`). Read with the normal numbers, Fan shows as Heat and Auto raises. `operation_mode` and `set_operation_mode()` convert for every unit with a zone role. - There is no `Tur` column. Turbo is `WdSpd` 6, only in Cool and Heat. Dry forces `WdSpd` 1. diff --git a/tests/test_zone_controller.py b/tests/test_zone_controller.py index ccbc173..a7a183c 100644 --- a/tests/test_zone_controller.py +++ b/tests/test_zone_controller.py @@ -12,7 +12,7 @@ from collections.abc import AsyncIterator -from aiogree.api import FanSpeed, GreeProp, OperationMode, ZoneRole +from aiogree.api import DeviceType, FanSpeed, GreeProp, OperationMode from aiogree.device import GreeDevice from aiogree.errors import GreeTurboUnavailable from aiogree.transport_udp import GreeUdpTransport @@ -103,10 +103,18 @@ async def test_discovery_names_the_ducted_unit_and_the_zones( async def test_the_ducted_unit_is_recognised(ac_unit: GreeDevice) -> None: """The name in the info columns and the 00 suffix make it the ducted unit.""" - assert ac_unit.zone_role is ZoneRole.AC_UNIT + assert ac_unit.device_type is DeviceType.ZONE_CONTROLLER assert not ac_unit.supports_property(GreeProp.ZONE_TARGET_TEMPERATURE) +async def test_the_device_type_is_in_the_diagnostics( + ac_unit: GreeDevice, zone: GreeDevice +) -> None: + """The type helps when a user sends diagnostics.""" + assert ac_unit.gather_diagnostics()["info"]["device_type"] == "zone_controller" + assert zone.gather_diagnostics()["info"]["device_type"] == "zone" + + @pytest.mark.parametrize( ("raw", "mode"), [ @@ -194,7 +202,7 @@ async def test_a_zone_is_recognised_and_polls_its_target_temperature( controller: FakeZoneController, zone: GreeDevice ) -> None: """A zone asks for StTem and reads it as degrees Celsius minus 16.""" - assert zone.zone_role is ZoneRole.ZONE + assert zone.device_type is DeviceType.ZONE assert zone.supports_property(GreeProp.ZONE_TARGET_TEMPERATURE) assert zone.zone_target_temperature == 18 @@ -236,7 +244,7 @@ async def test_a_zone_is_opened_and_closed_with_its_power( async def test_a_normal_unit_never_asks_for_the_zone_column() -> None: - """Only zones poll StTem. A normal unit sends the same columns as before.""" + """Only zones poll StTem. A standalone unit sends the same columns as before.""" unit = FakeGreeDevice() await unit.start() transport = GreeUdpTransport(unit.host, unit.port, max_retries=1, timeout=0.3) @@ -252,7 +260,7 @@ async def test_a_normal_unit_never_asks_for_the_zone_column() -> None: asked = {col for pack in unit.packs for col in pack.get("cols", [])} assert GreeProp.ZONE_TARGET_TEMPERATURE.value not in asked - assert device.zone_role is ZoneRole.NONE + assert device.device_type is DeviceType.AC_UNIT async def test_a_vrf_indoor_unit_keeps_the_normal_mode_numbers() -> None: @@ -266,5 +274,5 @@ async def test_a_vrf_indoor_unit_keeps_the_normal_mode_numbers() -> None: finally: gateway.close() - assert device.zone_role is ZoneRole.NONE + assert device.device_type is DeviceType.VRF_UNIT assert device.operation_mode is OperationMode.heat From fe1f4de626066b376ca8a877bcfe7e6d101e2027 Mon Sep 17 00:00:00 2001 From: Twintop Date: Sat, 10 Oct 2026 14:03:07 +1100 Subject: [PATCH 03/18] Climate: only set Quiet and Turbo on units that have them 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. --- custom_components/gree_custom/climate.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/custom_components/gree_custom/climate.py b/custom_components/gree_custom/climate.py index d4607d0..2d69bd6 100755 --- a/custom_components/gree_custom/climate.py +++ b/custom_components/gree_custom/climate.py @@ -717,8 +717,13 @@ async def async_set_fan_mode(self, fan_mode: str) -> None: ) try: - self.device.set_feature_quiet(fan_mode == GATTR_FEAT_QUIET_MODE) - self.device.set_feature_turbo(fan_mode == GATTR_FEAT_TURBO) + # Only touch the special modes the unit has. Setting one it does not + # have logs an error on every fan change (seen: Quiet on the ducted + # unit of a zone controller). + if self.device.supports_property(GreeProp.FEAT_QUIET_MODE): + self.device.set_feature_quiet(fan_mode == GATTR_FEAT_QUIET_MODE) + if self.device.supports_property(GreeProp.FEAT_TURBO_MODE): + self.device.set_feature_turbo(fan_mode == GATTR_FEAT_TURBO) if fan_mode not in (GATTR_FEAT_QUIET_MODE, GATTR_FEAT_TURBO): self.device.set_fan_speed(FanSpeed[fan_mode]) From 5bfa6cbc3f61542c2a471831f81cb60c9a13a4c7 Mon Sep 17 00:00:00 2001 From: Twintop Date: Sat, 10 Oct 2026 22:51:38 +1100 Subject: [PATCH 04/18] Keep the controller label when no sub-unit is bound 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. --- custom_components/gree_custom/helpers.py | 34 ++++++++++++++++++------ 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/custom_components/gree_custom/helpers.py b/custom_components/gree_custom/helpers.py index 5f4d770..142879e 100755 --- a/custom_components/gree_custom/helpers.py +++ b/custom_components/gree_custom/helpers.py @@ -424,6 +424,29 @@ def _controller_info(entry: ConfigEntry, sub_macs: set[str]) -> _ControllerInfo: return _ControllerInfo(UNDEFINED, UNDEFINED, UNDEFINED, UNDEFINED) +def _controller_label( + info: _ControllerInfo, controller_mac: str, exists: bool +) -> dict[str, Any]: + """Return the model and name arguments for a controller device. + + A bound sub-unit tells the kind of controller. Without one, an existing + device keeps the model and name the registry has, so nothing is returned, + and a new device gets the VRF label. translation_key does not take + UNDEFINED, so it is left out rather than passed as UNDEFINED. + """ + model, translation_key = info.model, info.translation_key + if isinstance(translation_key, UndefinedType): + if exists: + return {} + model, translation_key = VRF_CONTROLLER_MODEL, VRF_CONTROLLER_TRANSLATION_KEY + + return { + "model": model, + "translation_key": translation_key, + "translation_placeholders": {"mac": controller_mac[-5:]}, + } + + def reconcile_vrf_controllers( hass: HomeAssistant, entry: ConfigEntry, @@ -459,21 +482,16 @@ def reconcile_vrf_controllers( controller_ids: dict[str, str] = {} for controller_mac, sub_macs in sub_units.items(): info = _controller_info(entry, sub_macs) - model, translation_key = info.model, info.translation_key - if model is UNDEFINED and controller_mac not in existing_controllers: - model = VRF_CONTROLLER_MODEL - translation_key = VRF_CONTROLLER_TRANSLATION_KEY - controller = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, f"{VRF_CONTROLLER_ID_PREFIX}{controller_mac}")}, connections={(dr.CONNECTION_NETWORK_MAC, controller_mac)}, manufacturer="Gree", - model=model, sw_version=info.sw_version, hw_version=info.hw_version, - translation_key=translation_key, - translation_placeholders={"mac": controller_mac[-5:]}, + **_controller_label( + info, controller_mac, controller_mac in existing_controllers + ), ) controller_ids[controller_mac] = controller.id From d6abdcd0721707ad1347d92df9442859cef7ef7e Mon Sep 17 00:00:00 2001 From: Twintop Date: Sat, 10 Oct 2026 22:51:38 +1100 Subject: [PATCH 05/18] Device options: copy only the lists from the previous device 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. --- custom_components/gree_custom/config_flow.py | 55 ++++++++++++++----- .../gree_custom/config_schema.py | 8 ++- 2 files changed, 47 insertions(+), 16 deletions(-) diff --git a/custom_components/gree_custom/config_flow.py b/custom_components/gree_custom/config_flow.py index 52f491e..57283b8 100755 --- a/custom_components/gree_custom/config_flow.py +++ b/custom_components/gree_custom/config_flow.py @@ -40,6 +40,7 @@ from . import create_yaml_import_issue, delete_yaml_import_issue from .aiogree.api import ( + DeviceType, GreeDiscoveredDevice, gree_discover_device_local, gree_discover_devices_cloud, @@ -99,6 +100,7 @@ DOMAIN, ENCRYPTION_VERSION_AUTO, MAX_UNICAST_SCAN_HOSTS, + ZONE_ONLY_FEATURES, ) from .coordinator import GreeConfigEntry from .helpers import ( @@ -112,6 +114,36 @@ _LOGGER = logging.getLogger(__name__) +# The options that carry over from a device set up before, as defaults +_COPIED_OPTIONS = ( + CONF_HVAC_MODES, + CONF_FAN_MODES, + CONF_SWING_MODES, + CONF_SWING_HORIZONTAL_MODES, + CONF_FEATURES, +) + + +def _copied_options( + device: GreeDevice, options: Mapping[str, Any] | None +) -> dict[str, Any] | None: + """Take the options of another device as defaults for this one. + + Only the mode and feature lists carry over, not the name. The Zone Switch + is chosen per zone, because only an always open zone goes without it, so + it never carries over: a zone starts with it, any other device without it. + """ + if not options: + return None + + copied = {key: options[key] for key in _COPIED_OPTIONS if key in options} + if CONF_FEATURES in copied: + features = [f for f in copied[CONF_FEATURES] if f not in ZONE_ONLY_FEATURES] + if device.device_type is DeviceType.ZONE: + features.extend(ZONE_ONLY_FEATURES) + copied[CONF_FEATURES] = features + return copied + def _matches_cloud_account( stored: Mapping[str, Any] | None, cloud_conf: Mapping[str, Any] @@ -928,17 +960,7 @@ async def async_step_device_options( .get(CONF_DEVICE_OPTIONS, None) ) elif model_options := self._options_by_model.get(device.device_model_id): - default = { - key: model_options[key] - for key in ( - CONF_HVAC_MODES, - CONF_FAN_MODES, - CONF_SWING_MODES, - CONF_SWING_HORIZONTAL_MODES, - CONF_FEATURES, - ) - if key in model_options - } + default = _copied_options(device, model_options) # During reconfigure inject previous device options if not default and self.source == SOURCE_RECONFIGURE: @@ -949,13 +971,16 @@ async def async_step_device_options( .get(CONF_DEVICE_OPTIONS, {}) ) + # Otherwise start from the device of the same controller set up before + if not default: + default = _copied_options( + device, self._options_by_controller.get(device.mac_address_controller) + ) + data_schema = setup_device_options_schema( hass=self.hass, device=device, - default_values=( - default - or self._options_by_controller.get(device.mac_address_controller) - ), + default_values=default, ) return self.async_show_form( diff --git a/custom_components/gree_custom/config_schema.py b/custom_components/gree_custom/config_schema.py index e928d21..d15498c 100644 --- a/custom_components/gree_custom/config_schema.py +++ b/custom_components/gree_custom/config_schema.py @@ -702,7 +702,13 @@ def setup_device_options_schema( # noqa: C901 { probatio.Optional( CONF_FEATURES, - default=defaults.get(CONF_FEATURES, valid_features), + # Defaults copied from another device may hold features + # this one does not have. + default=[ + feature + for feature in defaults.get(CONF_FEATURES, valid_features) + if feature in valid_features + ], ): SelectSelector( config=SelectSelectorConfig( options=valid_features, From d666fe103a4344b84fa29cc891302903b98d9a3d Mon Sep 17 00:00:00 2001 From: Twintop Date: Sat, 10 Oct 2026 23:27:43 +1100 Subject: [PATCH 06/18] Loop over a copy of the repair issues when cleaning them up 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. --- custom_components/gree_custom/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/custom_components/gree_custom/__init__.py b/custom_components/gree_custom/__init__.py index 595e2dc..dc72989 100755 --- a/custom_components/gree_custom/__init__.py +++ b/custom_components/gree_custom/__init__.py @@ -418,7 +418,8 @@ def cleanup_device_connection_issues( registry = ir.async_get(hass) prefix = f"{ISSUE_DEVICE_CONNECTION_FAILED}_{config_entry_id}_" - for domain, issue_id in registry.issues: + # Loop over a copy, the delete below changes the dict + for domain, issue_id in list(registry.issues): if domain != DOMAIN or not issue_id.startswith(prefix): continue From 3a806afccea2e76d84229d7617c84e0e6585ba58 Mon Sep 17 00:00:00 2001 From: Twintop Date: Tue, 13 Oct 2026 09:22:31 +1100 Subject: [PATCH 07/18] Zone controllers: keep what tells a model apart in one table 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. --- custom_components/gree_custom/aiogree/api.py | 83 ++++++++++++++--- .../gree_custom/aiogree/device.py | 16 ++-- docs/protocol.md | 3 +- tests/fakes/zone.py | 16 +++- tests/test_zone_controller.py | 90 ++++++++++++++++++- 5 files changed, 184 insertions(+), 24 deletions(-) diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index 35e8b2e..d408691 100755 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -503,11 +503,70 @@ class DeviceType(StrEnum): ZONE_CONTROLLER_TYPES = frozenset({DeviceType.ZONE_CONTROLLER, DeviceType.ZONE}) -# A zone controller and its sub-units report a name that starts with this. -ZONE_CONTROLLER_NAME_PREFIX = "GR-ZCntrlr" -# The model ids of the sub-units in the sub-device list. -ZONE_CONTROLLER_MID_AC_UNIT = "5000" -ZONE_CONTROLLER_MID_ZONE = "5001" +class ZoneControllerModel(NamedTuple): + """What tells the sub-units of one zone controller model apart.""" + + # Every sub-unit answers the info request with the name of the + # controller. The entry with the longest prefix of that name is used. + name_prefix: str + # The last two characters of the MAC of the ducted unit. The other + # sub-units are the zones. + ac_unit_suffix: str + # The model ids (mid) in the sub-device list, only used by discovery to + # name the sub-units + mid_ac_unit: str + mid_zone: str + + +# The known zone controller models. The WiFi module sets these values itself, +# not the units, so they are the same for every controller with that module. +# +# A sub-unit gets the entry with the longest name_prefix that its name starts +# with. So the order here does not matter, and a more specific entry always +# wins over a general one. A model that shows other values only needs an +# entry with a longer prefix, for example "GR-ZCntrlr_3000_". +ZONE_CONTROLLER_MODELS: tuple[ZoneControllerModel, ...] = ( + # General entry: any controller with the zone controller firmware of the + # ME31-00/C13 WiFi module. The module builds the name as + # "GR-ZCntrlr__<2 digits>_", with "_EC" added + # since v1.27. The values below are fixed in that firmware (v1.21, v1.27 + # and v1.28 checked). Used when no longer prefix matches. + ZoneControllerModel( + name_prefix="GR-ZCntrlr", + ac_unit_suffix="00", + mid_ac_unit="5000", + mid_zone="5001", + ), + # LE60-13/GH (also sold as Braemar LE60), seen as + # "GR-ZCntrlr_5000_02_5a5f_EC". Both entries match that name, and this + # prefix is the longer one, so this entry is used. It has the same values + # as the general entry: it is here as an example of a more specific entry. + # A hypothetical "GR-ZCntrlr_3000_01_5a5f_EC" would not match it and gets + # the general entry. + ZoneControllerModel( + name_prefix="GR-ZCntrlr_5000_", + ac_unit_suffix="00", + mid_ac_unit="5000", + mid_zone="5001", + ), +) + + +def find_zone_controller_model(name: str) -> ZoneControllerModel | None: + """Return the zone controller model whose sub-units report this name. + + When several entries match, the one with the longest name_prefix wins. + """ + return max( + ( + model + for model in ZONE_CONTROLLER_MODELS + if name.startswith(model.name_prefix) + ), + key=lambda model: len(model.name_prefix), + default=None, + ) + # The ducted unit behind a zone controller numbers the modes in its own way. ZONE_CONTROLLER_MODES: dict[int, OperationMode] = { @@ -1340,18 +1399,20 @@ def _zone_controller_sub_unit_name(sub_mac: str, sub_dev: dict[str, Any]) -> str """Name a sub-unit of a zone controller. A zone controller calls every sub-unit "zone", so the name says nothing. - The model id tells the ducted unit from a zone, and the last two - characters of the MAC are the zone number. + The model id tells the ducted unit from a zone, see + ZONE_CONTROLLER_MODELS, and the last two characters of the MAC are the + zone number. Returns: The name, or None if the unit is not part of a zone controller """ mid = sub_dev.get("mid") - if mid == ZONE_CONTROLLER_MID_AC_UNIT: - return "AC unit" - if mid == ZONE_CONTROLLER_MID_ZONE: - return f"Zone {int(sub_mac[-2:], 16)}" + for model in ZONE_CONTROLLER_MODELS: + if mid == model.mid_ac_unit: + return "AC unit" + if mid == model.mid_zone: + return f"Zone {int(sub_mac[-2:], 16)}" return None diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index e5d3565..399d351 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -11,7 +11,6 @@ ZONE_CONTROLLER_FAN_TURBO, ZONE_CONTROLLER_MODES, ZONE_CONTROLLER_MODES_TO_RAW, - ZONE_CONTROLLER_NAME_PREFIX, ZONE_CONTROLLER_TYPES, ZONE_TEMPERATURE_OFFSET, DeviceType, @@ -27,6 +26,7 @@ TemperatureUnits, VerticalSwingMode, extract_fw_version, + find_zone_controller_model, ) from .cloud_api import FirmwareInfoResponse, GreeRegion, gree_get_latest_firmware_info from .const import ( @@ -202,16 +202,18 @@ def _detect_device_type(self) -> None: A unit that is not a sub-unit is a standalone unit. A sub-unit of a zone controller answers the info request with the name of the zone - controller: sub-unit `00` is the ducted unit, the others are zones. - Every other sub-unit is a VRF indoor unit. A zone also gets its - target temperature polled. + controller, and the MAC suffix of the model tells the ducted unit + from the zones (see ZONE_CONTROLLER_MODELS). Every other sub-unit is + a VRF indoor unit. A zone also gets its target temperature polled. """ - name = self._state.info.get(InfoProp.DEVICE_NAME, "") + model = find_zone_controller_model( + self._state.info.get(InfoProp.DEVICE_NAME, "") + ) if not self.is_sub_unit: self._device_type = DeviceType.AC_UNIT - elif not name.startswith(ZONE_CONTROLLER_NAME_PREFIX): + elif model is None: self._device_type = DeviceType.VRF_UNIT - elif self._mac_addr.endswith("00"): + elif self._mac_addr.endswith(model.ac_unit_suffix): self._device_type = DeviceType.ZONE_CONTROLLER else: self._device_type = DeviceType.ZONE diff --git a/docs/protocol.md b/docs/protocol.md index 2b79ff9..2a201c0 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -90,7 +90,8 @@ A zone controller (seen: LE60-13/GH with a ME31-00/C13 WiFi module, firmware `36 - The scan reply has no `cid`, see [Discovery and network](#discovery-and-network). - It answers only the `subDev` form of the sub-device list. Every unit in the list is called `zone`. The ducted unit has model id `5000` and MAC suffix `00`. The zones have model id `5001` and suffixes `01` to `08`. Discovery names them `AC unit` and `Zone 1`, `Zone 2` and so on. -- A sub-unit answers the info request with the name of the controller, which starts with `GR-ZCntrlr`. After the info fetch, `GreeDevice._detect_device_type()` sets `device_type` for every device: `ac_unit` for a unit that is not a sub-unit, `zone_controller` for sub-unit `00` of a zone controller, `zone` for its other sub-units, and `vrf_unit` for any other sub-unit. Only the two zone controller types (`ZONE_CONTROLLER_TYPES`) change behaviour. A zone also gets `StTem` added to its poll. +- A sub-unit answers the info request with the name of the controller, which starts with `GR-ZCntrlr`, and with the model id of the controller (`3017`), not its own. After the info fetch, `GreeDevice._detect_device_type()` sets `device_type` for every device: `ac_unit` for a unit that is not a sub-unit, `zone_controller` for sub-unit `00` of a zone controller, `zone` for its other sub-units, and `vrf_unit` for any other sub-unit. +- The name prefix, the MAC suffix of the ducted unit and the two model ids come from the WiFi module, not from the units: they are fixed in its firmware (v1.21, v1.27 and v1.28 checked). So they are the same for every controller with this module. The Braemar LE60 uses the same module, so it should show the same values (not tested). They are kept per model in `ZONE_CONTROLLER_MODELS`. A sub-unit gets the entry with the longest `name_prefix` that its name starts with, so the order does not matter and a more specific entry wins. The table has a general `GR-ZCntrlr` entry and, as an example of a more specific one, a `GR-ZCntrlr_5000_` entry for the LE60-13/GH with the same values. A model that shows other values only needs an entry with a longer prefix, for example `GR-ZCntrlr_3000_`. Only the two zone controller types (`ZONE_CONTROLLER_TYPES`) change behaviour. A zone also gets `StTem` added to its poll. - The ducted unit answers `Pow`, `Mod`, `WdSpd` and `AllErr`, nothing more. `Pow` of the ducted unit is the power of the whole system. The zones keep their own `Pow` while it is off, and get it back when it is turned on. - `Mod` uses its own numbers: 1 Cool, 2 Heat, 3 Dry, 4 Fan, 5 Auto (`ZONE_CONTROLLER_MODES`). Read with the normal numbers, Fan shows as Heat and Auto raises. `operation_mode` and `set_operation_mode()` convert for every unit with a zone role. - There is no `Tur` column. Turbo is `WdSpd` 6, only in Cool and Heat. Dry forces `WdSpd` 1. diff --git a/tests/fakes/zone.py b/tests/fakes/zone.py index de83aeb..eb395e8 100644 --- a/tests/fakes/zone.py +++ b/tests/fakes/zone.py @@ -17,7 +17,7 @@ from typing import Any, override -from aiogree.api import SubListForm +from aiogree.api import SubListForm, ZoneControllerModel, find_zone_controller_model from .vrf import FakeVrfGateway @@ -34,6 +34,7 @@ def __init__( name: str = ZONE_CONTROLLER_NAME, *, zones: int = 4, + model: ZoneControllerModel | None = None, **kwargs: Any, ) -> None: """Set up the controller. @@ -42,6 +43,8 @@ def __init__( mac: MAC of the controller. name: Name in the scan reply and in the info columns. zones: How many zones the controller has. + model: The MAC suffix of the ducted unit and the model ids to use. + By default the entry of ZONE_CONTROLLER_MODELS for `name`. kwargs: Anything FakeVrfGateway accepts. """ @@ -49,7 +52,11 @@ def __init__( kwargs.setdefault("max_columns", 25) super().__init__(mac=mac, name=name, sub_count=zones + 1, **kwargs) - self.ac_unit = f"{mac}00" + model = model or find_zone_controller_model(name) + if model is None: + raise ValueError(f"No zone controller model for the name {name}") + self.model = model + self.ac_unit = f"{mac}{model.ac_unit_suffix}" self.zone_macs = [f"{mac}{index:02d}" for index in range(1, zones + 1)] self.units: dict[str, dict[str, Any]] = { mac: {"Pow": 0, "Mod": 4, "WdSpd": 5}, @@ -87,9 +94,10 @@ def build_scan_info(self) -> dict[str, Any]: @override def sub_devices(self, form: SubListForm) -> list[dict[str, str]]: """Return the ducted unit and the zones, each called "zone".""" - units = [{"mac": self.ac_unit, "mid": "5000", "name": "zone"}] + units = [{"mac": self.ac_unit, "mid": self.model.mid_ac_unit, "name": "zone"}] units += [ - {"mac": zone, "mid": "5001", "name": "zone"} for zone in self.zone_macs + {"mac": zone, "mid": self.model.mid_zone, "name": "zone"} + for zone in self.zone_macs ] return units diff --git a/tests/test_zone_controller.py b/tests/test_zone_controller.py index a7a183c..91e0bd9 100644 --- a/tests/test_zone_controller.py +++ b/tests/test_zone_controller.py @@ -12,7 +12,14 @@ from collections.abc import AsyncIterator -from aiogree.api import DeviceType, FanSpeed, GreeProp, OperationMode +from aiogree import api +from aiogree.api import ( + DeviceType, + FanSpeed, + GreeProp, + OperationMode, + ZoneControllerModel, +) from aiogree.device import GreeDevice from aiogree.errors import GreeTurboUnavailable from aiogree.transport_udp import GreeUdpTransport @@ -96,6 +103,87 @@ async def test_discovery_names_the_ducted_unit_and_the_zones( assert not any("sub-devices" in line for line in gree_logs.warnings()) +def test_the_seen_name_gets_its_own_entry_and_others_the_general_one() -> None: + """The name of the real controller matches the longer GR-ZCntrlr_5000_ prefix.""" + seen = api.find_zone_controller_model("GR-ZCntrlr_5000_02_5a5f_EC") + unseen = api.find_zone_controller_model("GR-ZCntrlr_3000_01_5a5f_EC") + + assert seen is not None + assert seen.name_prefix == "GR-ZCntrlr_5000_" + assert unseen is not None + assert unseen.name_prefix == "GR-ZCntrlr" + assert api.find_zone_controller_model("GR-VRF_5a5f") is None + + +@pytest.mark.parametrize( + "reverse", [False, True], ids=["general first", "general last"] +) +def test_the_longest_matching_name_prefix_wins( + monkeypatch: pytest.MonkeyPatch, reverse: bool +) -> None: + """A more specific entry wins over a general one, whatever the order of the table.""" + general = ZoneControllerModel( + name_prefix="GR-ZCntrlr", + ac_unit_suffix="00", + mid_ac_unit="5000", + mid_zone="5001", + ) + specific = ZoneControllerModel( + name_prefix="GR-ZCntrlr_3000_", + ac_unit_suffix="10", + mid_ac_unit="3000", + mid_zone="3001", + ) + table = (specific, general) if reverse else (general, specific) + monkeypatch.setattr(api, "ZONE_CONTROLLER_MODELS", table) + + assert api.find_zone_controller_model("GR-ZCntrlr_3000_01_ffee_EC") == specific + assert api.find_zone_controller_model("GR-ZCntrlr_5000_02_ffee_EC") == general + + +async def test_another_model_only_needs_an_entry_in_the_table( + loopback_ip: str, discover_one: DiscoverOne, monkeypatch: pytest.MonkeyPatch +) -> None: + """A model with other values works once it is in ZONE_CONTROLLER_MODELS.""" + other = ZoneControllerModel( + name_prefix="GR-OtherZone", + ac_unit_suffix="10", + mid_ac_unit="6000", + mid_zone="6001", + ) + controller = FakeZoneController( + name="GR-OtherZone_6000_01_ffee", zones=2, model=other + ) + await controller.start(loopback_ip, DISCOVERY_PORT) + transports: list[GreeUdpTransport] = [] + try: + # Not in the table yet, so it is taken for a VRF gateway + unknown, transport = await bind_unit(controller, "01") + transports.append(transport) + assert unknown.device_type is DeviceType.VRF_UNIT + + monkeypatch.setattr( + api, "ZONE_CONTROLLER_MODELS", (*api.ZONE_CONTROLLER_MODELS, other) + ) + found = await discover_one(controller.host, timeout=1) + ac_unit, transport = await bind_unit(controller, "10") + transports.append(transport) + zone, transport = await bind_unit(controller, "01") + transports.append(transport) + finally: + for transport in transports: + await transport.disconnect() + controller.close() + + assert [(d.name, d.mac) for d in found] == [ + ("AC unit", f"{ZONE_CONTROLLER_MAC}10"), + ("Zone 1", f"{ZONE_CONTROLLER_MAC}01"), + ("Zone 2", f"{ZONE_CONTROLLER_MAC}02"), + ] + assert ac_unit.device_type is DeviceType.ZONE_CONTROLLER + assert zone.device_type is DeviceType.ZONE + + # # The ducted unit # From 9f1177502d51491a77bc74530b26300575115150 Mon Sep 17 00:00:00 2001 From: Twintop Date: Tue, 13 Oct 2026 09:23:38 +1100 Subject: [PATCH 08/18] Zones: use target_temperature instead of a property of their own 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. --- .../gree_custom/aiogree/device.py | 37 +++++++++++-------- custom_components/gree_custom/number.py | 6 +-- tests/test_zone_controller.py | 21 ++++++++--- 3 files changed, 39 insertions(+), 25 deletions(-) diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index 399d351..5029ea5 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -640,19 +640,6 @@ def device_type(self) -> DeviceType: """Return what kind of device this is. Known after the first bind.""" return self._device_type - @property - def zone_target_temperature(self) -> int | None: - """Return the target temperature of a zone in degrees Celsius.""" - raw = self._state.get(GreeProp.ZONE_TARGET_TEMPERATURE) - return None if raw is None else raw + ZONE_TEMPERATURE_OFFSET - - def set_zone_target_temperature(self, value: int) -> None: - """Set the target temperature of a zone in degrees Celsius.""" - value = max(MIN_TEMP_C, min(MAX_TEMP_C, int(value))) - self._state.set( - GreeProp.ZONE_TARGET_TEMPERATURE, value - ZONE_TEMPERATURE_OFFSET - ) - @property def has_held_values(self) -> bool: """Return True if sent values still wait for the device to confirm them.""" @@ -855,8 +842,16 @@ def set_target_temperature_unit(self, units: TemperatureUnits) -> None: self._state.set(GreeProp.TARGET_TEMPERATURE_UNIT, units) @property - def target_temperature(self) -> float: - """Return the target temperature in target_temperature_unit.""" + def target_temperature(self) -> float | None: + """Return the target temperature in target_temperature_unit. + + A zone has its own column in whole degrees Celsius, see + GreeProp.ZONE_TARGET_TEMPERATURE. It is None until the zone is read. + """ + + if self._device_type is DeviceType.ZONE: + raw = self._state.get(GreeProp.ZONE_TARGET_TEMPERATURE) + return None if raw is None else raw + ZONE_TEMPERATURE_OFFSET raw_c = self._state.get(GreeProp.TARGET_TEMPERATURE, 0) tem_rec = self._state.get(GreeProp.TARGET_TEMPERATURE_BIT, 0) @@ -869,7 +864,17 @@ def target_temperature(self) -> float: return 0.0 def set_target_temperature(self, value: float) -> None: - """Set the target temperature in target_temperature_unit.""" + """Set the target temperature in target_temperature_unit. + + A zone takes whole degrees Celsius from 16 to 30. + """ + + if self._device_type is DeviceType.ZONE: + degrees = max(MIN_TEMP_C, min(MAX_TEMP_C, round(value))) + self._state.set( + GreeProp.ZONE_TARGET_TEMPERATURE, degrees - ZONE_TEMPERATURE_OFFSET + ) + return if self.target_temperature_unit == TemperatureUnits.F: if not value.is_integer(): diff --git a/custom_components/gree_custom/number.py b/custom_components/gree_custom/number.py index e1ca1b7..0e288c9 100755 --- a/custom_components/gree_custom/number.py +++ b/custom_components/gree_custom/number.py @@ -42,7 +42,7 @@ class GreeNumberDescription( ): """Description of a Gree number.""" - value_func: Callable[[GreeDevice], int | None] + value_func: Callable[[GreeDevice], float | None] set_func: Callable[[GreeDevice, int], None] min_func: Callable[[GreeDevice], int] | None = None max_func: Callable[[GreeDevice], int] | None = None @@ -89,8 +89,8 @@ class GreeNumberDescription( native_min_value=MIN_TEMP_C, native_max_value=MAX_TEMP_C, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - value_func=lambda device: device.zone_target_temperature, - set_func=lambda device, value: device.set_zone_target_temperature(value), + value_func=lambda device: device.target_temperature, + set_func=lambda device, value: device.set_target_temperature(value), updates_device=True, ), ] diff --git a/tests/test_zone_controller.py b/tests/test_zone_controller.py index 91e0bd9..2f94db0 100644 --- a/tests/test_zone_controller.py +++ b/tests/test_zone_controller.py @@ -292,24 +292,33 @@ async def test_a_zone_is_recognised_and_polls_its_target_temperature( """A zone asks for StTem and reads it as degrees Celsius minus 16.""" assert zone.device_type is DeviceType.ZONE assert zone.supports_property(GreeProp.ZONE_TARGET_TEMPERATURE) - assert zone.zone_target_temperature == 18 + assert zone.target_temperature == 18 controller.units[controller.zone_macs[0]]["StTem"] = 5 await zone.fetch_device_status() - assert zone.zone_target_temperature == 21 + assert zone.target_temperature == 21 async def test_a_zone_target_temperature_is_sent_minus_16( controller: FakeZoneController, zone: GreeDevice ) -> None: - """19 degrees goes out as 3, and values outside 16 to 30 are clamped.""" - zone.set_zone_target_temperature(19) + """19 degrees goes out as 3, without SetTem, and values outside 16 to 30 are clamped.""" + controller.reset_record() + zone.set_target_temperature(19) await zone.push_device_status() assert controller.units[controller.zone_macs[0]]["StTem"] == 3 - - zone.set_zone_target_temperature(35) + sent = { + column + for pack in controller.packs + if pack.get("t") == "cmd" + for column in pack["opt"] + } + assert "StTem" in sent + assert not sent & {"SetTem", "TemRec"} + + zone.set_target_temperature(35) await zone.push_device_status() assert controller.units[controller.zone_macs[0]]["StTem"] == 14 From d7f57c411378ab69b0a16afe985ca52087dc675b Mon Sep 17 00:00:00 2001 From: Twintop Date: Tue, 13 Oct 2026 09:24:39 +1100 Subject: [PATCH 09/18] Zones: support Fahrenheit 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. --- custom_components/gree_custom/aiogree/api.py | 17 +++- .../gree_custom/aiogree/device.py | 51 +++++++++--- custom_components/gree_custom/const.py | 5 +- custom_components/gree_custom/number.py | 41 ++++++++-- custom_components/gree_custom/select.py | 10 ++- docs/entities.md | 4 +- docs/protocol.md | 1 + tests/fakes/zone.py | 25 +++++- tests/test_zone_controller.py | 80 ++++++++++++++++++- 9 files changed, 203 insertions(+), 31 deletions(-) diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index d408691..dbf6024 100755 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -97,17 +97,28 @@ class GreeProp(StrEnum): # ZONE CONTROLLER # target temperature of one zone, in whole degrees Celsius minus 16 ZONE_TARGET_TEMPERATURE = "StTem" + # used to distinguish between Fahrenheit values of a zone, like TemRec + ZONE_TARGET_TEMPERATURE_BIT = "StFahFlg" + # the unit of temperature of a zone controller and all its sub-units + ZONE_TEMPERATURE_UNIT = "TemUnit" PROP_KEY_TO_ENUM = {prop.value: prop for prop in GreeProp} -# Props every device is polled for by default. The beeper is only written, -# never read. The zone target temperature is only polled on zones, see +# Props only the sub-units of a zone controller are polled for, see # `GreeDevice.device_type`. +ZONE_CONTROLLER_PROPS: tuple[GreeProp, ...] = ( + GreeProp.ZONE_TARGET_TEMPERATURE, + GreeProp.ZONE_TARGET_TEMPERATURE_BIT, + GreeProp.ZONE_TEMPERATURE_UNIT, +) + +# Props every device is polled for by default. The beeper is only written, +# never read. POLLED_PROPS: tuple[GreeProp, ...] = tuple( p for p in GreeProp - if p not in (GreeProp.BEEPER, GreeProp.BEEPER_NEW, GreeProp.ZONE_TARGET_TEMPERATURE) + if p not in (GreeProp.BEEPER, GreeProp.BEEPER_NEW, *ZONE_CONTROLLER_PROPS) ) diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index 5029ea5..6a559f0 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -11,6 +11,7 @@ ZONE_CONTROLLER_FAN_TURBO, ZONE_CONTROLLER_MODES, ZONE_CONTROLLER_MODES_TO_RAW, + ZONE_CONTROLLER_PROPS, ZONE_CONTROLLER_TYPES, ZONE_TEMPERATURE_OFFSET, DeviceType, @@ -204,7 +205,9 @@ def _detect_device_type(self) -> None: zone controller answers the info request with the name of the zone controller, and the MAC suffix of the model tells the ducted unit from the zones (see ZONE_CONTROLLER_MODELS). Every other sub-unit is - a VRF indoor unit. A zone also gets its target temperature polled. + a VRF indoor unit. The sub-units of a zone controller also get its own + columns polled: the unit of temperature, and on a zone the target + temperature. """ model = find_zone_controller_model( self._state.info.get(InfoProp.DEVICE_NAME, "") @@ -215,9 +218,11 @@ def _detect_device_type(self) -> None: self._device_type = DeviceType.VRF_UNIT elif self._mac_addr.endswith(model.ac_unit_suffix): self._device_type = DeviceType.ZONE_CONTROLLER + self._state.add_polled(GreeProp.ZONE_TEMPERATURE_UNIT) else: self._device_type = DeviceType.ZONE - self._state.add_polled(GreeProp.ZONE_TARGET_TEMPERATURE) + for prop in ZONE_CONTROLLER_PROPS: + self._state.add_polled(prop) _LOGGER.debug("[%s] Device type: %s", self.unique_id, self._device_type) @@ -833,25 +838,39 @@ def set_horizontal_swing_mode(self, swing_mode: HorizontalSwingMode) -> None: def target_temperature_unit(self) -> TemperatureUnits: """Return the units of the target temperature.""" return TemperatureUnits( - self._state.get(GreeProp.TARGET_TEMPERATURE_UNIT) - or TemperatureUnits.C.value + self._state.get(self._temperature_unit_prop) or TemperatureUnits.C.value ) def set_target_temperature_unit(self, units: TemperatureUnits) -> None: """Set the units of the target temperature.""" - self._state.set(GreeProp.TARGET_TEMPERATURE_UNIT, units) + self._state.set(self._temperature_unit_prop, units) + + @property + def _temperature_unit_prop(self) -> GreeProp: + """Return the column with the unit. A zone controller has its own.""" + if self._device_type in ZONE_CONTROLLER_TYPES: + return GreeProp.ZONE_TEMPERATURE_UNIT + return GreeProp.TARGET_TEMPERATURE_UNIT @property def target_temperature(self) -> float | None: """Return the target temperature in target_temperature_unit. - A zone has its own column in whole degrees Celsius, see - GreeProp.ZONE_TARGET_TEMPERATURE. It is None until the zone is read. + A zone has its own columns, see GreeProp.ZONE_TARGET_TEMPERATURE: whole + degrees in Celsius, and in Fahrenheit the same table as SetTem and + TemRec. It is None until the zone is read. """ if self._device_type is DeviceType.ZONE: raw = self._state.get(GreeProp.ZONE_TARGET_TEMPERATURE) - return None if raw is None else raw + ZONE_TEMPERATURE_OFFSET + if raw is None: + return None + zone_c = raw + ZONE_TEMPERATURE_OFFSET + if self.target_temperature_unit == TemperatureUnits.F: + return gree_get_target_temperature_f( + zone_c, self._state.get(GreeProp.ZONE_TARGET_TEMPERATURE_BIT) or 0 + ) + return zone_c raw_c = self._state.get(GreeProp.TARGET_TEMPERATURE, 0) tem_rec = self._state.get(GreeProp.TARGET_TEMPERATURE_BIT, 0) @@ -866,13 +885,21 @@ def target_temperature(self) -> float | None: def set_target_temperature(self, value: float) -> None: """Set the target temperature in target_temperature_unit. - A zone takes whole degrees Celsius from 16 to 30. + A zone takes 16 to 30 degrees Celsius or 61 to 86 Fahrenheit. Its bit + is always sent along, because the controller keeps the old one when + only the temperature is sent. """ if self._device_type is DeviceType.ZONE: - degrees = max(MIN_TEMP_C, min(MAX_TEMP_C, round(value))) - self._state.set( - GreeProp.ZONE_TARGET_TEMPERATURE, degrees - ZONE_TEMPERATURE_OFFSET + if self.target_temperature_unit == TemperatureUnits.F: + raw_c, bit = gree_get_target_temp_props_from_f(round(value)) + else: + raw_c, bit = max(MIN_TEMP_C, min(MAX_TEMP_C, round(value))), 0 + self._state.update( + { + GreeProp.ZONE_TARGET_TEMPERATURE: raw_c - ZONE_TEMPERATURE_OFFSET, + GreeProp.ZONE_TARGET_TEMPERATURE_BIT: bit, + } ) return diff --git a/custom_components/gree_custom/const.py b/custom_components/gree_custom/const.py index b82502c..a883848 100755 --- a/custom_components/gree_custom/const.py +++ b/custom_components/gree_custom/const.py @@ -162,7 +162,10 @@ } CONF_TO_PROP_FEATURE_MAP: dict[str, list[GreeProp]] = { - GATTR_TEMP_UNITS: [GreeProp.TARGET_TEMPERATURE_UNIT], + GATTR_TEMP_UNITS: [ + GreeProp.TARGET_TEMPERATURE_UNIT, + GreeProp.ZONE_TEMPERATURE_UNIT, + ], GATTR_ZONE_TARGET_TEMPERATURE: [GreeProp.ZONE_TARGET_TEMPERATURE], # SENSORS **ATTR_SENSORS_TO_PROP_MAP, diff --git a/custom_components/gree_custom/number.py b/custom_components/gree_custom/number.py index 0e288c9..7751326 100755 --- a/custom_components/gree_custom/number.py +++ b/custom_components/gree_custom/number.py @@ -10,25 +10,33 @@ NumberEntityDescription, NumberMode, ) -from homeassistant.const import PERCENTAGE, UnitOfTemperature +from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .aiogree.api import DeviceType, HumidityControlMode, OperationMode +from .aiogree.api import ( + DeviceType, + HumidityControlMode, + OperationMode, + TemperatureUnits, +) from .aiogree.const import ( MAX_HUM_COOL_P, MAX_HUM_DRY_P, MAX_TEMP_C, + MAX_TEMP_F, MIN_HUM_COOL_P, MIN_HUM_DRY_P, MIN_TEMP_C, + MIN_TEMP_F, ) from .aiogree.device import GreeDevice from .const import ( GATTR_FEAT_HUMIDITY, GATTR_FEAT_HUMIDITY_TARGET, GATTR_ZONE_TARGET_TEMPERATURE, + UNITS_GREE_TO_HA, ) from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription @@ -46,6 +54,8 @@ class GreeNumberDescription( set_func: Callable[[GreeDevice, int], None] min_func: Callable[[GreeDevice], int] | None = None max_func: Callable[[GreeDevice], int] | None = None + # The unit, when the device decides it, such as Celsius or Fahrenheit + unit_func: Callable[[GreeDevice], str] | None = None updates_device: bool = True @@ -86,9 +96,17 @@ class GreeNumberDescription( device_class=NumberDeviceClass.TEMPERATURE, mode=NumberMode.BOX, native_step=1, - native_min_value=MIN_TEMP_C, - native_max_value=MAX_TEMP_C, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, + min_func=lambda device: ( + MIN_TEMP_F + if device.target_temperature_unit is TemperatureUnits.F + else MIN_TEMP_C + ), + max_func=lambda device: ( + MAX_TEMP_F + if device.target_temperature_unit is TemperatureUnits.F + else MAX_TEMP_C + ), + unit_func=lambda device: UNITS_GREE_TO_HA[device.target_temperature_unit], value_func=lambda device: device.target_temperature, set_func=lambda device, value: device.set_target_temperature(value), updates_device=True, @@ -173,6 +191,15 @@ def native_max_value(self) -> float: return super().native_max_value + @property + @override + def native_unit_of_measurement(self) -> str | None: + """Return the unit, from the device when it decides it.""" + if self.entity_description.unit_func is not None: + return self.entity_description.unit_func(self.device) + + return super().native_unit_of_measurement + @property @override def native_value(self) -> float | None: @@ -186,7 +213,9 @@ async def async_set_native_value(self, value: float) -> None: raise HomeAssistantError("Entity unavailable") try: - self.entity_description.set_func(self.device, int(value)) + # Round, not cut: a temperature converted by Home Assistant can + # be 69.8 for 70 degrees Fahrenheit + self.entity_description.set_func(self.device, round(value)) if self.entity_description.updates_device: await self.coordinator.push_device_status() diff --git a/custom_components/gree_custom/select.py b/custom_components/gree_custom/select.py index 3fea4c7..f0bd2b1 100755 --- a/custom_components/gree_custom/select.py +++ b/custom_components/gree_custom/select.py @@ -11,7 +11,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .aiogree.api import HumidityControlMode, OperationMode, TemperatureUnits +from .aiogree.api import ( + DeviceType, + HumidityControlMode, + OperationMode, + TemperatureUnits, +) from .aiogree.device import GreeDevice from .aiogree.errors import ( GreeContinuousDryUnavailable, @@ -62,6 +67,9 @@ def _set_humidity_control_mode(device: GreeDevice, mode: str) -> None: auto_device_support=True, key=GATTR_TEMP_UNITS, translation_key=GATTR_TEMP_UNITS, + # A zone controller has one unit for all its sub-units, set on the + # ducted unit + device_filter=lambda device: device.device_type is not DeviceType.ZONE, entity_category=EntityCategory.CONFIG, options=[f"º{member.name}" for member in TemperatureUnits], value_func=lambda device: f"º{device.target_temperature_unit.name}", diff --git a/docs/entities.md b/docs/entities.md index 6cc657f..8c79fe8 100644 --- a/docs/entities.md +++ b/docs/entities.md @@ -43,13 +43,13 @@ The unit has its own temperature scale, Celsius or Fahrenheit, which you change A zone controller (for example the Gree LE60-13/GH) has one ducted unit and up to eight zones behind one WiFi module. Discovery finds the ducted unit as **AC unit** and the zones as **Zone 1**, **Zone 2** and so on. Add all of them. -- The **AC unit** gets the climate entity. Its HVAC mode and fan mode work as above. Turbo is a fan speed of this unit, in Cool and Heat only. It has no target temperature and no room temperature: the zones have their own target temperature, and the controller does not report the room temperatures of the zones. Turning the AC unit off turns the whole system off. The zones keep their own setting and get it back when the system is turned on again. +- The **AC unit** gets the climate entity. Its HVAC mode and fan mode work as above. Turbo is a fan speed of this unit, in Cool and Heat only. It has no target temperature and no room temperature: the zones have their own target temperature, and the controller does not report the room temperatures of the zones. Turning the AC unit off turns the whole system off. The zones keep their own setting and get it back when the system is turned on again. Its **Temperature Units** select switches the whole controller, the wall panel included, between °C and °F. The zones follow at their next update. - Each **zone** gets no climate entity, because a zone has no mode of its own. It gets: | Entity | What it does | |---|---| | Switch with the name of the zone | Opens and closes the zone. It is the **Zone Switch** feature, so you can leave it out. Do that for a zone that must always stay open: turning such a zone off is refused by the controller or turns the whole system off. | -| Target Temperature | The target temperature of the zone, 16 to 30 degrees C. | +| Target Temperature | The target temperature of the zone, in the unit of the controller: 16 to 30 °C, or 61 to 86 °F. Home Assistant shows it in your own unit. | ## Sensors diff --git a/docs/protocol.md b/docs/protocol.md index 2a201c0..336b693 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -96,6 +96,7 @@ A zone controller (seen: LE60-13/GH with a ME31-00/C13 WiFi module, firmware `36 - `Mod` uses its own numbers: 1 Cool, 2 Heat, 3 Dry, 4 Fan, 5 Auto (`ZONE_CONTROLLER_MODES`). Read with the normal numbers, Fan shows as Heat and Auto raises. `operation_mode` and `set_operation_mode()` convert for every unit with a zone role. - There is no `Tur` column. Turbo is `WdSpd` 6, only in Cool and Heat. Dry forces `WdSpd` 1. - A zone answers `Pow` (the damper), `StTem` (its target temperature) and a copy of `Mod` and `WdSpd` of the ducted unit. `StTem` is the temperature in degrees Celsius minus 16 (`ZONE_TEMPERATURE_OFFSET`), so 0 to 14 for 16 to 30 degrees. The controller itself also answers `StTem1` to `StTem8`. +- The unit of temperature is `TemUnit` (0 °C, 1 °F), not `TemUn`. Every unit answers it. Written to the ducted unit, the controller and the wall panel switch, and the zones follow. In °F a zone uses the same table as `SetTem` and `TemRec` of a normal unit: `StTem` + 16 is the degrees Celsius, `StFahFlg` is the bit. Seen on the panel: 64 °F is `StTem` 2 with bit 0, 66 °F is 3 with 0, 67 °F is 3 with 1, 70 °F is 5 with 1. When only `StTem` is sent, the controller keeps the old bit, so `set_target_temperature()` always sends both. The zone controller columns (`ZONE_CONTROLLER_PROPS`) are polled only on its sub-units. - The full list of columns is a table in the WiFi module firmware. There is no column for the room temperature of a zone, in any released firmware, so it cannot be shown. - The status column limit measured by `probe_device_limits()` is 29. Requests far above the limit (about 40 columns and more) made the module reboot. diff --git a/tests/fakes/zone.py b/tests/fakes/zone.py index eb395e8..d9a9656 100644 --- a/tests/fakes/zone.py +++ b/tests/fakes/zone.py @@ -11,6 +11,9 @@ and `WdSpd` of a zone follow the ducted unit. - `Mod` is 1 Cool, 2 Heat, 3 Dry, 4 Fan, 5 Auto. Turbo is `WdSpd` 6. - `StTem` is the zone target temperature in degrees Celsius minus 16. +- `TemUnit` is the unit of temperature (0 Celsius, 1 Fahrenheit) of every unit. + Written to the ducted unit, all units follow. `StFahFlg` of a zone is its + Fahrenheit bit, like `TemRec`. - Every sub-unit answers the info columns with the name of the controller. - A status request with too many columns gets an empty result. """ @@ -59,11 +62,25 @@ def __init__( self.ac_unit = f"{mac}{model.ac_unit_suffix}" self.zone_macs = [f"{mac}{index:02d}" for index in range(1, zones + 1)] self.units: dict[str, dict[str, Any]] = { - mac: {"Pow": 0, "Mod": 4, "WdSpd": 5}, - self.ac_unit: {"Pow": 1, "Mod": 4, "WdSpd": 5, "AllErr": 0}, + mac: {"Pow": 0, "Mod": 4, "WdSpd": 5, "TemUnit": 0}, + self.ac_unit: { + "Pow": 1, + "Mod": 4, + "WdSpd": 5, + "AllErr": 0, + "TemUnit": 0, + "StFahFlg": 0, + }, } for zone in self.zone_macs: - self.units[zone] = {"Pow": 1, "Mod": 4, "WdSpd": 5, "StTem": 2} + self.units[zone] = { + "Pow": 1, + "Mod": 4, + "WdSpd": 5, + "StTem": 2, + "StFahFlg": 0, + "TemUnit": 0, + } self.info: dict[str, Any] = { "name": name, "mid": "3017", @@ -131,7 +148,7 @@ def build_command_result(self, pack: dict[str, Any]) -> dict[str, Any]: for option, value in zip(options, values, strict=False): if option in unit: unit[option] = value - if target == self.ac_unit and option in ("Mod", "WdSpd"): + if target == self.ac_unit and option in ("Mod", "WdSpd", "TemUnit"): for other in (self.mac, *self.zone_macs): self.units[other][option] = value diff --git a/tests/test_zone_controller.py b/tests/test_zone_controller.py index 2f94db0..ce017ce 100644 --- a/tests/test_zone_controller.py +++ b/tests/test_zone_controller.py @@ -14,10 +14,12 @@ from aiogree import api from aiogree.api import ( + ZONE_CONTROLLER_PROPS, DeviceType, FanSpeed, GreeProp, OperationMode, + TemperatureUnits, ZoneControllerModel, ) from aiogree.device import GreeDevice @@ -324,6 +326,80 @@ async def test_a_zone_target_temperature_is_sent_minus_16( assert controller.units[controller.zone_macs[0]]["StTem"] == 14 +# +# Fahrenheit. The values are the ones the wall panel showed. +# + + +def set_unit(controller: FakeZoneController, unit: TemperatureUnits) -> None: + """Change the unit on the wall panel. Every unit of the controller follows.""" + for values in controller.units.values(): + values["TemUnit"] = unit.value + + +async def test_a_zone_in_fahrenheit_reads_its_bit_like_tem_rec( + controller: FakeZoneController, zone: GreeDevice +) -> None: + """StTem plus 16 and StFahFlg give the same Fahrenheit table as SetTem and TemRec.""" + set_unit(controller, TemperatureUnits.F) + values = controller.units[controller.zone_macs[0]] + seen = [] + for st_tem, bit in ((2, 0), (3, 0), (3, 1), (5, 1)): + values.update({"StTem": st_tem, "StFahFlg": bit}) + await zone.fetch_device_status() + seen.append(zone.target_temperature) + + assert zone.target_temperature_unit is TemperatureUnits.F + assert seen == [64, 66, 67, 70] + + +async def test_a_zone_in_fahrenheit_is_sent_with_its_bit( + controller: FakeZoneController, zone: GreeDevice +) -> None: + """70 goes out as StTem 5 with StFahFlg 1, 66 as 3 with 0.""" + set_unit(controller, TemperatureUnits.F) + await zone.fetch_device_status() + values = controller.units[controller.zone_macs[0]] + + zone.set_target_temperature(70) + await zone.push_device_status() + assert (values["StTem"], values["StFahFlg"]) == (5, 1) + + zone.set_target_temperature(66) + await zone.push_device_status() + assert (values["StTem"], values["StFahFlg"]) == (3, 0) + + +async def test_a_celsius_target_clears_the_bit( + controller: FakeZoneController, zone: GreeDevice +) -> None: + """The controller keeps an old bit when only StTem comes, so it is always sent.""" + values = controller.units[controller.zone_macs[0]] + values["StFahFlg"] = 1 + await zone.fetch_device_status() + + zone.set_target_temperature(22) + await zone.push_device_status() + + assert (values["StTem"], values["StFahFlg"]) == (6, 0) + + +async def test_the_unit_is_set_on_the_ducted_unit_and_the_zones_follow( + controller: FakeZoneController, ac_unit: GreeDevice, zone: GreeDevice +) -> None: + """TemUnit, not TemUn, written to the ducted unit. The zones read it at the next poll.""" + assert ac_unit.supports_property(GreeProp.ZONE_TEMPERATURE_UNIT) + assert not ac_unit.supports_property(GreeProp.TARGET_TEMPERATURE_UNIT) + + ac_unit.set_target_temperature_unit(TemperatureUnits.F) + await ac_unit.push_device_status() + await zone.fetch_device_status() + + assert {values["TemUnit"] for values in controller.units.values()} == {1} + assert zone.target_temperature_unit is TemperatureUnits.F + assert zone.target_temperature == 64 + + async def test_a_zone_is_opened_and_closed_with_its_power( controller: FakeZoneController, zone: GreeDevice ) -> None: @@ -341,7 +417,7 @@ async def test_a_zone_is_opened_and_closed_with_its_power( async def test_a_normal_unit_never_asks_for_the_zone_column() -> None: - """Only zones poll StTem. A standalone unit sends the same columns as before.""" + """Only a zone controller polls its own columns. A standalone unit sends the same columns as before.""" unit = FakeGreeDevice() await unit.start() transport = GreeUdpTransport(unit.host, unit.port, max_retries=1, timeout=0.3) @@ -356,7 +432,7 @@ async def test_a_normal_unit_never_asks_for_the_zone_column() -> None: unit.close() asked = {col for pack in unit.packs for col in pack.get("cols", [])} - assert GreeProp.ZONE_TARGET_TEMPERATURE.value not in asked + assert not asked & {prop.value for prop in ZONE_CONTROLLER_PROPS} assert device.device_type is DeviceType.AC_UNIT From 6d4c77baff1ae2e22a46aee75d3917fc102e0369 Mon Sep 17 00:00:00 2001 From: Twintop Date: Tue, 13 Oct 2026 12:06:20 +1100 Subject: [PATCH 10/18] Zone controllers: detect and name the sub-units by name and MAC suffix 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. --- custom_components/gree_custom/aiogree/api.py | 95 ++++--------------- .../gree_custom/aiogree/device.py | 20 ++-- docs/protocol.md | 6 +- tests/fakes/zone.py | 16 +--- tests/test_zone_controller.py | 85 +++-------------- 5 files changed, 49 insertions(+), 173 deletions(-) diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index dbf6024..3d6b0f0 100755 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -514,69 +514,12 @@ class DeviceType(StrEnum): ZONE_CONTROLLER_TYPES = frozenset({DeviceType.ZONE_CONTROLLER, DeviceType.ZONE}) -class ZoneControllerModel(NamedTuple): - """What tells the sub-units of one zone controller model apart.""" - - # Every sub-unit answers the info request with the name of the - # controller. The entry with the longest prefix of that name is used. - name_prefix: str - # The last two characters of the MAC of the ducted unit. The other - # sub-units are the zones. - ac_unit_suffix: str - # The model ids (mid) in the sub-device list, only used by discovery to - # name the sub-units - mid_ac_unit: str - mid_zone: str - - -# The known zone controller models. The WiFi module sets these values itself, -# not the units, so they are the same for every controller with that module. -# -# A sub-unit gets the entry with the longest name_prefix that its name starts -# with. So the order here does not matter, and a more specific entry always -# wins over a general one. A model that shows other values only needs an -# entry with a longer prefix, for example "GR-ZCntrlr_3000_". -ZONE_CONTROLLER_MODELS: tuple[ZoneControllerModel, ...] = ( - # General entry: any controller with the zone controller firmware of the - # ME31-00/C13 WiFi module. The module builds the name as - # "GR-ZCntrlr__<2 digits>_", with "_EC" added - # since v1.27. The values below are fixed in that firmware (v1.21, v1.27 - # and v1.28 checked). Used when no longer prefix matches. - ZoneControllerModel( - name_prefix="GR-ZCntrlr", - ac_unit_suffix="00", - mid_ac_unit="5000", - mid_zone="5001", - ), - # LE60-13/GH (also sold as Braemar LE60), seen as - # "GR-ZCntrlr_5000_02_5a5f_EC". Both entries match that name, and this - # prefix is the longer one, so this entry is used. It has the same values - # as the general entry: it is here as an example of a more specific entry. - # A hypothetical "GR-ZCntrlr_3000_01_5a5f_EC" would not match it and gets - # the general entry. - ZoneControllerModel( - name_prefix="GR-ZCntrlr_5000_", - ac_unit_suffix="00", - mid_ac_unit="5000", - mid_zone="5001", - ), -) - - -def find_zone_controller_model(name: str) -> ZoneControllerModel | None: - """Return the zone controller model whose sub-units report this name. - - When several entries match, the one with the longest name_prefix wins. - """ - return max( - ( - model - for model in ZONE_CONTROLLER_MODELS - if name.startswith(model.name_prefix) - ), - key=lambda model: len(model.name_prefix), - default=None, - ) +# A sub-unit of a zone controller answers the info request with the name of +# the controller, which starts with this. The MAC suffix tells the ducted unit +# from the zones. The WiFi module sets both, not the units: they are fixed in +# its firmware (v1.21, v1.27 and v1.28 checked). +ZONE_CONTROLLER_NAME_PREFIX = "GR-ZCntrlr" +ZONE_CONTROLLER_AC_UNIT_SUFFIX = "00" # The ducted unit behind a zone controller numbers the modes in its own way. @@ -1406,25 +1349,23 @@ def _parse_sub_devices_list( return sub_devs -def _zone_controller_sub_unit_name(sub_mac: str, sub_dev: dict[str, Any]) -> str | None: +def _zone_controller_sub_unit_name(sub_mac: str, controller_name: str) -> str | None: """Name a sub-unit of a zone controller. - A zone controller calls every sub-unit "zone", so the name says nothing. - The model id tells the ducted unit from a zone, see - ZONE_CONTROLLER_MODELS, and the last two characters of the MAC are the - zone number. + A zone controller calls every sub-unit "zone", so that name says nothing. + The name of the controller tells that it is a zone controller, and the + last two characters of the MAC tell the ducted unit from the zones and + give the zone number. Returns: The name, or None if the unit is not part of a zone controller """ - mid = sub_dev.get("mid") - for model in ZONE_CONTROLLER_MODELS: - if mid == model.mid_ac_unit: - return "AC unit" - if mid == model.mid_zone: - return f"Zone {int(sub_mac[-2:], 16)}" - return None + if not controller_name.startswith(ZONE_CONTROLLER_NAME_PREFIX): + return None + if sub_mac.endswith(ZONE_CONTROLLER_AC_UNIT_SUFFIX): + return "AC unit" + return f"Zone {int(sub_mac[-2:], 16)}" async def _get_sub_devices_list( @@ -1529,7 +1470,9 @@ async def _get_sub_devices_list( discovered_subdevices: list[GreeDiscoveredDevice] = [] for sub_mac, sub_dev in merged.items(): new_dev: GreeDiscoveredDevice - zone_name = _zone_controller_sub_unit_name(sub_mac, sub_dev) + zone_name = _zone_controller_sub_unit_name( + sub_mac, parent_device.name if parent_device else "" + ) if parent_device: # TODO: get real-data from VRF discovery to check the result list fields new_dev = replace( diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index 6a559f0..8408093 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -8,9 +8,11 @@ from packaging.version import Version from .api import ( + ZONE_CONTROLLER_AC_UNIT_SUFFIX, ZONE_CONTROLLER_FAN_TURBO, ZONE_CONTROLLER_MODES, ZONE_CONTROLLER_MODES_TO_RAW, + ZONE_CONTROLLER_NAME_PREFIX, ZONE_CONTROLLER_PROPS, ZONE_CONTROLLER_TYPES, ZONE_TEMPERATURE_OFFSET, @@ -27,7 +29,6 @@ TemperatureUnits, VerticalSwingMode, extract_fw_version, - find_zone_controller_model, ) from .cloud_api import FirmwareInfoResponse, GreeRegion, gree_get_latest_firmware_info from .const import ( @@ -203,20 +204,17 @@ def _detect_device_type(self) -> None: A unit that is not a sub-unit is a standalone unit. A sub-unit of a zone controller answers the info request with the name of the zone - controller, and the MAC suffix of the model tells the ducted unit - from the zones (see ZONE_CONTROLLER_MODELS). Every other sub-unit is - a VRF indoor unit. The sub-units of a zone controller also get its own - columns polled: the unit of temperature, and on a zone the target - temperature. + controller, and its MAC suffix tells the ducted unit from the zones. + Every other sub-unit is a VRF indoor unit. The sub-units of a zone + controller also get its own columns polled: the unit of temperature, + and on a zone the target temperature. """ - model = find_zone_controller_model( - self._state.info.get(InfoProp.DEVICE_NAME, "") - ) + name = self._state.info.get(InfoProp.DEVICE_NAME, "") if not self.is_sub_unit: self._device_type = DeviceType.AC_UNIT - elif model is None: + elif not name.startswith(ZONE_CONTROLLER_NAME_PREFIX): self._device_type = DeviceType.VRF_UNIT - elif self._mac_addr.endswith(model.ac_unit_suffix): + elif self._mac_addr.endswith(ZONE_CONTROLLER_AC_UNIT_SUFFIX): self._device_type = DeviceType.ZONE_CONTROLLER self._state.add_polled(GreeProp.ZONE_TEMPERATURE_UNIT) else: diff --git a/docs/protocol.md b/docs/protocol.md index 336b693..b8c6b5c 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -89,9 +89,9 @@ A gateway keeps a cached copy of the state of every indoor unit. For a few secon A zone controller (seen: LE60-13/GH with a ME31-00/C13 WiFi module, firmware `362001062617+U-W05SAV1.27.bin`) drives the dampers of a ducted unit. It answers the scan like a VRF gateway, with `subCnt` above zero, so discovery handles it the same way. Its sub-units are not indoor units, though. - The scan reply has no `cid`, see [Discovery and network](#discovery-and-network). -- It answers only the `subDev` form of the sub-device list. Every unit in the list is called `zone`. The ducted unit has model id `5000` and MAC suffix `00`. The zones have model id `5001` and suffixes `01` to `08`. Discovery names them `AC unit` and `Zone 1`, `Zone 2` and so on. -- A sub-unit answers the info request with the name of the controller, which starts with `GR-ZCntrlr`, and with the model id of the controller (`3017`), not its own. After the info fetch, `GreeDevice._detect_device_type()` sets `device_type` for every device: `ac_unit` for a unit that is not a sub-unit, `zone_controller` for sub-unit `00` of a zone controller, `zone` for its other sub-units, and `vrf_unit` for any other sub-unit. -- The name prefix, the MAC suffix of the ducted unit and the two model ids come from the WiFi module, not from the units: they are fixed in its firmware (v1.21, v1.27 and v1.28 checked). So they are the same for every controller with this module. The Braemar LE60 uses the same module, so it should show the same values (not tested). They are kept per model in `ZONE_CONTROLLER_MODELS`. A sub-unit gets the entry with the longest `name_prefix` that its name starts with, so the order does not matter and a more specific entry wins. The table has a general `GR-ZCntrlr` entry and, as an example of a more specific one, a `GR-ZCntrlr_5000_` entry for the LE60-13/GH with the same values. A model that shows other values only needs an entry with a longer prefix, for example `GR-ZCntrlr_3000_`. Only the two zone controller types (`ZONE_CONTROLLER_TYPES`) change behaviour. A zone also gets `StTem` added to its poll. +- It answers only the `subDev` form of the sub-device list. Every unit in the list is called `zone`. The ducted unit has model id `5000` and MAC suffix `00`. The zones have model id `5001` and suffixes `01` to `08`. Discovery names them `AC unit` and `Zone 1`, `Zone 2` and so on, from the name of the controller in the scan reply and the MAC suffix. The model ids are not used. +- A sub-unit answers the info request with the name of the controller, which starts with `GR-ZCntrlr` (`ZONE_CONTROLLER_NAME_PREFIX`), and with the model id of the controller (`3017`), not its own. After the info fetch, `GreeDevice._detect_device_type()` sets `device_type` for every device: `ac_unit` for a unit that is not a sub-unit, `zone_controller` for sub-unit `00` (`ZONE_CONTROLLER_AC_UNIT_SUFFIX`) of a zone controller, `zone` for its other sub-units, and `vrf_unit` for any other sub-unit. +- The name prefix, the MAC suffix of the ducted unit and the model ids come from the WiFi module, not from the units: they are fixed in its firmware (v1.21, v1.27 and v1.28 checked). So they are the same for every controller with this module. The Braemar LE60 uses the same module, so it should show the same values (not tested). Only the two zone controller types (`ZONE_CONTROLLER_TYPES`) change behaviour. A zone also gets `StTem` added to its poll. - The ducted unit answers `Pow`, `Mod`, `WdSpd` and `AllErr`, nothing more. `Pow` of the ducted unit is the power of the whole system. The zones keep their own `Pow` while it is off, and get it back when it is turned on. - `Mod` uses its own numbers: 1 Cool, 2 Heat, 3 Dry, 4 Fan, 5 Auto (`ZONE_CONTROLLER_MODES`). Read with the normal numbers, Fan shows as Heat and Auto raises. `operation_mode` and `set_operation_mode()` convert for every unit with a zone role. - There is no `Tur` column. Turbo is `WdSpd` 6, only in Cool and Heat. Dry forces `WdSpd` 1. diff --git a/tests/fakes/zone.py b/tests/fakes/zone.py index d9a9656..186da76 100644 --- a/tests/fakes/zone.py +++ b/tests/fakes/zone.py @@ -20,7 +20,7 @@ from typing import Any, override -from aiogree.api import SubListForm, ZoneControllerModel, find_zone_controller_model +from aiogree.api import ZONE_CONTROLLER_AC_UNIT_SUFFIX, SubListForm from .vrf import FakeVrfGateway @@ -37,7 +37,6 @@ def __init__( name: str = ZONE_CONTROLLER_NAME, *, zones: int = 4, - model: ZoneControllerModel | None = None, **kwargs: Any, ) -> None: """Set up the controller. @@ -46,8 +45,6 @@ def __init__( mac: MAC of the controller. name: Name in the scan reply and in the info columns. zones: How many zones the controller has. - model: The MAC suffix of the ducted unit and the model ids to use. - By default the entry of ZONE_CONTROLLER_MODELS for `name`. kwargs: Anything FakeVrfGateway accepts. """ @@ -55,11 +52,7 @@ def __init__( kwargs.setdefault("max_columns", 25) super().__init__(mac=mac, name=name, sub_count=zones + 1, **kwargs) - model = model or find_zone_controller_model(name) - if model is None: - raise ValueError(f"No zone controller model for the name {name}") - self.model = model - self.ac_unit = f"{mac}{model.ac_unit_suffix}" + self.ac_unit = f"{mac}{ZONE_CONTROLLER_AC_UNIT_SUFFIX}" self.zone_macs = [f"{mac}{index:02d}" for index in range(1, zones + 1)] self.units: dict[str, dict[str, Any]] = { mac: {"Pow": 0, "Mod": 4, "WdSpd": 5, "TemUnit": 0}, @@ -111,10 +104,9 @@ def build_scan_info(self) -> dict[str, Any]: @override def sub_devices(self, form: SubListForm) -> list[dict[str, str]]: """Return the ducted unit and the zones, each called "zone".""" - units = [{"mac": self.ac_unit, "mid": self.model.mid_ac_unit, "name": "zone"}] + units = [{"mac": self.ac_unit, "mid": "5000", "name": "zone"}] units += [ - {"mac": zone, "mid": self.model.mid_zone, "name": "zone"} - for zone in self.zone_macs + {"mac": zone, "mid": "5001", "name": "zone"} for zone in self.zone_macs ] return units diff --git a/tests/test_zone_controller.py b/tests/test_zone_controller.py index ce017ce..51adba3 100644 --- a/tests/test_zone_controller.py +++ b/tests/test_zone_controller.py @@ -12,15 +12,14 @@ from collections.abc import AsyncIterator -from aiogree import api from aiogree.api import ( ZONE_CONTROLLER_PROPS, DeviceType, FanSpeed, GreeProp, OperationMode, + SubListForm, TemperatureUnits, - ZoneControllerModel, ) from aiogree.device import GreeDevice from aiogree.errors import GreeTurboUnavailable @@ -105,85 +104,29 @@ async def test_discovery_names_the_ducted_unit_and_the_zones( assert not any("sub-devices" in line for line in gree_logs.warnings()) -def test_the_seen_name_gets_its_own_entry_and_others_the_general_one() -> None: - """The name of the real controller matches the longer GR-ZCntrlr_5000_ prefix.""" - seen = api.find_zone_controller_model("GR-ZCntrlr_5000_02_5a5f_EC") - unseen = api.find_zone_controller_model("GR-ZCntrlr_3000_01_5a5f_EC") - - assert seen is not None - assert seen.name_prefix == "GR-ZCntrlr_5000_" - assert unseen is not None - assert unseen.name_prefix == "GR-ZCntrlr" - assert api.find_zone_controller_model("GR-VRF_5a5f") is None - - -@pytest.mark.parametrize( - "reverse", [False, True], ids=["general first", "general last"] -) -def test_the_longest_matching_name_prefix_wins( - monkeypatch: pytest.MonkeyPatch, reverse: bool +async def test_discovery_names_come_from_the_controller_name_and_the_suffix( + loopback_ip: str, discover_one: DiscoverOne ) -> None: - """A more specific entry wins over a general one, whatever the order of the table.""" - general = ZoneControllerModel( - name_prefix="GR-ZCntrlr", - ac_unit_suffix="00", - mid_ac_unit="5000", - mid_zone="5001", - ) - specific = ZoneControllerModel( - name_prefix="GR-ZCntrlr_3000_", - ac_unit_suffix="10", - mid_ac_unit="3000", - mid_zone="3001", - ) - table = (specific, general) if reverse else (general, specific) - monkeypatch.setattr(api, "ZONE_CONTROLLER_MODELS", table) + """The model ids in the sub-device list are not used, only the name and the MAC.""" - assert api.find_zone_controller_model("GR-ZCntrlr_3000_01_ffee_EC") == specific - assert api.find_zone_controller_model("GR-ZCntrlr_5000_02_ffee_EC") == general + class OtherIds(FakeZoneController): + def sub_devices(self, form: SubListForm) -> list[dict[str, str]]: + return [{**unit, "mid": "1234"} for unit in super().sub_devices(form)] - -async def test_another_model_only_needs_an_entry_in_the_table( - loopback_ip: str, discover_one: DiscoverOne, monkeypatch: pytest.MonkeyPatch -) -> None: - """A model with other values works once it is in ZONE_CONTROLLER_MODELS.""" - other = ZoneControllerModel( - name_prefix="GR-OtherZone", - ac_unit_suffix="10", - mid_ac_unit="6000", - mid_zone="6001", - ) - controller = FakeZoneController( - name="GR-OtherZone_6000_01_ffee", zones=2, model=other - ) + controller = OtherIds() await controller.start(loopback_ip, DISCOVERY_PORT) - transports: list[GreeUdpTransport] = [] try: - # Not in the table yet, so it is taken for a VRF gateway - unknown, transport = await bind_unit(controller, "01") - transports.append(transport) - assert unknown.device_type is DeviceType.VRF_UNIT - - monkeypatch.setattr( - api, "ZONE_CONTROLLER_MODELS", (*api.ZONE_CONTROLLER_MODELS, other) - ) found = await discover_one(controller.host, timeout=1) - ac_unit, transport = await bind_unit(controller, "10") - transports.append(transport) - zone, transport = await bind_unit(controller, "01") - transports.append(transport) finally: - for transport in transports: - await transport.disconnect() controller.close() - assert [(d.name, d.mac) for d in found] == [ - ("AC unit", f"{ZONE_CONTROLLER_MAC}10"), - ("Zone 1", f"{ZONE_CONTROLLER_MAC}01"), - ("Zone 2", f"{ZONE_CONTROLLER_MAC}02"), + assert [d.name for d in found] == [ + "AC unit", + "Zone 1", + "Zone 2", + "Zone 3", + "Zone 4", ] - assert ac_unit.device_type is DeviceType.ZONE_CONTROLLER - assert zone.device_type is DeviceType.ZONE # From 2f14e870e9c1cc2439d5f152095cc6032fbe5a8a Mon Sep 17 00:00:00 2001 From: Twintop Date: Tue, 13 Oct 2026 12:07:54 +1100 Subject: [PATCH 11/18] Pick the temperature columns by what a device answers 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. --- custom_components/gree_custom/aiogree/api.py | 15 ++---- .../gree_custom/aiogree/device.py | 38 +++++++------- .../gree_custom/aiogree/device_state.py | 10 ---- custom_components/gree_custom/select.py | 4 +- docs/protocol.md | 9 ++-- tests/fakes/zone.py | 6 ++- tests/test_zone_controller.py | 50 ++++++++++++++++--- 7 files changed, 76 insertions(+), 56 deletions(-) diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index 3d6b0f0..d94ff62 100755 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -105,20 +105,11 @@ class GreeProp(StrEnum): PROP_KEY_TO_ENUM = {prop.value: prop for prop in GreeProp} -# Props only the sub-units of a zone controller are polled for, see -# `GreeDevice.device_type`. -ZONE_CONTROLLER_PROPS: tuple[GreeProp, ...] = ( - GreeProp.ZONE_TARGET_TEMPERATURE, - GreeProp.ZONE_TARGET_TEMPERATURE_BIT, - GreeProp.ZONE_TEMPERATURE_UNIT, -) - # Props every device is polled for by default. The beeper is only written, -# never read. +# never read. The first poll drops every prop a device does not answer, so a +# unit without the zone controller columns asks for them only once. POLLED_PROPS: tuple[GreeProp, ...] = tuple( - p - for p in GreeProp - if p not in (GreeProp.BEEPER, GreeProp.BEEPER_NEW, *ZONE_CONTROLLER_PROPS) + p for p in GreeProp if p not in (GreeProp.BEEPER, GreeProp.BEEPER_NEW) ) diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index 8408093..fd11d47 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -13,7 +13,6 @@ ZONE_CONTROLLER_MODES, ZONE_CONTROLLER_MODES_TO_RAW, ZONE_CONTROLLER_NAME_PREFIX, - ZONE_CONTROLLER_PROPS, ZONE_CONTROLLER_TYPES, ZONE_TEMPERATURE_OFFSET, DeviceType, @@ -205,9 +204,12 @@ def _detect_device_type(self) -> None: A unit that is not a sub-unit is a standalone unit. A sub-unit of a zone controller answers the info request with the name of the zone controller, and its MAC suffix tells the ducted unit from the zones. - Every other sub-unit is a VRF indoor unit. The sub-units of a zone - controller also get its own columns polled: the unit of temperature, - and on a zone the target temperature. + Every other sub-unit is a VRF indoor unit. + + The columns a device uses come from what it answers. The type is only + 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 + also answers StTem, for example, always with 0. """ name = self._state.info.get(InfoProp.DEVICE_NAME, "") if not self.is_sub_unit: @@ -216,11 +218,8 @@ def _detect_device_type(self) -> None: self._device_type = DeviceType.VRF_UNIT elif self._mac_addr.endswith(ZONE_CONTROLLER_AC_UNIT_SUFFIX): self._device_type = DeviceType.ZONE_CONTROLLER - self._state.add_polled(GreeProp.ZONE_TEMPERATURE_UNIT) else: self._device_type = DeviceType.ZONE - for prop in ZONE_CONTROLLER_PROPS: - self._state.add_polled(prop) _LOGGER.debug("[%s] Device type: %s", self.unique_id, self._device_type) @@ -845,8 +844,13 @@ def set_target_temperature_unit(self, units: TemperatureUnits) -> None: @property def _temperature_unit_prop(self) -> GreeProp: - """Return the column with the unit. A zone controller has its own.""" - if self._device_type in ZONE_CONTROLLER_TYPES: + """Return the column with the unit: TemUn, or TemUnit when only that one is answered. + + The sub-units of a zone controller answer TemUnit and not TemUn. + """ + if not self._state.supports( + GreeProp.TARGET_TEMPERATURE_UNIT + ) and self._state.supports(GreeProp.ZONE_TEMPERATURE_UNIT): return GreeProp.ZONE_TEMPERATURE_UNIT return GreeProp.TARGET_TEMPERATURE_UNIT @@ -883,9 +887,9 @@ def target_temperature(self) -> float | None: def set_target_temperature(self, value: float) -> None: """Set the target temperature in target_temperature_unit. - A zone takes 16 to 30 degrees Celsius or 61 to 86 Fahrenheit. Its bit - is always sent along, because the controller keeps the old one when - only the temperature is sent. + A zone takes 16 to 30 degrees Celsius or 61 to 86 Fahrenheit. When + the zone has the bit, it is always sent along, because the controller + keeps the old one when only StTem is sent. """ if self._device_type is DeviceType.ZONE: @@ -893,12 +897,10 @@ def set_target_temperature(self, value: float) -> None: raw_c, bit = gree_get_target_temp_props_from_f(round(value)) else: raw_c, bit = max(MIN_TEMP_C, min(MAX_TEMP_C, round(value))), 0 - self._state.update( - { - GreeProp.ZONE_TARGET_TEMPERATURE: raw_c - ZONE_TEMPERATURE_OFFSET, - GreeProp.ZONE_TARGET_TEMPERATURE_BIT: bit, - } - ) + props = {GreeProp.ZONE_TARGET_TEMPERATURE: raw_c - ZONE_TEMPERATURE_OFFSET} + if self._state.supports(GreeProp.ZONE_TARGET_TEMPERATURE_BIT): + props[GreeProp.ZONE_TARGET_TEMPERATURE_BIT] = bit + self._state.update(props) return if self.target_temperature_unit == TemperatureUnits.F: diff --git a/custom_components/gree_custom/aiogree/device_state.py b/custom_components/gree_custom/aiogree/device_state.py index d584e40..07288e0 100755 --- a/custom_components/gree_custom/aiogree/device_state.py +++ b/custom_components/gree_custom/aiogree/device_state.py @@ -234,16 +234,6 @@ def supports(self, prop: GreeProp) -> bool: GreeProp.BEEPER_NEW, ) - def add_polled(self, prop: GreeProp) -> None: - """Start polling a prop that is not in the default poll. - - This is for props that only some kinds of device have, such as the - target temperature of a zone. Call it before the first status request. - After that the list of polled props only shrinks. - """ - if prop not in self._props_to_poll: - self._props_to_poll = (*self._props_to_poll, prop) - def remove(self, prop: GreeProp) -> None: """Remove a property from being polled.""" self._props_to_poll = tuple(p for p in self._props_to_poll if p != prop) diff --git a/custom_components/gree_custom/select.py b/custom_components/gree_custom/select.py index f0bd2b1..c6e914e 100755 --- a/custom_components/gree_custom/select.py +++ b/custom_components/gree_custom/select.py @@ -67,8 +67,8 @@ def _set_humidity_control_mode(device: GreeDevice, mode: str) -> None: auto_device_support=True, key=GATTR_TEMP_UNITS, translation_key=GATTR_TEMP_UNITS, - # A zone controller has one unit for all its sub-units, set on the - # ducted unit + # Every sub-unit of a zone controller answers TemUnit, but they share + # one unit, set on the ducted unit. So the zones get no select. device_filter=lambda device: device.device_type is not DeviceType.ZONE, entity_category=EntityCategory.CONFIG, options=[f"º{member.name}" for member in TemperatureUnits], diff --git a/docs/protocol.md b/docs/protocol.md index b8c6b5c..dca322d 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -91,12 +91,13 @@ A zone controller (seen: LE60-13/GH with a ME31-00/C13 WiFi module, firmware `36 - The scan reply has no `cid`, see [Discovery and network](#discovery-and-network). - It answers only the `subDev` form of the sub-device list. Every unit in the list is called `zone`. The ducted unit has model id `5000` and MAC suffix `00`. The zones have model id `5001` and suffixes `01` to `08`. Discovery names them `AC unit` and `Zone 1`, `Zone 2` and so on, from the name of the controller in the scan reply and the MAC suffix. The model ids are not used. - A sub-unit answers the info request with the name of the controller, which starts with `GR-ZCntrlr` (`ZONE_CONTROLLER_NAME_PREFIX`), and with the model id of the controller (`3017`), not its own. After the info fetch, `GreeDevice._detect_device_type()` sets `device_type` for every device: `ac_unit` for a unit that is not a sub-unit, `zone_controller` for sub-unit `00` (`ZONE_CONTROLLER_AC_UNIT_SUFFIX`) of a zone controller, `zone` for its other sub-units, and `vrf_unit` for any other sub-unit. -- The name prefix, the MAC suffix of the ducted unit and the model ids come from the WiFi module, not from the units: they are fixed in its firmware (v1.21, v1.27 and v1.28 checked). So they are the same for every controller with this module. The Braemar LE60 uses the same module, so it should show the same values (not tested). Only the two zone controller types (`ZONE_CONTROLLER_TYPES`) change behaviour. A zone also gets `StTem` added to its poll. -- The ducted unit answers `Pow`, `Mod`, `WdSpd` and `AllErr`, nothing more. `Pow` of the ducted unit is the power of the whole system. The zones keep their own `Pow` while it is off, and get it back when it is turned on. +- The name prefix, the MAC suffix of the ducted unit and the model ids come from the WiFi module, not from the units: they are fixed in its firmware (v1.21, v1.27 and v1.28 checked). So they are the same for every controller with this module. The Braemar LE60 uses the same module, so it should show the same values (not tested). Only the two zone controller types (`ZONE_CONTROLLER_TYPES`) change behaviour, and only where the columns cannot tell: the mode numbers, turbo, and which entities a sub-unit gets. +- The ducted unit answers `Pow`, `Mod`, `WdSpd`, `AllErr` and `TemUnit`, and `StTem` and `StFahFlg` always as 0. `Pow` of the ducted unit is the power of the whole system. The zones keep their own `Pow` while it is off, and get it back when it is turned on. No sub-unit answers `SetTem`, `TemRec`, `TemUn` or `Add0.5`. - `Mod` uses its own numbers: 1 Cool, 2 Heat, 3 Dry, 4 Fan, 5 Auto (`ZONE_CONTROLLER_MODES`). Read with the normal numbers, Fan shows as Heat and Auto raises. `operation_mode` and `set_operation_mode()` convert for every unit with a zone role. - There is no `Tur` column. Turbo is `WdSpd` 6, only in Cool and Heat. Dry forces `WdSpd` 1. -- A zone answers `Pow` (the damper), `StTem` (its target temperature) and a copy of `Mod` and `WdSpd` of the ducted unit. `StTem` is the temperature in degrees Celsius minus 16 (`ZONE_TEMPERATURE_OFFSET`), so 0 to 14 for 16 to 30 degrees. The controller itself also answers `StTem1` to `StTem8`. -- The unit of temperature is `TemUnit` (0 °C, 1 °F), not `TemUn`. Every unit answers it. Written to the ducted unit, the controller and the wall panel switch, and the zones follow. In °F a zone uses the same table as `SetTem` and `TemRec` of a normal unit: `StTem` + 16 is the degrees Celsius, `StFahFlg` is the bit. Seen on the panel: 64 °F is `StTem` 2 with bit 0, 66 °F is 3 with 0, 67 °F is 3 with 1, 70 °F is 5 with 1. When only `StTem` is sent, the controller keeps the old bit, so `set_target_temperature()` always sends both. The zone controller columns (`ZONE_CONTROLLER_PROPS`) are polled only on its sub-units. +- A zone answers `Pow` (the damper), `StTem` and `StFahFlg` (its target temperature), `TemUnit`, and a copy of `Mod` and `WdSpd` of the ducted unit. `StTem` is the temperature in degrees Celsius minus 16 (`ZONE_TEMPERATURE_OFFSET`), so 0 to 14 for 16 to 30 degrees. The controller itself also answers `StTem1` to `StTem8`, without the bit. +- The unit of temperature is `TemUnit` (0 °C, 1 °F), not `TemUn`. Every unit answers it. Written to the ducted unit, the controller and the wall panel switch, and the zones follow. In °F a zone uses the same table as `SetTem` and `TemRec` of a normal unit: `StTem` + 16 is the degrees Celsius, `StFahFlg` is the bit. Seen on the panel: 64 °F is `StTem` 2 with bit 0, 66 °F is 3 with 0, 67 °F is 3 with 1, 70 °F is 5 with 1. When only `StTem` is sent, the controller keeps the old bit, so `set_target_temperature()` sends both. +- The columns come from what a device answers. `StTem`, `StFahFlg` and `TemUnit` are in the default poll, and the first poll drops them on a unit that does not answer them, like any other column. A device uses `TemUn` when it answers it and `TemUnit` only without it, so every other unit works as before. The ducted unit also answers `StTem`, always 0, so only a zone uses `StTem` as its target temperature. - The full list of columns is a table in the WiFi module firmware. There is no column for the room temperature of a zone, in any released firmware, so it cannot be shown. - The status column limit measured by `probe_device_limits()` is 29. Requests far above the limit (about 40 columns and more) made the module reboot. diff --git a/tests/fakes/zone.py b/tests/fakes/zone.py index 186da76..d581715 100644 --- a/tests/fakes/zone.py +++ b/tests/fakes/zone.py @@ -7,8 +7,9 @@ calls every unit "zone". The ducted unit has model id 5000 and MAC suffix `00`. The zones have model id 5001 and suffixes `01` to `08`. - Each sub-unit answers only its own columns. The ducted unit has `Pow`, `Mod`, - `WdSpd` and `AllErr`. A zone has `Pow`, `Mod`, `WdSpd` and `StTem`. `Mod` - and `WdSpd` of a zone follow the ducted unit. + `WdSpd` and `AllErr`, and also `StTem`, always 0. A zone has `Pow`, `Mod`, + `WdSpd` and `StTem`. `Mod` and `WdSpd` of a zone follow the ducted unit. + No unit answers `SetTem`, `TemRec` or `TemUn`. - `Mod` is 1 Cool, 2 Heat, 3 Dry, 4 Fan, 5 Auto. Turbo is `WdSpd` 6. - `StTem` is the zone target temperature in degrees Celsius minus 16. - `TemUnit` is the unit of temperature (0 Celsius, 1 Fahrenheit) of every unit. @@ -61,6 +62,7 @@ def __init__( "Mod": 4, "WdSpd": 5, "AllErr": 0, + "StTem": 0, "TemUnit": 0, "StFahFlg": 0, }, diff --git a/tests/test_zone_controller.py b/tests/test_zone_controller.py index 51adba3..528f5cf 100644 --- a/tests/test_zone_controller.py +++ b/tests/test_zone_controller.py @@ -13,7 +13,6 @@ from collections.abc import AsyncIterator from aiogree.api import ( - ZONE_CONTROLLER_PROPS, DeviceType, FanSpeed, GreeProp, @@ -135,9 +134,13 @@ def sub_devices(self, form: SubListForm) -> list[dict[str, str]]: async def test_the_ducted_unit_is_recognised(ac_unit: GreeDevice) -> None: - """The name in the info columns and the 00 suffix make it the ducted unit.""" + """The name in the info columns and the 00 suffix make it the ducted unit. + + It answers StTem too, always with 0, so the columns alone cannot tell it + from a zone. + """ assert ac_unit.device_type is DeviceType.ZONE_CONTROLLER - assert not ac_unit.supports_property(GreeProp.ZONE_TARGET_TEMPERATURE) + assert ac_unit.supports_property(GreeProp.ZONE_TARGET_TEMPERATURE) async def test_the_device_type_is_in_the_diagnostics( @@ -359,9 +362,12 @@ async def test_a_zone_is_opened_and_closed_with_its_power( # -async def test_a_normal_unit_never_asks_for_the_zone_column() -> None: - """Only a zone controller polls its own columns. A standalone unit sends the same columns as before.""" - unit = FakeGreeDevice() +ZONE_COLUMNS = {"StTem", "StFahFlg", "TemUnit"} + + +async def test_a_normal_unit_asks_for_the_zone_columns_only_once() -> None: + """The first poll drops what a unit does not answer. After it, the same columns as before.""" + unit = FakeGreeDevice(unsupported_props=ZONE_COLUMNS) await unit.start() transport = GreeUdpTransport(unit.host, unit.port, max_retries=1, timeout=0.3) try: @@ -369,16 +375,44 @@ async def test_a_normal_unit_never_asks_for_the_zone_column() -> None: await device.bind_with_transport( local_controller_mac=unit.mac, local_transport=transport ) + first = {col for pack in unit.packs for col in pack.get("cols", [])} + unit.reset_record() await device.fetch_device_status() finally: await transport.disconnect() unit.close() - asked = {col for pack in unit.packs for col in pack.get("cols", [])} - assert not asked & {prop.value for prop in ZONE_CONTROLLER_PROPS} + later = {col for pack in unit.packs for col in pack.get("cols", [])} + assert first >= ZONE_COLUMNS + assert not later & ZONE_COLUMNS assert device.device_type is DeviceType.AC_UNIT +@pytest.mark.parametrize( + ("unsupported", "unit"), + [(set(), TemperatureUnits.C), ({"TemUn"}, TemperatureUnits.F)], + ids=["TemUn wins", "only TemUnit"], +) +async def test_the_unit_column_is_the_one_the_device_answers( + unsupported: set[str], unit: TemperatureUnits +) -> None: + """TemUn when the device answers it, as every unit did before. TemUnit only without it.""" + fake = FakeGreeDevice(unsupported_props=unsupported) + fake.values.update({"TemUn": 0, "TemUnit": 1}) + await fake.start() + transport = GreeUdpTransport(fake.host, fake.port, max_retries=1, timeout=0.3) + try: + device = GreeDevice(name="Zolderkamer", mac_addr=fake.mac) + await device.bind_with_transport( + local_controller_mac=fake.mac, local_transport=transport + ) + finally: + await transport.disconnect() + fake.close() + + assert device.target_temperature_unit is unit + + async def test_a_vrf_indoor_unit_keeps_the_normal_mode_numbers() -> None: """A VRF gateway has another name, so mode 4 still means Heat there.""" gateway = FakeVrfGateway() From 07f605c93267d2874da8f64d7fc776e83728cacc Mon Sep 17 00:00:00 2001 From: Twintop Date: Tue, 13 Oct 2026 12:08:55 +1100 Subject: [PATCH 12/18] Round temperatures in the device, and half degrees for zones in Celsius 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. --- .../gree_custom/aiogree/device.py | 24 +++++++-------- custom_components/gree_custom/number.py | 29 ++++++++++++++----- docs/entities.md | 2 +- docs/protocol.md | 2 +- tests/fakes/zone.py | 3 +- tests/test_zone_controller.py | 24 ++++++++++++++- 6 files changed, 60 insertions(+), 24 deletions(-) diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index fd11d47..88b8a89 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -34,10 +34,8 @@ DEFAULT_DEVICE_USERID, MAX_HUM_COOL_P, MAX_HUM_DRY_P, - MAX_TEMP_C, MIN_HUM_COOL_P, MIN_HUM_DRY_P, - MIN_TEMP_C, ) from .device_api_client import DeviceApiClient from .device_state import DeviceState @@ -858,9 +856,9 @@ def _temperature_unit_prop(self) -> GreeProp: def target_temperature(self) -> float | None: """Return the target temperature in target_temperature_unit. - A zone has its own columns, see GreeProp.ZONE_TARGET_TEMPERATURE: whole - degrees in Celsius, and in Fahrenheit the same table as SetTem and - TemRec. It is None until the zone is read. + A zone has its own columns, see GreeProp.ZONE_TARGET_TEMPERATURE. They + work like SetTem and TemRec, with the bit as the half degree in Celsius + and the same table in Fahrenheit. It is None until the zone is read. """ if self._device_type is DeviceType.ZONE: @@ -868,11 +866,10 @@ def target_temperature(self) -> float | None: if raw is None: return None zone_c = raw + ZONE_TEMPERATURE_OFFSET + bit = self._state.get(GreeProp.ZONE_TARGET_TEMPERATURE_BIT) or 0 if self.target_temperature_unit == TemperatureUnits.F: - return gree_get_target_temperature_f( - zone_c, self._state.get(GreeProp.ZONE_TARGET_TEMPERATURE_BIT) or 0 - ) - return zone_c + return gree_get_target_temperature_f(zone_c, bit) + return gree_get_target_temperature_c(zone_c, bit) raw_c = self._state.get(GreeProp.TARGET_TEMPERATURE, 0) tem_rec = self._state.get(GreeProp.TARGET_TEMPERATURE_BIT, 0) @@ -887,16 +884,17 @@ def target_temperature(self) -> float | None: def set_target_temperature(self, value: float) -> None: """Set the target temperature in target_temperature_unit. - A zone takes 16 to 30 degrees Celsius or 61 to 86 Fahrenheit. When - the zone has the bit, it is always sent along, because the controller - keeps the old one when only StTem is sent. + Celsius goes to the nearest half degree, Fahrenheit to whole degrees. + A zone uses its own columns the same way. When the zone has the bit, + it is always sent along, because the controller keeps the old one when + only StTem is sent. """ if self._device_type is DeviceType.ZONE: if self.target_temperature_unit == TemperatureUnits.F: raw_c, bit = gree_get_target_temp_props_from_f(round(value)) else: - raw_c, bit = max(MIN_TEMP_C, min(MAX_TEMP_C, round(value))), 0 + raw_c, bit = gree_get_target_temp_props_from_c(value) props = {GreeProp.ZONE_TARGET_TEMPERATURE: raw_c - ZONE_TEMPERATURE_OFFSET} if self._state.supports(GreeProp.ZONE_TARGET_TEMPERATURE_BIT): props[GreeProp.ZONE_TARGET_TEMPERATURE_BIT] = bit diff --git a/custom_components/gree_custom/number.py b/custom_components/gree_custom/number.py index 7751326..d012e98 100755 --- a/custom_components/gree_custom/number.py +++ b/custom_components/gree_custom/number.py @@ -17,6 +17,7 @@ from .aiogree.api import ( DeviceType, + GreeProp, HumidityControlMode, OperationMode, TemperatureUnits, @@ -51,11 +52,13 @@ class GreeNumberDescription( """Description of a Gree number.""" value_func: Callable[[GreeDevice], float | None] - set_func: Callable[[GreeDevice, int], None] + set_func: Callable[[GreeDevice, float], None] min_func: Callable[[GreeDevice], int] | None = None max_func: Callable[[GreeDevice], int] | None = None - # The unit, when the device decides it, such as Celsius or Fahrenheit + # The unit and the step, when the device decides them, such as Celsius + # in half degrees or whole Fahrenheit unit_func: Callable[[GreeDevice], str] | None = None + step_func: Callable[[GreeDevice], float] | None = None updates_device: bool = True @@ -70,7 +73,7 @@ class GreeNumberDescription( native_unit_of_measurement=PERCENTAGE, value_func=lambda device: device.feature_humidity_control_target, set_func=lambda device, value: device.set_feature_humidity_control_target( - value + int(value) ), additional_available_func=lambda device: ( device.operation_mode in (OperationMode.cool, OperationMode.dry) @@ -95,7 +98,6 @@ class GreeNumberDescription( device_filter=lambda device: device.device_type is DeviceType.ZONE, device_class=NumberDeviceClass.TEMPERATURE, mode=NumberMode.BOX, - native_step=1, min_func=lambda device: ( MIN_TEMP_F if device.target_temperature_unit is TemperatureUnits.F @@ -107,6 +109,12 @@ class GreeNumberDescription( else MAX_TEMP_C ), unit_func=lambda device: UNITS_GREE_TO_HA[device.target_temperature_unit], + step_func=lambda device: ( + 0.5 + if device.target_temperature_unit is TemperatureUnits.C + and device.supports_property(GreeProp.ZONE_TARGET_TEMPERATURE_BIT) + else 1 + ), value_func=lambda device: device.target_temperature, set_func=lambda device, value: device.set_target_temperature(value), updates_device=True, @@ -191,6 +199,15 @@ def native_max_value(self) -> float: return super().native_max_value + @property + @override + def native_step(self) -> float | None: + """Return the step, from the device when it decides it.""" + if self.entity_description.step_func is not None: + return self.entity_description.step_func(self.device) + + return super().native_step + @property @override def native_unit_of_measurement(self) -> str | None: @@ -213,9 +230,7 @@ async def async_set_native_value(self, value: float) -> None: raise HomeAssistantError("Entity unavailable") try: - # Round, not cut: a temperature converted by Home Assistant can - # be 69.8 for 70 degrees Fahrenheit - self.entity_description.set_func(self.device, round(value)) + self.entity_description.set_func(self.device, value) if self.entity_description.updates_device: await self.coordinator.push_device_status() diff --git a/docs/entities.md b/docs/entities.md index 8c79fe8..c9c96b4 100644 --- a/docs/entities.md +++ b/docs/entities.md @@ -49,7 +49,7 @@ A zone controller (for example the Gree LE60-13/GH) has one ducted unit and up t | Entity | What it does | |---|---| | Switch with the name of the zone | Opens and closes the zone. It is the **Zone Switch** feature, so you can leave it out. Do that for a zone that must always stay open: turning such a zone off is refused by the controller or turns the whole system off. | -| Target Temperature | The target temperature of the zone, in the unit of the controller: 16 to 30 °C, or 61 to 86 °F. Home Assistant shows it in your own unit. | +| Target Temperature | The target temperature of the zone, in the unit of the controller: 16 to 30 °C in half degrees, or 61 to 86 °F in whole degrees. Home Assistant shows it in your own unit. The wall panel shows only whole degrees in °C, so it shows 18 for 18.5. | ## Sensors diff --git a/docs/protocol.md b/docs/protocol.md index dca322d..ca05116 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -96,7 +96,7 @@ A zone controller (seen: LE60-13/GH with a ME31-00/C13 WiFi module, firmware `36 - `Mod` uses its own numbers: 1 Cool, 2 Heat, 3 Dry, 4 Fan, 5 Auto (`ZONE_CONTROLLER_MODES`). Read with the normal numbers, Fan shows as Heat and Auto raises. `operation_mode` and `set_operation_mode()` convert for every unit with a zone role. - There is no `Tur` column. Turbo is `WdSpd` 6, only in Cool and Heat. Dry forces `WdSpd` 1. - A zone answers `Pow` (the damper), `StTem` and `StFahFlg` (its target temperature), `TemUnit`, and a copy of `Mod` and `WdSpd` of the ducted unit. `StTem` is the temperature in degrees Celsius minus 16 (`ZONE_TEMPERATURE_OFFSET`), so 0 to 14 for 16 to 30 degrees. The controller itself also answers `StTem1` to `StTem8`, without the bit. -- The unit of temperature is `TemUnit` (0 °C, 1 °F), not `TemUn`. Every unit answers it. Written to the ducted unit, the controller and the wall panel switch, and the zones follow. In °F a zone uses the same table as `SetTem` and `TemRec` of a normal unit: `StTem` + 16 is the degrees Celsius, `StFahFlg` is the bit. Seen on the panel: 64 °F is `StTem` 2 with bit 0, 66 °F is 3 with 0, 67 °F is 3 with 1, 70 °F is 5 with 1. When only `StTem` is sent, the controller keeps the old bit, so `set_target_temperature()` sends both. +- The unit of temperature is `TemUnit` (0 °C, 1 °F), not `TemUn`. Every unit answers it. Written to the ducted unit, the controller and the wall panel switch, and the zones follow. `StTem` + 16 and `StFahFlg` work like `SetTem` and `TemRec` of a normal unit. In °C the bit is read as the half degree, like `TemRec`: the controller keeps `StTem` 2 with bit 1 (18.5 °C), while the wall panel shows only whole degrees (18). In °F it is the same table as `TemRec`. Seen on the panel: 64 °F is `StTem` 2 with bit 0, 66 °F is 3 with 0, 67 °F is 3 with 1, 70 °F is 5 with 1. When only `StTem` is sent, the controller keeps the old bit, so `set_target_temperature()` sends both. - The columns come from what a device answers. `StTem`, `StFahFlg` and `TemUnit` are in the default poll, and the first poll drops them on a unit that does not answer them, like any other column. A device uses `TemUn` when it answers it and `TemUnit` only without it, so every other unit works as before. The ducted unit also answers `StTem`, always 0, so only a zone uses `StTem` as its target temperature. - The full list of columns is a table in the WiFi module firmware. There is no column for the room temperature of a zone, in any released firmware, so it cannot be shown. - The status column limit measured by `probe_device_limits()` is 29. Requests far above the limit (about 40 columns and more) made the module reboot. diff --git a/tests/fakes/zone.py b/tests/fakes/zone.py index d581715..e7f1ea8 100644 --- a/tests/fakes/zone.py +++ b/tests/fakes/zone.py @@ -14,7 +14,8 @@ - `StTem` is the zone target temperature in degrees Celsius minus 16. - `TemUnit` is the unit of temperature (0 Celsius, 1 Fahrenheit) of every unit. Written to the ducted unit, all units follow. `StFahFlg` of a zone is its - Fahrenheit bit, like `TemRec`. + bit, like `TemRec`: the half degree in Celsius, the same table in + Fahrenheit. - Every sub-unit answers the info columns with the name of the controller. - A status request with too many columns gets an empty result. """ diff --git a/tests/test_zone_controller.py b/tests/test_zone_controller.py index 528f5cf..06a8292 100644 --- a/tests/test_zone_controller.py +++ b/tests/test_zone_controller.py @@ -272,6 +272,23 @@ async def test_a_zone_target_temperature_is_sent_minus_16( assert controller.units[controller.zone_macs[0]]["StTem"] == 14 +async def test_a_zone_takes_half_degrees_in_celsius( + controller: FakeZoneController, zone: GreeDevice +) -> None: + """The bit is the half degree, like TemRec. Seen on the real controller: 2 and 1 stay 18.5.""" + values = controller.units[controller.zone_macs[0]] + + zone.set_target_temperature(18.5) + await zone.push_device_status() + assert (values["StTem"], values["StFahFlg"]) == (2, 1) + await zone.fetch_device_status() + assert zone.target_temperature == 18.5 + + zone.set_target_temperature(21.2) + await zone.push_device_status() + assert (values["StTem"], values["StFahFlg"]) == (5, 0) + + # # Fahrenheit. The values are the ones the wall panel showed. # @@ -302,7 +319,7 @@ async def test_a_zone_in_fahrenheit_reads_its_bit_like_tem_rec( async def test_a_zone_in_fahrenheit_is_sent_with_its_bit( controller: FakeZoneController, zone: GreeDevice ) -> None: - """70 goes out as StTem 5 with StFahFlg 1, 66 as 3 with 0.""" + """70 goes out as StTem 5 with StFahFlg 1, 66 as 3 with 0. Fahrenheit is whole degrees.""" set_unit(controller, TemperatureUnits.F) await zone.fetch_device_status() values = controller.units[controller.zone_macs[0]] @@ -315,6 +332,11 @@ async def test_a_zone_in_fahrenheit_is_sent_with_its_bit( await zone.push_device_status() assert (values["StTem"], values["StFahFlg"]) == (3, 0) + # Home Assistant converts 21 C to 69.8 F + zone.set_target_temperature(69.8) + await zone.push_device_status() + assert (values["StTem"], values["StFahFlg"]) == (5, 1) + async def test_a_celsius_target_clears_the_bit( controller: FakeZoneController, zone: GreeDevice From b922a2653e2a3f76146d3149121956a8b3e78e8d Mon Sep 17 00:00:00 2001 From: Twintop Date: Wed, 14 Oct 2026 17:22:39 +1100 Subject: [PATCH 13/18] Keep the column of each sensor group on the device 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. --- .../gree_custom/aiogree/device.py | 64 +++++++++---------- .../gree_custom/aiogree/device_state.py | 9 ++- tests/test_device_state.py | 6 +- 3 files changed, 40 insertions(+), 39 deletions(-) diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index 88b8a89..9126109 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -106,6 +106,13 @@ def __init__( self._device_type: DeviceType = DeviceType.AC_UNIT + # The column of each group that the device answers, see + # _remove_unsupported_props. None until the first bind, or when the + # device answers none of them. + self._indoor_temp_sensor_prop: GreeProp | None = None + self._outdoor_temp_sensor_prop: GreeProp | None = None + self._humidity_sensor_prop: GreeProp | None = None + self._state = DeviceState( device_id=self.unique_id, capabilities=self._capabilities ) @@ -397,7 +404,7 @@ def _remove_unsupported_props(self) -> None: self._state.invalidate_missing_properties() # Sensors should also be invalidated if their values are not expected (=0) - self._state.invalidate_missing_property_group( + self._indoor_temp_sensor_prop = self._state.invalidate_missing_property_group( [ GreeProp.SENSOR_INDOOR_TEMPERATURE_1, GreeProp.SENSOR_INDOOR_TEMPERATURE_2, @@ -405,14 +412,14 @@ def _remove_unsupported_props(self) -> None: ] ) - self._state.invalidate_missing_property_group( + self._outdoor_temp_sensor_prop = self._state.invalidate_missing_property_group( [ GreeProp.SENSOR_OUTSIDE_TEMPERATURE_1, GreeProp.SENSOR_OUTSIDE_TEMPERATURE_2, ] ) - self._state.invalidate_missing_property_group( + self._humidity_sensor_prop = self._state.invalidate_missing_property_group( [ GreeProp.SENSOR_HUMIDITY_1, GreeProp.SENSOR_HUMIDITY_2, @@ -670,20 +677,15 @@ def indoors_temperature_c(self) -> int | None: if self._temp_processor_indoors is None: self._temp_processor_indoors = TempOffsetResolver() - for prop in ( - GreeProp.SENSOR_INDOOR_TEMPERATURE_1, - GreeProp.SENSOR_INDOOR_TEMPERATURE_2, - GreeProp.SENSOR_INDOOR_TEMPERATURE_3, - ): - if self._state.supports(prop): - raw_c = self._state.get(prop, None) - return ( - int(self._temp_processor_indoors.evaluate(raw_c)) - if raw_c is not None - else None - ) + if self._indoor_temp_sensor_prop is None: + return None - return None + raw_c = self._state.get(self._indoor_temp_sensor_prop) + return ( + int(self._temp_processor_indoors.evaluate(raw_c)) + if raw_c is not None + else None + ) @property def outdoors_temperature_c(self) -> int | None: @@ -692,32 +694,24 @@ def outdoors_temperature_c(self) -> int | None: if self._temp_processor_outdoors is None: self._temp_processor_outdoors = TempOffsetResolver() - for prop in ( - GreeProp.SENSOR_OUTSIDE_TEMPERATURE_1, - GreeProp.SENSOR_OUTSIDE_TEMPERATURE_2, - ): - if self._state.supports(prop): - raw_c = self._state.get(prop, None) - return ( - int(self._temp_processor_outdoors.evaluate(raw_c)) - if raw_c is not None - else None - ) + if self._outdoor_temp_sensor_prop is None: + return None - return None + raw_c = self._state.get(self._outdoor_temp_sensor_prop) + return ( + int(self._temp_processor_outdoors.evaluate(raw_c)) + if raw_c is not None + else None + ) @property def humidity(self) -> int | None: """Return the current humidity if available.""" - for prop in ( - GreeProp.SENSOR_HUMIDITY_1, - GreeProp.SENSOR_HUMIDITY_2, - ): - if self._state.supports(prop): - return self._state.get(prop, None) + if self._humidity_sensor_prop is None: + return None - return None + return self._state.get(self._humidity_sensor_prop) @property def power_mode(self) -> bool: diff --git a/custom_components/gree_custom/aiogree/device_state.py b/custom_components/gree_custom/aiogree/device_state.py index 07288e0..5b41ad0 100755 --- a/custom_components/gree_custom/aiogree/device_state.py +++ b/custom_components/gree_custom/aiogree/device_state.py @@ -258,8 +258,11 @@ def invalidate_missing_properties(self) -> None: def invalidate_missing_property_group( self, props: list[GreeProp], missing_value: int = 0 - ) -> None: - """Remove a group of properties from polling based on a ordered list of preference.""" + ) -> GreeProp | None: + """Remove a group of properties from polling based on a ordered list of preference. + + Returns the property that is kept, or None if none of them has a value. + """ # Keep the first (lowest priority number) non-zero value preferred = next( @@ -276,6 +279,8 @@ def invalidate_missing_property_group( self.remove(prop) + return preferred + @property def has_pending_updates(self) -> bool: """Does the state have pending values to be committed. diff --git a/tests/test_device_state.py b/tests/test_device_state.py index 3b07723..f017f12 100644 --- a/tests/test_device_state.py +++ b/tests/test_device_state.py @@ -207,8 +207,9 @@ def test_a_sensor_group_keeps_only_the_one_that_reports(state: DeviceState) -> N }, ) - state.invalidate_missing_property_group(group) + kept = state.invalidate_missing_property_group(group) + assert kept is GreeProp.SENSOR_INDOOR_TEMPERATURE_2 assert GreeProp.SENSOR_INDOOR_TEMPERATURE_2 in state.polled_properties assert GreeProp.SENSOR_INDOOR_TEMPERATURE_1 not in state.polled_properties assert GreeProp.SENSOR_INDOOR_TEMPERATURE_3 not in state.polled_properties @@ -221,8 +222,9 @@ def test_a_sensor_group_that_reports_nothing_is_dropped_whole( group = [GreeProp.SENSOR_HUMIDITY_1, GreeProp.SENSOR_HUMIDITY_2] seed(state, {GreeProp.SENSOR_HUMIDITY_1: 0, GreeProp.SENSOR_HUMIDITY_2: 0}) - state.invalidate_missing_property_group(group) + kept = state.invalidate_missing_property_group(group) + assert kept is None assert all(prop not in state.polled_properties for prop in group) From a7885286f74973542e1bbd87c0930a5a0eb325aa Mon Sep 17 00:00:00 2001 From: Twintop Date: Wed, 14 Oct 2026 17:23:47 +1100 Subject: [PATCH 14/18] Keep the target temperature columns in groups, like the sensors 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. --- custom_components/gree_custom/aiogree/api.py | 35 +++-- .../gree_custom/aiogree/device.py | 130 ++++++++++-------- custom_components/gree_custom/climate.py | 2 +- .../gree_custom/config_schema.py | 2 +- custom_components/gree_custom/const.py | 9 +- custom_components/gree_custom/number.py | 3 +- docs/architecture.md | 2 +- docs/protocol.md | 12 +- tests/test_device.py | 6 +- tests/test_device_api_client.py | 6 +- tests/test_device_state.py | 44 +++--- tests/test_zone_controller.py | 110 ++++++++++++--- 12 files changed, 230 insertions(+), 131 deletions(-) diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index d94ff62..88dbde3 100755 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -38,12 +38,15 @@ class GreeProp(StrEnum): # the swing mode of the vertical air blades SWING_VERTICAL = "SwUpDn" - # target temperature - TARGET_TEMPERATURE = "SetTem" - # used to distinguish between Fahrenheit values - TARGET_TEMPERATURE_BIT = "TemRec" + # target temperature, ordered by preference + TARGET_TEMPERATURE_1 = "SetTem" + TARGET_TEMPERATURE_2 = "StTem" # zone of a zone controller, Celsius minus 16 + # used to distinguish between Fahrenheit values, and the half degree in Celsius + TARGET_TEMPERATURE_BIT_1 = "TemRec" + TARGET_TEMPERATURE_BIT_2 = "StFahFlg" # defines the unit of temperature for the target temperature - TARGET_TEMPERATURE_UNIT = "TemUn" + TARGET_TEMPERATURE_UNIT_1 = "TemUn" + TARGET_TEMPERATURE_UNIT_2 = "TemUnit" # zone controller and all its sub-units # Quiet mode which slows down the fan to its most quiet speed. Not available in Dry and Fan mode. FEAT_QUIET_MODE = "Quiet" @@ -94,14 +97,6 @@ class GreeProp(StrEnum): # If set to 1 the unit will beep on every command (available on newer firmwares) BEEPER_NEW = "BuzzerCtrl" - # ZONE CONTROLLER - # target temperature of one zone, in whole degrees Celsius minus 16 - ZONE_TARGET_TEMPERATURE = "StTem" - # used to distinguish between Fahrenheit values of a zone, like TemRec - ZONE_TARGET_TEMPERATURE_BIT = "StFahFlg" - # the unit of temperature of a zone controller and all its sub-units - ZONE_TEMPERATURE_UNIT = "TemUnit" - PROP_KEY_TO_ENUM = {prop.value: prop for prop in GreeProp} @@ -526,8 +521,9 @@ class DeviceType(StrEnum): } # It has no turbo column. Turbo is one more fan speed. ZONE_CONTROLLER_FAN_TURBO = 6 -# A zone stores its target temperature as degrees Celsius minus this. -ZONE_TEMPERATURE_OFFSET = 16 +# Added to the raw value of a target temperature column to get degrees +# Celsius. StTem, the target temperature of a zone, is degrees Celsius minus 16. +TARGET_TEMPERATURE_OFFSETS: dict[GreeProp, int] = {GreeProp.TARGET_TEMPERATURE_2: 16} class GreeCommand(StrEnum): @@ -840,9 +836,12 @@ def _order_set_props(props: dict[str, int]) -> dict[str, int]: # Temperature-related properties for prop in ( - GreeProp.TARGET_TEMPERATURE_UNIT.value, - GreeProp.TARGET_TEMPERATURE_BIT.value, - GreeProp.TARGET_TEMPERATURE.value, + GreeProp.TARGET_TEMPERATURE_UNIT_1.value, + GreeProp.TARGET_TEMPERATURE_UNIT_2.value, + GreeProp.TARGET_TEMPERATURE_BIT_1.value, + GreeProp.TARGET_TEMPERATURE_BIT_2.value, + GreeProp.TARGET_TEMPERATURE_1.value, + GreeProp.TARGET_TEMPERATURE_2.value, ): if prop in remaining: ordered[prop] = remaining.pop(prop) diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index 9126109..9b76de8 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -8,13 +8,13 @@ from packaging.version import Version from .api import ( + TARGET_TEMPERATURE_OFFSETS, ZONE_CONTROLLER_AC_UNIT_SUFFIX, ZONE_CONTROLLER_FAN_TURBO, ZONE_CONTROLLER_MODES, ZONE_CONTROLLER_MODES_TO_RAW, ZONE_CONTROLLER_NAME_PREFIX, ZONE_CONTROLLER_TYPES, - ZONE_TEMPERATURE_OFFSET, DeviceType, EncryptionVersion, FanSpeed, @@ -112,6 +112,9 @@ def __init__( self._indoor_temp_sensor_prop: GreeProp | None = None self._outdoor_temp_sensor_prop: GreeProp | None = None self._humidity_sensor_prop: GreeProp | None = None + self._target_temp_prop: GreeProp | None = None + self._target_temp_bit_prop: GreeProp | None = None + self._temp_unit_prop: GreeProp | None = None self._state = DeviceState( device_id=self.unique_id, capabilities=self._capabilities @@ -213,8 +216,8 @@ def _detect_device_type(self) -> None: The columns a device uses come from what it answers. The type is only 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 - also answers StTem, for example, always with 0. + zone controller, the StTem of its ducted unit, which is always 0, and + which entities a sub-unit gets. """ name = self._state.info.get(InfoProp.DEVICE_NAME, "") if not self.is_sub_unit: @@ -426,6 +429,35 @@ def _remove_unsupported_props(self) -> None: ] ) + # The ducted unit of a zone controller answers StTem and StFahFlg too, + # always with 0, the same as a zone set to 16 degrees Celsius. It has + # no target temperature of its own, each zone has one. + if self._device_type is DeviceType.ZONE_CONTROLLER: + for prop in ( + GreeProp.TARGET_TEMPERATURE_2, + GreeProp.TARGET_TEMPERATURE_BIT_2, + ): + if prop in self._state.polled_properties: + self._state.remove(prop) + + # 0 is a valid value in these groups: StTem 0 is 16 degrees Celsius, + # the bits are 0 or 1, and unit 0 is Celsius. So only a column that + # the device does not answer is missing. + self._target_temp_prop = self._state.invalidate_missing_property_group( + [GreeProp.TARGET_TEMPERATURE_1, GreeProp.TARGET_TEMPERATURE_2], + missing_value=-1, + ) + + self._target_temp_bit_prop = self._state.invalidate_missing_property_group( + [GreeProp.TARGET_TEMPERATURE_BIT_1, GreeProp.TARGET_TEMPERATURE_BIT_2], + missing_value=-1, + ) + + self._temp_unit_prop = self._state.invalidate_missing_property_group( + [GreeProp.TARGET_TEMPERATURE_UNIT_1, GreeProp.TARGET_TEMPERATURE_UNIT_2], + missing_value=-1, + ) + # As far as it is known, both values at 0 is not a valid combination. if ( GreeProp.FEATURE_HUMIDITY_CONTROL in self._state.polled_properties @@ -826,73 +858,60 @@ def set_horizontal_swing_mode(self, swing_mode: HorizontalSwingMode) -> None: @property def target_temperature_unit(self) -> TemperatureUnits: """Return the units of the target temperature.""" + if self._temp_unit_prop is None: + return TemperatureUnits.C return TemperatureUnits( - self._state.get(self._temperature_unit_prop) or TemperatureUnits.C.value + self._state.get(self._temp_unit_prop) or TemperatureUnits.C.value ) def set_target_temperature_unit(self, units: TemperatureUnits) -> None: """Set the units of the target temperature.""" - self._state.set(self._temperature_unit_prop, units) + if self._temp_unit_prop is None: + _LOGGER.warning("[%s] The device has no temperature unit", self.unique_id) + return + self._state.set(self._temp_unit_prop, units) @property - def _temperature_unit_prop(self) -> GreeProp: - """Return the column with the unit: TemUn, or TemUnit when only that one is answered. + def has_target_temperature_bit(self) -> bool: + """Return True if the device answers a bit for the target temperature. - The sub-units of a zone controller answer TemUnit and not TemUn. + Only then can it hold half degrees in Celsius. """ - if not self._state.supports( - GreeProp.TARGET_TEMPERATURE_UNIT - ) and self._state.supports(GreeProp.ZONE_TEMPERATURE_UNIT): - return GreeProp.ZONE_TEMPERATURE_UNIT - return GreeProp.TARGET_TEMPERATURE_UNIT + return self._target_temp_bit_prop is not None @property def target_temperature(self) -> float | None: """Return the target temperature in target_temperature_unit. - A zone has its own columns, see GreeProp.ZONE_TARGET_TEMPERATURE. They - work like SetTem and TemRec, with the bit as the half degree in Celsius - and the same table in Fahrenheit. It is None until the zone is read. + The bit is the half degree in Celsius, and picks the value from the + table in Fahrenheit. Without a bit column it is 0. None until the + device is read, or when it has no target temperature. """ + if self._target_temp_prop is None: + return None - if self._device_type is DeviceType.ZONE: - raw = self._state.get(GreeProp.ZONE_TARGET_TEMPERATURE) - if raw is None: - return None - zone_c = raw + ZONE_TEMPERATURE_OFFSET - bit = self._state.get(GreeProp.ZONE_TARGET_TEMPERATURE_BIT) or 0 - if self.target_temperature_unit == TemperatureUnits.F: - return gree_get_target_temperature_f(zone_c, bit) - return gree_get_target_temperature_c(zone_c, bit) - - raw_c = self._state.get(GreeProp.TARGET_TEMPERATURE, 0) - tem_rec = self._state.get(GreeProp.TARGET_TEMPERATURE_BIT, 0) - - if raw_c is not None and tem_rec is not None: - if self.target_temperature_unit == TemperatureUnits.F: - return gree_get_target_temperature_f(raw_c, tem_rec) - if self.target_temperature_unit == TemperatureUnits.C: - return gree_get_target_temperature_c(raw_c, tem_rec) - return 0.0 + raw = self._state.get(self._target_temp_prop) + if raw is None: + return None + + raw_c = raw + TARGET_TEMPERATURE_OFFSETS.get(self._target_temp_prop, 0) + bit = 0 + if self._target_temp_bit_prop is not None: + bit = self._state.get(self._target_temp_bit_prop) or 0 + + if self.target_temperature_unit == TemperatureUnits.F: + return gree_get_target_temperature_f(raw_c, bit) + return gree_get_target_temperature_c(raw_c, bit) def set_target_temperature(self, value: float) -> None: """Set the target temperature in target_temperature_unit. Celsius goes to the nearest half degree, Fahrenheit to whole degrees. - A zone uses its own columns the same way. When the zone has the bit, - it is always sent along, because the controller keeps the old one when - only StTem is sent. + The bit is sent along when the device answers it. A zone needs that, + because it keeps the old bit when only StTem is sent. """ - - if self._device_type is DeviceType.ZONE: - if self.target_temperature_unit == TemperatureUnits.F: - raw_c, bit = gree_get_target_temp_props_from_f(round(value)) - else: - raw_c, bit = gree_get_target_temp_props_from_c(value) - props = {GreeProp.ZONE_TARGET_TEMPERATURE: raw_c - ZONE_TEMPERATURE_OFFSET} - if self._state.supports(GreeProp.ZONE_TARGET_TEMPERATURE_BIT): - props[GreeProp.ZONE_TARGET_TEMPERATURE_BIT] = bit - self._state.update(props) + if self._target_temp_prop is None: + _LOGGER.warning("[%s] The device has no target temperature", self.unique_id) return if self.target_temperature_unit == TemperatureUnits.F: @@ -902,16 +921,15 @@ def set_target_temperature(self, value: float) -> None: value, round(value), ) - raw_c, tem_rec = gree_get_target_temp_props_from_f(round(value)) + raw_c, bit = gree_get_target_temp_props_from_f(round(value)) else: - raw_c, tem_rec = gree_get_target_temp_props_from_c(value) + raw_c, bit = gree_get_target_temp_props_from_c(value) - self._state.update( - { - GreeProp.TARGET_TEMPERATURE: raw_c, - GreeProp.TARGET_TEMPERATURE_BIT: tem_rec, - } - ) + offset = TARGET_TEMPERATURE_OFFSETS.get(self._target_temp_prop, 0) + props = {self._target_temp_prop: raw_c - offset} + if self._target_temp_bit_prop is not None: + props[self._target_temp_bit_prop] = bit + self._state.update(props) if self.feature_smart_heat or self.feature_energy_saving: _LOGGER.info( diff --git a/custom_components/gree_custom/climate.py b/custom_components/gree_custom/climate.py index 2d69bd6..0153959 100755 --- a/custom_components/gree_custom/climate.py +++ b/custom_components/gree_custom/climate.py @@ -210,7 +210,7 @@ def __init__( # Only offer a target temperature when the unit has one. if any( mode in hvac_modes for mode in (HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO) - ) and self.device.supports_property(GreeProp.TARGET_TEMPERATURE): + ) and self.device.supports_property(GreeProp.TARGET_TEMPERATURE_1): self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE if fan_modes: diff --git a/custom_components/gree_custom/config_schema.py b/custom_components/gree_custom/config_schema.py index d15498c..3c995ef 100644 --- a/custom_components/gree_custom/config_schema.py +++ b/custom_components/gree_custom/config_schema.py @@ -719,7 +719,7 @@ def setup_device_options_schema( # noqa: C901 } ) - if device.supports_property(GreeProp.TARGET_TEMPERATURE): + if device.supports_property(GreeProp.TARGET_TEMPERATURE_1): schema.update( { probatio.Required( diff --git a/custom_components/gree_custom/const.py b/custom_components/gree_custom/const.py index a883848..c126492 100755 --- a/custom_components/gree_custom/const.py +++ b/custom_components/gree_custom/const.py @@ -163,10 +163,13 @@ CONF_TO_PROP_FEATURE_MAP: dict[str, list[GreeProp]] = { GATTR_TEMP_UNITS: [ - GreeProp.TARGET_TEMPERATURE_UNIT, - GreeProp.ZONE_TEMPERATURE_UNIT, + GreeProp.TARGET_TEMPERATURE_UNIT_1, + GreeProp.TARGET_TEMPERATURE_UNIT_2, + ], + GATTR_ZONE_TARGET_TEMPERATURE: [ + GreeProp.TARGET_TEMPERATURE_1, + GreeProp.TARGET_TEMPERATURE_2, ], - GATTR_ZONE_TARGET_TEMPERATURE: [GreeProp.ZONE_TARGET_TEMPERATURE], # SENSORS **ATTR_SENSORS_TO_PROP_MAP, # FEATURES diff --git a/custom_components/gree_custom/number.py b/custom_components/gree_custom/number.py index d012e98..e36e980 100755 --- a/custom_components/gree_custom/number.py +++ b/custom_components/gree_custom/number.py @@ -17,7 +17,6 @@ from .aiogree.api import ( DeviceType, - GreeProp, HumidityControlMode, OperationMode, TemperatureUnits, @@ -112,7 +111,7 @@ class GreeNumberDescription( step_func=lambda device: ( 0.5 if device.target_temperature_unit is TemperatureUnits.C - and device.supports_property(GreeProp.ZONE_TARGET_TEMPERATURE_BIT) + and device.has_target_temperature_bit else 1 ), value_func=lambda device: device.target_temperature, diff --git a/docs/architecture.md b/docs/architecture.md index 9b82e5d..5405a5f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -69,7 +69,7 @@ reference counting rather than on the code saying what it means. 1. Bind. Try the local transport first, then MQTT. Binding is the key exchange, see [protocol.md](protocol.md#encryption). Right after the key exchange, `probe_device_limits()` measures how many columns one status request may carry on this firmware, see [protocol.md](protocol.md#requests-and-batching). 2. `fetch_device_info()`. Asks for the `InfoProp` columns (MAC, name, model, firmware). 3. `fetch_device_status()`. Asks for all polled `GreeProp` columns. The request is split into batches, see [protocol.md](protocol.md#requests-and-batching). -4. `_remove_unsupported_props()`. Any prop the device did not return is removed from polling for the life of the object. If the device returned nothing at all, this raises `GreeProtocolError` instead, so setup fails and Home Assistant retries later. +4. `_remove_unsupported_props()`. Any prop the device did not return is removed from polling for the life of the object. If the device returned nothing at all, this raises `GreeProtocolError` instead, so setup fails and Home Assistant retries later. Where one value has several columns (the sensors, and the target temperature, its bit and the unit), it keeps one column per group and saves it on the device, which then reads and writes only that column. See [protocol.md](protocol.md#temperature). After that the coordinator calls `fetch_device_status()` every `scan_interval` seconds (default 60). diff --git a/docs/protocol.md b/docs/protocol.md index ca05116..44e5505 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -17,7 +17,7 @@ Facts about how Gree devices behave on the wire. Most of these are not visible f - Some firmwares cap the number of columns per request. `U-CS532Z(LT)V3.75` answers 29 columns, returns an empty result for 30, and stops replying at 31 or more. Pack size and the names of the columns play no role: `Pow` repeated 30 times in a 258 byte packet fails the same way. - The limit differs per firmware, so `DeviceApiClient.probe_device_limits()` measures it once, right after binding. A probe asks for `STATUS_CANARY_PROP` (`Pow`, which every unit answers) plus distinct throw-away names (`X00`, `X01`, ...). Distinct names matter: a device may merge repeated names before it counts them. Each probe is one request with one attempt and `PROBE_TIMEOUT` (5 s), so a unit that goes silent on a too large request does not stall the bind. - A probe passes when `Pow` comes back and no column with an empty name comes back. Anything else fails: no reply, an empty result, or an error. The empty name is how some firmwares mark a request they had to cut short (seen: 65 columns asked, 64 answered plus one column named `""`). -- The first probe asks for as many columns as the default poll has (`POLLED_PROPS`, 30 today: every `GreeProp` except the two beeper props). Passing that probe means no limit affects anything the component sends; it does not mean the device has no limit at all. If it passes there is no limit worth keeping and `max_props` stays `None`. If it fails, a binary search between `MIN_PACK_PROPS` and the first failing size minus one looks for the largest size that still passes. If even `MIN_PACK_PROPS` fails, the limit is set to `MIN_PACK_PROPS` and one warning is logged; the device is most likely answering nothing at all, which the empty status guard in `GreeDevice` then catches. +- The first probe asks for as many columns as the default poll has (`POLLED_PROPS`, 33 today: every `GreeProp` except the two beeper props). Passing that probe means no limit affects anything the component sends; it does not mean the device has no limit at all. If it passes there is no limit worth keeping and `max_props` stays `None`. If it fails, a binary search between `MIN_PACK_PROPS` and the first failing size minus one looks for the largest size that still passes. If even `MIN_PACK_PROPS` fails, the limit is set to `MIN_PACK_PROPS` and one warning is logged; the device is most likely answering nothing at all, which the empty status guard in `GreeDevice` then catches. - The result lives on the client and is reset on unbind, so a rebind measures again. Cost: one tiny request on a device without a limit, about five on a device with one. A probe that gets no reply costs 5 s. - A unit can have both limits at once. The `U-CS532Z(LT)V3.75` unit caps at 29 columns and also stops replying at about 800 bytes in one packet, which matches the "about 760 bytes unencrypted" note above `MAX_PACK_SIZE`. The 512 byte cap keeps normal polls far below the size limit; the measured column limit handles the other one. - To measure a unit, run `tools/probe_status_limit.py --host ` from the repo root. It sends `Pow` repeated N times (growing count, tiny packets) and then 10 columns with padded names (growing bytes, fixed count), each as exactly one packet, and prints where each run first fails. See [development.md](development.md#tools). @@ -31,6 +31,8 @@ Facts about how Gree devices behave on the wire. Most of these are not visible f ## Temperature - `SetTem` is a whole number of degrees. `TemRec` adds the half degree. +- The target temperature, its bit and the unit each come in two columns, in order of preference: `SetTem` and `StTem` (`TARGET_TEMPERATURE_1` and `_2`), `TemRec` and `StFahFlg` (`TARGET_TEMPERATURE_BIT_1` and `_2`), `TemUn` and `TemUnit` (`TARGET_TEMPERATURE_UNIT_1` and `_2`). Like the sensor groups, `_remove_unsupported_props()` keeps the first column of each group that the device answers, and the device reads and writes only that one. 0 is a valid value in all three groups (`StTem` 0 is 16 °C), so only a column the device does not answer counts as missing (`missing_value=-1`). +- The bit is only sent to a device that answers one. A device without it gets whole degrees. - Some devices report sensor temperatures with a +40 offset. `TempOffsetResolver` in `aiogree/helpers.py` detects this from the values it sees. - Fahrenheit uses the protocol's own lookup table, not a formula. @@ -91,13 +93,13 @@ A zone controller (seen: LE60-13/GH with a ME31-00/C13 WiFi module, firmware `36 - The scan reply has no `cid`, see [Discovery and network](#discovery-and-network). - It answers only the `subDev` form of the sub-device list. Every unit in the list is called `zone`. The ducted unit has model id `5000` and MAC suffix `00`. The zones have model id `5001` and suffixes `01` to `08`. Discovery names them `AC unit` and `Zone 1`, `Zone 2` and so on, from the name of the controller in the scan reply and the MAC suffix. The model ids are not used. - A sub-unit answers the info request with the name of the controller, which starts with `GR-ZCntrlr` (`ZONE_CONTROLLER_NAME_PREFIX`), and with the model id of the controller (`3017`), not its own. After the info fetch, `GreeDevice._detect_device_type()` sets `device_type` for every device: `ac_unit` for a unit that is not a sub-unit, `zone_controller` for sub-unit `00` (`ZONE_CONTROLLER_AC_UNIT_SUFFIX`) of a zone controller, `zone` for its other sub-units, and `vrf_unit` for any other sub-unit. -- The name prefix, the MAC suffix of the ducted unit and the model ids come from the WiFi module, not from the units: they are fixed in its firmware (v1.21, v1.27 and v1.28 checked). So they are the same for every controller with this module. The Braemar LE60 uses the same module, so it should show the same values (not tested). Only the two zone controller types (`ZONE_CONTROLLER_TYPES`) change behaviour, and only where the columns cannot tell: the mode numbers, turbo, and which entities a sub-unit gets. -- The ducted unit answers `Pow`, `Mod`, `WdSpd`, `AllErr` and `TemUnit`, and `StTem` and `StFahFlg` always as 0. `Pow` of the ducted unit is the power of the whole system. The zones keep their own `Pow` while it is off, and get it back when it is turned on. No sub-unit answers `SetTem`, `TemRec`, `TemUn` or `Add0.5`. +- The name prefix, the MAC suffix of the ducted unit and the model ids come from the WiFi module, not from the units: they are fixed in its firmware (v1.21, v1.27 and v1.28 checked). So they are the same for every controller with this module. The Braemar LE60 uses the same module, so it should show the same values (not tested). Only the two zone controller types (`ZONE_CONTROLLER_TYPES`) change behaviour, and only where the columns cannot tell: the mode numbers, turbo, the `StTem` of the ducted unit, and which entities a sub-unit gets. +- The ducted unit answers `Pow`, `Mod`, `WdSpd`, `AllErr` and `TemUnit`, and `StTem` and `StFahFlg` always as 0. That looks the same as a zone set to 16 °C, so the ducted unit drops those two after its first poll. `Pow` of the ducted unit is the power of the whole system. The zones keep their own `Pow` while it is off, and get it back when it is turned on. No sub-unit answers `SetTem`, `TemRec`, `TemUn` or `Add0.5`. - `Mod` uses its own numbers: 1 Cool, 2 Heat, 3 Dry, 4 Fan, 5 Auto (`ZONE_CONTROLLER_MODES`). Read with the normal numbers, Fan shows as Heat and Auto raises. `operation_mode` and `set_operation_mode()` convert for every unit with a zone role. - There is no `Tur` column. Turbo is `WdSpd` 6, only in Cool and Heat. Dry forces `WdSpd` 1. -- A zone answers `Pow` (the damper), `StTem` and `StFahFlg` (its target temperature), `TemUnit`, and a copy of `Mod` and `WdSpd` of the ducted unit. `StTem` is the temperature in degrees Celsius minus 16 (`ZONE_TEMPERATURE_OFFSET`), so 0 to 14 for 16 to 30 degrees. The controller itself also answers `StTem1` to `StTem8`, without the bit. +- A zone answers `Pow` (the damper), `StTem` and `StFahFlg` (its target temperature), `TemUnit`, and a copy of `Mod` and `WdSpd` of the ducted unit. `StTem` is the temperature in degrees Celsius minus 16 (`TARGET_TEMPERATURE_OFFSETS`), so 0 to 14 for 16 to 30 degrees. The controller itself also answers `StTem1` to `StTem8`, without the bit. - The unit of temperature is `TemUnit` (0 °C, 1 °F), not `TemUn`. Every unit answers it. Written to the ducted unit, the controller and the wall panel switch, and the zones follow. `StTem` + 16 and `StFahFlg` work like `SetTem` and `TemRec` of a normal unit. In °C the bit is read as the half degree, like `TemRec`: the controller keeps `StTem` 2 with bit 1 (18.5 °C), while the wall panel shows only whole degrees (18). In °F it is the same table as `TemRec`. Seen on the panel: 64 °F is `StTem` 2 with bit 0, 66 °F is 3 with 0, 67 °F is 3 with 1, 70 °F is 5 with 1. When only `StTem` is sent, the controller keeps the old bit, so `set_target_temperature()` sends both. -- The columns come from what a device answers. `StTem`, `StFahFlg` and `TemUnit` are in the default poll, and the first poll drops them on a unit that does not answer them, like any other column. A device uses `TemUn` when it answers it and `TemUnit` only without it, so every other unit works as before. The ducted unit also answers `StTem`, always 0, so only a zone uses `StTem` as its target temperature. +- The columns come from what a device answers. `StTem`, `StFahFlg` and `TemUnit` are in the default poll, and the first poll drops them on a unit that does not answer them, like any other column. They are the second column of their group (see [Temperature](#temperature)), so a device that answers `SetTem`, `TemRec` or `TemUn` keeps using those, and every other unit works as before. - The full list of columns is a table in the WiFi module firmware. There is no column for the room temperature of a zone, in any released firmware, so it cannot be shown. - The status column limit measured by `probe_device_limits()` is 29. Requests far above the limit (about 40 columns and more) made the module reboot. diff --git a/tests/test_device.py b/tests/test_device.py index 5bbc864..2054c16 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -57,9 +57,9 @@ GreeProp.POWER.value: 1, GreeProp.OP_MODE.value: OperationMode.cool.value, GreeProp.FAN_SPEED.value: FanSpeed.auto.value, - GreeProp.TARGET_TEMPERATURE.value: 21, - GreeProp.TARGET_TEMPERATURE_BIT.value: 0, - GreeProp.TARGET_TEMPERATURE_UNIT.value: TemperatureUnits.C.value, + GreeProp.TARGET_TEMPERATURE_1.value: 21, + GreeProp.TARGET_TEMPERATURE_BIT_1.value: 0, + GreeProp.TARGET_TEMPERATURE_UNIT_1.value: TemperatureUnits.C.value, GreeProp.SENSOR_INDOOR_TEMPERATURE_3.value: 61, GreeProp.SENSOR_OUTSIDE_TEMPERATURE_1.value: 52, GreeProp.SENSOR_HUMIDITY_1.value: 55, diff --git a/tests/test_device_api_client.py b/tests/test_device_api_client.py index 74f1168..5180006 100644 --- a/tests/test_device_api_client.py +++ b/tests/test_device_api_client.py @@ -246,7 +246,7 @@ async def test_a_query_raises_when_the_device_goes_quiet( device.reset_record() with pytest.raises(GreeConnectionError): - await client.query_props([GreeProp.TARGET_TEMPERATURE.value]) + await client.query_props([GreeProp.TARGET_TEMPERATURE_1.value]) finally: await transport.disconnect() @@ -272,7 +272,7 @@ async def test_a_sweep_goes_on_when_the_device_answers_the_canary( result = await client.query_props( [ - GreeProp.TARGET_TEMPERATURE.value, + GreeProp.TARGET_TEMPERATURE_1.value, "ElcDatDte", "ElcDatHor", GreeProp.OP_MODE.value, @@ -285,7 +285,7 @@ async def test_a_sweep_goes_on_when_the_device_answers_the_canary( await transport.disconnect() assert result.prop_values == { - GreeProp.TARGET_TEMPERATURE.value: "21", + GreeProp.TARGET_TEMPERATURE_1.value: "21", GreeProp.OP_MODE.value: "1", } assert sorted(result.missing_props) == ["ElcDatDte", "ElcDatHor"] diff --git a/tests/test_device_state.py b/tests/test_device_state.py index f017f12..82e80fe 100644 --- a/tests/test_device_state.py +++ b/tests/test_device_state.py @@ -243,28 +243,28 @@ def test_the_views_are_read_only(state: DeviceState) -> None: def test_a_stale_report_does_not_undo_a_sent_value(timed_state: DeviceState) -> None: """The gateway still reports the old value, but the read shows the sent one.""" - seed(timed_state, {GreeProp.TARGET_TEMPERATURE: 21}) - timed_state.hold({GreeProp.TARGET_TEMPERATURE: 24}) + seed(timed_state, {GreeProp.TARGET_TEMPERATURE_1: 21}) + timed_state.hold({GreeProp.TARGET_TEMPERATURE_1: 24}) - seed(timed_state, {GreeProp.TARGET_TEMPERATURE: 21}) + seed(timed_state, {GreeProp.TARGET_TEMPERATURE_1: 21}) - assert timed_state.get(GreeProp.TARGET_TEMPERATURE) == 24 - assert timed_state.raw[GreeProp.TARGET_TEMPERATURE] == 21 - assert timed_state.held == {GreeProp.TARGET_TEMPERATURE: 24} + assert timed_state.get(GreeProp.TARGET_TEMPERATURE_1) == 24 + assert timed_state.raw[GreeProp.TARGET_TEMPERATURE_1] == 21 + assert timed_state.held == {GreeProp.TARGET_TEMPERATURE_1: 24} def test_a_report_of_the_sent_value_ends_the_hold(timed_state: DeviceState) -> None: """Once confirmed, later reports count again, for example from a remote.""" - seed(timed_state, {GreeProp.TARGET_TEMPERATURE: 21}) - timed_state.hold({GreeProp.TARGET_TEMPERATURE: 24}) + seed(timed_state, {GreeProp.TARGET_TEMPERATURE_1: 21}) + timed_state.hold({GreeProp.TARGET_TEMPERATURE_1: 24}) - seed(timed_state, {GreeProp.TARGET_TEMPERATURE: 24}) + seed(timed_state, {GreeProp.TARGET_TEMPERATURE_1: 24}) assert timed_state.held == {} - seed(timed_state, {GreeProp.TARGET_TEMPERATURE: 19}) + seed(timed_state, {GreeProp.TARGET_TEMPERATURE_1: 19}) - assert timed_state.get(GreeProp.TARGET_TEMPERATURE) == 19 + assert timed_state.get(GreeProp.TARGET_TEMPERATURE_1) == 19 def test_a_hold_ends_when_its_time_is_up( @@ -317,30 +317,30 @@ def test_a_new_command_starts_a_new_hold( def test_a_pending_value_wins_over_a_held_one(timed_state: DeviceState) -> None: """A change that is not sent yet is newer than the one that was sent.""" - seed(timed_state, {GreeProp.TARGET_TEMPERATURE: 21}) - timed_state.hold({GreeProp.TARGET_TEMPERATURE: 24}) - timed_state.set(GreeProp.TARGET_TEMPERATURE, 25) + seed(timed_state, {GreeProp.TARGET_TEMPERATURE_1: 21}) + timed_state.hold({GreeProp.TARGET_TEMPERATURE_1: 24}) + timed_state.set(GreeProp.TARGET_TEMPERATURE_1, 25) - assert timed_state.get(GreeProp.TARGET_TEMPERATURE) == 25 - assert timed_state.pending == {GreeProp.TARGET_TEMPERATURE: 25} - assert timed_state.held == {GreeProp.TARGET_TEMPERATURE: 24} + assert timed_state.get(GreeProp.TARGET_TEMPERATURE_1) == 25 + assert timed_state.pending == {GreeProp.TARGET_TEMPERATURE_1: 25} + assert timed_state.held == {GreeProp.TARGET_TEMPERATURE_1: 24} timed_state.clear_pending() - assert timed_state.get(GreeProp.TARGET_TEMPERATURE) == 24 + assert timed_state.get(GreeProp.TARGET_TEMPERATURE_1) == 24 def test_a_change_back_to_the_stale_value_is_still_sent( timed_state: DeviceState, ) -> None: """The device was told 24, so going back to 21 is a real change.""" - seed(timed_state, {GreeProp.TARGET_TEMPERATURE: 21}) - timed_state.hold({GreeProp.TARGET_TEMPERATURE: 24}) + seed(timed_state, {GreeProp.TARGET_TEMPERATURE_1: 21}) + timed_state.hold({GreeProp.TARGET_TEMPERATURE_1: 24}) - timed_state.set(GreeProp.TARGET_TEMPERATURE, 21) + timed_state.set(GreeProp.TARGET_TEMPERATURE_1, 21) assert timed_state.has_pending_updates - timed_state.set(GreeProp.TARGET_TEMPERATURE, 24) + timed_state.set(GreeProp.TARGET_TEMPERATURE_1, 24) assert not timed_state.has_pending_updates diff --git a/tests/test_zone_controller.py b/tests/test_zone_controller.py index 06a8292..ecd8e7f 100644 --- a/tests/test_zone_controller.py +++ b/tests/test_zone_controller.py @@ -134,13 +134,24 @@ def sub_devices(self, form: SubListForm) -> list[dict[str, str]]: async def test_the_ducted_unit_is_recognised(ac_unit: GreeDevice) -> None: - """The name in the info columns and the 00 suffix make it the ducted unit. + """The name in the info columns and the 00 suffix make it the ducted unit.""" + assert ac_unit.device_type is DeviceType.ZONE_CONTROLLER + + +async def test_the_ducted_unit_has_no_target_temperature( + controller: FakeZoneController, ac_unit: GreeDevice +) -> None: + """It answers StTem and StFahFlg, always with 0, the same as a zone at 16 degrees. - It answers StTem too, always with 0, so the columns alone cannot tell it - from a zone. + Only the type tells them apart, so the ducted unit stops asking for both. """ - assert ac_unit.device_type is DeviceType.ZONE_CONTROLLER - assert ac_unit.supports_property(GreeProp.ZONE_TARGET_TEMPERATURE) + controller.reset_record() + await ac_unit.fetch_device_status() + + asked = {col for pack in controller.packs for col in pack.get("cols", [])} + assert not asked & {"StTem", "StFahFlg"} + assert "TemUnit" in asked + assert ac_unit.target_temperature is None async def test_the_device_type_is_in_the_diagnostics( @@ -239,7 +250,7 @@ async def test_a_zone_is_recognised_and_polls_its_target_temperature( ) -> None: """A zone asks for StTem and reads it as degrees Celsius minus 16.""" assert zone.device_type is DeviceType.ZONE - assert zone.supports_property(GreeProp.ZONE_TARGET_TEMPERATURE) + assert zone.supports_property(GreeProp.TARGET_TEMPERATURE_2) assert zone.target_temperature == 18 controller.units[controller.zone_macs[0]]["StTem"] = 5 @@ -248,6 +259,20 @@ async def test_a_zone_is_recognised_and_polls_its_target_temperature( assert zone.target_temperature == 21 +async def test_a_zone_at_16_degrees_keeps_its_target_temperature( + controller: FakeZoneController, +) -> None: + """StTem 0 is 16 degrees, a valid value. Only a column the zone does not answer is dropped.""" + controller.units[controller.zone_macs[0]]["StTem"] = 0 + zone, transport = await bind_unit(controller, "01") + try: + assert zone.supports_property(GreeProp.TARGET_TEMPERATURE_2) + assert zone.supports_property(GreeProp.TARGET_TEMPERATURE_BIT_2) + assert zone.target_temperature == 16 + finally: + await transport.disconnect() + + async def test_a_zone_target_temperature_is_sent_minus_16( controller: FakeZoneController, zone: GreeDevice ) -> None: @@ -356,8 +381,8 @@ async def test_the_unit_is_set_on_the_ducted_unit_and_the_zones_follow( controller: FakeZoneController, ac_unit: GreeDevice, zone: GreeDevice ) -> None: """TemUnit, not TemUn, written to the ducted unit. The zones read it at the next poll.""" - assert ac_unit.supports_property(GreeProp.ZONE_TEMPERATURE_UNIT) - assert not ac_unit.supports_property(GreeProp.TARGET_TEMPERATURE_UNIT) + assert ac_unit.supports_property(GreeProp.TARGET_TEMPERATURE_UNIT_2) + assert not ac_unit.supports_property(GreeProp.TARGET_TEMPERATURE_UNIT_1) ac_unit.set_target_temperature_unit(TemperatureUnits.F) await ac_unit.push_device_status() @@ -410,17 +435,59 @@ async def test_a_normal_unit_asks_for_the_zone_columns_only_once() -> None: assert device.device_type is DeviceType.AC_UNIT +OLD_COLUMNS = [ + GreeProp.TARGET_TEMPERATURE_1, + GreeProp.TARGET_TEMPERATURE_BIT_1, + GreeProp.TARGET_TEMPERATURE_UNIT_1, +] +NEW_COLUMNS = [ + GreeProp.TARGET_TEMPERATURE_2, + GreeProp.TARGET_TEMPERATURE_BIT_2, + GreeProp.TARGET_TEMPERATURE_UNIT_2, +] + + @pytest.mark.parametrize( - ("unsupported", "unit"), - [(set(), TemperatureUnits.C), ({"TemUn"}, TemperatureUnits.F)], - ids=["TemUn wins", "only TemUnit"], + ("unsupported", "kept", "dropped", "target"), + [ + (set(), OLD_COLUMNS, NEW_COLUMNS, 21), + ({"SetTem", "TemRec", "TemUn"}, NEW_COLUMNS, OLD_COLUMNS, 67), + ], + ids=["the old columns win", "only the new columns"], ) -async def test_the_unit_column_is_the_one_the_device_answers( - unsupported: set[str], unit: TemperatureUnits +async def test_each_temperature_group_keeps_the_column_the_device_answers( + unsupported: set[str], + kept: list[GreeProp], + dropped: list[GreeProp], + target: float, ) -> None: - """TemUn when the device answers it, as every unit did before. TemUnit only without it.""" + """SetTem, TemRec and TemUn when the device answers them, as every unit did before. + + The new columns only without them: StTem 3 with bit 1 in Fahrenheit is 67. + """ fake = FakeGreeDevice(unsupported_props=unsupported) - fake.values.update({"TemUn": 0, "TemUnit": 1}) + fake.values.update( + {"SetTem": 21, "TemRec": 0, "TemUn": 0, "StTem": 3, "StFahFlg": 1, "TemUnit": 1} + ) + await fake.start() + transport = GreeUdpTransport(fake.host, fake.port, max_retries=1, timeout=0.3) + try: + device = GreeDevice(name="Zolderkamer", mac_addr=fake.mac) + await device.bind_with_transport( + local_controller_mac=fake.mac, local_transport=transport + ) + finally: + await transport.disconnect() + fake.close() + + assert all(device.supports_property(prop) for prop in kept) + assert not any(device.supports_property(prop) for prop in dropped) + assert device.target_temperature == target + + +async def test_a_unit_without_the_bit_gets_the_target_alone() -> None: + """The bit only goes out to a unit that answers it.""" + fake = FakeGreeDevice(unsupported_props={"TemRec", "StFahFlg"}) await fake.start() transport = GreeUdpTransport(fake.host, fake.port, max_retries=1, timeout=0.3) try: @@ -428,11 +495,22 @@ async def test_the_unit_column_is_the_one_the_device_answers( await device.bind_with_transport( local_controller_mac=fake.mac, local_transport=transport ) + fake.reset_record() + device.set_target_temperature(22) + await device.push_device_status() finally: await transport.disconnect() fake.close() - assert device.target_temperature_unit is unit + sent = { + column + for pack in fake.packs + if pack.get("t") == "cmd" + for column in pack["opt"] + } + assert "SetTem" in sent + assert not sent & {"TemRec", "StFahFlg"} + assert not device.has_target_temperature_bit async def test_a_vrf_indoor_unit_keeps_the_normal_mode_numbers() -> None: From e21a9868a0080783faece4fdd3ee07849768c30a Mon Sep 17 00:00:00 2001 From: Twintop Date: Wed, 14 Oct 2026 21:34:17 +1100 Subject: [PATCH 15/18] Zone controllers: say they are local only, and fix a docstring 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. --- custom_components/gree_custom/aiogree/device.py | 3 ++- docs/entities.md | 2 ++ supported-devices.md | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index 9b76de8..96d0af3 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -875,7 +875,8 @@ def set_target_temperature_unit(self, units: TemperatureUnits) -> None: def has_target_temperature_bit(self) -> bool: """Return True if the device answers a bit for the target temperature. - Only then can it hold half degrees in Celsius. + Only then can it hold half degrees in Celsius, and every whole degree + in Fahrenheit. Without it, Fahrenheit goes in steps of about 2. """ return self._target_temp_bit_prop is not None diff --git a/docs/entities.md b/docs/entities.md index c9c96b4..3170bac 100644 --- a/docs/entities.md +++ b/docs/entities.md @@ -43,6 +43,8 @@ The unit has its own temperature scale, Celsius or Fahrenheit, which you change A zone controller (for example the Gree LE60-13/GH) has one ducted unit and up to eight zones behind one WiFi module. Discovery finds the ducted unit as **AC unit** and the zones as **Zone 1**, **Zone 2** and so on. Add all of them. +A zone controller is local only, so add it with local discovery. Its WiFi module (ME31-00/C13) uses an older Gree cloud protocol, not MQTT, so a cloud-only setup cannot control it, and cloud discovery lists only the AC unit. + - The **AC unit** gets the climate entity. Its HVAC mode and fan mode work as above. Turbo is a fan speed of this unit, in Cool and Heat only. It has no target temperature and no room temperature: the zones have their own target temperature, and the controller does not report the room temperatures of the zones. Turning the AC unit off turns the whole system off. The zones keep their own setting and get it back when the system is turned on again. Its **Temperature Units** select switches the whole controller, the wall panel included, between °C and °F. The zones follow at their next update. - Each **zone** gets no climate entity, because a zone has no mode of its own. It gets: diff --git a/supported-devices.md b/supported-devices.md index b3a8565..c6d7d56 100644 --- a/supported-devices.md +++ b/supported-devices.md @@ -20,7 +20,7 @@ Tested on the following hardware: - Gree KFR-35GW/(35549)FNhAc-B1: ECB encryption, basic functions work; Wi‑Fi setup may require Bluetooth/Gree+ app pairing. - Gree MC31-00/F Central Air Conditioner Remote Control Module - Gree GWH18AGD-K6DNA1D/I (encryption version 1) -- Gree LE60-13/GH zone controller with ME31-00/C13 WiFi module (firmware 362001062617 V1.27, encryption version 1): AC unit and zones, see [Zone controller](docs/entities.md#zone-controller) +- Gree LE60-13/GH zone controller with ME31-00/C13 WiFi module (firmware 362001062617 V1.27, encryption version 1): AC unit and zones, local only, see [Zone controller](docs/entities.md#zone-controller) ## Kolin - Kolin KAG-100WCINV (encryption version 2) From e33b6f7c9fc9f08b30e670d11054f724089ff108 Mon Sep 17 00:00:00 2001 From: Twintop Date: Wed, 14 Oct 2026 22:47:52 +1100 Subject: [PATCH 16/18] Keep the cloud login out of logs and diagnostics 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. --- custom_components/gree_custom/__init__.py | 16 ++++---- custom_components/gree_custom/aiogree/api.py | 15 +++---- .../gree_custom/aiogree/cloud_api.py | 33 ++++++++++++---- custom_components/gree_custom/config_flow.py | 13 ++++--- custom_components/gree_custom/const.py | 5 ++- custom_components/gree_custom/diagnostics.py | 5 +-- docs/actions.md | 2 +- docs/troubleshooting.md | 4 +- tests/test_cloud_api.py | 39 +++++++++++++++++++ tests/test_discovery_merge.py | 11 ++++++ 10 files changed, 108 insertions(+), 35 deletions(-) diff --git a/custom_components/gree_custom/__init__.py b/custom_components/gree_custom/__init__.py index dc72989..4a93297 100755 --- a/custom_components/gree_custom/__init__.py +++ b/custom_components/gree_custom/__init__.py @@ -9,7 +9,6 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import ( - CONF_EMAIL, CONF_HOST, CONF_NAME, CONF_PORT, @@ -65,6 +64,7 @@ DEFAULT_SCAN_INTERVAL, DOMAIN, ENCRYPTION_VERSION_AUTO, + TO_REDACT, ) from .coordinator import GreeConfigEntry, GreeCoordinator from .helpers import get_vrf_controller_mac, reconcile_vrf_controllers, try_find_new_ip @@ -114,15 +114,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool: # noqa: C901 """Set up Gree from a config entry.""" - _LOGGER.info( - "Setup entry '%s': %s", - entry.entry_id, - entry.title, - ) + # The title of a cloud entry holds the email, so log the unique id: the + # user id of the account, or local_only. + _LOGGER.info("Setup entry '%s': %s", entry.entry_id, entry.unique_id) _LOGGER.debug( "Setup entry '%s': data=%s", entry.entry_id, - json.dumps(async_redact_data(entry.data, ["encryption_key"])), + json.dumps(async_redact_data(entry.data, TO_REDACT)), ) conf = entry.data @@ -192,7 +190,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool ) if mac_controller_cloud and cloud_conf and not mqtt_transport: - _LOGGER.debug("Creating MQTT transport for %s", cloud_conf[CONF_EMAIL]) + _LOGGER.debug( + "Creating MQTT transport for user %s", cloud_conf.get(CONF_UID) + ) userid: int = cloud_conf.get(CONF_UID, 0) token: str = cloud_conf.get(CONF_TOKEN, "") diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index 88dbde3..dea5668 100755 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -2,7 +2,7 @@ import asyncio from collections.abc import Mapping -from dataclasses import dataclass, fields, replace +from dataclasses import dataclass, field, fields, replace from enum import IntEnum, StrEnum, unique import json import logging @@ -585,12 +585,13 @@ class GreeDiscoveredDevice: mac_controller_local: str = "" mac_controller_mqtt: str = "" user_id: int = DEFAULT_DEVICE_USERID - key: str | None = None + # Not in the repr, which discovery logs + key: str | None = field(default=None, repr=False) # Local host: str | None = None port: int | None = None - # Cloud - username: str | None = None + # Cloud. The email of the account, not in the repr either + username: str | None = field(default=None, repr=False) # Properties name: str = "" catalog: str = "" @@ -1785,8 +1786,8 @@ def gree_merge_discovered_devices( # Override with non-empty local values # So that local info overrides cloud, especially for MAC - for field in fields(GreeDiscoveredDevice): - value = getattr(local, field.name) + for device_field in fields(GreeDiscoveredDevice): + value = getattr(local, device_field.name) if value is None: continue @@ -1794,7 +1795,7 @@ def gree_merge_discovered_devices( if isinstance(value, str) and value == "": continue - setattr(merged_device, field.name, value) + setattr(merged_device, device_field.name, value) # Prefer cloud name and uid merged_device.name = cloud.name diff --git a/custom_components/gree_custom/aiogree/cloud_api.py b/custom_components/gree_custom/aiogree/cloud_api.py index 1c394d5..1110e25 100755 --- a/custom_components/gree_custom/aiogree/cloud_api.py +++ b/custom_components/gree_custom/aiogree/cloud_api.py @@ -14,7 +14,7 @@ import json import logging from types import TracebackType -from typing import Self +from typing import Any, Self import aiohttp from cryptography.hazmat.backends import default_backend @@ -27,6 +27,26 @@ _LOGGER = logging.getLogger(__name__) +# The parts of a login response that may be logged. The rest, such as the +# session token, the email and the user name, is redacted, because users post +# their debug logs. +LOGIN_LOG_KEYS = frozenset({"r", "msg", "uid", "alive"}) + + +def _loggable_login_response(data: Any) -> Any: + """Return the login response with everything but LOGIN_LOG_KEYS redacted.""" + if not isinstance(data, dict): + return "[redacted]" + loggable: dict[str, Any] = {} + for key, value in data.items(): + if isinstance(value, dict): + loggable[key] = _loggable_login_response(value) + elif key in LOGIN_LOG_KEYS: + loggable[key] = value + else: + loggable[key] = "[redacted]" + return loggable + @dataclass class CloudHome: @@ -318,11 +338,10 @@ async def login(self) -> CloudCredentials: encrypted_response = await self._send_request("/App/UserLoginV2", body) decrypted = self._decrypt(base64.b64decode(encrypted_response)) - - _LOGGER.debug("Login response (decrypted): %s", decrypted) - data = json.loads(decrypted) - _LOGGER.debug("Login response (parsed): %s", data) + + loggable = _loggable_login_response(data) + _LOGGER.debug("Login response: %s", loggable) # Check for error response if "r" in data and data["r"] != 200: @@ -338,10 +357,10 @@ async def login(self) -> CloudCredentials: user_id = data["data"].get("uid") token = data["data"].get("token") else: - raise GreeCloudError(f"Unexpected login response format: {data}") + raise GreeCloudError(f"Unexpected login response format: {loggable}") if not user_id or not token: - raise GreeCloudLoginError(f"Missing uid or token in response: {data}") + raise GreeCloudLoginError(f"Missing uid or token in response: {loggable}") self.user_id = user_id self.token = token diff --git a/custom_components/gree_custom/config_flow.py b/custom_components/gree_custom/config_flow.py index 57283b8..580b11e 100755 --- a/custom_components/gree_custom/config_flow.py +++ b/custom_components/gree_custom/config_flow.py @@ -100,6 +100,7 @@ DOMAIN, ENCRYPTION_VERSION_AUTO, MAX_UNICAST_SCAN_HOSTS, + TO_REDACT, ZONE_ONLY_FEATURES, ) from .coordinator import GreeConfigEntry @@ -285,7 +286,7 @@ async def async_step_import(self, import_config: dict) -> ConfigFlowResult: _LOGGER.debug( "YAML import: new entry with config: %s", - async_redact_data(data, ["encryption_key", "password", "token"]), + async_redact_data(data, TO_REDACT), ) return self.async_create_entry( title=title, @@ -454,7 +455,7 @@ async def async_step_reauth( ) -> ConfigFlowResult: """Process a Reauth request.""" reauth_entry = self._get_reauth_entry() - _LOGGER.debug("Reauth entry: %s", reauth_entry.title) + _LOGGER.debug("Reauth entry: %s", reauth_entry.entry_id) return await self.async_step_cloud_add() @@ -464,7 +465,7 @@ async def async_step_reconfigure( """Handle reconfiguration of an existing entry.""" reconfigure_entry = self._get_reconfigure_entry() reconfigure_data = dict(reconfigure_entry.data) - _LOGGER.debug("Reconfiguring: %s", reconfigure_entry.title) + _LOGGER.debug("Reconfiguring: %s", reconfigure_entry.entry_id) # If on the local-only entry, exit early and continue with the local method only if reconfigure_entry.unique_id == CONFENTRY_ID_LOCAL_ONLY: @@ -577,9 +578,9 @@ async def async_step_cloud_add( self._discovered_devices_cloud = {d.mac: d for d in discovered} _LOGGER.info( - "Discovered %d devices from the cloud account: %s", + "Discovered %d devices from the cloud account of user %s", len(self._discovered_devices_cloud), - self._cloud_api.username, + self._cloud_api.user_id, ) return await self._setup_next_setup_method() @@ -1128,7 +1129,7 @@ async def _async_finish(self) -> ConfigFlowResult: # noqa: C901 _LOGGER.debug( "New entry with config: %s", - async_redact_data(data, ["encryption_key"]), + async_redact_data(data, TO_REDACT), ) return self.async_create_entry( title=title, diff --git a/custom_components/gree_custom/const.py b/custom_components/gree_custom/const.py index c126492..80abc7f 100755 --- a/custom_components/gree_custom/const.py +++ b/custom_components/gree_custom/const.py @@ -1,7 +1,7 @@ """Constants for the Gree integration.""" from homeassistant.components.climate import HVACMode # pyright: ignore[reportPrivateImportUsage] -from homeassistant.const import UnitOfTemperature +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TOKEN, UnitOfTemperature from .aiogree.api import ( FanSpeed, @@ -42,6 +42,9 @@ CONF_ALL_DEVICE_OPTIONS = "device_options" CONF_UID = "uid" CONF_ENCRYPTION_KEY = "encryption_key" +# Left out of debug logs and diagnostics, because users post both: the device +# key and the cloud login. +TO_REDACT = [CONF_ENCRYPTION_KEY, CONF_PASSWORD, CONF_TOKEN, CONF_EMAIL] CONF_ENCRYPTION_VERSION = "encryption_version" CONF_DISABLE_AVAILABLE_CHECK = "disable_available_check" CONF_MAX_ONLINE_ATTEMPTS = "max_online_attempts" diff --git a/custom_components/gree_custom/diagnostics.py b/custom_components/gree_custom/diagnostics.py index 0aac90a..7fd9f14 100755 --- a/custom_components/gree_custom/diagnostics.py +++ b/custom_components/gree_custom/diagnostics.py @@ -4,11 +4,10 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry -from .const import CONF_DEVICES, CONF_ENCRYPTION_KEY, DOMAIN +from .const import CONF_DEVICES, DOMAIN, TO_REDACT from .coordinator import GreeConfigEntry, GreeCoordinator from .helpers import get_vrf_controller_mac, get_vrf_sub_units @@ -34,7 +33,7 @@ async def async_get_config_entry_diagnostics( # diagnostics["entry_data"]["advanced"]["encryption_key"] # ) - return async_redact_data(diagnostics, [CONF_ENCRYPTION_KEY, CONF_PASSWORD]) + return async_redact_data(diagnostics, TO_REDACT) async def async_get_device_diagnostics( diff --git a/docs/actions.md b/docs/actions.md index a1f1486..ebc61ad 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -59,6 +59,6 @@ missing: ## Diagnostics download -Every config entry and every device has a **Download diagnostics** item in its three dot menu. The download is a JSON file with the configuration and the last known state of each device. Keys and passwords are redacted. +Every config entry and every device has a **Download diagnostics** item in its three dot menu. The download is a JSON file with the configuration and the last known state of each device. Keys and the cloud login (token, password and email) are redacted. The device diagnostics also list the properties the integration polls and the values the device sent that the integration does not know. The diagnostics of a VRF gateway device hold the diagnostics of each of its indoor units. Attach the file to a bug report. See [troubleshooting.md](troubleshooting.md#how-to-report-a-bug). diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 146c497..64c7df3 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -15,7 +15,7 @@ When Home Assistant runs in a Docker container on a bridge network, the discover ## Enable debug logging -Debug logs show every packet in both directions, with the keys redacted. Turn them on before you reproduce a problem. +Debug logs show every packet in both directions, with the keys and the cloud login (token, password and email) redacted. Turn them on before you reproduce a problem. In the UI: open the integration under **Settings** > **Devices & Services**, click the three dots and choose **Enable debug logging**. Reproduce the problem, then choose **Disable debug logging**. Home Assistant then downloads the log. @@ -91,7 +91,7 @@ Run the checks under [Time out and cannot connect errors](#time-out-and-cannot-c 2. The Home Assistant version and the integration version, from HACS or `manifest.json`. 3. Whether the device is controlled locally or through the cloud. 4. What you did, what you expected, and what happened. -5. The diagnostics download of the config entry or the device. See [actions.md](actions.md#diagnostics-download). Keys and passwords are redacted for you. +5. The diagnostics download of the config entry or the device. See [actions.md](actions.md#diagnostics-download). Keys and the cloud login (token, password and email) are redacted for you. 6. A debug log of the moment the problem happened. See [Enable debug logging](#enable-debug-logging). Remove anything personal before you paste it. A report without a log usually gets a request for one first, which costs a round trip. diff --git a/tests/test_cloud_api.py b/tests/test_cloud_api.py index 2728d6c..8e08843 100644 --- a/tests/test_cloud_api.py +++ b/tests/test_cloud_api.py @@ -21,6 +21,7 @@ from aiogree.errors import GreeCloudError, GreeCloudLoginError import pytest +from .conftest import RecordingHandler from .fakes.cloud import TOKEN, USER_ID, FakeGreeCloud, cloud_device REGION = GreeRegion.EU @@ -116,6 +117,44 @@ async def test_a_login_without_a_token_raises(cloud_factory: CloudFactory) -> No await client.login() +async def test_the_login_log_leaves_out_the_token_and_the_account( + cloud_factory: CloudFactory, gree_logs: RecordingHandler +) -> None: + """Users post their debug logs, so only the result and the user id are logged.""" + _, client = await cloud_factory( # type: ignore[misc] + login_response={ + "r": 200, + "uid": USER_ID, + "token": TOKEN, + "email": "rob@example.com", + "uname": "rob", + "tel": "0612345678", + } + ) + + await client.login() + + logged = "\n".join(gree_logs.messages()) + assert "Login response" in logged + assert str(USER_ID) in logged + for secret in (TOKEN, "rob@example.com", "'rob'", "0612345678"): + assert secret not in logged + + +async def test_a_login_error_leaves_out_the_account( + cloud_factory: CloudFactory, +) -> None: + """The error text ends up in the log too.""" + _, client = await cloud_factory( # type: ignore[misc] + login_response={"uid": 7, "token": "", "email": "rob@example.com"} + ) + + with pytest.raises(GreeCloudLoginError) as err: + await client.login() + + assert "rob@example.com" not in str(err.value) + + async def test_an_unknown_login_format_raises(cloud_factory: CloudFactory) -> None: """Better an error than a client that thinks it is logged in.""" _, client = await cloud_factory(login_response={"something": "else"}) # type: ignore[misc] diff --git a/tests/test_discovery_merge.py b/tests/test_discovery_merge.py index a8f6619..bf9dd34 100644 --- a/tests/test_discovery_merge.py +++ b/tests/test_discovery_merge.py @@ -168,3 +168,14 @@ def test_the_friendly_name_says_where_a_device_was_found() -> None: assert local.friendly_name == f"Zolderkamer, {LOCAL_MAC} (Local)" assert cloud.friendly_name == f"{CLOUD_MAC} (Cloud)" + + +def test_the_repr_leaves_out_the_key_and_the_account() -> None: + """Discovery logs every device with repr(), and users post their debug logs.""" + device = GreeDiscoveredDevice( + mac=CLOUD_MAC, key="V1sT9p0aQ3zXcR7m", username="rob@example.com" + ) + + assert CLOUD_MAC in repr(device) + assert "V1sT9p0aQ3zXcR7m" not in repr(device) + assert "rob@example.com" not in repr(device) From 3f5549578fa49b364e9a469a9b132bde2dc32ef3 Mon Sep 17 00:00:00 2001 From: Twintop Date: Wed, 14 Oct 2026 23:02:54 +1100 Subject: [PATCH 17/18] Cloud: keep the sub-units of a gateway in the device list 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. --- .../gree_custom/aiogree/cloud_api.py | 41 ++++++++++++++--- docs/entities.md | 2 +- docs/protocol.md | 1 + tests/fakes/cloud.py | 32 +++++++++++++ tests/test_cloud_api.py | 28 +++++++++++- tests/test_discovery_merge.py | 45 ++++++++++++++++++- 6 files changed, 141 insertions(+), 8 deletions(-) diff --git a/custom_components/gree_custom/aiogree/cloud_api.py b/custom_components/gree_custom/aiogree/cloud_api.py index 1110e25..af25fa3 100755 --- a/custom_components/gree_custom/aiogree/cloud_api.py +++ b/custom_components/gree_custom/aiogree/cloud_api.py @@ -510,9 +510,15 @@ def _filter_duplicate_devices_complete( ) -> list[CloudDeviceInfoResponse]: """Filter out duplicate devices with same key. - When devices share the same encryption key but have different MACs - (one normal, one ending with '00'), keep only the one with '00'. - The device without '00' suffix doesn't respond to commands. + A gateway can be listed next to its sub-units, which have the MAC of + the gateway as pmac. The gateway is left out and every sub-unit is + kept. Seen on a zone controller: the gateway, the AC unit (MAC ending + in 00) and the zones (01 to 08), all with one key. + + Of the other devices, when devices share the same encryption key but + have different MACs (one normal, one ending with '00'), keep only the + one with '00'. The device without '00' suffix doesn't respond to + commands. Args: devices: List of devices to filter @@ -521,9 +527,33 @@ def _filter_duplicate_devices_complete( Filtered list without duplicates """ + # A device can have its own MAC as pmac, which does not make it a + # gateway. Only a listed device that another device points to is one. + listed = {device.mac for device in devices} + gateways = { + device.pmac + for device in devices + if device.pmac and device.pmac != device.mac and device.pmac in listed + } + if gateways: + _LOGGER.debug( + "Filtering out gateway(s) listed with their sub-units: %s", + sorted(gateways), + ) + sub_units = [ + device + for device in devices + if device.pmac in gateways and device.mac not in gateways + ] + others = [ + device + for device in devices + if device.mac not in gateways and device.pmac not in gateways + ] + # Group devices by encryption key key_groups: dict[str, list[CloudDeviceInfoResponse]] = {} - for device in devices: + for device in others: if device.key not in key_groups: key_groups[device.key] = [] key_groups[device.key].append(device) @@ -555,7 +585,8 @@ def _filter_duplicate_devices_complete( # No device with '00' found, keep all (shouldn't happen but be safe) filtered.extend(group) - return filtered + kept = {id(device) for device in [*sub_units, *filtered]} + return [device for device in devices if id(device) in kept] async def gree_get_latest_firmware_info( diff --git a/docs/entities.md b/docs/entities.md index 3170bac..550fcd2 100644 --- a/docs/entities.md +++ b/docs/entities.md @@ -43,7 +43,7 @@ The unit has its own temperature scale, Celsius or Fahrenheit, which you change A zone controller (for example the Gree LE60-13/GH) has one ducted unit and up to eight zones behind one WiFi module. Discovery finds the ducted unit as **AC unit** and the zones as **Zone 1**, **Zone 2** and so on. Add all of them. -A zone controller is local only, so add it with local discovery. Its WiFi module (ME31-00/C13) uses an older Gree cloud protocol, not MQTT, so a cloud-only setup cannot control it, and cloud discovery lists only the AC unit. +A zone controller is controlled locally. Add it with local discovery, or with both discovery methods: then the AC unit and the zones get the names they have in the Gree app. A cloud-only setup cannot control it, because its WiFi module (ME31-00/C13) uses an older Gree cloud protocol, not MQTT. - The **AC unit** gets the climate entity. Its HVAC mode and fan mode work as above. Turbo is a fan speed of this unit, in Cool and Heat only. It has no target temperature and no room temperature: the zones have their own target temperature, and the controller does not report the room temperatures of the zones. Turning the AC unit off turns the whole system off. The zones keep their own setting and get it back when the system is turned on again. Its **Temperature Units** select switches the whole controller, the wall panel included, between °C and °F. The zones follow at their next update. - Each **zone** gets no climate entity, because a zone has no mode of its own. It gets: diff --git a/docs/protocol.md b/docs/protocol.md index 44e5505..8d69fd2 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -100,6 +100,7 @@ A zone controller (seen: LE60-13/GH with a ME31-00/C13 WiFi module, firmware `36 - A zone answers `Pow` (the damper), `StTem` and `StFahFlg` (its target temperature), `TemUnit`, and a copy of `Mod` and `WdSpd` of the ducted unit. `StTem` is the temperature in degrees Celsius minus 16 (`TARGET_TEMPERATURE_OFFSETS`), so 0 to 14 for 16 to 30 degrees. The controller itself also answers `StTem1` to `StTem8`, without the bit. - The unit of temperature is `TemUnit` (0 °C, 1 °F), not `TemUn`. Every unit answers it. Written to the ducted unit, the controller and the wall panel switch, and the zones follow. `StTem` + 16 and `StFahFlg` work like `SetTem` and `TemRec` of a normal unit. In °C the bit is read as the half degree, like `TemRec`: the controller keeps `StTem` 2 with bit 1 (18.5 °C), while the wall panel shows only whole degrees (18). In °F it is the same table as `TemRec`. Seen on the panel: 64 °F is `StTem` 2 with bit 0, 66 °F is 3 with 0, 67 °F is 3 with 1, 70 °F is 5 with 1. When only `StTem` is sent, the controller keeps the old bit, so `set_target_temperature()` sends both. - The columns come from what a device answers. `StTem`, `StFahFlg` and `TemUnit` are in the default poll, and the first poll drops them on a unit that does not answer them, like any other column. They are the second column of their group (see [Temperature](#temperature)), so a device that answers `SetTem`, `TemRec` or `TemUn` keeps using those, and every other unit works as before. +- In a Gree Cloud account the controller is listed as the gateway (12-character MAC, empty `pmac`, mid `50`) and each sub-unit (`pmac` is the gateway MAC, mid `5000` or `5001`), all with the same key. `_filter_duplicate_devices_complete()` leaves out a listed device that other devices point to with their `pmac`, and keeps its sub-units. The WiFi module has no MQTT client, only Gree's older TCP cloud, so the sub-units are controlled locally. With both discovery methods, each one is offered once, with the name from the Gree app. - The full list of columns is a table in the WiFi module firmware. There is no column for the room temperature of a zone, in any released firmware, so it cannot be shown. - The status column limit measured by `probe_device_limits()` is 29. Requests far above the limit (about 40 columns and more) made the module reboot. diff --git a/tests/fakes/cloud.py b/tests/fakes/cloud.py index 94a28b7..2fb3e1d 100644 --- a/tests/fakes/cloud.py +++ b/tests/fakes/cloud.py @@ -82,6 +82,38 @@ def cloud_device(mac: str, key: str, name: str = "Zolderkamer") -> dict[str, Any } +def zone_controller_cloud_devices(gateway: str, key: str) -> list[dict[str, Any]]: + """Return a zone controller as a Gree Cloud account lists it. + + Seen on a real LE60-13/GH: the gateway with an empty pmac, and next to it + the AC unit (00) and the zones, each with the gateway as pmac. They all + share the key of the WiFi module. The order is the one the cloud gave. + """ + + def unit(mac: str, pmac: str, name: str, mid: str, sort: int) -> dict[str, Any]: + return { + **cloud_device(mac, key, name=name), + "pmac": pmac, + "mid": mid, + "catalog": "", + "subdivCode": "", + "vender": "2", + "ver": "V1.2.1", + "prodModel": "", + "hid": "", + "homeSort": sort, + } + + return [ + unit(f"{gateway}00", gateway, "Zone Controller", "5000", 0), + unit(f"{gateway}02", gateway, "Zone2", "5001", 1), + unit(f"{gateway}03", gateway, "Zone3", "5001", 2), + unit(f"{gateway}04", gateway, "Zone4", "5001", 3), + unit(gateway, "", "Zone Controller", "50", 4), + unit(f"{gateway}01", gateway, "Zone1", "5001", 5), + ] + + class FakeGreeCloud: """A Gree Cloud server on a free port.""" diff --git a/tests/test_cloud_api.py b/tests/test_cloud_api.py index 8e08843..87a5430 100644 --- a/tests/test_cloud_api.py +++ b/tests/test_cloud_api.py @@ -22,7 +22,13 @@ import pytest from .conftest import RecordingHandler -from .fakes.cloud import TOKEN, USER_ID, FakeGreeCloud, cloud_device +from .fakes.cloud import ( + TOKEN, + USER_ID, + FakeGreeCloud, + cloud_device, + zone_controller_cloud_devices, +) REGION = GreeRegion.EU GATEWAY_MAC = "9424b8fd5ba3" @@ -247,6 +253,26 @@ async def test_a_duplicate_device_keeps_the_one_that_answers( assert [device.mac for device in devices] == [SUB_MAC] +async def test_a_zone_controller_keeps_its_sub_units( + cloud_factory: CloudFactory, +) -> None: + """The gateway is listed next to its AC unit and zones, all with one key. + + The gateway does not answer, its sub-units do, so only the gateway goes. + """ + _, client = await cloud_factory( # type: ignore[misc] + homes=[{"id": 1, "name": "Thuis"}], + devices_per_home={1: zone_controller_cloud_devices(GATEWAY_MAC, DEVICE_KEY)}, + ) + await client.login() + + devices = await client.get_all_devices() + + assert [device.mac for device in devices] == [ + f"{GATEWAY_MAC}{suffix}" for suffix in ("00", "02", "03", "04", "01") + ] + + async def test_two_devices_with_different_keys_are_both_kept( cloud_factory: CloudFactory, ) -> None: diff --git a/tests/test_discovery_merge.py b/tests/test_discovery_merge.py index bf9dd34..a290b71 100644 --- a/tests/test_discovery_merge.py +++ b/tests/test_discovery_merge.py @@ -22,8 +22,9 @@ import pytest from .conftest import DISCOVERY_PORT -from .fakes.cloud import FakeGreeCloud, cloud_device +from .fakes.cloud import FakeGreeCloud, cloud_device, zone_controller_cloud_devices from .fakes.device import FakeGreeDevice +from .fakes.zone import ZONE_CONTROLLER_MAC, FakeZoneController LOCAL_MAC = "f4911e3f1ac8" CLOUD_MAC = "c03937b12280" @@ -91,6 +92,48 @@ async def test_discovery_joins_the_cloud_and_the_network( assert found[0].name == "Zolderkamer" +async def test_discovery_offers_every_unit_of_a_zone_controller_once( + monkeypatch: pytest.MonkeyPatch, loopback_ip: str +) -> None: + """With the cloud and the network, each unit comes back once. + + The name is the one from the app. The address and the controller MAC + come from the network, so the units are controlled locally. + """ + server = FakeGreeCloud( + homes=[{"id": 1, "name": "Thuis"}], + devices_per_home={ + 1: zone_controller_cloud_devices(ZONE_CONTROLLER_MAC, DEVICE_KEY) + }, + ) + await server.start() + monkeypatch.setitem(CLOUD_SERVERS, GreeRegion.EU, server.url) + api = GreeCloudApi.for_server(GreeRegion.EU, "rob@example.com", "hunter2") + controller = FakeZoneController() + await controller.start(loopback_ip, DISCOVERY_PORT) + + try: + await api.login() + found = await gree_discover_devices(api, [loopback_ip], timeout=1) + finally: + controller.close() + await api.close() + await server.close() + + assert [device.mac for device in found] == [ + f"{ZONE_CONTROLLER_MAC}{index:02d}" for index in range(5) + ] + assert [device.name for device in found] == [ + "Zone Controller", + "Zone1", + "Zone2", + "Zone3", + "Zone4", + ] + assert {device.host for device in found} == {loopback_ip} + assert {device.mac_controller_local for device in found} == {ZONE_CONTROLLER_MAC} + + def test_a_device_that_is_only_local_is_kept() -> None: """Most installs have no cloud account at all.""" local = GreeDiscoveredDevice(mac=LOCAL_MAC, host="192.168.1.10", name="Zolderkamer") From c0d0eea6371a51d590baa90515955adef659bf00 Mon Sep 17 00:00:00 2001 From: Twintop Date: Tue, 20 Oct 2026 08:41:57 +1100 Subject: [PATCH 18/18] Cloud: a zone's MQTT controller is its gateway, and correct a TemRec 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. --- custom_components/gree_custom/aiogree/api.py | 7 +++ .../gree_custom/aiogree/helpers.py | 4 +- docs/protocol.md | 4 +- tests/test_discovery_merge.py | 46 +++++++++++++++++++ 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index dea5668..5bf673d 100755 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -1690,6 +1690,13 @@ async def gree_discover_devices_cloud( for dev in responses: mac, mac_controller = gree_extract_macs(dev.mac) + # A sub-unit names its gateway in pmac. Only use it when it is also the + # first 12 characters of the sub-unit MAC: that controller is known to + # answer (seen on a zone controller, whose zones end in 01 to 08). What + # pmac holds for other devices is not known yet. + pmac = gree_extract_macs(dev.pmac)[0] if dev.pmac else "" + if pmac and mac != pmac and mac[:12] == pmac: + mac_controller = pmac discovered_devices.append( GreeDiscoveredDevice( mac=mac, diff --git a/custom_components/gree_custom/aiogree/helpers.py b/custom_components/gree_custom/aiogree/helpers.py index b4b2b69..cd09686 100755 --- a/custom_components/gree_custom/aiogree/helpers.py +++ b/custom_components/gree_custom/aiogree/helpers.py @@ -76,7 +76,9 @@ def gree_extract_macs(raw_mac: str) -> tuple[str, str]: # For VRF units, the MAC will be of >12 characters and end in "00" # where for MQTT the first 12 are the main_device MAC and the full string the sub_device # For imported configs in mac@main_mac format separate them - # TODO: Cloud has a "pmac" field. Check if it is "parent mac" aka main_device + # TODO: Cloud has a "pmac" field. Check if it is "parent mac" aka main_device. + # For a zone controller it is the gateway, the first 12 characters of each + # sub-unit MAC, and gree_discover_devices_cloud() uses it in that case only. if "@" in normalized_mac: mac, mac_controller = normalized_mac.split("@", 1) elif len(normalized_mac) > 12 and normalized_mac.endswith("00"): diff --git a/docs/protocol.md b/docs/protocol.md index 8d69fd2..73f7200 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -32,7 +32,7 @@ Facts about how Gree devices behave on the wire. Most of these are not visible f - `SetTem` is a whole number of degrees. `TemRec` adds the half degree. - The target temperature, its bit and the unit each come in two columns, in order of preference: `SetTem` and `StTem` (`TARGET_TEMPERATURE_1` and `_2`), `TemRec` and `StFahFlg` (`TARGET_TEMPERATURE_BIT_1` and `_2`), `TemUn` and `TemUnit` (`TARGET_TEMPERATURE_UNIT_1` and `_2`). Like the sensor groups, `_remove_unsupported_props()` keeps the first column of each group that the device answers, and the device reads and writes only that one. 0 is a valid value in all three groups (`StTem` 0 is 16 °C), so only a column the device does not answer counts as missing (`missing_value=-1`). -- The bit is only sent to a device that answers one. A device without it gets whole degrees. +- The bit is only sent to a device that answers one. That was already so: `DeviceState.set()` drops a column the device did not answer, so a device without a bit gets whole degrees either way. `set_target_temperature()` now leaves it out by itself, so the `is unsupported` error is no longer logged. - Some devices report sensor temperatures with a +40 offset. `TempOffsetResolver` in `aiogree/helpers.py` detects this from the values it sees. - Fahrenheit uses the protocol's own lookup table, not a formula. @@ -100,7 +100,7 @@ A zone controller (seen: LE60-13/GH with a ME31-00/C13 WiFi module, firmware `36 - A zone answers `Pow` (the damper), `StTem` and `StFahFlg` (its target temperature), `TemUnit`, and a copy of `Mod` and `WdSpd` of the ducted unit. `StTem` is the temperature in degrees Celsius minus 16 (`TARGET_TEMPERATURE_OFFSETS`), so 0 to 14 for 16 to 30 degrees. The controller itself also answers `StTem1` to `StTem8`, without the bit. - The unit of temperature is `TemUnit` (0 °C, 1 °F), not `TemUn`. Every unit answers it. Written to the ducted unit, the controller and the wall panel switch, and the zones follow. `StTem` + 16 and `StFahFlg` work like `SetTem` and `TemRec` of a normal unit. In °C the bit is read as the half degree, like `TemRec`: the controller keeps `StTem` 2 with bit 1 (18.5 °C), while the wall panel shows only whole degrees (18). In °F it is the same table as `TemRec`. Seen on the panel: 64 °F is `StTem` 2 with bit 0, 66 °F is 3 with 0, 67 °F is 3 with 1, 70 °F is 5 with 1. When only `StTem` is sent, the controller keeps the old bit, so `set_target_temperature()` sends both. - The columns come from what a device answers. `StTem`, `StFahFlg` and `TemUnit` are in the default poll, and the first poll drops them on a unit that does not answer them, like any other column. They are the second column of their group (see [Temperature](#temperature)), so a device that answers `SetTem`, `TemRec` or `TemUn` keeps using those, and every other unit works as before. -- In a Gree Cloud account the controller is listed as the gateway (12-character MAC, empty `pmac`, mid `50`) and each sub-unit (`pmac` is the gateway MAC, mid `5000` or `5001`), all with the same key. `_filter_duplicate_devices_complete()` leaves out a listed device that other devices point to with their `pmac`, and keeps its sub-units. The WiFi module has no MQTT client, only Gree's older TCP cloud, so the sub-units are controlled locally. With both discovery methods, each one is offered once, with the name from the Gree app. +- In a Gree Cloud account the controller is listed as the gateway (12-character MAC, empty `pmac`, mid `50`) and each sub-unit (`pmac` is the gateway MAC, mid `5000` or `5001`), all with the same key. `_filter_duplicate_devices_complete()` leaves out a listed device that other devices point to with their `pmac`, and keeps its sub-units. `gree_discover_devices_cloud()` uses the `pmac` of a sub-unit as its MQTT controller MAC only when it is also the first 12 characters of the sub-unit MAC, as here; what `pmac` holds for a VRF is not known yet. The WiFi module has no MQTT client, only Gree's older TCP cloud, so the sub-units are controlled locally. With both discovery methods, each one is offered once, with the name from the Gree app. - The full list of columns is a table in the WiFi module firmware. There is no column for the room temperature of a zone, in any released firmware, so it cannot be shown. - The status column limit measured by `probe_device_limits()` is 29. Requests far above the limit (about 40 columns and more) made the module reboot. diff --git a/tests/test_discovery_merge.py b/tests/test_discovery_merge.py index a290b71..562959d 100644 --- a/tests/test_discovery_merge.py +++ b/tests/test_discovery_merge.py @@ -134,6 +134,52 @@ async def test_discovery_offers_every_unit_of_a_zone_controller_once( assert {device.mac_controller_local for device in found} == {ZONE_CONTROLLER_MAC} +async def discover_in_cloud( + monkeypatch: pytest.MonkeyPatch, devices: list[dict] +) -> list[GreeDiscoveredDevice]: + """Run cloud discovery against an account that lists these devices.""" + server = FakeGreeCloud( + homes=[{"id": 1, "name": "Thuis"}], devices_per_home={1: devices} + ) + await server.start() + monkeypatch.setitem(CLOUD_SERVERS, GreeRegion.EU, server.url) + api = GreeCloudApi.for_server(GreeRegion.EU, "rob@example.com", "hunter2") + try: + await api.login() + return await gree_discover_devices_cloud(api) + finally: + await api.close() + await server.close() + + +async def test_the_sub_units_of_a_zone_controller_get_the_gateway_as_controller( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Their pmac is the gateway, which is also the start of their MAC.""" + found = await discover_in_cloud( + monkeypatch, zone_controller_cloud_devices(ZONE_CONTROLLER_MAC, DEVICE_KEY) + ) + + assert sorted(device.mac for device in found) == [ + f"{ZONE_CONTROLLER_MAC}{index:02d}" for index in range(5) + ] + assert {device.mac_controller_mqtt for device in found} == {ZONE_CONTROLLER_MAC} + + +async def test_a_pmac_that_is_not_the_start_of_the_mac_is_not_used( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """What such a pmac means is not known, so the MAC rules stay as they are.""" + found = await discover_in_cloud( + monkeypatch, + [{**cloud_device("9424b8fd5ba301", DEVICE_KEY), "pmac": CLOUD_MAC}], + ) + + assert [(d.mac, d.mac_controller_mqtt) for d in found] == [ + ("9424b8fd5ba301", "9424b8fd5ba301") + ] + + def test_a_device_that_is_only_local_is_kept() -> None: """Most installs have no cloud account at all.""" local = GreeDiscoveredDevice(mac=LOCAL_MAC, host="192.168.1.10", name="Zolderkamer")