From 43f488115d75628a39b1ec3acb6efb40aff6609e Mon Sep 17 00:00:00 2001 From: Twintop Date: Fri, 9 Oct 2026 20:35:38 +1100 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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