diff --git a/custom_components/gree_custom/__init__.py b/custom_components/gree_custom/__init__.py index 595e2dc..4a93297 100755 --- a/custom_components/gree_custom/__init__.py +++ b/custom_components/gree_custom/__init__.py @@ -9,7 +9,6 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import ( - CONF_EMAIL, CONF_HOST, CONF_NAME, CONF_PORT, @@ -65,6 +64,7 @@ DEFAULT_SCAN_INTERVAL, DOMAIN, ENCRYPTION_VERSION_AUTO, + TO_REDACT, ) from .coordinator import GreeConfigEntry, GreeCoordinator from .helpers import get_vrf_controller_mac, reconcile_vrf_controllers, try_find_new_ip @@ -114,15 +114,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool: # noqa: C901 """Set up Gree from a config entry.""" - _LOGGER.info( - "Setup entry '%s': %s", - entry.entry_id, - entry.title, - ) + # The title of a cloud entry holds the email, so log the unique id: the + # user id of the account, or local_only. + _LOGGER.info("Setup entry '%s': %s", entry.entry_id, entry.unique_id) _LOGGER.debug( "Setup entry '%s': data=%s", entry.entry_id, - json.dumps(async_redact_data(entry.data, ["encryption_key"])), + json.dumps(async_redact_data(entry.data, TO_REDACT)), ) conf = entry.data @@ -192,7 +190,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool ) if mac_controller_cloud and cloud_conf and not mqtt_transport: - _LOGGER.debug("Creating MQTT transport for %s", cloud_conf[CONF_EMAIL]) + _LOGGER.debug( + "Creating MQTT transport for user %s", cloud_conf.get(CONF_UID) + ) userid: int = cloud_conf.get(CONF_UID, 0) token: str = cloud_conf.get(CONF_TOKEN, "") @@ -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..5bf673d 100755 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -2,7 +2,7 @@ import asyncio from collections.abc import Mapping -from dataclasses import dataclass, fields, replace +from dataclasses import dataclass, field, fields, replace from enum import IntEnum, StrEnum, unique import json import logging @@ -38,12 +38,15 @@ class GreeProp(StrEnum): # the swing mode of the vertical air blades SWING_VERTICAL = "SwUpDn" - # target temperature - TARGET_TEMPERATURE = "SetTem" - # used to distinguish between Fahrenheit values - TARGET_TEMPERATURE_BIT = "TemRec" + # target temperature, ordered by preference + TARGET_TEMPERATURE_1 = "SetTem" + TARGET_TEMPERATURE_2 = "StTem" # zone of a zone controller, Celsius minus 16 + # used to distinguish between Fahrenheit values, and the half degree in Celsius + TARGET_TEMPERATURE_BIT_1 = "TemRec" + TARGET_TEMPERATURE_BIT_2 = "StFahFlg" # defines the unit of temperature for the target temperature - TARGET_TEMPERATURE_UNIT = "TemUn" + TARGET_TEMPERATURE_UNIT_1 = "TemUn" + TARGET_TEMPERATURE_UNIT_2 = "TemUnit" # zone controller and all its sub-units # Quiet mode which slows down the fan to its most quiet speed. Not available in Dry and Fan mode. FEAT_QUIET_MODE = "Quiet" @@ -97,7 +100,9 @@ class GreeProp(StrEnum): 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 +476,56 @@ 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 +# Added to the raw value of a target temperature column to get degrees +# Celsius. StTem, the target temperature of a zone, is degrees Celsius minus 16. +TARGET_TEMPERATURE_OFFSETS: dict[GreeProp, int] = {GreeProp.TARGET_TEMPERATURE_2: 16} + + class GreeCommand(StrEnum): """Enumeration of Gree commands.""" @@ -530,12 +585,13 @@ class GreeDiscoveredDevice: mac_controller_local: str = "" mac_controller_mqtt: str = "" user_id: int = DEFAULT_DEVICE_USERID - key: str | None = None + # Not in the repr, which discovery logs + key: str | None = field(default=None, repr=False) # Local host: str | None = None port: int | None = None - # Cloud - username: str | None = None + # Cloud. The email of the account, not in the repr either + username: str | None = field(default=None, repr=False) # Properties name: str = "" catalog: str = "" @@ -781,9 +837,12 @@ def _order_set_props(props: dict[str, int]) -> dict[str, int]: # Temperature-related properties for prop in ( - GreeProp.TARGET_TEMPERATURE_UNIT.value, - GreeProp.TARGET_TEMPERATURE_BIT.value, - GreeProp.TARGET_TEMPERATURE.value, + GreeProp.TARGET_TEMPERATURE_UNIT_1.value, + GreeProp.TARGET_TEMPERATURE_UNIT_2.value, + GreeProp.TARGET_TEMPERATURE_BIT_1.value, + GreeProp.TARGET_TEMPERATURE_BIT_2.value, + GreeProp.TARGET_TEMPERATURE_1.value, + GreeProp.TARGET_TEMPERATURE_2.value, ): if prop in remaining: ordered[prop] = remaining.pop(prop) @@ -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:]}", ), @@ -1607,6 +1690,13 @@ async def gree_discover_devices_cloud( for dev in responses: mac, mac_controller = gree_extract_macs(dev.mac) + # A sub-unit names its gateway in pmac. Only use it when it is also the + # first 12 characters of the sub-unit MAC: that controller is known to + # answer (seen on a zone controller, whose zones end in 01 to 08). What + # pmac holds for other devices is not known yet. + pmac = gree_extract_macs(dev.pmac)[0] if dev.pmac else "" + if pmac and mac != pmac and mac[:12] == pmac: + mac_controller = pmac discovered_devices.append( GreeDiscoveredDevice( mac=mac, @@ -1703,8 +1793,8 @@ def gree_merge_discovered_devices( # Override with non-empty local values # So that local info overrides cloud, especially for MAC - for field in fields(GreeDiscoveredDevice): - value = getattr(local, field.name) + for device_field in fields(GreeDiscoveredDevice): + value = getattr(local, device_field.name) if value is None: continue @@ -1712,7 +1802,7 @@ def gree_merge_discovered_devices( if isinstance(value, str) and value == "": continue - setattr(merged_device, field.name, value) + setattr(merged_device, device_field.name, value) # Prefer cloud name and uid merged_device.name = cloud.name diff --git a/custom_components/gree_custom/aiogree/cloud_api.py b/custom_components/gree_custom/aiogree/cloud_api.py index 1c394d5..af25fa3 100755 --- a/custom_components/gree_custom/aiogree/cloud_api.py +++ b/custom_components/gree_custom/aiogree/cloud_api.py @@ -14,7 +14,7 @@ import json import logging from types import TracebackType -from typing import Self +from typing import Any, Self import aiohttp from cryptography.hazmat.backends import default_backend @@ -27,6 +27,26 @@ _LOGGER = logging.getLogger(__name__) +# The parts of a login response that may be logged. The rest, such as the +# session token, the email and the user name, is redacted, because users post +# their debug logs. +LOGIN_LOG_KEYS = frozenset({"r", "msg", "uid", "alive"}) + + +def _loggable_login_response(data: Any) -> Any: + """Return the login response with everything but LOGIN_LOG_KEYS redacted.""" + if not isinstance(data, dict): + return "[redacted]" + loggable: dict[str, Any] = {} + for key, value in data.items(): + if isinstance(value, dict): + loggable[key] = _loggable_login_response(value) + elif key in LOGIN_LOG_KEYS: + loggable[key] = value + else: + loggable[key] = "[redacted]" + return loggable + @dataclass class CloudHome: @@ -318,11 +338,10 @@ async def login(self) -> CloudCredentials: encrypted_response = await self._send_request("/App/UserLoginV2", body) decrypted = self._decrypt(base64.b64decode(encrypted_response)) - - _LOGGER.debug("Login response (decrypted): %s", decrypted) - data = json.loads(decrypted) - _LOGGER.debug("Login response (parsed): %s", data) + + loggable = _loggable_login_response(data) + _LOGGER.debug("Login response: %s", loggable) # Check for error response if "r" in data and data["r"] != 200: @@ -338,10 +357,10 @@ async def login(self) -> CloudCredentials: user_id = data["data"].get("uid") token = data["data"].get("token") else: - raise GreeCloudError(f"Unexpected login response format: {data}") + raise GreeCloudError(f"Unexpected login response format: {loggable}") if not user_id or not token: - raise GreeCloudLoginError(f"Missing uid or token in response: {data}") + raise GreeCloudLoginError(f"Missing uid or token in response: {loggable}") self.user_id = user_id self.token = token @@ -491,9 +510,15 @@ def _filter_duplicate_devices_complete( ) -> list[CloudDeviceInfoResponse]: """Filter out duplicate devices with same key. - When devices share the same encryption key but have different MACs - (one normal, one ending with '00'), keep only the one with '00'. - The device without '00' suffix doesn't respond to commands. + A gateway can be listed next to its sub-units, which have the MAC of + the gateway as pmac. The gateway is left out and every sub-unit is + kept. Seen on a zone controller: the gateway, the AC unit (MAC ending + in 00) and the zones (01 to 08), all with one key. + + Of the other devices, when devices share the same encryption key but + have different MACs (one normal, one ending with '00'), keep only the + one with '00'. The device without '00' suffix doesn't respond to + commands. Args: devices: List of devices to filter @@ -502,9 +527,33 @@ def _filter_duplicate_devices_complete( Filtered list without duplicates """ + # A device can have its own MAC as pmac, which does not make it a + # gateway. Only a listed device that another device points to is one. + listed = {device.mac for device in devices} + gateways = { + device.pmac + for device in devices + if device.pmac and device.pmac != device.mac and device.pmac in listed + } + if gateways: + _LOGGER.debug( + "Filtering out gateway(s) listed with their sub-units: %s", + sorted(gateways), + ) + sub_units = [ + device + for device in devices + if device.pmac in gateways and device.mac not in gateways + ] + others = [ + device + for device in devices + if device.mac not in gateways and device.pmac not in gateways + ] + # Group devices by encryption key key_groups: dict[str, list[CloudDeviceInfoResponse]] = {} - for device in devices: + for device in others: if device.key not in key_groups: key_groups[device.key] = [] key_groups[device.key].append(device) @@ -536,7 +585,8 @@ def _filter_duplicate_devices_complete( # No device with '00' found, keep all (shouldn't happen but be safe) filtered.extend(group) - return filtered + kept = {id(device) for device in [*sub_units, *filtered]} + return [device for device in devices if id(device) in kept] async def gree_get_latest_firmware_info( diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index ad61ded..96d0af3 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 ( + TARGET_TEMPERATURE_OFFSETS, + ZONE_CONTROLLER_AC_UNIT_SUFFIX, + ZONE_CONTROLLER_FAN_TURBO, + ZONE_CONTROLLER_MODES, + ZONE_CONTROLLER_MODES_TO_RAW, + ZONE_CONTROLLER_NAME_PREFIX, + ZONE_CONTROLLER_TYPES, + DeviceType, EncryptionVersion, FanSpeed, GreeProp, @@ -96,6 +104,18 @@ def __init__( self._firmware_code: str | None = None self._firmware_protocol_version: str = "" + self._device_type: DeviceType = DeviceType.AC_UNIT + + # The column of each group that the device answers, see + # _remove_unsupported_props. None until the first bind, or when the + # device answers none of them. + self._indoor_temp_sensor_prop: GreeProp | None = None + self._outdoor_temp_sensor_prop: GreeProp | None = None + self._humidity_sensor_prop: GreeProp | None = None + self._target_temp_prop: GreeProp | None = None + self._target_temp_bit_prop: GreeProp | None = None + self._temp_unit_prop: GreeProp | None = None + self._state = DeviceState( device_id=self.unique_id, capabilities=self._capabilities ) @@ -175,6 +195,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 +206,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, the StTem of its ducted unit, which is always 0, and + which entities a sub-unit gets. + """ + name = self._state.info.get(InfoProp.DEVICE_NAME, "") + if not self.is_sub_unit: + 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: @@ -361,7 +407,7 @@ def _remove_unsupported_props(self) -> None: self._state.invalidate_missing_properties() # Sensors should also be invalidated if their values are not expected (=0) - self._state.invalidate_missing_property_group( + self._indoor_temp_sensor_prop = self._state.invalidate_missing_property_group( [ GreeProp.SENSOR_INDOOR_TEMPERATURE_1, GreeProp.SENSOR_INDOOR_TEMPERATURE_2, @@ -369,20 +415,49 @@ def _remove_unsupported_props(self) -> None: ] ) - self._state.invalidate_missing_property_group( + self._outdoor_temp_sensor_prop = self._state.invalidate_missing_property_group( [ GreeProp.SENSOR_OUTSIDE_TEMPERATURE_1, GreeProp.SENSOR_OUTSIDE_TEMPERATURE_2, ] ) - self._state.invalidate_missing_property_group( + self._humidity_sensor_prop = self._state.invalidate_missing_property_group( [ GreeProp.SENSOR_HUMIDITY_1, GreeProp.SENSOR_HUMIDITY_2, ] ) + # The ducted unit of a zone controller answers StTem and StFahFlg too, + # always with 0, the same as a zone set to 16 degrees Celsius. It has + # no target temperature of its own, each zone has one. + if self._device_type is DeviceType.ZONE_CONTROLLER: + for prop in ( + GreeProp.TARGET_TEMPERATURE_2, + GreeProp.TARGET_TEMPERATURE_BIT_2, + ): + if prop in self._state.polled_properties: + self._state.remove(prop) + + # 0 is a valid value in these groups: StTem 0 is 16 degrees Celsius, + # the bits are 0 or 1, and unit 0 is Celsius. So only a column that + # the device does not answer is missing. + self._target_temp_prop = self._state.invalidate_missing_property_group( + [GreeProp.TARGET_TEMPERATURE_1, GreeProp.TARGET_TEMPERATURE_2], + missing_value=-1, + ) + + self._target_temp_bit_prop = self._state.invalidate_missing_property_group( + [GreeProp.TARGET_TEMPERATURE_BIT_1, GreeProp.TARGET_TEMPERATURE_BIT_2], + missing_value=-1, + ) + + self._temp_unit_prop = self._state.invalidate_missing_property_group( + [GreeProp.TARGET_TEMPERATURE_UNIT_1, GreeProp.TARGET_TEMPERATURE_UNIT_2], + missing_value=-1, + ) + # As far as it is known, both values at 0 is not a valid combination. if ( GreeProp.FEATURE_HUMIDITY_CONTROL in self._state.polled_properties @@ -400,6 +475,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 +526,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 +674,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.""" @@ -621,20 +709,15 @@ def indoors_temperature_c(self) -> int | None: if self._temp_processor_indoors is None: self._temp_processor_indoors = TempOffsetResolver() - for prop in ( - GreeProp.SENSOR_INDOOR_TEMPERATURE_1, - GreeProp.SENSOR_INDOOR_TEMPERATURE_2, - GreeProp.SENSOR_INDOOR_TEMPERATURE_3, - ): - if self._state.supports(prop): - raw_c = self._state.get(prop, None) - return ( - int(self._temp_processor_indoors.evaluate(raw_c)) - if raw_c is not None - else None - ) + if self._indoor_temp_sensor_prop is None: + return None - return None + raw_c = self._state.get(self._indoor_temp_sensor_prop) + return ( + int(self._temp_processor_indoors.evaluate(raw_c)) + if raw_c is not None + else None + ) @property def outdoors_temperature_c(self) -> int | None: @@ -643,32 +726,24 @@ def outdoors_temperature_c(self) -> int | None: if self._temp_processor_outdoors is None: self._temp_processor_outdoors = TempOffsetResolver() - for prop in ( - GreeProp.SENSOR_OUTSIDE_TEMPERATURE_1, - GreeProp.SENSOR_OUTSIDE_TEMPERATURE_2, - ): - if self._state.supports(prop): - raw_c = self._state.get(prop, None) - return ( - int(self._temp_processor_outdoors.evaluate(raw_c)) - if raw_c is not None - else None - ) + if self._outdoor_temp_sensor_prop is None: + return None - return None + raw_c = self._state.get(self._outdoor_temp_sensor_prop) + return ( + int(self._temp_processor_outdoors.evaluate(raw_c)) + if raw_c is not None + else None + ) @property def humidity(self) -> int | None: """Return the current humidity if available.""" - for prop in ( - GreeProp.SENSOR_HUMIDITY_1, - GreeProp.SENSOR_HUMIDITY_2, - ): - if self._state.supports(prop): - return self._state.get(prop, None) + if self._humidity_sensor_prop is None: + return None - return None + return self._state.get(self._humidity_sensor_prop) @property def power_mode(self) -> bool: @@ -682,6 +757,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 +786,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. @@ -761,31 +858,62 @@ def set_horizontal_swing_mode(self, swing_mode: HorizontalSwingMode) -> None: @property def target_temperature_unit(self) -> TemperatureUnits: """Return the units of the target temperature.""" + if self._temp_unit_prop is None: + return TemperatureUnits.C return TemperatureUnits( - self._state.get(GreeProp.TARGET_TEMPERATURE_UNIT) - or TemperatureUnits.C.value + self._state.get(self._temp_unit_prop) or TemperatureUnits.C.value ) def set_target_temperature_unit(self, units: TemperatureUnits) -> None: """Set the units of the target temperature.""" - self._state.set(GreeProp.TARGET_TEMPERATURE_UNIT, units) + if self._temp_unit_prop is None: + _LOGGER.warning("[%s] The device has no temperature unit", self.unique_id) + return + self._state.set(self._temp_unit_prop, units) + + @property + def has_target_temperature_bit(self) -> bool: + """Return True if the device answers a bit for the target temperature. + + Only then can it hold half degrees in Celsius, and every whole degree + in Fahrenheit. Without it, Fahrenheit goes in steps of about 2. + """ + return self._target_temp_bit_prop is not None @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. - raw_c = self._state.get(GreeProp.TARGET_TEMPERATURE, 0) - tem_rec = self._state.get(GreeProp.TARGET_TEMPERATURE_BIT, 0) + The bit is the half degree in Celsius, and picks the value from the + table in Fahrenheit. Without a bit column it is 0. None until the + device is read, or when it has no target temperature. + """ + if self._target_temp_prop is None: + return None - if raw_c is not None and tem_rec is not None: - if self.target_temperature_unit == TemperatureUnits.F: - return gree_get_target_temperature_f(raw_c, tem_rec) - if self.target_temperature_unit == TemperatureUnits.C: - return gree_get_target_temperature_c(raw_c, tem_rec) - return 0.0 + raw = self._state.get(self._target_temp_prop) + if raw is None: + return None + + raw_c = raw + TARGET_TEMPERATURE_OFFSETS.get(self._target_temp_prop, 0) + bit = 0 + if self._target_temp_bit_prop is not None: + bit = self._state.get(self._target_temp_bit_prop) or 0 + + if self.target_temperature_unit == TemperatureUnits.F: + return gree_get_target_temperature_f(raw_c, bit) + return gree_get_target_temperature_c(raw_c, bit) def set_target_temperature(self, value: float) -> None: - """Set the target temperature in target_temperature_unit.""" + """Set the target temperature in target_temperature_unit. + + Celsius goes to the nearest half degree, Fahrenheit to whole degrees. + The bit is sent along when the device answers it. A zone needs that, + because it keeps the old bit when only StTem is sent. + """ + if self._target_temp_prop is None: + _LOGGER.warning("[%s] The device has no target temperature", self.unique_id) + return if self.target_temperature_unit == TemperatureUnits.F: if not value.is_integer(): @@ -794,16 +922,15 @@ def set_target_temperature(self, value: float) -> None: value, round(value), ) - raw_c, tem_rec = gree_get_target_temp_props_from_f(round(value)) + raw_c, bit = gree_get_target_temp_props_from_f(round(value)) else: - raw_c, tem_rec = gree_get_target_temp_props_from_c(value) + raw_c, bit = gree_get_target_temp_props_from_c(value) - self._state.update( - { - GreeProp.TARGET_TEMPERATURE: raw_c, - GreeProp.TARGET_TEMPERATURE_BIT: tem_rec, - } - ) + offset = TARGET_TEMPERATURE_OFFSETS.get(self._target_temp_prop, 0) + props = {self._target_temp_prop: raw_c - offset} + if self._target_temp_bit_prop is not None: + props[self._target_temp_bit_prop] = bit + self._state.update(props) if self.feature_smart_heat or self.feature_energy_saving: _LOGGER.info( @@ -928,6 +1055,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 +1074,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/aiogree/device_state.py b/custom_components/gree_custom/aiogree/device_state.py index 07288e0..5b41ad0 100755 --- a/custom_components/gree_custom/aiogree/device_state.py +++ b/custom_components/gree_custom/aiogree/device_state.py @@ -258,8 +258,11 @@ def invalidate_missing_properties(self) -> None: def invalidate_missing_property_group( self, props: list[GreeProp], missing_value: int = 0 - ) -> None: - """Remove a group of properties from polling based on a ordered list of preference.""" + ) -> GreeProp | None: + """Remove a group of properties from polling based on a ordered list of preference. + + Returns the property that is kept, or None if none of them has a value. + """ # Keep the first (lowest priority number) non-zero value preferred = next( @@ -276,6 +279,8 @@ def invalidate_missing_property_group( self.remove(prop) + return preferred + @property def has_pending_updates(self) -> bool: """Does the state have pending values to be committed. diff --git a/custom_components/gree_custom/aiogree/helpers.py b/custom_components/gree_custom/aiogree/helpers.py index b4b2b69..cd09686 100755 --- a/custom_components/gree_custom/aiogree/helpers.py +++ b/custom_components/gree_custom/aiogree/helpers.py @@ -76,7 +76,9 @@ def gree_extract_macs(raw_mac: str) -> tuple[str, str]: # For VRF units, the MAC will be of >12 characters and end in "00" # where for MQTT the first 12 are the main_device MAC and the full string the sub_device # For imported configs in mac@main_mac format separate them - # TODO: Cloud has a "pmac" field. Check if it is "parent mac" aka main_device + # TODO: Cloud has a "pmac" field. Check if it is "parent mac" aka main_device. + # For a zone controller it is the gateway, the first 12 characters of each + # sub-unit MAC, and gree_discover_devices_cloud() uses it in that case only. if "@" in normalized_mac: mac, mac_controller = normalized_mac.split("@", 1) elif len(normalized_mac) > 12 and normalized_mac.endswith("00"): diff --git a/custom_components/gree_custom/climate.py b/custom_components/gree_custom/climate.py index 99c21e4..0153959 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] = [ @@ -199,7 +210,7 @@ def __init__( # Only offer a target temperature when the unit has one. if any( mode in hvac_modes for mode in (HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO) - ) and self.device.supports_property(GreeProp.TARGET_TEMPERATURE): + ) and self.device.supports_property(GreeProp.TARGET_TEMPERATURE_1): self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE if fan_modes: @@ -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..580b11e 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,8 @@ DOMAIN, ENCRYPTION_VERSION_AUTO, MAX_UNICAST_SCAN_HOSTS, + TO_REDACT, + ZONE_ONLY_FEATURES, ) from .coordinator import GreeConfigEntry from .helpers import ( @@ -112,6 +115,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] @@ -253,7 +286,7 @@ async def async_step_import(self, import_config: dict) -> ConfigFlowResult: _LOGGER.debug( "YAML import: new entry with config: %s", - async_redact_data(data, ["encryption_key", "password", "token"]), + async_redact_data(data, TO_REDACT), ) return self.async_create_entry( title=title, @@ -422,7 +455,7 @@ async def async_step_reauth( ) -> ConfigFlowResult: """Process a Reauth request.""" reauth_entry = self._get_reauth_entry() - _LOGGER.debug("Reauth entry: %s", reauth_entry.title) + _LOGGER.debug("Reauth entry: %s", reauth_entry.entry_id) return await self.async_step_cloud_add() @@ -432,7 +465,7 @@ async def async_step_reconfigure( """Handle reconfiguration of an existing entry.""" reconfigure_entry = self._get_reconfigure_entry() reconfigure_data = dict(reconfigure_entry.data) - _LOGGER.debug("Reconfiguring: %s", reconfigure_entry.title) + _LOGGER.debug("Reconfiguring: %s", reconfigure_entry.entry_id) # If on the local-only entry, exit early and continue with the local method only if reconfigure_entry.unique_id == CONFENTRY_ID_LOCAL_ONLY: @@ -545,9 +578,9 @@ async def async_step_cloud_add( self._discovered_devices_cloud = {d.mac: d for d in discovered} _LOGGER.info( - "Discovered %d devices from the cloud account: %s", + "Discovered %d devices from the cloud account of user %s", len(self._discovered_devices_cloud), - self._cloud_api.username, + self._cloud_api.user_id, ) return await self._setup_next_setup_method() @@ -928,17 +961,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 +972,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( @@ -1103,7 +1129,7 @@ async def _async_finish(self) -> ConfigFlowResult: # noqa: C901 _LOGGER.debug( "New entry with config: %s", - async_redact_data(data, ["encryption_key"]), + async_redact_data(data, TO_REDACT), ) return self.async_create_entry( title=title, diff --git a/custom_components/gree_custom/config_schema.py b/custom_components/gree_custom/config_schema.py index eb36e25..3c995ef 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, @@ -707,7 +719,7 @@ def setup_device_options_schema( # noqa: C901 } ) - if device.supports_property(GreeProp.TARGET_TEMPERATURE): + if device.supports_property(GreeProp.TARGET_TEMPERATURE_1): schema.update( { probatio.Required( diff --git a/custom_components/gree_custom/const.py b/custom_components/gree_custom/const.py index 7e3c089..80abc7f 100755 --- a/custom_components/gree_custom/const.py +++ b/custom_components/gree_custom/const.py @@ -1,7 +1,7 @@ """Constants for the Gree integration.""" from homeassistant.components.climate import HVACMode # pyright: ignore[reportPrivateImportUsage] -from homeassistant.const import UnitOfTemperature +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TOKEN, UnitOfTemperature from .aiogree.api import ( FanSpeed, @@ -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 @@ -38,6 +42,9 @@ CONF_ALL_DEVICE_OPTIONS = "device_options" CONF_UID = "uid" CONF_ENCRYPTION_KEY = "encryption_key" +# Left out of debug logs and diagnostics, because users post both: the device +# key and the cloud login. +TO_REDACT = [CONF_ENCRYPTION_KEY, CONF_PASSWORD, CONF_TOKEN, CONF_EMAIL] CONF_ENCRYPTION_VERSION = "encryption_version" CONF_DISABLE_AVAILABLE_CHECK = "disable_available_check" CONF_MAX_ONLINE_ATTEMPTS = "max_online_attempts" @@ -100,6 +107,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 +143,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 +165,14 @@ } CONF_TO_PROP_FEATURE_MAP: dict[str, list[GreeProp]] = { - GATTR_TEMP_UNITS: [GreeProp.TARGET_TEMPERATURE_UNIT], + GATTR_TEMP_UNITS: [ + GreeProp.TARGET_TEMPERATURE_UNIT_1, + GreeProp.TARGET_TEMPERATURE_UNIT_2, + ], + GATTR_ZONE_TARGET_TEMPERATURE: [ + GreeProp.TARGET_TEMPERATURE_1, + GreeProp.TARGET_TEMPERATURE_2, + ], # SENSORS **ATTR_SENSORS_TO_PROP_MAP, # FEATURES @@ -228,6 +253,7 @@ GATTR_FEAT_ENERGY_SAVING, GATTR_FEAT_SENSOR_LIGHT, GATTR_FAULTS, + GATTR_ZONE, ] UNITS_GREE_TO_HA = { diff --git a/custom_components/gree_custom/diagnostics.py b/custom_components/gree_custom/diagnostics.py index 0aac90a..7fd9f14 100755 --- a/custom_components/gree_custom/diagnostics.py +++ b/custom_components/gree_custom/diagnostics.py @@ -4,11 +4,10 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry -from .const import CONF_DEVICES, CONF_ENCRYPTION_KEY, DOMAIN +from .const import CONF_DEVICES, DOMAIN, TO_REDACT from .coordinator import GreeConfigEntry, GreeCoordinator from .helpers import get_vrf_controller_mac, get_vrf_sub_units @@ -34,7 +33,7 @@ async def async_get_config_entry_diagnostics( # diagnostics["entry_data"]["advanced"]["encryption_key"] # ) - return async_redact_data(diagnostics, [CONF_ENCRYPTION_KEY, CONF_PASSWORD]) + return async_redact_data(diagnostics, TO_REDACT) async def async_get_device_diagnostics( diff --git a/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..e36e980 100755 --- a/custom_components/gree_custom/number.py +++ b/custom_components/gree_custom/number.py @@ -15,10 +15,29 @@ 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, + 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 +50,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 +72,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 +89,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.has_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 +200,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 +229,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/actions.md b/docs/actions.md index a1f1486..ebc61ad 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -59,6 +59,6 @@ missing: ## Diagnostics download -Every config entry and every device has a **Download diagnostics** item in its three dot menu. The download is a JSON file with the configuration and the last known state of each device. Keys and passwords are redacted. +Every config entry and every device has a **Download diagnostics** item in its three dot menu. The download is a JSON file with the configuration and the last known state of each device. Keys and the cloud login (token, password and email) are redacted. The device diagnostics also list the properties the integration polls and the values the device sent that the integration does not know. The diagnostics of a VRF gateway device hold the diagnostics of each of its indoor units. Attach the file to a bug report. See [troubleshooting.md](troubleshooting.md#how-to-report-a-bug). diff --git a/docs/architecture.md b/docs/architecture.md index 7498585..5405a5f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -69,7 +69,7 @@ reference counting rather than on the code saying what it means. 1. Bind. Try the local transport first, then MQTT. Binding is the key exchange, see [protocol.md](protocol.md#encryption). Right after the key exchange, `probe_device_limits()` measures how many columns one status request may carry on this firmware, see [protocol.md](protocol.md#requests-and-batching). 2. `fetch_device_info()`. Asks for the `InfoProp` columns (MAC, name, model, firmware). 3. `fetch_device_status()`. Asks for all polled `GreeProp` columns. The request is split into batches, see [protocol.md](protocol.md#requests-and-batching). -4. `_remove_unsupported_props()`. Any prop the device did not return is removed from polling for the life of the object. If the device returned nothing at all, this raises `GreeProtocolError` instead, so setup fails and Home Assistant retries later. +4. `_remove_unsupported_props()`. Any prop the device did not return is removed from polling for the life of the object. If the device returned nothing at all, this raises `GreeProtocolError` instead, so setup fails and Home Assistant retries later. Where one value has several columns (the sensors, and the target temperature, its bit and the unit), it keeps one column per group and saves it on the device, which then reads and writes only that column. See [protocol.md](protocol.md#temperature). After that the coordinator calls `fetch_device_status()` every `scan_interval` seconds (default 60). @@ -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..550fcd2 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,20 @@ 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. + +A zone controller is controlled locally. Add it with local discovery, or with both discovery methods: then the AC unit and the zones get the names they have in the Gree app. A cloud-only setup cannot control it, because its WiFi module (ME31-00/C13) uses an older Gree cloud protocol, not MQTT. + +- The **AC unit** gets the climate entity. Its HVAC mode and fan mode work as above. Turbo is a fan speed of this unit, in Cool and Heat only. It has no target temperature and no room temperature: the zones have their own target temperature, and the controller does not report the room temperatures of the zones. Turning the AC unit off turns the whole system off. The zones keep their own setting and get it back when the system is turned on again. Its **Temperature Units** select switches the whole controller, the wall panel included, between °C and °F. The zones follow at their next update. +- Each **zone** gets no climate entity, because a zone has no mode of its own. It gets: + +| 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..73f7200 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -17,7 +17,7 @@ Facts about how Gree devices behave on the wire. Most of these are not visible f - Some firmwares cap the number of columns per request. `U-CS532Z(LT)V3.75` answers 29 columns, returns an empty result for 30, and stops replying at 31 or more. Pack size and the names of the columns play no role: `Pow` repeated 30 times in a 258 byte packet fails the same way. - The limit differs per firmware, so `DeviceApiClient.probe_device_limits()` measures it once, right after binding. A probe asks for `STATUS_CANARY_PROP` (`Pow`, which every unit answers) plus distinct throw-away names (`X00`, `X01`, ...). Distinct names matter: a device may merge repeated names before it counts them. Each probe is one request with one attempt and `PROBE_TIMEOUT` (5 s), so a unit that goes silent on a too large request does not stall the bind. - A probe passes when `Pow` comes back and no column with an empty name comes back. Anything else fails: no reply, an empty result, or an error. The empty name is how some firmwares mark a request they had to cut short (seen: 65 columns asked, 64 answered plus one column named `""`). -- The first probe asks for as many columns as the default poll has (`POLLED_PROPS`, 30 today: every `GreeProp` except the two beeper props). Passing that probe means no limit affects anything the component sends; it does not mean the device has no limit at all. If it passes there is no limit worth keeping and `max_props` stays `None`. If it fails, a binary search between `MIN_PACK_PROPS` and the first failing size minus one looks for the largest size that still passes. If even `MIN_PACK_PROPS` fails, the limit is set to `MIN_PACK_PROPS` and one warning is logged; the device is most likely answering nothing at all, which the empty status guard in `GreeDevice` then catches. +- The first probe asks for as many columns as the default poll has (`POLLED_PROPS`, 33 today: every `GreeProp` except the two beeper props). Passing that probe means no limit affects anything the component sends; it does not mean the device has no limit at all. If it passes there is no limit worth keeping and `max_props` stays `None`. If it fails, a binary search between `MIN_PACK_PROPS` and the first failing size minus one looks for the largest size that still passes. If even `MIN_PACK_PROPS` fails, the limit is set to `MIN_PACK_PROPS` and one warning is logged; the device is most likely answering nothing at all, which the empty status guard in `GreeDevice` then catches. - The result lives on the client and is reset on unbind, so a rebind measures again. Cost: one tiny request on a device without a limit, about five on a device with one. A probe that gets no reply costs 5 s. - A unit can have both limits at once. The `U-CS532Z(LT)V3.75` unit caps at 29 columns and also stops replying at about 800 bytes in one packet, which matches the "about 760 bytes unencrypted" note above `MAX_PACK_SIZE`. The 512 byte cap keeps normal polls far below the size limit; the measured column limit handles the other one. - To measure a unit, run `tools/probe_status_limit.py --host ` from the repo root. It sends `Pow` repeated N times (growing count, tiny packets) and then 10 columns with padded names (growing bytes, fixed count), each as exactly one packet, and prints where each run first fails. See [development.md](development.md#tools). @@ -31,6 +31,8 @@ Facts about how Gree devices behave on the wire. Most of these are not visible f ## Temperature - `SetTem` is a whole number of degrees. `TemRec` adds the half degree. +- The target temperature, its bit and the unit each come in two columns, in order of preference: `SetTem` and `StTem` (`TARGET_TEMPERATURE_1` and `_2`), `TemRec` and `StFahFlg` (`TARGET_TEMPERATURE_BIT_1` and `_2`), `TemUn` and `TemUnit` (`TARGET_TEMPERATURE_UNIT_1` and `_2`). Like the sensor groups, `_remove_unsupported_props()` keeps the first column of each group that the device answers, and the device reads and writes only that one. 0 is a valid value in all three groups (`StTem` 0 is 16 °C), so only a column the device does not answer counts as missing (`missing_value=-1`). +- The bit is only sent to a device that answers one. That was already so: `DeviceState.set()` drops a column the device did not answer, so a device without a bit gets whole degrees either way. `set_target_temperature()` now leaves it out by itself, so the `is unsupported` error is no longer logged. - Some devices report sensor temperatures with a +40 offset. `TempOffsetResolver` in `aiogree/helpers.py` detects this from the values it sees. - Fahrenheit uses the protocol's own lookup table, not a formula. @@ -84,6 +86,24 @@ 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, the `StTem` of the ducted unit, and which entities a sub-unit gets. +- The ducted unit answers `Pow`, `Mod`, `WdSpd`, `AllErr` and `TemUnit`, and `StTem` and `StFahFlg` always as 0. That looks the same as a zone set to 16 °C, so the ducted unit drops those two after its first poll. `Pow` of the ducted unit is the power of the whole system. The zones keep their own `Pow` while it is off, and get it back when it is turned on. No sub-unit answers `SetTem`, `TemRec`, `TemUn` or `Add0.5`. +- `Mod` uses its own numbers: 1 Cool, 2 Heat, 3 Dry, 4 Fan, 5 Auto (`ZONE_CONTROLLER_MODES`). Read with the normal numbers, Fan shows as Heat and Auto raises. `operation_mode` and `set_operation_mode()` convert for every unit with a zone role. +- There is no `Tur` column. Turbo is `WdSpd` 6, only in Cool and Heat. Dry forces `WdSpd` 1. +- A zone answers `Pow` (the damper), `StTem` and `StFahFlg` (its target temperature), `TemUnit`, and a copy of `Mod` and `WdSpd` of the ducted unit. `StTem` is the temperature in degrees Celsius minus 16 (`TARGET_TEMPERATURE_OFFSETS`), so 0 to 14 for 16 to 30 degrees. The controller itself also answers `StTem1` to `StTem8`, without the bit. +- The unit of temperature is `TemUnit` (0 °C, 1 °F), not `TemUn`. Every unit answers it. Written to the ducted unit, the controller and the wall panel switch, and the zones follow. `StTem` + 16 and `StFahFlg` work like `SetTem` and `TemRec` of a normal unit. In °C the bit is read as the half degree, like `TemRec`: the controller keeps `StTem` 2 with bit 1 (18.5 °C), while the wall panel shows only whole degrees (18). In °F it is the same table as `TemRec`. Seen on the panel: 64 °F is `StTem` 2 with bit 0, 66 °F is 3 with 0, 67 °F is 3 with 1, 70 °F is 5 with 1. When only `StTem` is sent, the controller keeps the old bit, so `set_target_temperature()` sends both. +- The columns come from what a device answers. `StTem`, `StFahFlg` and `TemUnit` are in the default poll, and the first poll drops them on a unit that does not answer them, like any other column. They are the second column of their group (see [Temperature](#temperature)), so a device that answers `SetTem`, `TemRec` or `TemUn` keeps using those, and every other unit works as before. +- In a Gree Cloud account the controller is listed as the gateway (12-character MAC, empty `pmac`, mid `50`) and each sub-unit (`pmac` is the gateway MAC, mid `5000` or `5001`), all with the same key. `_filter_duplicate_devices_complete()` leaves out a listed device that other devices point to with their `pmac`, and keeps its sub-units. `gree_discover_devices_cloud()` uses the `pmac` of a sub-unit as its MQTT controller MAC only when it is also the first 12 characters of the sub-unit MAC, as here; what `pmac` holds for a VRF is not known yet. The WiFi module has no MQTT client, only Gree's older TCP cloud, so the sub-units are controlled locally. With both discovery methods, each one is offered once, with the name from the Gree app. +- The full list of columns is a table in the WiFi module firmware. There is no column for the room temperature of a zone, in any released firmware, so it cannot be shown. +- The status column limit measured by `probe_device_limits()` is 29. Requests far above the limit (about 40 columns and more) made the module reboot. + ## 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/docs/troubleshooting.md b/docs/troubleshooting.md index 146c497..64c7df3 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -15,7 +15,7 @@ When Home Assistant runs in a Docker container on a bridge network, the discover ## Enable debug logging -Debug logs show every packet in both directions, with the keys redacted. Turn them on before you reproduce a problem. +Debug logs show every packet in both directions, with the keys and the cloud login (token, password and email) redacted. Turn them on before you reproduce a problem. In the UI: open the integration under **Settings** > **Devices & Services**, click the three dots and choose **Enable debug logging**. Reproduce the problem, then choose **Disable debug logging**. Home Assistant then downloads the log. @@ -91,7 +91,7 @@ Run the checks under [Time out and cannot connect errors](#time-out-and-cannot-c 2. The Home Assistant version and the integration version, from HACS or `manifest.json`. 3. Whether the device is controlled locally or through the cloud. 4. What you did, what you expected, and what happened. -5. The diagnostics download of the config entry or the device. See [actions.md](actions.md#diagnostics-download). Keys and passwords are redacted for you. +5. The diagnostics download of the config entry or the device. See [actions.md](actions.md#diagnostics-download). Keys and the cloud login (token, password and email) are redacted for you. 6. A debug log of the moment the problem happened. See [Enable debug logging](#enable-debug-logging). Remove anything personal before you paste it. A report without a log usually gets a request for one first, which costs a round trip. diff --git a/supported-devices.md b/supported-devices.md index 0c5092b..c6d7d56 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, local only, see [Zone controller](docs/entities.md#zone-controller) ## Kolin - Kolin KAG-100WCINV (encryption version 2) diff --git a/tests/fakes/cloud.py b/tests/fakes/cloud.py index 94a28b7..2fb3e1d 100644 --- a/tests/fakes/cloud.py +++ b/tests/fakes/cloud.py @@ -82,6 +82,38 @@ def cloud_device(mac: str, key: str, name: str = "Zolderkamer") -> dict[str, Any } +def zone_controller_cloud_devices(gateway: str, key: str) -> list[dict[str, Any]]: + """Return a zone controller as a Gree Cloud account lists it. + + Seen on a real LE60-13/GH: the gateway with an empty pmac, and next to it + the AC unit (00) and the zones, each with the gateway as pmac. They all + share the key of the WiFi module. The order is the one the cloud gave. + """ + + def unit(mac: str, pmac: str, name: str, mid: str, sort: int) -> dict[str, Any]: + return { + **cloud_device(mac, key, name=name), + "pmac": pmac, + "mid": mid, + "catalog": "", + "subdivCode": "", + "vender": "2", + "ver": "V1.2.1", + "prodModel": "", + "hid": "", + "homeSort": sort, + } + + return [ + unit(f"{gateway}00", gateway, "Zone Controller", "5000", 0), + unit(f"{gateway}02", gateway, "Zone2", "5001", 1), + unit(f"{gateway}03", gateway, "Zone3", "5001", 2), + unit(f"{gateway}04", gateway, "Zone4", "5001", 3), + unit(gateway, "", "Zone Controller", "50", 4), + unit(f"{gateway}01", gateway, "Zone1", "5001", 5), + ] + + class FakeGreeCloud: """A Gree Cloud server on a free port.""" diff --git a/tests/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_cloud_api.py b/tests/test_cloud_api.py index 2728d6c..87a5430 100644 --- a/tests/test_cloud_api.py +++ b/tests/test_cloud_api.py @@ -21,7 +21,14 @@ from aiogree.errors import GreeCloudError, GreeCloudLoginError import pytest -from .fakes.cloud import TOKEN, USER_ID, FakeGreeCloud, cloud_device +from .conftest import RecordingHandler +from .fakes.cloud import ( + TOKEN, + USER_ID, + FakeGreeCloud, + cloud_device, + zone_controller_cloud_devices, +) REGION = GreeRegion.EU GATEWAY_MAC = "9424b8fd5ba3" @@ -116,6 +123,44 @@ async def test_a_login_without_a_token_raises(cloud_factory: CloudFactory) -> No await client.login() +async def test_the_login_log_leaves_out_the_token_and_the_account( + cloud_factory: CloudFactory, gree_logs: RecordingHandler +) -> None: + """Users post their debug logs, so only the result and the user id are logged.""" + _, client = await cloud_factory( # type: ignore[misc] + login_response={ + "r": 200, + "uid": USER_ID, + "token": TOKEN, + "email": "rob@example.com", + "uname": "rob", + "tel": "0612345678", + } + ) + + await client.login() + + logged = "\n".join(gree_logs.messages()) + assert "Login response" in logged + assert str(USER_ID) in logged + for secret in (TOKEN, "rob@example.com", "'rob'", "0612345678"): + assert secret not in logged + + +async def test_a_login_error_leaves_out_the_account( + cloud_factory: CloudFactory, +) -> None: + """The error text ends up in the log too.""" + _, client = await cloud_factory( # type: ignore[misc] + login_response={"uid": 7, "token": "", "email": "rob@example.com"} + ) + + with pytest.raises(GreeCloudLoginError) as err: + await client.login() + + assert "rob@example.com" not in str(err.value) + + async def test_an_unknown_login_format_raises(cloud_factory: CloudFactory) -> None: """Better an error than a client that thinks it is logged in.""" _, client = await cloud_factory(login_response={"something": "else"}) # type: ignore[misc] @@ -208,6 +253,26 @@ async def test_a_duplicate_device_keeps_the_one_that_answers( assert [device.mac for device in devices] == [SUB_MAC] +async def test_a_zone_controller_keeps_its_sub_units( + cloud_factory: CloudFactory, +) -> None: + """The gateway is listed next to its AC unit and zones, all with one key. + + The gateway does not answer, its sub-units do, so only the gateway goes. + """ + _, client = await cloud_factory( # type: ignore[misc] + homes=[{"id": 1, "name": "Thuis"}], + devices_per_home={1: zone_controller_cloud_devices(GATEWAY_MAC, DEVICE_KEY)}, + ) + await client.login() + + devices = await client.get_all_devices() + + assert [device.mac for device in devices] == [ + f"{GATEWAY_MAC}{suffix}" for suffix in ("00", "02", "03", "04", "01") + ] + + async def test_two_devices_with_different_keys_are_both_kept( cloud_factory: CloudFactory, ) -> None: diff --git a/tests/test_device.py b/tests/test_device.py index 5bbc864..2054c16 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -57,9 +57,9 @@ GreeProp.POWER.value: 1, GreeProp.OP_MODE.value: OperationMode.cool.value, GreeProp.FAN_SPEED.value: FanSpeed.auto.value, - GreeProp.TARGET_TEMPERATURE.value: 21, - GreeProp.TARGET_TEMPERATURE_BIT.value: 0, - GreeProp.TARGET_TEMPERATURE_UNIT.value: TemperatureUnits.C.value, + GreeProp.TARGET_TEMPERATURE_1.value: 21, + GreeProp.TARGET_TEMPERATURE_BIT_1.value: 0, + GreeProp.TARGET_TEMPERATURE_UNIT_1.value: TemperatureUnits.C.value, GreeProp.SENSOR_INDOOR_TEMPERATURE_3.value: 61, GreeProp.SENSOR_OUTSIDE_TEMPERATURE_1.value: 52, GreeProp.SENSOR_HUMIDITY_1.value: 55, diff --git a/tests/test_device_api_client.py b/tests/test_device_api_client.py index 74f1168..5180006 100644 --- a/tests/test_device_api_client.py +++ b/tests/test_device_api_client.py @@ -246,7 +246,7 @@ async def test_a_query_raises_when_the_device_goes_quiet( device.reset_record() with pytest.raises(GreeConnectionError): - await client.query_props([GreeProp.TARGET_TEMPERATURE.value]) + await client.query_props([GreeProp.TARGET_TEMPERATURE_1.value]) finally: await transport.disconnect() @@ -272,7 +272,7 @@ async def test_a_sweep_goes_on_when_the_device_answers_the_canary( result = await client.query_props( [ - GreeProp.TARGET_TEMPERATURE.value, + GreeProp.TARGET_TEMPERATURE_1.value, "ElcDatDte", "ElcDatHor", GreeProp.OP_MODE.value, @@ -285,7 +285,7 @@ async def test_a_sweep_goes_on_when_the_device_answers_the_canary( await transport.disconnect() assert result.prop_values == { - GreeProp.TARGET_TEMPERATURE.value: "21", + GreeProp.TARGET_TEMPERATURE_1.value: "21", GreeProp.OP_MODE.value: "1", } assert sorted(result.missing_props) == ["ElcDatDte", "ElcDatHor"] diff --git a/tests/test_device_state.py b/tests/test_device_state.py index 3b07723..82e80fe 100644 --- a/tests/test_device_state.py +++ b/tests/test_device_state.py @@ -207,8 +207,9 @@ def test_a_sensor_group_keeps_only_the_one_that_reports(state: DeviceState) -> N }, ) - state.invalidate_missing_property_group(group) + kept = state.invalidate_missing_property_group(group) + assert kept is GreeProp.SENSOR_INDOOR_TEMPERATURE_2 assert GreeProp.SENSOR_INDOOR_TEMPERATURE_2 in state.polled_properties assert GreeProp.SENSOR_INDOOR_TEMPERATURE_1 not in state.polled_properties assert GreeProp.SENSOR_INDOOR_TEMPERATURE_3 not in state.polled_properties @@ -221,8 +222,9 @@ def test_a_sensor_group_that_reports_nothing_is_dropped_whole( group = [GreeProp.SENSOR_HUMIDITY_1, GreeProp.SENSOR_HUMIDITY_2] seed(state, {GreeProp.SENSOR_HUMIDITY_1: 0, GreeProp.SENSOR_HUMIDITY_2: 0}) - state.invalidate_missing_property_group(group) + kept = state.invalidate_missing_property_group(group) + assert kept is None assert all(prop not in state.polled_properties for prop in group) @@ -241,28 +243,28 @@ def test_the_views_are_read_only(state: DeviceState) -> None: def test_a_stale_report_does_not_undo_a_sent_value(timed_state: DeviceState) -> None: """The gateway still reports the old value, but the read shows the sent one.""" - seed(timed_state, {GreeProp.TARGET_TEMPERATURE: 21}) - timed_state.hold({GreeProp.TARGET_TEMPERATURE: 24}) + seed(timed_state, {GreeProp.TARGET_TEMPERATURE_1: 21}) + timed_state.hold({GreeProp.TARGET_TEMPERATURE_1: 24}) - seed(timed_state, {GreeProp.TARGET_TEMPERATURE: 21}) + seed(timed_state, {GreeProp.TARGET_TEMPERATURE_1: 21}) - assert timed_state.get(GreeProp.TARGET_TEMPERATURE) == 24 - assert timed_state.raw[GreeProp.TARGET_TEMPERATURE] == 21 - assert timed_state.held == {GreeProp.TARGET_TEMPERATURE: 24} + assert timed_state.get(GreeProp.TARGET_TEMPERATURE_1) == 24 + assert timed_state.raw[GreeProp.TARGET_TEMPERATURE_1] == 21 + assert timed_state.held == {GreeProp.TARGET_TEMPERATURE_1: 24} def test_a_report_of_the_sent_value_ends_the_hold(timed_state: DeviceState) -> None: """Once confirmed, later reports count again, for example from a remote.""" - seed(timed_state, {GreeProp.TARGET_TEMPERATURE: 21}) - timed_state.hold({GreeProp.TARGET_TEMPERATURE: 24}) + seed(timed_state, {GreeProp.TARGET_TEMPERATURE_1: 21}) + timed_state.hold({GreeProp.TARGET_TEMPERATURE_1: 24}) - seed(timed_state, {GreeProp.TARGET_TEMPERATURE: 24}) + seed(timed_state, {GreeProp.TARGET_TEMPERATURE_1: 24}) assert timed_state.held == {} - seed(timed_state, {GreeProp.TARGET_TEMPERATURE: 19}) + seed(timed_state, {GreeProp.TARGET_TEMPERATURE_1: 19}) - assert timed_state.get(GreeProp.TARGET_TEMPERATURE) == 19 + assert timed_state.get(GreeProp.TARGET_TEMPERATURE_1) == 19 def test_a_hold_ends_when_its_time_is_up( @@ -315,30 +317,30 @@ def test_a_new_command_starts_a_new_hold( def test_a_pending_value_wins_over_a_held_one(timed_state: DeviceState) -> None: """A change that is not sent yet is newer than the one that was sent.""" - seed(timed_state, {GreeProp.TARGET_TEMPERATURE: 21}) - timed_state.hold({GreeProp.TARGET_TEMPERATURE: 24}) - timed_state.set(GreeProp.TARGET_TEMPERATURE, 25) + seed(timed_state, {GreeProp.TARGET_TEMPERATURE_1: 21}) + timed_state.hold({GreeProp.TARGET_TEMPERATURE_1: 24}) + timed_state.set(GreeProp.TARGET_TEMPERATURE_1, 25) - assert timed_state.get(GreeProp.TARGET_TEMPERATURE) == 25 - assert timed_state.pending == {GreeProp.TARGET_TEMPERATURE: 25} - assert timed_state.held == {GreeProp.TARGET_TEMPERATURE: 24} + assert timed_state.get(GreeProp.TARGET_TEMPERATURE_1) == 25 + assert timed_state.pending == {GreeProp.TARGET_TEMPERATURE_1: 25} + assert timed_state.held == {GreeProp.TARGET_TEMPERATURE_1: 24} timed_state.clear_pending() - assert timed_state.get(GreeProp.TARGET_TEMPERATURE) == 24 + assert timed_state.get(GreeProp.TARGET_TEMPERATURE_1) == 24 def test_a_change_back_to_the_stale_value_is_still_sent( timed_state: DeviceState, ) -> None: """The device was told 24, so going back to 21 is a real change.""" - seed(timed_state, {GreeProp.TARGET_TEMPERATURE: 21}) - timed_state.hold({GreeProp.TARGET_TEMPERATURE: 24}) + seed(timed_state, {GreeProp.TARGET_TEMPERATURE_1: 21}) + timed_state.hold({GreeProp.TARGET_TEMPERATURE_1: 24}) - timed_state.set(GreeProp.TARGET_TEMPERATURE, 21) + timed_state.set(GreeProp.TARGET_TEMPERATURE_1, 21) assert timed_state.has_pending_updates - timed_state.set(GreeProp.TARGET_TEMPERATURE, 24) + timed_state.set(GreeProp.TARGET_TEMPERATURE_1, 24) assert not timed_state.has_pending_updates diff --git a/tests/test_discovery_merge.py b/tests/test_discovery_merge.py index a8f6619..562959d 100644 --- a/tests/test_discovery_merge.py +++ b/tests/test_discovery_merge.py @@ -22,8 +22,9 @@ import pytest from .conftest import DISCOVERY_PORT -from .fakes.cloud import FakeGreeCloud, cloud_device +from .fakes.cloud import FakeGreeCloud, cloud_device, zone_controller_cloud_devices from .fakes.device import FakeGreeDevice +from .fakes.zone import ZONE_CONTROLLER_MAC, FakeZoneController LOCAL_MAC = "f4911e3f1ac8" CLOUD_MAC = "c03937b12280" @@ -91,6 +92,94 @@ async def test_discovery_joins_the_cloud_and_the_network( assert found[0].name == "Zolderkamer" +async def test_discovery_offers_every_unit_of_a_zone_controller_once( + monkeypatch: pytest.MonkeyPatch, loopback_ip: str +) -> None: + """With the cloud and the network, each unit comes back once. + + The name is the one from the app. The address and the controller MAC + come from the network, so the units are controlled locally. + """ + server = FakeGreeCloud( + homes=[{"id": 1, "name": "Thuis"}], + devices_per_home={ + 1: zone_controller_cloud_devices(ZONE_CONTROLLER_MAC, DEVICE_KEY) + }, + ) + await server.start() + monkeypatch.setitem(CLOUD_SERVERS, GreeRegion.EU, server.url) + api = GreeCloudApi.for_server(GreeRegion.EU, "rob@example.com", "hunter2") + controller = FakeZoneController() + await controller.start(loopback_ip, DISCOVERY_PORT) + + try: + await api.login() + found = await gree_discover_devices(api, [loopback_ip], timeout=1) + finally: + controller.close() + await api.close() + await server.close() + + assert [device.mac for device in found] == [ + f"{ZONE_CONTROLLER_MAC}{index:02d}" for index in range(5) + ] + assert [device.name for device in found] == [ + "Zone Controller", + "Zone1", + "Zone2", + "Zone3", + "Zone4", + ] + assert {device.host for device in found} == {loopback_ip} + assert {device.mac_controller_local for device in found} == {ZONE_CONTROLLER_MAC} + + +async def discover_in_cloud( + monkeypatch: pytest.MonkeyPatch, devices: list[dict] +) -> list[GreeDiscoveredDevice]: + """Run cloud discovery against an account that lists these devices.""" + server = FakeGreeCloud( + homes=[{"id": 1, "name": "Thuis"}], devices_per_home={1: devices} + ) + await server.start() + monkeypatch.setitem(CLOUD_SERVERS, GreeRegion.EU, server.url) + api = GreeCloudApi.for_server(GreeRegion.EU, "rob@example.com", "hunter2") + try: + await api.login() + return await gree_discover_devices_cloud(api) + finally: + await api.close() + await server.close() + + +async def test_the_sub_units_of_a_zone_controller_get_the_gateway_as_controller( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Their pmac is the gateway, which is also the start of their MAC.""" + found = await discover_in_cloud( + monkeypatch, zone_controller_cloud_devices(ZONE_CONTROLLER_MAC, DEVICE_KEY) + ) + + assert sorted(device.mac for device in found) == [ + f"{ZONE_CONTROLLER_MAC}{index:02d}" for index in range(5) + ] + assert {device.mac_controller_mqtt for device in found} == {ZONE_CONTROLLER_MAC} + + +async def test_a_pmac_that_is_not_the_start_of_the_mac_is_not_used( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """What such a pmac means is not known, so the MAC rules stay as they are.""" + found = await discover_in_cloud( + monkeypatch, + [{**cloud_device("9424b8fd5ba301", DEVICE_KEY), "pmac": CLOUD_MAC}], + ) + + assert [(d.mac, d.mac_controller_mqtt) for d in found] == [ + ("9424b8fd5ba301", "9424b8fd5ba301") + ] + + def test_a_device_that_is_only_local_is_kept() -> None: """Most installs have no cloud account at all.""" local = GreeDiscoveredDevice(mac=LOCAL_MAC, host="192.168.1.10", name="Zolderkamer") @@ -168,3 +257,14 @@ def test_the_friendly_name_says_where_a_device_was_found() -> None: assert local.friendly_name == f"Zolderkamer, {LOCAL_MAC} (Local)" assert cloud.friendly_name == f"{CLOUD_MAC} (Cloud)" + + +def test_the_repr_leaves_out_the_key_and_the_account() -> None: + """Discovery logs every device with repr(), and users post their debug logs.""" + device = GreeDiscoveredDevice( + mac=CLOUD_MAC, key="V1sT9p0aQ3zXcR7m", username="rob@example.com" + ) + + assert CLOUD_MAC in repr(device) + assert "V1sT9p0aQ3zXcR7m" not in repr(device) + assert "rob@example.com" not in repr(device) diff --git a/tests/test_zone_controller.py b/tests/test_zone_controller.py new file mode 100644 index 0000000..ecd8e7f --- /dev/null +++ b/tests/test_zone_controller.py @@ -0,0 +1,528 @@ +"""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.""" + assert ac_unit.device_type is DeviceType.ZONE_CONTROLLER + + +async def test_the_ducted_unit_has_no_target_temperature( + controller: FakeZoneController, ac_unit: GreeDevice +) -> None: + """It answers StTem and StFahFlg, always with 0, the same as a zone at 16 degrees. + + Only the type tells them apart, so the ducted unit stops asking for both. + """ + controller.reset_record() + await ac_unit.fetch_device_status() + + asked = {col for pack in controller.packs for col in pack.get("cols", [])} + assert not asked & {"StTem", "StFahFlg"} + assert "TemUnit" in asked + assert ac_unit.target_temperature is None + + +async def test_the_device_type_is_in_the_diagnostics( + 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.TARGET_TEMPERATURE_2) + 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_at_16_degrees_keeps_its_target_temperature( + controller: FakeZoneController, +) -> None: + """StTem 0 is 16 degrees, a valid value. Only a column the zone does not answer is dropped.""" + controller.units[controller.zone_macs[0]]["StTem"] = 0 + zone, transport = await bind_unit(controller, "01") + try: + assert zone.supports_property(GreeProp.TARGET_TEMPERATURE_2) + assert zone.supports_property(GreeProp.TARGET_TEMPERATURE_BIT_2) + assert zone.target_temperature == 16 + finally: + await transport.disconnect() + + +async def test_a_zone_target_temperature_is_sent_minus_16( + controller: FakeZoneController, zone: GreeDevice +) -> None: + """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.TARGET_TEMPERATURE_UNIT_2) + assert not ac_unit.supports_property(GreeProp.TARGET_TEMPERATURE_UNIT_1) + + ac_unit.set_target_temperature_unit(TemperatureUnits.F) + await ac_unit.push_device_status() + 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 + + +OLD_COLUMNS = [ + GreeProp.TARGET_TEMPERATURE_1, + GreeProp.TARGET_TEMPERATURE_BIT_1, + GreeProp.TARGET_TEMPERATURE_UNIT_1, +] +NEW_COLUMNS = [ + GreeProp.TARGET_TEMPERATURE_2, + GreeProp.TARGET_TEMPERATURE_BIT_2, + GreeProp.TARGET_TEMPERATURE_UNIT_2, +] + + +@pytest.mark.parametrize( + ("unsupported", "kept", "dropped", "target"), + [ + (set(), OLD_COLUMNS, NEW_COLUMNS, 21), + ({"SetTem", "TemRec", "TemUn"}, NEW_COLUMNS, OLD_COLUMNS, 67), + ], + ids=["the old columns win", "only the new columns"], +) +async def test_each_temperature_group_keeps_the_column_the_device_answers( + unsupported: set[str], + kept: list[GreeProp], + dropped: list[GreeProp], + target: float, +) -> None: + """SetTem, TemRec and TemUn when the device answers them, as every unit did before. + + The new columns only without them: StTem 3 with bit 1 in Fahrenheit is 67. + """ + fake = FakeGreeDevice(unsupported_props=unsupported) + fake.values.update( + {"SetTem": 21, "TemRec": 0, "TemUn": 0, "StTem": 3, "StFahFlg": 1, "TemUnit": 1} + ) + await fake.start() + transport = GreeUdpTransport(fake.host, fake.port, max_retries=1, timeout=0.3) + try: + device = GreeDevice(name="Zolderkamer", mac_addr=fake.mac) + await device.bind_with_transport( + local_controller_mac=fake.mac, local_transport=transport + ) + finally: + await transport.disconnect() + fake.close() + + assert all(device.supports_property(prop) for prop in kept) + assert not any(device.supports_property(prop) for prop in dropped) + assert device.target_temperature == target + + +async def test_a_unit_without_the_bit_gets_the_target_alone() -> None: + """The bit only goes out to a unit that answers it.""" + fake = FakeGreeDevice(unsupported_props={"TemRec", "StFahFlg"}) + await fake.start() + transport = GreeUdpTransport(fake.host, fake.port, max_retries=1, timeout=0.3) + try: + device = GreeDevice(name="Zolderkamer", mac_addr=fake.mac) + await device.bind_with_transport( + local_controller_mac=fake.mac, local_transport=transport + ) + fake.reset_record() + device.set_target_temperature(22) + await device.push_device_status() + finally: + await transport.disconnect() + fake.close() + + sent = { + column + for pack in fake.packs + if pack.get("t") == "cmd" + for column in pack["opt"] + } + assert "SetTem" in sent + assert not sent & {"TemRec", "StFahFlg"} + assert not device.has_target_temperature_bit + + +async def test_a_vrf_indoor_unit_keeps_the_normal_mode_numbers() -> None: + """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