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 diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index 3b7ff5e..d94ff62 100755 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -94,10 +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" + # 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 +# Props every device is polled for by default. The beeper is only written, +# 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) ) @@ -471,6 +481,55 @@ class HumidityControlMode(IntEnum): smart_dry = 2 # This is only available in cool operation mode +@unique +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. 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. + """ + + # 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 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. +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 +1340,25 @@ def _parse_sub_devices_list( return sub_devs +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 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 + + """ + 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( mac_addr_controller: str, uid: int, @@ -1383,11 +1461,15 @@ 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, 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( 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 +1479,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..88b8a89 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -8,6 +8,14 @@ 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_TYPES, + ZONE_TEMPERATURE_OFFSET, + DeviceType, EncryptionVersion, FanSpeed, GreeProp, @@ -96,6 +104,8 @@ def __init__( self._firmware_code: str | None = None self._firmware_protocol_version: str = "" + self._device_type: DeviceType = DeviceType.AC_UNIT + 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_device_type() await self.fetch_device_status(first_fetch=True) self._remove_unsupported_props() return @@ -185,6 +196,31 @@ 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_device_type(self) -> None: + """Find out what kind of device this is. + + 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 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: + 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(ZONE_CONTROLLER_AC_UNIT_SUFFIX): + self._device_type = DeviceType.ZONE_CONTROLLER + else: + self._device_type = DeviceType.ZONE + + _LOGGER.debug("[%s] Device type: %s", self.unique_id, self._device_type) + async def unbind_device(self) -> None: """Properly disconnect the device from transport.""" if not self._api.bound: @@ -400,6 +436,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, @@ -450,6 +487,13 @@ 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._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 # This assumes that the full state is fetched at least once before this method is called return self._state.supports(property) @@ -591,6 +635,11 @@ 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 device_type(self) -> DeviceType: + """Return what kind of device this is. Known after the first bind.""" + return self._device_type + @property def has_held_values(self) -> bool: """Return True if sent values still wait for the device to confirm them.""" @@ -682,6 +731,11 @@ def set_power_mode(self, value: bool) -> None: @property def operation_mode(self) -> OperationMode: """Return the current operation mode.""" + if self._device_type in ZONE_CONTROLLER_TYPES: + 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 +760,29 @@ 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._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, + 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._device_type in ZONE_CONTROLLER_TYPES + 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. @@ -762,17 +833,43 @@ 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: 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 @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 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: + raw = self._state.get(GreeProp.ZONE_TARGET_TEMPERATURE) + if raw is None: + return None + zone_c = raw + ZONE_TEMPERATURE_OFFSET + bit = self._state.get(GreeProp.ZONE_TARGET_TEMPERATURE_BIT) or 0 + if self.target_temperature_unit == TemperatureUnits.F: + return gree_get_target_temperature_f(zone_c, bit) + return gree_get_target_temperature_c(zone_c, bit) raw_c = self._state.get(GreeProp.TARGET_TEMPERATURE, 0) tem_rec = self._state.get(GreeProp.TARGET_TEMPERATURE_BIT, 0) @@ -785,7 +882,24 @@ 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. + + 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 = gree_get_target_temp_props_from_c(value) + props = {GreeProp.ZONE_TARGET_TEMPERATURE: raw_c - ZONE_TEMPERATURE_OFFSET} + if self._state.supports(GreeProp.ZONE_TARGET_TEMPERATURE_BIT): + props[GreeProp.ZONE_TARGET_TEMPERATURE_BIT] = bit + self._state.update(props) + return if self.target_temperature_unit == TemperatureUnits.F: if not value.is_integer(): @@ -928,6 +1042,8 @@ def set_feature_quiet(self, value: bool) -> None: @property def feature_turbo(self) -> bool: """Return the turbo mode state.""" + 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) def set_feature_turbo(self, value: bool) -> None: @@ -945,6 +1061,15 @@ def set_feature_turbo(self, value: bool) -> None: "Turbo mode is only available under Cool or Heat modes" ) + 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: + 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/climate.py b/custom_components/gree_custom/climate.py index 99c21e4..2d69bd6 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 ( + DeviceType, + FanSpeed, + GreeProp, + HorizontalSwingMode, + VerticalSwingMode, +) 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.device_type is DeviceType.ZONE: + continue + options: dict[str, Any] = coordinator.device_config.get(CONF_DEVICE_OPTIONS, {}) hvac_modes: list[HVACMode] = [ @@ -706,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]) 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 eb36e25..d15498c 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 DeviceType, GreeDiscoveredDevice, GreeProp 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.device_type is DeviceType.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) @@ -696,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, diff --git a/custom_components/gree_custom/const.py b/custom_components/gree_custom/const.py index 7e3c089..a883848 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 @@ -100,6 +104,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 +140,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, @@ -147,7 +162,11 @@ } 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, # FEATURES @@ -228,6 +247,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/helpers.py b/custom_components/gree_custom/helpers.py index c18de13..142879e 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,69 @@ 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 _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, @@ -398,35 +468,30 @@ 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) 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, - translation_placeholders={"mac": controller_mac[-5:]}, + sw_version=info.sw_version, + hw_version=info.hw_version, + **_controller_label( + info, controller_mac, controller_mac in existing_controllers + ), ) controller_ids[controller_mac] = controller.id 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..d012e98 100755 --- a/custom_components/gree_custom/number.py +++ b/custom_components/gree_custom/number.py @@ -15,10 +15,30 @@ 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 ( + DeviceType, + GreeProp, + 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 +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 from .platform_helpers import supported_descriptions @@ -31,10 +51,14 @@ class GreeNumberDescription( ): """Description of a Gree number.""" - value_func: Callable[[GreeDevice], int] - set_func: Callable[[GreeDevice, int], None] + value_func: Callable[[GreeDevice], float | None] + set_func: Callable[[GreeDevice, float], None] min_func: Callable[[GreeDevice], int] | None = None max_func: Callable[[GreeDevice], int] | None = None + # 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 @@ -49,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) @@ -66,7 +90,35 @@ 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.device_type is DeviceType.ZONE, + device_class=NumberDeviceClass.TEMPERATURE, + mode=NumberMode.BOX, + 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], + 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, + ), ] @@ -149,7 +201,25 @@ def native_max_value(self) -> float: @property @override - def native_value(self) -> float: + 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: + """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: """Return the state of the sensor.""" return self.entity_description.value_func(self.device) @@ -160,7 +230,7 @@ async def async_set_native_value(self, value: float) -> None: raise HomeAssistantError("Entity unavailable") try: - self.entity_description.set_func(self.device, int(value)) + self.entity_description.set_func(self.device, value) if self.entity_description.updates_device: await self.coordinator.push_device_status() 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/select.py b/custom_components/gree_custom/select.py index 3fea4c7..c6e914e 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, + # 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], value_func=lambda device: f"º{device.target_temperature_unit.name}", diff --git a/custom_components/gree_custom/switch.py b/custom_components/gree_custom/switch.py index 052a966..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 +from .aiogree.api import DeviceType, HumidityControlMode, OperationMode, SleepMode 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.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 c172cf1..0eba2ca 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,13 +207,17 @@ "powersave": "Energy Saving", "light_sensor": "Display Auto Brightness", "faults": "Fault Detection", - "humidity_control": "Humidity Control" + "humidity_control": "Humidity Control", + "zone": "Zone Switch" } } }, "device": { "vrf_controller": { "name": "VRF gateway {mac}" + }, + "zone_controller": { + "name": "Zone controller {mac}" } }, "entity": { @@ -283,6 +288,9 @@ }, "humidity_control_target": { "name": "Humidity Control Target" + }, + "zone_target_temperature": { + "name": "Target Temperature" } }, "select": { 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/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..c9c96b4 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`. @@ -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. 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, 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 Created when the unit has the sensor. Nothing to enable. diff --git a/docs/protocol.md b/docs/protocol.md index 9b3469e..ca05116 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -84,6 +84,23 @@ 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, from the name of the controller in the scan reply and the MAC suffix. The model ids are not used. +- A sub-unit answers the info request with the name of the controller, which starts with `GR-ZCntrlr` (`ZONE_CONTROLLER_NAME_PREFIX`), and with the model id of the controller (`3017`), not its own. After the info fetch, `GreeDevice._detect_device_type()` sets `device_type` for every device: `ac_unit` for a unit that is not a sub-unit, `zone_controller` for sub-unit `00` (`ZONE_CONTROLLER_AC_UNIT_SUFFIX`) of a zone controller, `zone` for its other sub-units, and `vrf_unit` for any other sub-unit. +- The name prefix, the MAC suffix of the ducted unit and the model ids come from the WiFi module, not from the units: they are fixed in its firmware (v1.21, v1.27 and v1.28 checked). So they are the same for every controller with this module. The Braemar LE60 uses the same module, so it should show the same values (not tested). Only the two zone controller types (`ZONE_CONTROLLER_TYPES`) change behaviour, and only where the columns cannot tell: the mode numbers, turbo, and which entities a sub-unit gets. +- The ducted unit answers `Pow`, `Mod`, `WdSpd`, `AllErr` and `TemUnit`, and `StTem` and `StFahFlg` always as 0. `Pow` of the ducted unit is the power of the whole system. The zones keep their own `Pow` while it is off, and get it back when it is turned on. No sub-unit answers `SetTem`, `TemRec`, `TemUn` or `Add0.5`. +- `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. `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. + ## 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..e7f1ea8 --- /dev/null +++ b/tests/fakes/zone.py @@ -0,0 +1,150 @@ +"""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`, 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. + Written to the ducted unit, all units follow. `StFahFlg` of a zone is its + 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. +""" + +from typing import Any, override + +from aiogree.api import ZONE_CONTROLLER_AC_UNIT_SUFFIX, 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}{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}, + self.ac_unit: { + "Pow": 1, + "Mod": 4, + "WdSpd": 5, + "AllErr": 0, + "StTem": 0, + "TemUnit": 0, + "StFahFlg": 0, + }, + } + for zone in self.zone_macs: + self.units[zone] = { + "Pow": 1, + "Mod": 4, + "WdSpd": 5, + "StTem": 2, + "StFahFlg": 0, + "TemUnit": 0, + } + 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", "TemUnit"): + 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..06a8292 --- /dev/null +++ b/tests/test_zone_controller.py @@ -0,0 +1,450 @@ +"""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 ( + DeviceType, + FanSpeed, + GreeProp, + OperationMode, + SubListForm, + TemperatureUnits, +) +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()) + + +async def test_discovery_names_come_from_the_controller_name_and_the_suffix( + loopback_ip: str, discover_one: DiscoverOne +) -> None: + """The model ids in the sub-device list are not used, only the name and the MAC.""" + + class OtherIds(FakeZoneController): + def sub_devices(self, form: SubListForm) -> list[dict[str, str]]: + return [{**unit, "mid": "1234"} for unit in super().sub_devices(form)] + + controller = OtherIds() + await controller.start(loopback_ip, DISCOVERY_PORT) + try: + found = await discover_one(controller.host, timeout=1) + finally: + controller.close() + + assert [d.name for d in found] == [ + "AC unit", + "Zone 1", + "Zone 2", + "Zone 3", + "Zone 4", + ] + + +# +# 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. + + 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 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"), + [ + (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.device_type is DeviceType.ZONE + assert zone.supports_property(GreeProp.ZONE_TARGET_TEMPERATURE) + assert zone.target_temperature == 18 + + controller.units[controller.zone_macs[0]]["StTem"] = 5 + await zone.fetch_device_status() + + 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, 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 + 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 + + +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. +# + + +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. Fahrenheit is whole degrees.""" + 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) + + # 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 +) -> 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: + """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 +# + + +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: + device = GreeDevice(name="Zolderkamer", mac_addr=unit.mac) + 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() + + 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() + 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.device_type is DeviceType.VRF_UNIT + assert device.operation_mode is OperationMode.heat