From e58bb1999d10c7264472400495e5c1468e44c8a7 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Thu, 9 Jul 2026 16:01:34 +0100 Subject: [PATCH 01/10] Improve API to closely follow device behaviour This improves the error handling and ensures a better valid state for the unit following the remote/app behaviour. Adds more errors in the API and hadles them in HA. Also, this provides the initial steps to support other sleep modes. --- custom_components/gree_custom/aiogree/api.py | 15 +- .../gree_custom/aiogree/device.py | 186 ++++++++++++++++-- .../gree_custom/aiogree/errors.py | 28 +++ custom_components/gree_custom/climate.py | 45 +++-- custom_components/gree_custom/manifest.json | 2 +- custom_components/gree_custom/switch.py | 17 +- .../gree_custom/translations/en.json | 9 +- .../gree_custom/translations/pt.json | 9 +- 8 files changed, 262 insertions(+), 49 deletions(-) diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index 613f415..df8ecd9 100644 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -49,9 +49,10 @@ class GreeProp(StrEnum): FEAT_XFAN = "Blo" # controls Health ("Cold plasma") mode, only for devices equipped with "anion generator", which absorbs dust and kills bacteria FEAT_HEALTH = "Health" - # sleep mode, which gradually changes the temperature in Cool, Heat and Dry mode - FEAT_SLEEP_MODE_SWING = "SwhSlp" + # sleep mode enabled, which gradually changes the temperature in Cool and Heat modes FEAT_SLEEP_MODE = "SlpMod" + # sleep mode setting, controls different sleep modes + FEAT_SLEEP_MODE_TYPE = "SwhSlp" # turns all indicators and the display on the unit on or off FEAT_LIGHT = "Lig" # Anti Freeze maintain the room temperature steadily at 8°C and prevent the room from freezing by heating operation when nobody is at home for long in severe winter @@ -392,6 +393,16 @@ class VerticalSwingMode(IntEnum): swing_lower = 11 +@unique +class SleepMode(IntEnum): + """Enumeration of sleep modes types.""" + + disabled = 0 + normal = 1 + advanced = 2 + diy = 3 + + class GreeCommand(IntEnum): """Enumeration of Gree commands.""" diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index 3fc12a1..25a39c4 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -13,6 +13,7 @@ HorizontalSwingMode, OperationMode, OtherProps, + SleepMode, TemperatureUnits, VerticalSwingMode, gree_get_device_info, @@ -23,7 +24,18 @@ ) from .cipher import CipherBase, get_cipher from .const import DEFAULT_DEVICE_UID -from .errors import GreeBindingError, GreeConnectionError, GreeError, GreeProtocolError +from .errors import ( + GreeBindingError, + GreeConnectionError, + GreeEnergySavingUnavailable, + GreeError, + GreeProtocolError, + GreeQuietIgnored, + GreeSleepUnavailable, + GreeSmartHeatUnavailable, + GreeTurboIgnored, + GreeTurboUnavailable, +) from .helpers import ( TempOffsetResolver, gree_get_target_temp_props_from_c, @@ -473,7 +485,7 @@ async def query_props( async def query_props_all( self, request_batch: int = 1, error_as_missing: bool = False ) -> tuple[dict[str, str], list[str]]: - """Query all possible props to the log.""" + """Query all possible props.""" all_props = [ *[prop.value for prop in GreeProp], @@ -486,7 +498,10 @@ def supports_property(self, property: GreeProp) -> bool: """Returns True if the device endpoint supports the property.""" # 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 property in self._raw_state if property is not GreeProp.BEEPER else True + return property in self._raw_state or property in ( + GreeProp.BEEPER, + GreeProp.BEEPER_NEW, + ) @property def ip(self) -> str: @@ -626,7 +641,25 @@ def fan_speed(self) -> FanSpeed: return FanSpeed(self._get_prop_raw(GreeProp.FAN_SPEED, FanSpeed.auto.value)) def set_fan_speed(self, speed: FanSpeed): - """Sets the device fan speed mode.""" + """Sets the device fan speed mode. + + Setting a fan speed other than 'Auto' will deactivate Energy Saving and Smart Heat features. + """ + + if speed is not FanSpeed.auto and self.feature_energy_saving: + self.set_feature_energy_saving(False) + _LOGGER.warning( + "%s: Energy saving mode disabled because of fan mode setting", + self.mac_address, + ) + + if speed is not FanSpeed.auto and self.feature_smart_heat: + self.set_feature_smart_heat(False) + _LOGGER.warning( + "%s: Smart Heat mode disabled because of fan mode setting", + self.mac_address, + ) + self._set_device_status({GreeProp.FAN_SPEED: speed}) @property @@ -681,7 +714,24 @@ def target_temperature(self) -> float: return 0.0 def set_target_temperature(self, value: float) -> None: - """Sets the target temperature in target_temperature_unit.""" + """Sets the target temperature in target_temperature_unit. + + Changing the target temperature will deactivate Energy Saving and Smart Heat features. + """ + + if self.feature_energy_saving: + self.set_feature_energy_saving(False) + _LOGGER.warning( + "%s: Energy saving mode disabled because of target temperature change", + self.mac_address, + ) + + if self.feature_smart_heat: + self.set_feature_smart_heat(False) + _LOGGER.warning( + "%s: Smart Heat mode disabled because of target temperature change", + self.mac_address, + ) if self.target_temperature_unit == TemperatureUnits.F: if not value.is_integer(): @@ -738,19 +788,50 @@ def set_feature_health(self, value: bool) -> None: self._set_device_status({GreeProp.FEAT_HEALTH: 1 if value else 0}) @property - def feature_sleep(self) -> bool: + def feature_sleep(self) -> SleepMode: """Return the sleep mode state.""" - val1 = self._bool_from_raw_state(GreeProp.FEAT_SLEEP_MODE_SWING) - val2 = self._bool_from_raw_state(GreeProp.FEAT_SLEEP_MODE) - return val1 is True or val2 is True + sleep_enabled = self._bool_from_raw_state(GreeProp.FEAT_SLEEP_MODE) + mode = SleepMode( + self._get_prop_raw(GreeProp.FEAT_SLEEP_MODE_TYPE, SleepMode.disabled.value) + ) + + if sleep_enabled and mode is SleepMode.disabled: + _LOGGER.warning( + "Inconsistent Sleep mode properties. Mode enabled and type disabled" + ) + return SleepMode.normal + + if not sleep_enabled and mode is not SleepMode.disabled: + _LOGGER.warning( + "Inconsistent Sleep mode properties. Mode disabled and type enabled" + ) + return SleepMode.disabled + + return mode + + def set_feature_sleep(self, mode: SleepMode): + """Set the sleep mode state. + + This feature is only available under `Cool` or `Heat` modes. + This feature is incompatible with `Power Saving` and `Smart Heat`, and will force disable them if activated. + """ + + if mode is not SleepMode.disabled and self.operation_mode not in ( + OperationMode.cool, + OperationMode.heat, + ): + raise GreeSleepUnavailable("Sleep is only available in Cool and Heat") + + # Mirror the remote/app functionality + if mode is not SleepMode.disabled: + self.set_feature_energy_saving(False) + self.set_feature_smart_heat(False) - def set_feature_sleep(self, value: bool) -> None: - """Set the sleep mode state.""" self._set_device_status( { - GreeProp.FEAT_SLEEP_MODE: 1 if value else 0, - GreeProp.FEAT_SLEEP_MODE_SWING: 1 if value else 0, + GreeProp.FEAT_SLEEP_MODE: (1 if mode is not SleepMode.disabled else 0), + GreeProp.FEAT_SLEEP_MODE_TYPE: mode.value, } ) @@ -769,7 +850,17 @@ def feature_quiet(self) -> bool: return self._bool_from_raw_state(GreeProp.FEAT_QUIET_MODE) def set_feature_quiet(self, value: bool) -> None: - """Set the quiet mode state.""" + """Set the quiet mode state. + + This mode is ignored if Energy Saving or Smart Heat features are active. + """ + + # Mirror physical behaviour + if value and (self.feature_energy_saving or self.feature_smart_heat): + raise GreeQuietIgnored( + "Quiet ignored because Energy Saving or Smart Heat are active" + ) + self._set_device_status({GreeProp.FEAT_QUIET_MODE: 1 if value else 0}) @property @@ -778,7 +869,26 @@ def feature_turbo(self) -> bool: return self._bool_from_raw_state(GreeProp.FEAT_TURBO_MODE) def set_feature_turbo(self, value: bool) -> None: - """Set the turbo mode state.""" + """Set the turbo mode state. + + This mode is only availabe under `Cool` or `Heat` modes. + This mode is ignored if Energy Saving or Smart Heat features are active. + """ + + if value and self.operation_mode not in ( + OperationMode.cool, + OperationMode.heat, + ): + raise GreeTurboUnavailable( + "Turbo mode is only available under Cool or Heat modes" + ) + + # Mirror physical behaviour + if value and (self.feature_energy_saving or self.feature_smart_heat): + raise GreeTurboIgnored( + "Turbo ignored because Energy Saving or Smart Heat are active" + ) + self._set_device_status({GreeProp.FEAT_TURBO_MODE: 1 if value else 0}) @property @@ -787,7 +897,28 @@ def feature_smart_heat(self) -> bool: return self._bool_from_raw_state(GreeProp.FEAT_SMART_HEAT_8C) def set_feature_smart_heat(self, value: bool) -> None: - """Set the smart heat (8ºC / anti-freeze) mode state.""" + """Set the smart heat (8ºC / anti-freeze) mode state. + + This mode is only availabe under `Heat` mode. + This feature is incompatible with `Sleep` and `Energy Saving`, and will force disable them if activated. + This feature changes fan to `Auto` speed. + The device will ignore the temperature setting. + """ + + if value and self.operation_mode is not OperationMode.heat: + raise GreeSmartHeatUnavailable( + "Smart Heat mode is only available under Heat mode" + ) + + # Mirror physical behaviour + if value: + self.set_feature_sleep(SleepMode.disabled) + self.set_feature_energy_saving(False) + self.set_feature_turbo(False) + self.set_feature_quiet(False) + self.set_fan_speed(FanSpeed.auto) + # TODO: Keep the previous fan speed to apply when the feature is deactivated again + self._set_device_status({GreeProp.FEAT_SMART_HEAT_8C: 1 if value else 0}) @property @@ -796,7 +927,28 @@ def feature_energy_saving(self) -> bool: return self._bool_from_raw_state(GreeProp.FEAT_ENERGY_SAVING) def set_feature_energy_saving(self, value: bool) -> None: - """Set the energy saving mode state.""" + """Set the energy saving mode state. + + This feature is only available under `Cool` mode. + This feature is incompatible with `Sleep` and `Smart Heat`, and will force disable them if activated. + This feature changes fan to `Auto` speed. + The device will ignore the temperature setting. + """ + + if value and self.operation_mode is not OperationMode.cool: + raise GreeEnergySavingUnavailable( + "Energy saving is only available under Cool mode." + ) + + # Mirror the remote/app functionality + if value: + self.set_feature_sleep(SleepMode.disabled) + self.set_feature_smart_heat(False) + self.set_feature_turbo(False) + self.set_feature_quiet(False) + self.set_fan_speed(FanSpeed.auto) + # TODO: Keep the previous fan speed to apply when the feature is deactivated again + self._set_device_status({GreeProp.FEAT_ENERGY_SAVING: 1 if value else 0}) @property diff --git a/custom_components/gree_custom/aiogree/errors.py b/custom_components/gree_custom/aiogree/errors.py index 196ac93..f15c3a5 100644 --- a/custom_components/gree_custom/aiogree/errors.py +++ b/custom_components/gree_custom/aiogree/errors.py @@ -15,3 +15,31 @@ class GreeProtocolError(GreeError): class GreeBindingError(GreeError): """Failed to obtain encryption key.""" + + +class GreeUnsupportedState(GreeError): + """The requested state/feature is not valid or available.""" + + +class GreeSleepUnavailable(GreeUnsupportedState): + """Sleep mode is only available under Cool or Heat modes.""" + + +class GreeEnergySavingUnavailable(GreeUnsupportedState): + """Energy Saving mode is only available under Cool mode.""" + + +class GreeSmartHeatUnavailable(GreeUnsupportedState): + """Smart Heat mode is only available under Heat mode.""" + + +class GreeTurboUnavailable(GreeUnsupportedState): + """Turbo mode is only available under Cool and Heat modes.""" + + +class GreeTurboIgnored(GreeUnsupportedState): + """Turbo mode is ignored when Energy Saving or Smart Heat are enabled.""" + + +class GreeQuietIgnored(GreeUnsupportedState): + """Quiet mode is ignored when Energy Saving or Smart Heat are enabled.""" diff --git a/custom_components/gree_custom/climate.py b/custom_components/gree_custom/climate.py index 6ddb280..8ea852a 100644 --- a/custom_components/gree_custom/climate.py +++ b/custom_components/gree_custom/climate.py @@ -39,6 +39,7 @@ from .aiogree.api import FanSpeed, GreeProp, HorizontalSwingMode, VerticalSwingMode from .aiogree.const import MAX_TEMP_C, MAX_TEMP_F, MIN_TEMP_C, MIN_TEMP_F +from .aiogree.errors import GreeQuietIgnored, GreeTurboIgnored, GreeTurboUnavailable from .const import ( ATTR_EXTERNAL_HUMIDITY_SENSOR, ATTR_EXTERNAL_TEMPERATURE_SENSOR, @@ -695,10 +696,18 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode): def get_fan_mode(self) -> str: """Converts Gree Fan Modes to HA. Accounts for the 2 special modes.""" - if self._attr_fan_modes and GATTR_FEAT_QUIET_MODE in self._attr_fan_modes and self.device.feature_quiet: + if ( + self._attr_fan_modes + and GATTR_FEAT_QUIET_MODE in self._attr_fan_modes + and self.device.feature_quiet + ): return GATTR_FEAT_QUIET_MODE - if self._attr_fan_modes and GATTR_FEAT_TURBO in self._attr_fan_modes and self.device.feature_turbo: + if ( + self._attr_fan_modes + and GATTR_FEAT_TURBO in self._attr_fan_modes + and self.device.feature_turbo + ): return GATTR_FEAT_TURBO return self.device.fan_speed.name @@ -717,22 +726,6 @@ async def async_set_fan_mode(self, fan_mode: str): translation_domain=DOMAIN, translation_key="entity_unavailable" ) - if fan_mode == GATTR_FEAT_TURBO and self._attr_hvac_mode in ( - HVACMode.DRY, - HVACMode.FAN_ONLY, - ): - raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="turbo_availability" - ) - - if fan_mode == GATTR_FEAT_QUIET_MODE and self._attr_hvac_mode not in ( - HVACMode.DRY, - HVACMode.COOL, - ): - raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="quiet_availability" - ) - try: self.device.set_feature_quiet(fan_mode == GATTR_FEAT_QUIET_MODE) self.device.set_feature_turbo(fan_mode == GATTR_FEAT_TURBO) @@ -746,6 +739,22 @@ async def async_set_fan_mode(self, fan_mode: str): self.coordinator.async_update_listeners() await self.coordinator.async_request_refresh() + + except GreeTurboUnavailable as err: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="turbo_availability" + ) from err + + except GreeTurboIgnored as err: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="turbo_ignored" + ) from err + + except GreeQuietIgnored as err: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="quiet_ignored" + ) from err + except Exception as err: _LOGGER.exception("Error in '%s'", "async_set_fan_mode") raise HomeAssistantError( diff --git a/custom_components/gree_custom/manifest.json b/custom_components/gree_custom/manifest.json index 0e2aece..2364d55 100755 --- a/custom_components/gree_custom/manifest.json +++ b/custom_components/gree_custom/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/RobHofmann/HomeAssistant-GreeClimateComponent/issues", "requirements": ["pycryptodome", "asyncio_dgram"], - "version": "4.0.0-alpha.101" + "version": "4.0.0-alpha.102" } diff --git a/custom_components/gree_custom/switch.py b/custom_components/gree_custom/switch.py index 4b53bc8..637dc94 100644 --- a/custom_components/gree_custom/switch.py +++ b/custom_components/gree_custom/switch.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .aiogree.api import GreeProp, OperationMode +from .aiogree.api import GreeProp, OperationMode, SleepMode from .aiogree.device import GreeDevice from .const import ( ATTR_AUTO_LIGHT, @@ -78,16 +78,20 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): translation_key=GATTR_FEAT_SLEEP_MODE, additional_available_func=( lambda device: ( - device.operation_mode - in [OperationMode.cool, OperationMode.dry, OperationMode.heat] + device.operation_mode in [OperationMode.cool, OperationMode.heat] ) ), - value_func=lambda device, _: device.feature_sleep, - set_func=lambda device, _, value: device.set_feature_sleep(value), + value_func=lambda device, _: device.feature_sleep is SleepMode.normal, + set_func=lambda device, _, value: device.set_feature_sleep( + SleepMode.normal if value else SleepMode.disabled + ), ), GreeSwitchDescription( key=GATTR_FEAT_SMART_HEAT_8C, translation_key=GATTR_FEAT_SMART_HEAT_8C, + additional_available_func=( + lambda device: device.operation_mode is OperationMode.heat + ), value_func=lambda device, _: device.feature_smart_heat, set_func=lambda device, _, value: device.set_feature_smart_heat(value), ), @@ -106,6 +110,9 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): GreeSwitchDescription( key=GATTR_FEAT_ENERGY_SAVING, translation_key=GATTR_FEAT_ENERGY_SAVING, + additional_available_func=( + lambda device: device.operation_mode is OperationMode.cool + ), value_func=lambda device, _: device.feature_energy_saving, set_func=lambda device, _, value: device.set_feature_energy_saving(value), ), diff --git a/custom_components/gree_custom/translations/en.json b/custom_components/gree_custom/translations/en.json index 17bf465..7d27fcd 100755 --- a/custom_components/gree_custom/translations/en.json +++ b/custom_components/gree_custom/translations/en.json @@ -301,10 +301,13 @@ }, "exceptions": { "turbo_availability": { - "message": "Turbo mode is not available in Dry and Fan-only modes." + "message": "Turbo mode is only available in Cool and Heat modes." }, - "quiet_availability": { - "message": "Quiet mode is only available in Dry and Cool modes." + "turbo_ignored": { + "message": "Turbo mode is ignored when Energy Saving or Smart Heat are enabled." + }, + "quiet_ignored": { + "message": "Quiet mode is ignored when Energy Saving or Smart Heat are enabled." }, "entity_unavailable": { "message": "The entity is unavailable." diff --git a/custom_components/gree_custom/translations/pt.json b/custom_components/gree_custom/translations/pt.json index 1900450..e3a8e04 100755 --- a/custom_components/gree_custom/translations/pt.json +++ b/custom_components/gree_custom/translations/pt.json @@ -309,10 +309,13 @@ }, "exceptions": { "turbo_availability": { - "message": "Modo Turbo não está disponível nos modos de Secar e Ventilação." + "message": "A função Turbo só está disponível nos modos de Arrefecer ou Aquecer" }, - "quiet_availability": { - "message": "Modo Silencioso apenas disponível nos modos de Secar e Ventilação." + "turbo_ignored": { + "message": "A função Turbo é ignorada quando os modos de Poupança de Energia ou Fora de Casa estão ativos." + }, + "quiet_ignored": { + "message": "A função Silenciosa é ignorada quando os modos de Poupança de Energia ou Fora de Casa estão ativos." }, "entity_unavailable": { "message": "A entidade não está disponível." From bd39d4dafabff85c3b7b50ae7481f35cc97396f7 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Thu, 9 Jul 2026 16:14:41 +0100 Subject: [PATCH 02/10] Show sleep mode active for all sleep modes --- custom_components/gree_custom/switch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/gree_custom/switch.py b/custom_components/gree_custom/switch.py index 637dc94..b2c2a29 100644 --- a/custom_components/gree_custom/switch.py +++ b/custom_components/gree_custom/switch.py @@ -81,7 +81,7 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): device.operation_mode in [OperationMode.cool, OperationMode.heat] ) ), - value_func=lambda device, _: device.feature_sleep is SleepMode.normal, + value_func=lambda device, _: device.feature_sleep is not SleepMode.disabled, set_func=lambda device, _, value: device.set_feature_sleep( SleepMode.normal if value else SleepMode.disabled ), From 7cf437343cfee95e7b6243e8f8066dd56fc0127e Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Thu, 9 Jul 2026 20:14:10 +0100 Subject: [PATCH 03/10] Add support for humidity Control features Uses a select for the mode and a number for the humidity target. As far as I can see, having bothe properties at 0 is invalid and should mean the feature is not supported. Revisit this if there are reported problems. --- custom_components/gree_custom/__init__.py | 1 + custom_components/gree_custom/aiogree/api.py | 17 +- .../gree_custom/aiogree/const.py | 3 + .../gree_custom/aiogree/device.py | 134 ++++++++++++- .../gree_custom/aiogree/errors.py | 12 ++ .../gree_custom/aiogree/helpers.py | 37 +++- custom_components/gree_custom/config_flow.py | 5 +- custom_components/gree_custom/const.py | 5 + custom_components/gree_custom/icons.json | 8 + custom_components/gree_custom/number.py | 178 ++++++++++++++++++ custom_components/gree_custom/select.py | 50 ++++- .../gree_custom/translations/en.json | 21 ++- .../gree_custom/translations/pt.json | 21 ++- 13 files changed, 472 insertions(+), 20 deletions(-) create mode 100644 custom_components/gree_custom/number.py diff --git a/custom_components/gree_custom/__init__.py b/custom_components/gree_custom/__init__.py index 70564f7..e8e3fa4 100755 --- a/custom_components/gree_custom/__init__.py +++ b/custom_components/gree_custom/__init__.py @@ -47,6 +47,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, + Platform.NUMBER, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index df8ecd9..7cc9b8f 100644 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -63,6 +63,10 @@ class GreeProp(StrEnum): FEAT_ANTI_DIRECT_BLOW = "AntiDirectBlow" # use light sensor for unit display FEAT_SENSOR_LIGHT = "LigSen" + # humidity control mode. uses dry under cool mode + FEATURE_HUMIDITY = "Dmod" + # humidity control mode. sets the humidity target for the humidity control mode. (HUM% - 15) / 5 + FEATURE_HUMIDITY_TARGET = "Dwet" # SENSORS # indoor temperature sensor, used to read the current room temperature, if available @@ -119,14 +123,12 @@ class OtherProps(StrEnum): _UNKN_Dfltr = "Dfltr" _UNKN_DFPoint = "DFPoint" _UNKN_DIYGra1PoiAmo = "DIYGra1PoiAmo" - _UNKN_Dmod = "Dmod" _UNKN_DnPLLRSwing = "DnPLLRSwing" _UNKN_DnPRLRSwing = "DnPRLRSwing" _UNKN_DnPUDSwing = "DnPUDSwing" _UNKN_Dpump = "Dpump" _UNKN_DsplySt = "DsplySt" _UNKN_DwatFul = "DwatFul" - _UNKN_Dwet = "Dwet" _UNKN_Elc1Kwh = "Elc1Kwh" _UNKN_ElcAllKwhClr = "ElcAllKwhClr" _UNKN_ElcAllKwhH = "ElcAllKwhH" @@ -403,6 +405,17 @@ class SleepMode(IntEnum): diy = 3 +@unique +class HumidityControlMode(IntEnum): + """Enumeration of the humidty control modes.""" + + disabled = 15 + target_dry = 0 + smart_dry = 2 + # This is only available in dry operation mode + continuous_dry = 1 + + class GreeCommand(IntEnum): """Enumeration of Gree commands.""" diff --git a/custom_components/gree_custom/aiogree/const.py b/custom_components/gree_custom/aiogree/const.py index 9114aa2..fac9e43 100644 --- a/custom_components/gree_custom/aiogree/const.py +++ b/custom_components/gree_custom/aiogree/const.py @@ -6,5 +6,8 @@ MIN_TEMP_F = 61 MAX_TEMP_F = 86 +MIN_HUM_P = 40 +MAX_HUM_P = 80 + DEFAULT_DEVICE_UID = 0 DEFAULT_DEVICE_PORT = 7000 diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index 25a39c4..ff30a14 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -11,6 +11,7 @@ GreeDiscoveredDevice, GreeProp, HorizontalSwingMode, + HumidityControlMode, OperationMode, OtherProps, SleepMode, @@ -23,12 +24,15 @@ gree_try_bind, ) from .cipher import CipherBase, get_cipher -from .const import DEFAULT_DEVICE_UID +from .const import DEFAULT_DEVICE_UID, MIN_HUM_P from .errors import ( GreeBindingError, GreeConnectionError, + GreeContinuousDryUnavailable, GreeEnergySavingUnavailable, GreeError, + GreeHumidityControlTargetUnavailable, + GreeHumidityControlUnavailable, GreeProtocolError, GreeQuietIgnored, GreeSleepUnavailable, @@ -38,6 +42,8 @@ ) from .helpers import ( TempOffsetResolver, + gree_get_target_humidity_p, + gree_get_target_humidity_prop_from_p, gree_get_target_temp_props_from_c, gree_get_target_temp_props_from_f, gree_get_target_temperature_c, @@ -55,6 +61,9 @@ def chunked(iterable, size): yield chunk +ALL_PROPS = [prop for prop in GreeProp] + + class GreeDevice: """Representation of a Gree device.""" @@ -69,6 +78,7 @@ def __init__( uid: int = DEFAULT_DEVICE_UID, max_connection_attempts: int = 5, timeout: int = 10, + capabilities: list[GreeProp] = ALL_PROPS, ) -> None: """Initialize the Gree device.""" @@ -100,6 +110,8 @@ def __init__( self._transport = GreeTransport(ip_addr, port, max_connection_attempts, timeout) + self._uniqueid: str = self._mac_addr + self._encryption_version: EncryptionVersion | None = encryption_version self._encryption_key: str = encryption_key self._cipher: CipherBase | None = None @@ -107,9 +119,9 @@ def __init__( self._raw_state: dict[GreeProp, int] = {} self._new_raw_state: dict[GreeProp, int] = {} + self._capabilities: list[GreeProp] = capabilities self._is_bound: bool = False self._is_available: bool = False - self._uniqueid: str = self._mac_addr self._props_to_update: list[GreeProp] = list(GreeProp) # Don't poll the beeper state @@ -324,7 +336,13 @@ async def push_device_status(self): def _set_device_status(self, props: dict[GreeProp, int]) -> None: """Sets a new local device status. Use 'update_device_status' to update the device.""" - self._new_raw_state.update(props) + + # Don't send props that are not part of the device capabilities + filtered_props = { + prop: value for prop, value in props.items() if self.supports_property(prop) + } + + self._new_raw_state.update(filtered_props) def _bool_from_raw_state(self, prop: GreeProp, default: int = 0) -> bool: prop_value: int | None = self._get_prop_raw(prop, default) @@ -339,7 +357,7 @@ def _remove_unsupported_props(self): # with an empty string, or nothing at all # If that is the case, _state_raw should not contain that property # In case it still has it, we remove it here as well - for p in list(self._props_to_update): + for p in self._props_to_update: if not self.supports_property(p): self._props_to_update.remove(p) self._raw_state.pop(p, None) @@ -379,6 +397,22 @@ def _remove_unsupported_props(self): GreeProp.SENSOR_HUMIDITY, ) + # As far as it is known, both values at 0 is not a valid combination. + # Might need to change this if problems are reported + if ( + GreeProp.FEATURE_HUMIDITY in self._props_to_update + and self._get_prop_raw(GreeProp.FEATURE_HUMIDITY, 0) == 0 + and self._get_prop_raw(GreeProp.FEATURE_HUMIDITY_TARGET, 0) == 0 + ): + self._props_to_update.remove(GreeProp.FEATURE_HUMIDITY) + self._props_to_update.remove(GreeProp.FEATURE_HUMIDITY_TARGET) + self._raw_state.pop(GreeProp.FEATURE_HUMIDITY, None) + self._raw_state.pop(GreeProp.FEATURE_HUMIDITY_TARGET, None) + _LOGGER.debug( + "No longer updating property due to bad value: %s", + (GreeProp.FEATURE_HUMIDITY, GreeProp.FEATURE_HUMIDITY_TARGET), + ) + def _get_prop_raw(self, prop: GreeProp, default: int | None = None) -> int | None: """Get the raw value of a property. If does not exist, returns default.""" if prop not in self._raw_state: @@ -498,7 +532,9 @@ def supports_property(self, property: GreeProp) -> bool: """Returns True if the device endpoint supports the property.""" # 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 property in self._raw_state or property in ( + + supported = property in self._raw_state and property in self._capabilities + return supported or property in ( GreeProp.BEEPER, GreeProp.BEEPER_NEW, ) @@ -633,6 +669,10 @@ def operation_mode(self) -> OperationMode: def set_operation_mode(self, mode: OperationMode): """Sets the device operation mode.""" + + # Disable Humidity Control + self.set_feature_humidity_control(HumidityControlMode.disabled) + self._set_device_status({GreeProp.OP_MODE: mode}) @property @@ -959,3 +999,87 @@ def feature_anti_direct_blow(self) -> bool: def set_feature_anti_direct_blow(self, value: bool) -> None: """Set the anti direct blow mode state.""" self._set_device_status({GreeProp.FEAT_ANTI_DIRECT_BLOW: 1 if value else 0}) + + @property + def feature_humidity_control(self) -> HumidityControlMode: + """Returns the current humidity control mode.""" + + return HumidityControlMode( + self._get_prop_raw( + GreeProp.FEATURE_HUMIDITY, HumidityControlMode.disabled.value + ) + ) + + def set_feature_humidity_control(self, mode: HumidityControlMode) -> None: + """Sets the Humidy Control mode. + + This feature is only available under `Cool` mode. + """ + + if ( + mode + not in (HumidityControlMode.disabled, HumidityControlMode.continuous_dry) + and self.operation_mode is not OperationMode.cool + ): + raise GreeHumidityControlUnavailable( + "Humidity Control is only available in Cool" + ) + + if ( + mode is HumidityControlMode.continuous_dry + and self.operation_mode is not OperationMode.dry + ): + raise GreeContinuousDryUnavailable( + "Continuous Dry is only available in dry operation mode" + ) + + match mode: + case HumidityControlMode.disabled: + target = 0 + + case HumidityControlMode.target_dry: + target = gree_get_target_humidity_prop_from_p(MIN_HUM_P) + + case HumidityControlMode.smart_dry: + target = 3 + + case HumidityControlMode.continuous_dry: + target = 3 + + self._set_device_status( + { + GreeProp.FEATURE_HUMIDITY: mode.value, + GreeProp.FEATURE_HUMIDITY_TARGET: target, + } + ) + + @property + def feature_humidity_control_target(self) -> int: + """Return the current set target humidity value.""" + + raw_value: int = self._get_prop_raw(GreeProp.FEATURE_HUMIDITY_TARGET, 0) # type: ignore + return gree_get_target_humidity_p(raw_value) + + def set_feature_humidity_control_target( + self, humidty_target_percentage: int + ) -> None: + """Sets the target humidity percentage. + + The device only accepts multiples of 5 in a range from 40% to 80%. + """ + + if ( + self.operation_mode is not OperationMode.cool + and self.feature_humidity_control is not HumidityControlMode.target_dry + ): + raise GreeHumidityControlTargetUnavailable( + "Humidity Control with a target humidity is only available in Cool with Normal Dry mode" + ) + + target = gree_get_target_humidity_prop_from_p(humidty_target_percentage) + + self._set_device_status( + { + GreeProp.FEATURE_HUMIDITY_TARGET: target, + } + ) diff --git a/custom_components/gree_custom/aiogree/errors.py b/custom_components/gree_custom/aiogree/errors.py index f15c3a5..6c0ce80 100644 --- a/custom_components/gree_custom/aiogree/errors.py +++ b/custom_components/gree_custom/aiogree/errors.py @@ -43,3 +43,15 @@ class GreeTurboIgnored(GreeUnsupportedState): class GreeQuietIgnored(GreeUnsupportedState): """Quiet mode is ignored when Energy Saving or Smart Heat are enabled.""" + + +class GreeHumidityControlUnavailable(GreeUnsupportedState): + """Humidty Control is only available under Cool mode.""" + + +class GreeHumidityControlTargetUnavailable(GreeUnsupportedState): + """Humidity Control with a target humidity is only available in Cool with Normal Dry mode.""" + + +class GreeContinuousDryUnavailable(GreeUnsupportedState): + """Humidity Control Continuos Dry only available in Dry operation mode.""" diff --git a/custom_components/gree_custom/aiogree/helpers.py b/custom_components/gree_custom/aiogree/helpers.py index 1142f41..fa3089c 100644 --- a/custom_components/gree_custom/aiogree/helpers.py +++ b/custom_components/gree_custom/aiogree/helpers.py @@ -2,7 +2,7 @@ import logging -from .const import MAX_TEMP_C, MAX_TEMP_F, MIN_TEMP_C, MIN_TEMP_F +from .const import MAX_HUM_P, MAX_TEMP_C, MAX_TEMP_F, MIN_HUM_P, MIN_TEMP_C, MIN_TEMP_F TEMSEN_OFFSET = 40 @@ -173,3 +173,38 @@ def gree_get_target_temperature_c(SetTem: int, TemRec: int) -> float: # Returns the original temperature as a float. return SetTem + (0.5 if TemRec else 0.0) + + +def gree_get_target_humidity_prop_from_p(desired_humidty_percentage: int) -> int: + """Calculates the prop value for a given humidty percentage.""" + + if desired_humidty_percentage > MAX_HUM_P: + _LOGGER.warning( + "The desired humidity is greater than allowed. Clamping to highest value: %d > %d", + desired_humidty_percentage, + MAX_HUM_P, + ) + desired_humidty_percentage = MAX_HUM_P + + if desired_humidty_percentage < MIN_HUM_P: + _LOGGER.warning( + "The desired humidity is lower than allowed. Clamping to lowest value: %d < %d", + desired_humidty_percentage, + MIN_HUM_P, + ) + desired_humidty_percentage = MIN_HUM_P + + if desired_humidty_percentage % 5 != 0: + _LOGGER.warning( + "Humidity target %s is not a multiple of 5; rounding to the nearest multiple", + desired_humidty_percentage, + ) + desired_humidty_percentage = round(desired_humidty_percentage / 5) * 5 + + return int((desired_humidty_percentage - 15) / 5) + + +def gree_get_target_humidity_p(Dwet: int) -> int: + """Return a humidty percentage based on the device property value.""" + + return 5 * Dwet + 15 diff --git a/custom_components/gree_custom/config_flow.py b/custom_components/gree_custom/config_flow.py index 8501fe7..4ff18a9 100644 --- a/custom_components/gree_custom/config_flow.py +++ b/custom_components/gree_custom/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Gree integration.""" -from __future__ import annotations - from collections.abc import Mapping from ipaddress import IPv4Address, IPv4Network, ip_address, ip_network import logging @@ -81,6 +79,7 @@ GATTR_FEAT_ENERGY_SAVING, GATTR_FEAT_FRESH_AIR, GATTR_FEAT_HEALTH, + GATTR_FEAT_HUMIDITY, GATTR_FEAT_LIGHT, GATTR_FEAT_QUIET_MODE, GATTR_FEAT_SENSOR_LIGHT, @@ -303,6 +302,8 @@ def build_options_schema( valid_features.append(GATTR_FEAT_ENERGY_SAVING) if device.supports_property(GreeProp.SENSOR_FAULT): valid_features.append(GATTR_FAULTS) + if device.supports_property(GreeProp.FEATURE_HUMIDITY): + valid_features.append(GATTR_FEAT_HUMIDITY) schema.update( { diff --git a/custom_components/gree_custom/const.py b/custom_components/gree_custom/const.py index f9dcbc5..1e29fd6 100755 --- a/custom_components/gree_custom/const.py +++ b/custom_components/gree_custom/const.py @@ -75,6 +75,10 @@ GATTR_FEAT_QUIET_MODE = "quiet" # Turbo mode sets fan speed to the maximum. Fan speed cannot be changed while active and only available in Dry and Cool mode GATTR_FEAT_TURBO = "turbo" +# Humidy Control. Allows dry mode under cooling operation +GATTR_FEAT_HUMIDITY = "humidity_control" +# Humidy Control Target. Sets humidity target for humidity control +GATTR_FEAT_HUMIDITY_TARGET = "humidity_control_target" GATTR_TEMP_UNITS = "temperature_units" GATTR_INDOOR_TEMPERATURE = "indoor_temperature" @@ -102,6 +106,7 @@ GATTR_FEAT_ENERGY_SAVING: GreeProp.FEAT_ENERGY_SAVING, GATTR_FEAT_LIGHT: GreeProp.FEAT_LIGHT, GATTR_FAULTS: GreeProp.SENSOR_FAULT, + GATTR_FEAT_HUMIDITY: GreeProp.FEATURE_HUMIDITY, } # HVAC modes - these come from Home Assistant and are standard diff --git a/custom_components/gree_custom/icons.json b/custom_components/gree_custom/icons.json index fdc9bed..6cda525 100755 --- a/custom_components/gree_custom/icons.json +++ b/custom_components/gree_custom/icons.json @@ -48,6 +48,14 @@ "select": { "temperature_units": { "default": "mdi:thermometer-alert" + }, + "humidity_control": { + "default": "mdi:water-sync" + } + }, + "number": { + "humidity_control_target": { + "default": "mdi:water-percent" } }, "switch": { diff --git a/custom_components/gree_custom/number.py b/custom_components/gree_custom/number.py new file mode 100644 index 0000000..1e90f3d --- /dev/null +++ b/custom_components/gree_custom/number.py @@ -0,0 +1,178 @@ +"""Support for Gree number entities (e.g., target humidty control).""" + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.const import CONF_MAC, PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .aiogree.api import GreeProp, HumidityControlMode, OperationMode +from .aiogree.const import MAX_HUM_P, MIN_HUM_P +from .aiogree.device import GreeDevice +from .const import ( + CONF_ADVANCED, + CONF_DEVICES, + CONF_DISABLE_AVAILABLE_CHECK, + CONF_FEATURES, + CONF_RESTORE_STATES, + DEFAULT_DISABLE_AVAILABLE_CHECK, + DEFAULT_RESTORE_STATES, + DEFAULT_SUPPORTED_FEATURES, + GATTR_FEAT_HUMIDITY, + GATTR_FEAT_HUMIDITY_TARGET, +) +from .coordinator import GreeConfigEntry, GreeCoordinator +from .entity import GreeEntity, GreeEntityDescription + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: GreeConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up switches from a config entry.""" + + entities: list[GreeNumber] = [] + + for d in entry.data.get(CONF_DEVICES, []): + mac = d.get(CONF_MAC, "") + coordinator: GreeCoordinator = entry.runtime_data[mac] + if not coordinator: + _LOGGER.error( + "Cannot create Gree numbers. No coordinator found for device '%s'", + mac, + ) + continue + + descriptions: list[GreeNumberDescription] = [] + + conf_supported_features = d.get(CONF_FEATURES, DEFAULT_SUPPORTED_FEATURES) + if ( + GATTR_FEAT_HUMIDITY in conf_supported_features + and coordinator.device.supports_property(GreeProp.FEATURE_HUMIDITY) + ): + descriptions.append( + GreeNumberDescription( + key=GATTR_FEAT_HUMIDITY_TARGET, + translation_key=GATTR_FEAT_HUMIDITY_TARGET, + device_class=NumberDeviceClass.HUMIDITY, + mode="auto", + native_max_value=MAX_HUM_P, + native_min_value=MIN_HUM_P, + native_step=5, + 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) + ), + additional_available_func=lambda device: ( + device.operation_mode is OperationMode.cool + and device.feature_humidity_control + is HumidityControlMode.target_dry + ), + updates_device=True, + ) + ) + + _LOGGER.debug( + "Adding Select Entities for device '%s': %s", + coordinator.device.mac_address, + [d.key for d in descriptions], + ) + + entities.extend( + GreeNumber( + description, + coordinator, + d.get(CONF_RESTORE_STATES, DEFAULT_RESTORE_STATES), + check_availability=( + not entry.data[CONF_ADVANCED].get( + CONF_DISABLE_AVAILABLE_CHECK, DEFAULT_DISABLE_AVAILABLE_CHECK + ) + ), + ) + for description in descriptions + ) + + async_add_entities(entities) + + +@dataclass(frozen=True, kw_only=True) +class GreeNumberDescription(GreeEntityDescription, NumberEntityDescription): + """Description of a Gree number.""" + + entity_category = None + entity_registry_enabled_default = True + entity_registry_visible_default = True + force_update = False + icon = None + has_entity_name = True + name = None + translation_key = None + translation_placeholders = None + unit_of_measurement = None + max_value: None = None + min_value: None = None + step: None = None + + additional_available_func = lambda _: True # noqa: E731 + value_func: Callable[[GreeDevice], int] + set_func: Callable[[GreeDevice, int], None] + updates_device: bool = True + + +class GreeNumber(GreeEntity, NumberEntity): # pyright: ignore[reportIncompatibleVariableOverride] + """Defines a Gree Number entity.""" + + entity_description: GreeNumberDescription + + def __init__( + self, + description: GreeNumberDescription, + coordinator: GreeCoordinator, + restore_state: bool = True, + check_availability: bool = True, + ) -> None: + """Initialize switch.""" + super().__init__(description, coordinator, restore_state, check_availability) + + self.entity_description = description # pyright: ignore[reportIncompatibleVariableOverride] + _LOGGER.debug( + "Initialized number: %s (check_availability=%s)", + self.unique_id, + self.check_availability, + ) + + @property + def native_value(self) -> int: # pyright: ignore[reportIncompatibleVariableOverride] + """Return the state of the sensor.""" + return self.entity_description.value_func(self.device) + + async def async_set_native_value(self, value: int) -> None: + """Update the current value.""" + if not self.available: + raise HomeAssistantError("Entity unavailable") + + try: + self.entity_description.set_func(self.device, value) + + if self.entity_description.updates_device: + await self.coordinator.push_device_status() + + # notify coordinator listeners of state change so that dependent entities are updated immediately + self.coordinator.async_update_listeners() + + except Exception as err: + raise HomeAssistantError("Failed to turn on switch") from err + + self.async_write_ha_state() diff --git a/custom_components/gree_custom/select.py b/custom_components/gree_custom/select.py index c2b2fff..b7d4d9e 100644 --- a/custom_components/gree_custom/select.py +++ b/custom_components/gree_custom/select.py @@ -2,7 +2,7 @@ from collections.abc import Callable import logging -from typing import Generic, TypeVar +from typing import TypeVar from attr import dataclass @@ -13,15 +13,20 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .aiogree.api import GreeProp, TemperatureUnits +from .aiogree.api import GreeProp, HumidityControlMode, OperationMode, TemperatureUnits from .aiogree.device import GreeDevice +from .aiogree.errors import GreeContinuousDryUnavailable, GreeHumidityControlUnavailable from .const import ( CONF_ADVANCED, CONF_DEVICES, CONF_DISABLE_AVAILABLE_CHECK, + CONF_FEATURES, CONF_RESTORE_STATES, DEFAULT_DISABLE_AVAILABLE_CHECK, DEFAULT_RESTORE_STATES, + DEFAULT_SUPPORTED_FEATURES, + DOMAIN, + GATTR_FEAT_HUMIDITY, GATTR_TEMP_UNITS, ) from .coordinator import GreeConfigEntry, GreeCoordinator @@ -32,6 +37,21 @@ T = TypeVar("T") # T can be any type +def _set_humidity_control_mode(device: GreeDevice, mode: str) -> None: + try: + device.set_feature_humidity_control(HumidityControlMode[mode]) + + except GreeHumidityControlUnavailable as err: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="humidity_mode_unavailable" + ) from err + + except GreeContinuousDryUnavailable as err: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="continuous_dry_unavailable" + ) from err + + async def async_setup_entry( hass: HomeAssistant, entry: GreeConfigEntry, @@ -68,6 +88,25 @@ async def async_setup_entry( ) ) + conf_supported_features = d.get(CONF_FEATURES, DEFAULT_SUPPORTED_FEATURES) + if ( + GATTR_FEAT_HUMIDITY in conf_supported_features + and coordinator.device.supports_property(GreeProp.FEATURE_HUMIDITY) + ): + descriptions.append( + GreeSelectDescription[GreeDevice]( + key=GATTR_FEAT_HUMIDITY, + translation_key=GATTR_FEAT_HUMIDITY, + options=[f"{member.name}" for member in HumidityControlMode], + value_func=lambda device: device.feature_humidity_control.name, + set_func=_set_humidity_control_mode, + additional_available_func=lambda device: ( + device.operation_mode in (OperationMode.cool, OperationMode.dry) + ), + updates_device=True, + ) + ) + _LOGGER.debug( "Adding Select Entities for device '%s': %s", coordinator.device.mac_address, @@ -92,7 +131,7 @@ async def async_setup_entry( @dataclass(frozen=True, kw_only=True) -class GreeSelectDescription(GreeEntityDescription, SelectEntityDescription, Generic[T]): +class GreeSelectDescription[T](GreeEntityDescription, SelectEntityDescription): """Description of a Gree switch.""" additional_available_func = lambda _: True # noqa: E731 @@ -144,11 +183,6 @@ def __init__( self._attr_options, ) - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - _LOGGER.debug("Updating Select Entity for %s", self.device.unique_id) - self._attr_current_option = self.entity_description.value_func(self.device) - @property def current_option(self) -> str | None: # pyright: ignore[reportIncompatibleVariableOverride] """Return the selected entity option to represent the entity state.""" diff --git a/custom_components/gree_custom/translations/en.json b/custom_components/gree_custom/translations/en.json index 7d27fcd..eb26110 100755 --- a/custom_components/gree_custom/translations/en.json +++ b/custom_components/gree_custom/translations/en.json @@ -184,7 +184,8 @@ "anti_direct_blow": "Anti Direct Blow", "powersave": "Energy Saving", "light_sensor": "Display Auto Brightness", - "faults": "Fault Detection" + "faults": "Fault Detection", + "humidity_control": "Humidity Control" } } }, @@ -253,11 +254,23 @@ "number": { "target_temp_step": { "name": "Temperature Step" + }, + "humidity_control_target": { + "name": "Humidity Control Target" } }, "select": { "temperature_units": { "name": "Temperature Units" + }, + "humidity_control": { + "name": "Humidity Control", + "state": { + "disabled": "Disabled", + "target_dry": "Normal Dry", + "smart_dry": "Smart Dry", + "continuous_dry": "Continuous Dry" + } } }, "switch": { @@ -326,6 +339,12 @@ }, "invalid_config_data": { "message": "There was a problem performing the action. The configuration entry has invalid data." + }, + "humidity_mode_unavailable": { + "message": "Humidity Control is only available in Cool mode." + }, + "continuous_dry_unavailable": { + "message": "Continuous Dry is only available in Dry mode." } }, "services": { diff --git a/custom_components/gree_custom/translations/pt.json b/custom_components/gree_custom/translations/pt.json index e3a8e04..d315af8 100755 --- a/custom_components/gree_custom/translations/pt.json +++ b/custom_components/gree_custom/translations/pt.json @@ -184,7 +184,8 @@ "anti_direct_blow": "Anti Sopro Direto", "powersave": "Poupança de Energia", "light_sensor": "Brilho Automático do Visor", - "faults": "Falha de Operação" + "faults": "Falha de Operação", + "humidity_control": "Controlo de Humidade" } } }, @@ -261,11 +262,23 @@ "number": { "target_temp_step": { "name": "Incremento de Temperatura" + }, + "humidity_control_target": { + "name": "Valor de Controlo de Humidade" } }, "select": { "temperature_units": { "name": "Unidade de Temperatura" + }, + "humidity_control": { + "name": "Controlo de Humidade", + "state": { + "disabled": "Desativado", + "target_dry": "Desumidificar", + "smart_dry": "Desumidificar Inteligente", + "continuous_dry": "Desumidificar Contínuo" + } } }, "switch": { @@ -334,6 +347,12 @@ }, "invalid_config_data": { "message": "Ocorreu um erro a executar a ação. A configuração para o dispositivo contém dados inválidos." + }, + "humidity_mode_unavailable": { + "message": "O Controlo de Humidade só está disponível no modo de Arrefecer." + }, + "continuous_dry_unavailable": { + "message": "Desumidificar Contínuo só está disponível no modo de Secar." } }, "services": { From 5e3cd5c0d76e1400fab9698f0512299e11c16c64 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Tue, 14 Jul 2026 12:19:17 +0100 Subject: [PATCH 04/10] Separate continuous mode to its own switch --- custom_components/gree_custom/aiogree/api.py | 2 +- .../gree_custom/aiogree/device.py | 20 +++++---- .../gree_custom/aiogree/errors.py | 2 +- .../gree_custom/aiogree/helpers.py | 44 +++++++++---------- custom_components/gree_custom/entity.py | 2 - custom_components/gree_custom/icons.json | 3 ++ custom_components/gree_custom/number.py | 2 +- custom_components/gree_custom/select.py | 21 ++++++--- custom_components/gree_custom/switch.py | 33 +++++++++++++- .../gree_custom/translations/en.json | 6 ++- .../gree_custom/translations/pt.json | 8 ++-- 11 files changed, 94 insertions(+), 49 deletions(-) diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index 7cc9b8f..1fb1a64 100644 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -407,7 +407,7 @@ class SleepMode(IntEnum): @unique class HumidityControlMode(IntEnum): - """Enumeration of the humidty control modes.""" + """Enumeration of the humidity control modes.""" disabled = 15 target_dry = 0 diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index ff30a14..d527a04 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -61,9 +61,6 @@ def chunked(iterable, size): yield chunk -ALL_PROPS = [prop for prop in GreeProp] - - class GreeDevice: """Representation of a Gree device.""" @@ -78,7 +75,7 @@ def __init__( uid: int = DEFAULT_DEVICE_UID, max_connection_attempts: int = 5, timeout: int = 10, - capabilities: list[GreeProp] = ALL_PROPS, + capabilities: list[GreeProp] | None = None, ) -> None: """Initialize the Gree device.""" @@ -119,7 +116,12 @@ def __init__( self._raw_state: dict[GreeProp, int] = {} self._new_raw_state: dict[GreeProp, int] = {} - self._capabilities: list[GreeProp] = capabilities + + if capabilities is None: + self._capabilities: list[GreeProp] = list(GreeProp) + else: + self._capabilities: list[GreeProp] = capabilities + self._is_bound: bool = False self._is_available: bool = False @@ -670,7 +672,7 @@ def operation_mode(self) -> OperationMode: def set_operation_mode(self, mode: OperationMode): """Sets the device operation mode.""" - # Disable Humidity Control + # Force disable Humidity Control self.set_feature_humidity_control(HumidityControlMode.disabled) self._set_device_status({GreeProp.OP_MODE: mode}) @@ -1057,11 +1059,11 @@ def set_feature_humidity_control(self, mode: HumidityControlMode) -> None: def feature_humidity_control_target(self) -> int: """Return the current set target humidity value.""" - raw_value: int = self._get_prop_raw(GreeProp.FEATURE_HUMIDITY_TARGET, 0) # type: ignore + raw_value: int = self._get_prop_raw(GreeProp.FEATURE_HUMIDITY_TARGET, 0) return gree_get_target_humidity_p(raw_value) def set_feature_humidity_control_target( - self, humidty_target_percentage: int + self, humidity_target_percentage: int ) -> None: """Sets the target humidity percentage. @@ -1076,7 +1078,7 @@ def set_feature_humidity_control_target( "Humidity Control with a target humidity is only available in Cool with Normal Dry mode" ) - target = gree_get_target_humidity_prop_from_p(humidty_target_percentage) + target = gree_get_target_humidity_prop_from_p(humidity_target_percentage) self._set_device_status( { diff --git a/custom_components/gree_custom/aiogree/errors.py b/custom_components/gree_custom/aiogree/errors.py index 6c0ce80..e27c103 100644 --- a/custom_components/gree_custom/aiogree/errors.py +++ b/custom_components/gree_custom/aiogree/errors.py @@ -46,7 +46,7 @@ class GreeQuietIgnored(GreeUnsupportedState): class GreeHumidityControlUnavailable(GreeUnsupportedState): - """Humidty Control is only available under Cool mode.""" + """Humidity Control is only available under Cool mode.""" class GreeHumidityControlTargetUnavailable(GreeUnsupportedState): diff --git a/custom_components/gree_custom/aiogree/helpers.py b/custom_components/gree_custom/aiogree/helpers.py index fa3089c..bf06a8b 100644 --- a/custom_components/gree_custom/aiogree/helpers.py +++ b/custom_components/gree_custom/aiogree/helpers.py @@ -60,7 +60,7 @@ def _check(self) -> None: penalty_no = self._penalty(lo, hi) penalty_off = self._penalty(lo - self._offset, hi - self._offset) if penalty_no == penalty_off: - return # still ambiguous – keep collecting data + return # still ambiguous - keep collecting data self._has_offset = penalty_off < penalty_no def _penalty(self, lo: float, hi: float) -> float: @@ -93,8 +93,8 @@ def gree_get_target_temp_props_from_f(desired_temp_f: int) -> tuple[int, int]: desired_temp_f = MIN_TEMP_F celsius = (desired_temp_f - 32.0) * 5.0 / 9.0 - SetTem = round(celsius) - TemRec = int((celsius - SetTem) > -0.001) + SetTem = round(celsius) # noqa: N806 + TemRec = int((celsius - SetTem) > -0.001) # noqa: N806 return SetTem, TemRec @@ -118,24 +118,24 @@ def gree_get_target_temp_props_from_c(desired_temp_c: float) -> tuple[int, int]: ) desired_temp_c = MIN_TEMP_C - # Encode any floating‐point temperature T into: + # Encode any floating-point temperature T into: # ‣ temp_int: the integer (°C) portion of the nearest 0.0/0.5 step, # ‣ half_bit: 1 if the nearest step has a ".5", else 0. # This "finds the closest multiple of 0.5" to T, then: # n = round(T * 2) # temp_int = n >> 1 (i.e. floor(n/2)) - # half_bit = n & 1 (1 if it's an odd half‐step) + # half_bit = n & 1 (1 if it's an odd half-step) # 1) Compute "twice T" and round to nearest integer: # math.floor(T * 2 + 0.5) is equivalent to rounding ties upward. - n = int(round(desired_temp_c * 2)) + n: int = round(desired_temp_c * 2) # 2) The low bit of n says ".5" (odd) versus ".0" (even): - TemRec = n & 1 + TemRec = n & 1 # noqa: N806 - # 3) Shifting right by 1 gives floor(n/2), i.e. the integer °C of that nearest half‐step: - SetTem = n >> 1 + # 3) Shifting right by 1 gives floor(n/2), i.e. the integer °C of that nearest half-step: + SetTem = n >> 1 # noqa: N806 return SetTem, TemRec @@ -175,36 +175,36 @@ def gree_get_target_temperature_c(SetTem: int, TemRec: int) -> float: return SetTem + (0.5 if TemRec else 0.0) -def gree_get_target_humidity_prop_from_p(desired_humidty_percentage: int) -> int: - """Calculates the prop value for a given humidty percentage.""" +def gree_get_target_humidity_prop_from_p(desired_humidity_percentage: int) -> int: + """Calculates the prop value for a given humidity percentage.""" - if desired_humidty_percentage > MAX_HUM_P: + if desired_humidity_percentage > MAX_HUM_P: _LOGGER.warning( "The desired humidity is greater than allowed. Clamping to highest value: %d > %d", - desired_humidty_percentage, + desired_humidity_percentage, MAX_HUM_P, ) - desired_humidty_percentage = MAX_HUM_P + desired_humidity_percentage = MAX_HUM_P - if desired_humidty_percentage < MIN_HUM_P: + if desired_humidity_percentage < MIN_HUM_P: _LOGGER.warning( "The desired humidity is lower than allowed. Clamping to lowest value: %d < %d", - desired_humidty_percentage, + desired_humidity_percentage, MIN_HUM_P, ) - desired_humidty_percentage = MIN_HUM_P + desired_humidity_percentage = MIN_HUM_P - if desired_humidty_percentage % 5 != 0: + if desired_humidity_percentage % 5 != 0: _LOGGER.warning( "Humidity target %s is not a multiple of 5; rounding to the nearest multiple", - desired_humidty_percentage, + desired_humidity_percentage, ) - desired_humidty_percentage = round(desired_humidty_percentage / 5) * 5 + desired_humidity_percentage = round(desired_humidity_percentage / 5) * 5 - return int((desired_humidty_percentage - 15) / 5) + return int((desired_humidity_percentage - 15) / 5) def gree_get_target_humidity_p(Dwet: int) -> int: - """Return a humidty percentage based on the device property value.""" + """Return a humidity percentage based on the device property value.""" return 5 * Dwet + 15 diff --git a/custom_components/gree_custom/entity.py b/custom_components/gree_custom/entity.py index f8c048a..02b9a24 100755 --- a/custom_components/gree_custom/entity.py +++ b/custom_components/gree_custom/entity.py @@ -1,7 +1,5 @@ """Base entity for Gree integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass, field diff --git a/custom_components/gree_custom/icons.json b/custom_components/gree_custom/icons.json index 6cda525..bb6e46e 100755 --- a/custom_components/gree_custom/icons.json +++ b/custom_components/gree_custom/icons.json @@ -94,6 +94,9 @@ }, "beeper": { "default": "mdi:volume-high" + }, + "humidity_control": { + "default": "mdi:chevron-double-down" } } }, diff --git a/custom_components/gree_custom/number.py b/custom_components/gree_custom/number.py index 1e90f3d..0ef73c5 100644 --- a/custom_components/gree_custom/number.py +++ b/custom_components/gree_custom/number.py @@ -1,4 +1,4 @@ -"""Support for Gree number entities (e.g., target humidty control).""" +"""Support for Gree number entities (e.g., target humidity control).""" from collections.abc import Callable from dataclasses import dataclass diff --git a/custom_components/gree_custom/select.py b/custom_components/gree_custom/select.py index b7d4d9e..819470c 100644 --- a/custom_components/gree_custom/select.py +++ b/custom_components/gree_custom/select.py @@ -15,7 +15,7 @@ from .aiogree.api import GreeProp, HumidityControlMode, OperationMode, TemperatureUnits from .aiogree.device import GreeDevice -from .aiogree.errors import GreeContinuousDryUnavailable, GreeHumidityControlUnavailable +from .aiogree.errors import GreeHumidityControlUnavailable from .const import ( CONF_ADVANCED, CONF_DEVICES, @@ -46,10 +46,13 @@ def _set_humidity_control_mode(device: GreeDevice, mode: str) -> None: translation_domain=DOMAIN, translation_key="humidity_mode_unavailable" ) from err - except GreeContinuousDryUnavailable as err: - raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="continuous_dry_unavailable" - ) from err + +def _get_humidity_control_mode(device: GreeDevice) -> str: + # Get the mode from the device and ignore the continuous dry + hum_mode = device.feature_humidity_control + if hum_mode == HumidityControlMode.continuous_dry: + hum_mode = HumidityControlMode.disabled + return hum_mode.name async def async_setup_entry( @@ -97,8 +100,12 @@ async def async_setup_entry( GreeSelectDescription[GreeDevice]( key=GATTR_FEAT_HUMIDITY, translation_key=GATTR_FEAT_HUMIDITY, - options=[f"{member.name}" for member in HumidityControlMode], - value_func=lambda device: device.feature_humidity_control.name, + options=[ + member.name + for member in HumidityControlMode + if member != HumidityControlMode.continuous_dry + ], + value_func=_get_humidity_control_mode, set_func=_set_humidity_control_mode, additional_available_func=lambda device: ( device.operation_mode in (OperationMode.cool, OperationMode.dry) diff --git a/custom_components/gree_custom/switch.py b/custom_components/gree_custom/switch.py index b2c2a29..e5e9f33 100644 --- a/custom_components/gree_custom/switch.py +++ b/custom_components/gree_custom/switch.py @@ -16,8 +16,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .aiogree.api import GreeProp, OperationMode, SleepMode +from .aiogree.api import GreeProp, HumidityControlMode, OperationMode, SleepMode from .aiogree.device import GreeDevice +from .aiogree.errors import GreeContinuousDryUnavailable from .const import ( ATTR_AUTO_LIGHT, ATTR_AUTO_XFAN, @@ -30,11 +31,13 @@ DEFAULT_DISABLE_AVAILABLE_CHECK, DEFAULT_RESTORE_STATES, DEFAULT_SUPPORTED_FEATURES, + DOMAIN, GATTR_ANTI_DIRECT_BLOW, GATTR_BEEPER, GATTR_FEAT_ENERGY_SAVING, GATTR_FEAT_FRESH_AIR, GATTR_FEAT_HEALTH, + GATTR_FEAT_HUMIDITY, GATTR_FEAT_LIGHT, GATTR_FEAT_SENSOR_LIGHT, GATTR_FEAT_SLEEP_MODE, @@ -47,6 +50,22 @@ _LOGGER = logging.getLogger(__name__) +def _set_humidity_control_continuous( + device: GreeDevice, coordinator: GreeCoordinator, state: bool +) -> None: + try: + device.set_feature_humidity_control( + HumidityControlMode.continuous_dry + if state + else HumidityControlMode.disabled + ) + + except GreeContinuousDryUnavailable as err: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="continuous_dry_unavailable" + ) from err + + @dataclass(frozen=True, kw_only=True) class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): """Description of a Gree switch.""" @@ -116,6 +135,18 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): value_func=lambda device, _: device.feature_energy_saving, set_func=lambda device, _, value: device.set_feature_energy_saving(value), ), + GreeSwitchDescription( + key=GATTR_FEAT_HUMIDITY, + translation_key=GATTR_FEAT_HUMIDITY, + value_func=lambda device, _: ( + device.feature_humidity_control == HumidityControlMode.continuous_dry + ), + set_func=_set_humidity_control_continuous, + additional_available_func=( + lambda device: device.operation_mode is OperationMode.dry + ), + updates_device=True, + ), GreeSwitchDescription( key=GATTR_FEAT_LIGHT, translation_key=GATTR_FEAT_LIGHT, diff --git a/custom_components/gree_custom/translations/en.json b/custom_components/gree_custom/translations/en.json index eb26110..fe15df9 100755 --- a/custom_components/gree_custom/translations/en.json +++ b/custom_components/gree_custom/translations/en.json @@ -268,8 +268,7 @@ "state": { "disabled": "Disabled", "target_dry": "Normal Dry", - "smart_dry": "Smart Dry", - "continuous_dry": "Continuous Dry" + "smart_dry": "Smart Dry" } } }, @@ -309,6 +308,9 @@ }, "beeper": { "name": "Beeper" + }, + "humidity_control": { + "name": "Continuous Dry" } } }, diff --git a/custom_components/gree_custom/translations/pt.json b/custom_components/gree_custom/translations/pt.json index d315af8..0a7d613 100755 --- a/custom_components/gree_custom/translations/pt.json +++ b/custom_components/gree_custom/translations/pt.json @@ -275,9 +275,8 @@ "name": "Controlo de Humidade", "state": { "disabled": "Desativado", - "target_dry": "Desumidificar", - "smart_dry": "Desumidificar Inteligente", - "continuous_dry": "Desumidificar Contínuo" + "target_dry": "Secar", + "smart_dry": "Secagem Inteligente" } } }, @@ -317,6 +316,9 @@ }, "beeper": { "name": "Aviso Sonoro" + }, + "humidity_control": { + "name": "Secagem Contínua" } } }, From 5d98b2d6c12ce873448db5e4879c08fa14afa016 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Mon, 20 Jul 2026 11:56:36 +0100 Subject: [PATCH 05/10] Revert: Move continuous back to Humidity Control select This makes more sense as Humity Control as 3 modes and is available in both Dry and Cool. --- custom_components/gree_custom/aiogree/api.py | 7 +- .../gree_custom/aiogree/const.py | 7 +- .../gree_custom/aiogree/device.py | 72 +++++++++++++------ .../gree_custom/aiogree/errors.py | 12 ++-- .../gree_custom/aiogree/helpers.py | 18 ++--- custom_components/gree_custom/config_flow.py | 2 +- custom_components/gree_custom/const.py | 2 +- custom_components/gree_custom/icons.json | 3 - custom_components/gree_custom/number.py | 36 ++++++++-- custom_components/gree_custom/select.py | 30 ++++---- custom_components/gree_custom/switch.py | 33 +-------- .../gree_custom/translations/en.json | 11 +-- .../gree_custom/translations/pt.json | 13 ++-- 13 files changed, 138 insertions(+), 108 deletions(-) diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index 1fb1a64..7e590a4 100644 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -64,7 +64,7 @@ class GreeProp(StrEnum): # use light sensor for unit display FEAT_SENSOR_LIGHT = "LigSen" # humidity control mode. uses dry under cool mode - FEATURE_HUMIDITY = "Dmod" + FEATURE_HUMIDITY_CONTROL = "Dmod" # humidity control mode. sets the humidity target for the humidity control mode. (HUM% - 15) / 5 FEATURE_HUMIDITY_TARGET = "Dwet" @@ -411,9 +411,8 @@ class HumidityControlMode(IntEnum): disabled = 15 target_dry = 0 - smart_dry = 2 - # This is only available in dry operation mode - continuous_dry = 1 + continuous_dry = 1 # This is only available in dry operation mode + smart_dry = 2 # This is only available in cool operation mode class GreeCommand(IntEnum): diff --git a/custom_components/gree_custom/aiogree/const.py b/custom_components/gree_custom/aiogree/const.py index fac9e43..ec7208e 100644 --- a/custom_components/gree_custom/aiogree/const.py +++ b/custom_components/gree_custom/aiogree/const.py @@ -6,8 +6,11 @@ MIN_TEMP_F = 61 MAX_TEMP_F = 86 -MIN_HUM_P = 40 -MAX_HUM_P = 80 +MIN_HUM_COOL_P = 40 +MAX_HUM_COOL_P = 80 + +MIN_HUM_DRY_P = 30 +MAX_HUM_DRY_P = 70 DEFAULT_DEVICE_UID = 0 DEFAULT_DEVICE_PORT = 7000 diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index d527a04..d3c6cd4 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -24,7 +24,13 @@ gree_try_bind, ) from .cipher import CipherBase, get_cipher -from .const import DEFAULT_DEVICE_UID, MIN_HUM_P +from .const import ( + DEFAULT_DEVICE_UID, + MAX_HUM_COOL_P, + MAX_HUM_DRY_P, + MIN_HUM_COOL_P, + MIN_HUM_DRY_P, +) from .errors import ( GreeBindingError, GreeConnectionError, @@ -36,6 +42,7 @@ GreeProtocolError, GreeQuietIgnored, GreeSleepUnavailable, + GreeSmartDryUnavailable, GreeSmartHeatUnavailable, GreeTurboIgnored, GreeTurboUnavailable, @@ -402,17 +409,17 @@ def _remove_unsupported_props(self): # As far as it is known, both values at 0 is not a valid combination. # Might need to change this if problems are reported if ( - GreeProp.FEATURE_HUMIDITY in self._props_to_update - and self._get_prop_raw(GreeProp.FEATURE_HUMIDITY, 0) == 0 + GreeProp.FEATURE_HUMIDITY_CONTROL in self._props_to_update + and self._get_prop_raw(GreeProp.FEATURE_HUMIDITY_CONTROL, 0) == 0 and self._get_prop_raw(GreeProp.FEATURE_HUMIDITY_TARGET, 0) == 0 ): - self._props_to_update.remove(GreeProp.FEATURE_HUMIDITY) + self._props_to_update.remove(GreeProp.FEATURE_HUMIDITY_CONTROL) self._props_to_update.remove(GreeProp.FEATURE_HUMIDITY_TARGET) - self._raw_state.pop(GreeProp.FEATURE_HUMIDITY, None) + self._raw_state.pop(GreeProp.FEATURE_HUMIDITY_CONTROL, None) self._raw_state.pop(GreeProp.FEATURE_HUMIDITY_TARGET, None) _LOGGER.debug( "No longer updating property due to bad value: %s", - (GreeProp.FEATURE_HUMIDITY, GreeProp.FEATURE_HUMIDITY_TARGET), + (GreeProp.FEATURE_HUMIDITY_CONTROL, GreeProp.FEATURE_HUMIDITY_TARGET), ) def _get_prop_raw(self, prop: GreeProp, default: int | None = None) -> int | None: @@ -1008,23 +1015,31 @@ def feature_humidity_control(self) -> HumidityControlMode: return HumidityControlMode( self._get_prop_raw( - GreeProp.FEATURE_HUMIDITY, HumidityControlMode.disabled.value + GreeProp.FEATURE_HUMIDITY_CONTROL, HumidityControlMode.disabled.value ) ) def set_feature_humidity_control(self, mode: HumidityControlMode) -> None: """Sets the Humidy Control mode. - This feature is only available under `Cool` mode. + HumidityControlMode.smart_dry is only available under `Cool` mode. + HumidityControlMode.continuous_dry is only available under `Dry` mode. """ + if mode != HumidityControlMode.disabled and self.operation_mode not in ( + OperationMode.cool, + OperationMode.dry, + ): + raise GreeHumidityControlUnavailable( + "Humidity Control is only available in Cool and Dry modes" + ) + if ( - mode - not in (HumidityControlMode.disabled, HumidityControlMode.continuous_dry) + mode == HumidityControlMode.smart_dry and self.operation_mode is not OperationMode.cool ): - raise GreeHumidityControlUnavailable( - "Humidity Control is only available in Cool" + raise GreeSmartDryUnavailable( + "Smart Dry is only available in Cool operation mode" ) if ( @@ -1032,7 +1047,7 @@ def set_feature_humidity_control(self, mode: HumidityControlMode) -> None: and self.operation_mode is not OperationMode.dry ): raise GreeContinuousDryUnavailable( - "Continuous Dry is only available in dry operation mode" + "Continuous Dry is only available in Dry operation mode" ) match mode: @@ -1040,17 +1055,24 @@ def set_feature_humidity_control(self, mode: HumidityControlMode) -> None: target = 0 case HumidityControlMode.target_dry: - target = gree_get_target_humidity_prop_from_p(MIN_HUM_P) + if self.operation_mode == OperationMode.cool: + target = gree_get_target_humidity_prop_from_p( + MIN_HUM_COOL_P, MIN_HUM_COOL_P, MAX_HUM_COOL_P + ) + else: + target = gree_get_target_humidity_prop_from_p( + MIN_HUM_DRY_P, MIN_HUM_DRY_P, MAX_HUM_DRY_P + ) case HumidityControlMode.smart_dry: - target = 3 + target = 3 # It's possible the device ignores this value in this mode case HumidityControlMode.continuous_dry: - target = 3 + target = 3 # It's possible the device ignores this value in this mode self._set_device_status( { - GreeProp.FEATURE_HUMIDITY: mode.value, + GreeProp.FEATURE_HUMIDITY_CONTROL: mode.value, GreeProp.FEATURE_HUMIDITY_TARGET: target, } ) @@ -1070,15 +1092,19 @@ def set_feature_humidity_control_target( The device only accepts multiples of 5 in a range from 40% to 80%. """ - if ( - self.operation_mode is not OperationMode.cool - and self.feature_humidity_control is not HumidityControlMode.target_dry - ): + if self.feature_humidity_control is not HumidityControlMode.target_dry: raise GreeHumidityControlTargetUnavailable( - "Humidity Control with a target humidity is only available in Cool with Normal Dry mode" + "Humidity Control with a target humidity is only available in Normal Dry mode" ) - target = gree_get_target_humidity_prop_from_p(humidity_target_percentage) + if self.operation_mode == OperationMode.cool: + target = gree_get_target_humidity_prop_from_p( + humidity_target_percentage, MIN_HUM_COOL_P, MAX_HUM_COOL_P + ) + else: + target = gree_get_target_humidity_prop_from_p( + humidity_target_percentage, MIN_HUM_DRY_P, MAX_HUM_DRY_P + ) self._set_device_status( { diff --git a/custom_components/gree_custom/aiogree/errors.py b/custom_components/gree_custom/aiogree/errors.py index e27c103..f288fd8 100644 --- a/custom_components/gree_custom/aiogree/errors.py +++ b/custom_components/gree_custom/aiogree/errors.py @@ -49,9 +49,13 @@ class GreeHumidityControlUnavailable(GreeUnsupportedState): """Humidity Control is only available under Cool mode.""" -class GreeHumidityControlTargetUnavailable(GreeUnsupportedState): - """Humidity Control with a target humidity is only available in Cool with Normal Dry mode.""" +class GreeContinuousDryUnavailable(GreeUnsupportedState): + """Humidity Control Continuous Dry only available in Dry operation mode.""" -class GreeContinuousDryUnavailable(GreeUnsupportedState): - """Humidity Control Continuos Dry only available in Dry operation mode.""" +class GreeSmartDryUnavailable(GreeUnsupportedState): + """Humidity Control Smart Dry only available in Cool operation mode.""" + + +class GreeHumidityControlTargetUnavailable(GreeUnsupportedState): + """Humidity Control with a target humidity is only available in Cool with Normal Dry mode.""" diff --git a/custom_components/gree_custom/aiogree/helpers.py b/custom_components/gree_custom/aiogree/helpers.py index bf06a8b..e88f862 100644 --- a/custom_components/gree_custom/aiogree/helpers.py +++ b/custom_components/gree_custom/aiogree/helpers.py @@ -2,7 +2,7 @@ import logging -from .const import MAX_HUM_P, MAX_TEMP_C, MAX_TEMP_F, MIN_HUM_P, MIN_TEMP_C, MIN_TEMP_F +from .const import MAX_TEMP_C, MAX_TEMP_F, MIN_TEMP_C, MIN_TEMP_F TEMSEN_OFFSET = 40 @@ -175,24 +175,26 @@ def gree_get_target_temperature_c(SetTem: int, TemRec: int) -> float: return SetTem + (0.5 if TemRec else 0.0) -def gree_get_target_humidity_prop_from_p(desired_humidity_percentage: int) -> int: +def gree_get_target_humidity_prop_from_p( + desired_humidity_percentage: int, min_val: int, max_val: int +) -> int: """Calculates the prop value for a given humidity percentage.""" - if desired_humidity_percentage > MAX_HUM_P: + if desired_humidity_percentage > max_val: _LOGGER.warning( "The desired humidity is greater than allowed. Clamping to highest value: %d > %d", desired_humidity_percentage, - MAX_HUM_P, + max_val, ) - desired_humidity_percentage = MAX_HUM_P + desired_humidity_percentage = max_val - if desired_humidity_percentage < MIN_HUM_P: + if desired_humidity_percentage < min_val: _LOGGER.warning( "The desired humidity is lower than allowed. Clamping to lowest value: %d < %d", desired_humidity_percentage, - MIN_HUM_P, + min_val, ) - desired_humidity_percentage = MIN_HUM_P + desired_humidity_percentage = min_val if desired_humidity_percentage % 5 != 0: _LOGGER.warning( diff --git a/custom_components/gree_custom/config_flow.py b/custom_components/gree_custom/config_flow.py index 4ff18a9..d5d7a0b 100644 --- a/custom_components/gree_custom/config_flow.py +++ b/custom_components/gree_custom/config_flow.py @@ -302,7 +302,7 @@ def build_options_schema( valid_features.append(GATTR_FEAT_ENERGY_SAVING) if device.supports_property(GreeProp.SENSOR_FAULT): valid_features.append(GATTR_FAULTS) - if device.supports_property(GreeProp.FEATURE_HUMIDITY): + if device.supports_property(GreeProp.FEATURE_HUMIDITY_CONTROL): valid_features.append(GATTR_FEAT_HUMIDITY) schema.update( diff --git a/custom_components/gree_custom/const.py b/custom_components/gree_custom/const.py index 1e29fd6..a801f30 100755 --- a/custom_components/gree_custom/const.py +++ b/custom_components/gree_custom/const.py @@ -106,7 +106,7 @@ GATTR_FEAT_ENERGY_SAVING: GreeProp.FEAT_ENERGY_SAVING, GATTR_FEAT_LIGHT: GreeProp.FEAT_LIGHT, GATTR_FAULTS: GreeProp.SENSOR_FAULT, - GATTR_FEAT_HUMIDITY: GreeProp.FEATURE_HUMIDITY, + GATTR_FEAT_HUMIDITY: GreeProp.FEATURE_HUMIDITY_CONTROL, } # HVAC modes - these come from Home Assistant and are standard diff --git a/custom_components/gree_custom/icons.json b/custom_components/gree_custom/icons.json index bb6e46e..6cda525 100755 --- a/custom_components/gree_custom/icons.json +++ b/custom_components/gree_custom/icons.json @@ -94,9 +94,6 @@ }, "beeper": { "default": "mdi:volume-high" - }, - "humidity_control": { - "default": "mdi:chevron-double-down" } } }, diff --git a/custom_components/gree_custom/number.py b/custom_components/gree_custom/number.py index 0ef73c5..94de0c7 100644 --- a/custom_components/gree_custom/number.py +++ b/custom_components/gree_custom/number.py @@ -15,7 +15,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .aiogree.api import GreeProp, HumidityControlMode, OperationMode -from .aiogree.const import MAX_HUM_P, MIN_HUM_P +from .aiogree.const import MAX_HUM_COOL_P, MAX_HUM_DRY_P, MIN_HUM_COOL_P, MIN_HUM_DRY_P from .aiogree.device import GreeDevice from .const import ( CONF_ADVANCED, @@ -59,7 +59,7 @@ async def async_setup_entry( conf_supported_features = d.get(CONF_FEATURES, DEFAULT_SUPPORTED_FEATURES) if ( GATTR_FEAT_HUMIDITY in conf_supported_features - and coordinator.device.supports_property(GreeProp.FEATURE_HUMIDITY) + and coordinator.device.supports_property(GreeProp.FEATURE_HUMIDITY_CONTROL) ): descriptions.append( GreeNumberDescription( @@ -67,8 +67,6 @@ async def async_setup_entry( translation_key=GATTR_FEAT_HUMIDITY_TARGET, device_class=NumberDeviceClass.HUMIDITY, mode="auto", - native_max_value=MAX_HUM_P, - native_min_value=MIN_HUM_P, native_step=5, native_unit_of_measurement=PERCENTAGE, value_func=lambda device: device.feature_humidity_control_target, @@ -76,10 +74,20 @@ async def async_setup_entry( device.set_feature_humidity_control_target(value) ), additional_available_func=lambda device: ( - device.operation_mode is OperationMode.cool + device.operation_mode in (OperationMode.cool, OperationMode.dry) and device.feature_humidity_control is HumidityControlMode.target_dry ), + min_func=lambda device: ( + MIN_HUM_COOL_P + if device.operation_mode == OperationMode.cool + else MIN_HUM_DRY_P + ), + max_func=lambda device: ( + MAX_HUM_COOL_P + if device.operation_mode == OperationMode.cool + else MAX_HUM_DRY_P + ), updates_device=True, ) ) @@ -128,6 +136,8 @@ class GreeNumberDescription(GreeEntityDescription, NumberEntityDescription): additional_available_func = lambda _: True # noqa: E731 value_func: Callable[[GreeDevice], int] set_func: Callable[[GreeDevice, int], None] + min_func: Callable[[GreeDevice], int] | None = None + max_func: Callable[[GreeDevice], int] | None = None updates_device: bool = True @@ -153,6 +163,22 @@ def __init__( self.check_availability, ) + @property + def native_min_value(self) -> float: + """Return the minimum allowed value.""" + if self.entity_description.min_func is not None: + return self.entity_description.min_func(self.device) + + return self.entity_description.native_min_value + + @property + def native_max_value(self) -> float: + """Return the maximum allowed value.""" + if self.entity_description.max_func is not None: + return self.entity_description.max_func(self.device) + + return self.entity_description.native_max_value + @property def native_value(self) -> int: # pyright: ignore[reportIncompatibleVariableOverride] """Return the state of the sensor.""" diff --git a/custom_components/gree_custom/select.py b/custom_components/gree_custom/select.py index 819470c..a3047b5 100644 --- a/custom_components/gree_custom/select.py +++ b/custom_components/gree_custom/select.py @@ -15,7 +15,11 @@ from .aiogree.api import GreeProp, HumidityControlMode, OperationMode, TemperatureUnits from .aiogree.device import GreeDevice -from .aiogree.errors import GreeHumidityControlUnavailable +from .aiogree.errors import ( + GreeContinuousDryUnavailable, + GreeHumidityControlUnavailable, + GreeSmartDryUnavailable, +) from .const import ( CONF_ADVANCED, CONF_DEVICES, @@ -46,13 +50,15 @@ def _set_humidity_control_mode(device: GreeDevice, mode: str) -> None: translation_domain=DOMAIN, translation_key="humidity_mode_unavailable" ) from err + except GreeSmartDryUnavailable as err: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="smart_dry_unavailable" + ) from err -def _get_humidity_control_mode(device: GreeDevice) -> str: - # Get the mode from the device and ignore the continuous dry - hum_mode = device.feature_humidity_control - if hum_mode == HumidityControlMode.continuous_dry: - hum_mode = HumidityControlMode.disabled - return hum_mode.name + except GreeContinuousDryUnavailable as err: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="continuous_dry_unavailable" + ) from err async def async_setup_entry( @@ -94,18 +100,14 @@ async def async_setup_entry( conf_supported_features = d.get(CONF_FEATURES, DEFAULT_SUPPORTED_FEATURES) if ( GATTR_FEAT_HUMIDITY in conf_supported_features - and coordinator.device.supports_property(GreeProp.FEATURE_HUMIDITY) + and coordinator.device.supports_property(GreeProp.FEATURE_HUMIDITY_CONTROL) ): descriptions.append( GreeSelectDescription[GreeDevice]( key=GATTR_FEAT_HUMIDITY, translation_key=GATTR_FEAT_HUMIDITY, - options=[ - member.name - for member in HumidityControlMode - if member != HumidityControlMode.continuous_dry - ], - value_func=_get_humidity_control_mode, + options=[member.name for member in HumidityControlMode], + value_func=lambda device: device.feature_humidity_control, set_func=_set_humidity_control_mode, additional_available_func=lambda device: ( device.operation_mode in (OperationMode.cool, OperationMode.dry) diff --git a/custom_components/gree_custom/switch.py b/custom_components/gree_custom/switch.py index e5e9f33..b2c2a29 100644 --- a/custom_components/gree_custom/switch.py +++ b/custom_components/gree_custom/switch.py @@ -16,9 +16,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .aiogree.api import GreeProp, HumidityControlMode, OperationMode, SleepMode +from .aiogree.api import GreeProp, OperationMode, SleepMode from .aiogree.device import GreeDevice -from .aiogree.errors import GreeContinuousDryUnavailable from .const import ( ATTR_AUTO_LIGHT, ATTR_AUTO_XFAN, @@ -31,13 +30,11 @@ DEFAULT_DISABLE_AVAILABLE_CHECK, DEFAULT_RESTORE_STATES, DEFAULT_SUPPORTED_FEATURES, - DOMAIN, GATTR_ANTI_DIRECT_BLOW, GATTR_BEEPER, GATTR_FEAT_ENERGY_SAVING, GATTR_FEAT_FRESH_AIR, GATTR_FEAT_HEALTH, - GATTR_FEAT_HUMIDITY, GATTR_FEAT_LIGHT, GATTR_FEAT_SENSOR_LIGHT, GATTR_FEAT_SLEEP_MODE, @@ -50,22 +47,6 @@ _LOGGER = logging.getLogger(__name__) -def _set_humidity_control_continuous( - device: GreeDevice, coordinator: GreeCoordinator, state: bool -) -> None: - try: - device.set_feature_humidity_control( - HumidityControlMode.continuous_dry - if state - else HumidityControlMode.disabled - ) - - except GreeContinuousDryUnavailable as err: - raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="continuous_dry_unavailable" - ) from err - - @dataclass(frozen=True, kw_only=True) class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): """Description of a Gree switch.""" @@ -135,18 +116,6 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): value_func=lambda device, _: device.feature_energy_saving, set_func=lambda device, _, value: device.set_feature_energy_saving(value), ), - GreeSwitchDescription( - key=GATTR_FEAT_HUMIDITY, - translation_key=GATTR_FEAT_HUMIDITY, - value_func=lambda device, _: ( - device.feature_humidity_control == HumidityControlMode.continuous_dry - ), - set_func=_set_humidity_control_continuous, - additional_available_func=( - lambda device: device.operation_mode is OperationMode.dry - ), - updates_device=True, - ), GreeSwitchDescription( key=GATTR_FEAT_LIGHT, translation_key=GATTR_FEAT_LIGHT, diff --git a/custom_components/gree_custom/translations/en.json b/custom_components/gree_custom/translations/en.json index fe15df9..590fb21 100755 --- a/custom_components/gree_custom/translations/en.json +++ b/custom_components/gree_custom/translations/en.json @@ -268,7 +268,8 @@ "state": { "disabled": "Disabled", "target_dry": "Normal Dry", - "smart_dry": "Smart Dry" + "smart_dry": "Smart Dry", + "continuous_dry": "Continuous Dry" } } }, @@ -308,9 +309,6 @@ }, "beeper": { "name": "Beeper" - }, - "humidity_control": { - "name": "Continuous Dry" } } }, @@ -343,10 +341,13 @@ "message": "There was a problem performing the action. The configuration entry has invalid data." }, "humidity_mode_unavailable": { - "message": "Humidity Control is only available in Cool mode." + "message": "Humidity Control is only available in Cool and Dry modes." }, "continuous_dry_unavailable": { "message": "Continuous Dry is only available in Dry mode." + }, + "smart_dry_unavailable": { + "message": "Smart Dry is only available in Cool mode." } }, "services": { diff --git a/custom_components/gree_custom/translations/pt.json b/custom_components/gree_custom/translations/pt.json index 0a7d613..bcf657e 100755 --- a/custom_components/gree_custom/translations/pt.json +++ b/custom_components/gree_custom/translations/pt.json @@ -276,7 +276,8 @@ "state": { "disabled": "Desativado", "target_dry": "Secar", - "smart_dry": "Secagem Inteligente" + "smart_dry": "Secagem Inteligente", + "continuous_dry": "Secagem Contínua" } } }, @@ -316,9 +317,6 @@ }, "beeper": { "name": "Aviso Sonoro" - }, - "humidity_control": { - "name": "Secagem Contínua" } } }, @@ -351,10 +349,13 @@ "message": "Ocorreu um erro a executar a ação. A configuração para o dispositivo contém dados inválidos." }, "humidity_mode_unavailable": { - "message": "O Controlo de Humidade só está disponível no modo de Arrefecer." + "message": "O Controlo de Humidade só está disponível nos modos de Arrefecer e Secar." }, "continuous_dry_unavailable": { - "message": "Desumidificar Contínuo só está disponível no modo de Secar." + "message": "A Secagem Contínua só está disponível no modo de Secar." + }, + "smart_dry_unavailable": { + "message": "A Secagem Inteligente só está disponível no modo de Arrefecer." } }, "services": { From 7c35326061bf53b9dcb37f5dbe4625448b85ddca Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Mon, 20 Jul 2026 15:24:33 +0100 Subject: [PATCH 06/10] Normalize entity creation Move common logic to platform helpers and normalize the entity creation logic --- custom_components/gree_custom/__init__.py | 2 - .../gree_custom/binary_sensor.py | 5 +- custom_components/gree_custom/climate.py | 78 +++----- custom_components/gree_custom/config_flow.py | 6 +- custom_components/gree_custom/const.py | 16 +- custom_components/gree_custom/entity.py | 14 +- custom_components/gree_custom/manifest.json | 2 +- custom_components/gree_custom/number.py | 162 +++++++--------- .../gree_custom/platform_helpers.py | 105 +++++++++++ custom_components/gree_custom/select.py | 150 ++++++--------- custom_components/gree_custom/sensor.py | 129 ++++++------- custom_components/gree_custom/switch.py | 173 ++++++------------ 12 files changed, 386 insertions(+), 456 deletions(-) create mode 100644 custom_components/gree_custom/platform_helpers.py diff --git a/custom_components/gree_custom/__init__.py b/custom_components/gree_custom/__init__.py index e8e3fa4..11365b5 100755 --- a/custom_components/gree_custom/__init__.py +++ b/custom_components/gree_custom/__init__.py @@ -38,8 +38,6 @@ DEFAULT_SCAN_INTERVAL, DOMAIN, ) - -# Home Assistant imports from .coordinator import GreeConfigEntry, GreeCoordinator from .helpers import try_find_new_ip from .services import async_setup_services diff --git a/custom_components/gree_custom/binary_sensor.py b/custom_components/gree_custom/binary_sensor.py index 3a2f3ab..f4988cf 100644 --- a/custom_components/gree_custom/binary_sensor.py +++ b/custom_components/gree_custom/binary_sensor.py @@ -32,10 +32,11 @@ @dataclass(frozen=True, kw_only=True) -class GreeBinarySensorDescription(GreeEntityDescription, BinarySensorEntityDescription): +class GreeBinarySensorDescription( + GreeEntityDescription, BinarySensorEntityDescription, frozen_or_thawed=True +): """Description of a Gree binary sensor.""" - additional_available_func = lambda _: True # noqa: E731 value_func: Callable[[GreeDevice], bool | None] diff --git a/custom_components/gree_custom/climate.py b/custom_components/gree_custom/climate.py index 8ea852a..096bd64 100644 --- a/custom_components/gree_custom/climate.py +++ b/custom_components/gree_custom/climate.py @@ -2,8 +2,6 @@ import logging -from attr import dataclass - from homeassistant.components.climate import ( ATTR_FAN_MODE, ATTR_HVAC_MODE, @@ -17,7 +15,6 @@ from homeassistant.const import ( ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, - CONF_MAC, EVENT_CORE_CONFIG_UPDATE, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -34,7 +31,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import UNDEFINED from homeassistant.util.unit_conversion import TemperatureConverter from .aiogree.api import FanSpeed, GreeProp, HorizontalSwingMode, VerticalSwingMode @@ -43,19 +39,13 @@ from .const import ( ATTR_EXTERNAL_HUMIDITY_SENSOR, ATTR_EXTERNAL_TEMPERATURE_SENSOR, - CONF_ADVANCED, - CONF_DEVICES, - CONF_DISABLE_AVAILABLE_CHECK, CONF_FAN_MODES, CONF_HVAC_MODES, - CONF_RESTORE_STATES, CONF_SWING_HORIZONTAL_MODES, CONF_SWING_MODES, CONF_TEMPERATURE_STEP, - DEFAULT_DISABLE_AVAILABLE_CHECK, DEFAULT_FAN_MODES, DEFAULT_HVAC_MODES, - DEFAULT_RESTORE_STATES, DEFAULT_SWING_HORIZONTAL_MODES, DEFAULT_SWING_MODES, DEFAULT_TARGET_TEMP_STEP, @@ -68,29 +58,18 @@ ) from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription +from .platform_helpers import iter_platform_context _LOGGER = logging.getLogger(__name__) GATTR_CLIMATE = "hvac" -@dataclass(frozen=True, kw_only=True) -class GreeClimateDescription(GreeEntityDescription, ClimateEntityDescription): +class GreeClimateDescription( + GreeEntityDescription, ClimateEntityDescription, frozen_or_thawed=True +): """Description of a Gree Climate entity.""" - additional_available_func = lambda _: True # noqa: E731 - device_class = None - entity_category = None - entity_registry_enabled_default = True - entity_registry_visible_default = True - force_update = False - icon = None - has_entity_name = True - name = UNDEFINED - translation_key = None - translation_placeholders = None - unit_of_measurement = None - async def async_setup_entry( hass: HomeAssistant, @@ -101,27 +80,20 @@ async def async_setup_entry( entities: list[GreeClimate] = [] - for d in entry.data.get(CONF_DEVICES, []): - mac = d.get(CONF_MAC, "") - coordinator: GreeCoordinator = entry.runtime_data[mac] - if not coordinator: - _LOGGER.error( - "Cannot create Gree Climate. No coordinator found for device '%s'", - mac, - ) - continue - + for ctx in iter_platform_context(entry, "Climates"): hvac_modes: list[HVACMode] = [ HVACMode[mode.upper()] for mode in ( - d[CONF_HVAC_MODES] - if d[CONF_HVAC_MODES] is not None + ctx.device_config[CONF_HVAC_MODES] + if ctx.device_config[CONF_HVAC_MODES] is not None else DEFAULT_HVAC_MODES ) ] fan_modes: list[str] = ( - d[CONF_FAN_MODES] if d[CONF_FAN_MODES] is not None else DEFAULT_FAN_MODES + ctx.device_config[CONF_FAN_MODES] + if ctx.device_config[CONF_FAN_MODES] is not None + else DEFAULT_FAN_MODES ) fan_modes = sorted( fan_modes, @@ -131,8 +103,8 @@ async def async_setup_entry( ) swing_modes: list[str] = ( - d[CONF_SWING_MODES] - if d[CONF_SWING_MODES] is not None + ctx.device_config[CONF_SWING_MODES] + if ctx.device_config[CONF_SWING_MODES] is not None else DEFAULT_SWING_MODES ) swing_modes = sorted( @@ -145,8 +117,8 @@ async def async_setup_entry( ) swing_horizontal_modes: list[str] = ( - d[CONF_SWING_HORIZONTAL_MODES] - if d[CONF_SWING_HORIZONTAL_MODES] is not None + ctx.device_config[CONF_SWING_HORIZONTAL_MODES] + if ctx.device_config[CONF_SWING_HORIZONTAL_MODES] is not None else DEFAULT_SWING_HORIZONTAL_MODES ) swing_horizontal_modes = sorted( @@ -166,7 +138,7 @@ async def async_setup_entry( _LOGGER.debug( "Adding Climate Entity for device '%s'", - coordinator.device.mac_address, + ctx.coordinator.device.mac_address, ) entities.append( @@ -175,20 +147,22 @@ async def async_setup_entry( key=GATTR_CLIMATE, translation_key=GATTR_CLIMATE, ), - coordinator, + ctx.coordinator, hvac_modes, fan_modes, swing_modes, swing_horizontal_modes, - temperature_step=d.get(CONF_TEMPERATURE_STEP, DEFAULT_TARGET_TEMP_STEP), - restore_state=d.get(CONF_RESTORE_STATES, DEFAULT_RESTORE_STATES), - check_availability=( - not entry.data[CONF_ADVANCED].get( - CONF_DISABLE_AVAILABLE_CHECK, DEFAULT_DISABLE_AVAILABLE_CHECK - ) + temperature_step=ctx.device_config.get( + CONF_TEMPERATURE_STEP, DEFAULT_TARGET_TEMP_STEP + ), + restore_state=ctx.restore_state, + check_availability=ctx.check_availability, + external_temperature_sensor_id=ctx.device_config.get( + ATTR_EXTERNAL_TEMPERATURE_SENSOR + ), + external_humidity_sensor_id=ctx.device_config.get( + ATTR_EXTERNAL_HUMIDITY_SENSOR ), - external_temperature_sensor_id=d.get(ATTR_EXTERNAL_TEMPERATURE_SENSOR), - external_humidity_sensor_id=d.get(ATTR_EXTERNAL_HUMIDITY_SENSOR), ) ) diff --git a/custom_components/gree_custom/config_flow.py b/custom_components/gree_custom/config_flow.py index d5d7a0b..1b0d007 100644 --- a/custom_components/gree_custom/config_flow.py +++ b/custom_components/gree_custom/config_flow.py @@ -290,10 +290,8 @@ def build_options_schema( valid_features.append(GATTR_FEAT_SMART_HEAT_8C) if device.supports_property(GreeProp.FEAT_LIGHT): valid_features.append(GATTR_FEAT_LIGHT) - if device.supports_property(GreeProp.FEAT_LIGHT) and device.supports_property( - GreeProp.FEAT_SENSOR_LIGHT - ): - valid_features.append(GATTR_FEAT_SENSOR_LIGHT) + if device.supports_property(GreeProp.FEAT_SENSOR_LIGHT): + valid_features.append(GATTR_FEAT_SENSOR_LIGHT) if device.supports_property(GreeProp.FEAT_HEALTH): valid_features.append(GATTR_FEAT_HEALTH) if device.supports_property(GreeProp.FEAT_ANTI_DIRECT_BLOW): diff --git a/custom_components/gree_custom/const.py b/custom_components/gree_custom/const.py index a801f30..f23a2e1 100755 --- a/custom_components/gree_custom/const.py +++ b/custom_components/gree_custom/const.py @@ -83,7 +83,7 @@ GATTR_TEMP_UNITS = "temperature_units" GATTR_INDOOR_TEMPERATURE = "indoor_temperature" GATTR_OUTDOOR_TEMPERATURE = "outdoor_temperature" -GATTR_HUMIDITY = "rooom_humidity" +GATTR_HUMIDITY = "room_humidity" GATTR_FAULTS = "faults" @@ -96,7 +96,18 @@ # Map each feature constant to its corresponding GreeProp CONF_TO_PROP_FEATURE_MAP = { + # SENSORS + GATTR_INDOOR_TEMPERATURE: GreeProp.SENSOR_TEMPERATURE, + GATTR_OUTDOOR_TEMPERATURE: GreeProp.SENSOR_OUTSIDE_TEMPERATURE, + GATTR_HUMIDITY: GreeProp.SENSOR_HUMIDITY, + GATTR_FAULTS: GreeProp.SENSOR_FAULT, + # SELECT + GATTR_FEAT_HUMIDITY: GreeProp.FEATURE_HUMIDITY_CONTROL, + GATTR_TEMP_UNITS: GreeProp.TARGET_TEMPERATURE_UNIT, + # FEATURES GATTR_BEEPER: GreeProp.BEEPER, + GATTR_FEAT_LIGHT: GreeProp.FEAT_LIGHT, + GATTR_FEAT_SENSOR_LIGHT: GreeProp.FEAT_SENSOR_LIGHT, GATTR_FEAT_FRESH_AIR: GreeProp.FEAT_FRESH_AIR, GATTR_FEAT_XFAN: GreeProp.FEAT_XFAN, GATTR_FEAT_SLEEP_MODE: GreeProp.FEAT_SLEEP_MODE, @@ -104,9 +115,6 @@ GATTR_FEAT_HEALTH: GreeProp.FEAT_HEALTH, GATTR_ANTI_DIRECT_BLOW: GreeProp.FEAT_ANTI_DIRECT_BLOW, GATTR_FEAT_ENERGY_SAVING: GreeProp.FEAT_ENERGY_SAVING, - GATTR_FEAT_LIGHT: GreeProp.FEAT_LIGHT, - GATTR_FAULTS: GreeProp.SENSOR_FAULT, - GATTR_FEAT_HUMIDITY: GreeProp.FEATURE_HUMIDITY_CONTROL, } # HVAC modes - these come from Home Assistant and are standard diff --git a/custom_components/gree_custom/entity.py b/custom_components/gree_custom/entity.py index 02b9a24..9e02ad6 100755 --- a/custom_components/gree_custom/entity.py +++ b/custom_components/gree_custom/entity.py @@ -1,7 +1,6 @@ """Base entity for Gree integration.""" from collections.abc import Callable -from dataclasses import dataclass, field from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.entity import DeviceInfo, EntityDescription @@ -79,16 +78,9 @@ def available(self): # pyright: ignore[reportIncompatibleVariableOverride] return custom_available and coordinator_ok and device_ok -@dataclass(frozen=True, kw_only=True) -class GreeEntityDescription(EntityDescription): +class GreeEntityDescription(EntityDescription, frozen_or_thawed=True): """Description of a Gree switch.""" - # Restore the last state by default since the device can be controlled externally, - # this way HA sets the device to its last known HA state. - # This will be overridden by entry configuration - # restore_state: bool = True - + feature_key_override: str | None = None # Use this to conditionally block the entity availability independent of the device availability - additional_available_func: Callable[[GreeDevice], bool] = field( - default=lambda _: True - ) + additional_available_func: Callable[[GreeDevice], bool] = lambda _: True diff --git a/custom_components/gree_custom/manifest.json b/custom_components/gree_custom/manifest.json index 2364d55..396c81f 100755 --- a/custom_components/gree_custom/manifest.json +++ b/custom_components/gree_custom/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/RobHofmann/HomeAssistant-GreeClimateComponent/issues", "requirements": ["pycryptodome", "asyncio_dgram"], - "version": "4.0.0-alpha.102" + "version": "4.0.0-alpha.103" } diff --git a/custom_components/gree_custom/number.py b/custom_components/gree_custom/number.py index 94de0c7..e9aa72e 100644 --- a/custom_components/gree_custom/number.py +++ b/custom_components/gree_custom/number.py @@ -1,7 +1,6 @@ """Support for Gree number entities (e.g., target humidity control).""" from collections.abc import Callable -from dataclasses import dataclass import logging from homeassistant.components.number import ( @@ -9,32 +8,71 @@ NumberEntity, NumberEntityDescription, ) -from homeassistant.const import CONF_MAC, PERCENTAGE +from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .aiogree.api import GreeProp, HumidityControlMode, OperationMode +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.device import GreeDevice -from .const import ( - CONF_ADVANCED, - CONF_DEVICES, - CONF_DISABLE_AVAILABLE_CHECK, - CONF_FEATURES, - CONF_RESTORE_STATES, - DEFAULT_DISABLE_AVAILABLE_CHECK, - DEFAULT_RESTORE_STATES, - DEFAULT_SUPPORTED_FEATURES, - GATTR_FEAT_HUMIDITY, - GATTR_FEAT_HUMIDITY_TARGET, -) +from .const import GATTR_FEAT_HUMIDITY, GATTR_FEAT_HUMIDITY_TARGET from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription +from .platform_helpers import ( + entity_feature_key, + filter_descriptions, + iter_platform_context, + supported_features, +) _LOGGER = logging.getLogger(__name__) +class GreeNumberDescription( + GreeEntityDescription, NumberEntityDescription, frozen_or_thawed=True +): + """Description of a Gree number.""" + + value_func: Callable[[GreeDevice], int] + set_func: Callable[[GreeDevice, int], None] + min_func: Callable[[GreeDevice], int] | None = None + max_func: Callable[[GreeDevice], int] | None = None + updates_device: bool = True + + +NUMBER_TYPES: list[GreeNumberDescription] = [ + GreeNumberDescription( + feature_key_override=GATTR_FEAT_HUMIDITY, + key=GATTR_FEAT_HUMIDITY_TARGET, + translation_key=GATTR_FEAT_HUMIDITY_TARGET, + device_class=NumberDeviceClass.HUMIDITY, + mode="auto", + native_step=5, + 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 + ), + additional_available_func=lambda device: ( + device.operation_mode in (OperationMode.cool, OperationMode.dry) + and device.feature_humidity_control is HumidityControlMode.target_dry + ), + min_func=lambda device: ( + MIN_HUM_COOL_P + if device.operation_mode == OperationMode.cool + else MIN_HUM_DRY_P + ), + max_func=lambda device: ( + MAX_HUM_COOL_P + if device.operation_mode == OperationMode.cool + else MAX_HUM_DRY_P + ), + updates_device=True, + ) +] + + async def async_setup_entry( hass: HomeAssistant, entry: GreeConfigEntry, @@ -44,70 +82,24 @@ async def async_setup_entry( entities: list[GreeNumber] = [] - for d in entry.data.get(CONF_DEVICES, []): - mac = d.get(CONF_MAC, "") - coordinator: GreeCoordinator = entry.runtime_data[mac] - if not coordinator: - _LOGGER.error( - "Cannot create Gree numbers. No coordinator found for device '%s'", - mac, - ) - continue - - descriptions: list[GreeNumberDescription] = [] - - conf_supported_features = d.get(CONF_FEATURES, DEFAULT_SUPPORTED_FEATURES) - if ( - GATTR_FEAT_HUMIDITY in conf_supported_features - and coordinator.device.supports_property(GreeProp.FEATURE_HUMIDITY_CONTROL) - ): - descriptions.append( - GreeNumberDescription( - key=GATTR_FEAT_HUMIDITY_TARGET, - translation_key=GATTR_FEAT_HUMIDITY_TARGET, - device_class=NumberDeviceClass.HUMIDITY, - mode="auto", - native_step=5, - 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) - ), - additional_available_func=lambda device: ( - device.operation_mode in (OperationMode.cool, OperationMode.dry) - and device.feature_humidity_control - is HumidityControlMode.target_dry - ), - min_func=lambda device: ( - MIN_HUM_COOL_P - if device.operation_mode == OperationMode.cool - else MIN_HUM_DRY_P - ), - max_func=lambda device: ( - MAX_HUM_COOL_P - if device.operation_mode == OperationMode.cool - else MAX_HUM_DRY_P - ), - updates_device=True, - ) - ) + for ctx in iter_platform_context(entry, "Numbers"): + supported = supported_features( + ctx.device_config, + ctx.coordinator, + [entity_feature_key(description) for description in NUMBER_TYPES], + ) + + descriptions = filter_descriptions(NUMBER_TYPES, supported) _LOGGER.debug( - "Adding Select Entities for device '%s': %s", - coordinator.device.mac_address, + "Adding Number Entities for device '%s': %s", + ctx.coordinator.device.mac_address, [d.key for d in descriptions], ) entities.extend( GreeNumber( - description, - coordinator, - d.get(CONF_RESTORE_STATES, DEFAULT_RESTORE_STATES), - check_availability=( - not entry.data[CONF_ADVANCED].get( - CONF_DISABLE_AVAILABLE_CHECK, DEFAULT_DISABLE_AVAILABLE_CHECK - ) - ), + description, ctx.coordinator, ctx.restore_state, ctx.check_availability ) for description in descriptions ) @@ -115,32 +107,6 @@ async def async_setup_entry( async_add_entities(entities) -@dataclass(frozen=True, kw_only=True) -class GreeNumberDescription(GreeEntityDescription, NumberEntityDescription): - """Description of a Gree number.""" - - entity_category = None - entity_registry_enabled_default = True - entity_registry_visible_default = True - force_update = False - icon = None - has_entity_name = True - name = None - translation_key = None - translation_placeholders = None - unit_of_measurement = None - max_value: None = None - min_value: None = None - step: None = None - - additional_available_func = lambda _: True # noqa: E731 - value_func: Callable[[GreeDevice], int] - set_func: Callable[[GreeDevice, int], None] - min_func: Callable[[GreeDevice], int] | None = None - max_func: Callable[[GreeDevice], int] | None = None - updates_device: bool = True - - class GreeNumber(GreeEntity, NumberEntity): # pyright: ignore[reportIncompatibleVariableOverride] """Defines a Gree Number entity.""" diff --git a/custom_components/gree_custom/platform_helpers.py b/custom_components/gree_custom/platform_helpers.py new file mode 100644 index 0000000..b53f85b --- /dev/null +++ b/custom_components/gree_custom/platform_helpers.py @@ -0,0 +1,105 @@ +"""Helpers for the Gree integration.""" + +from collections.abc import Iterator, Sequence +from dataclasses import dataclass +import logging +from typing import TypeVar + +from homeassistant.const import CONF_MAC +from homeassistant.helpers.entity_platform import Any + +from .const import ( + CONF_ADVANCED, + CONF_DEVICES, + CONF_DISABLE_AVAILABLE_CHECK, + CONF_FEATURES, + CONF_RESTORE_STATES, + CONF_TO_PROP_FEATURE_MAP, + DEFAULT_DISABLE_AVAILABLE_CHECK, + DEFAULT_RESTORE_STATES, + DEFAULT_SUPPORTED_FEATURES, +) +from .coordinator import GreeConfigEntry, GreeCoordinator +from .entity import GreeEntityDescription + +_LOGGER = logging.getLogger(__name__) + + +T = TypeVar("T", bound=GreeEntityDescription) + + +@dataclass(slots=True) +class GreePlatformContext: + """Provides the context for platform entity creation.""" + + device_config: dict[str, Any] + coordinator: GreeCoordinator + restore_state: bool + check_availability: bool + + +def iter_platform_context( + entry: GreeConfigEntry, + platform: str, +) -> Iterator[GreePlatformContext]: + """Yield context for every configured device.""" + + check_availability = not entry.data[CONF_ADVANCED].get( + CONF_DISABLE_AVAILABLE_CHECK, + DEFAULT_DISABLE_AVAILABLE_CHECK, + ) + + for device_config in entry.data.get(CONF_DEVICES, []): + mac = device_config.get(CONF_MAC, "") + + coordinator = entry.runtime_data.get(mac) + if coordinator is None: + _LOGGER.error( + "Cannot create Gree %s. No coordinator found for device '%s'", + platform, + mac, + ) + continue + + yield GreePlatformContext( + device_config=device_config, + coordinator=coordinator, + restore_state=device_config.get( + CONF_RESTORE_STATES, + DEFAULT_RESTORE_STATES, + ), + check_availability=check_availability, + ) + + +def supported_features( + device_config: dict, coordinator: GreeCoordinator, subset: list[str] | None = None +) -> set[str]: + """Extracts supported features from a device config and device support.""" + features: list[str] = device_config.get( + CONF_FEATURES, + DEFAULT_SUPPORTED_FEATURES, + ) + + if subset is not None: + features = [feature for feature in features if feature in subset] + + supported: set[str] = set() + + for feature in features: + prop = CONF_TO_PROP_FEATURE_MAP.get(feature) + if prop and coordinator.device.supports_property(prop): + supported.add(feature) + + return supported + + +def filter_descriptions(descriptions: Sequence[T], supported: set[str]) -> list[T]: + """Filters a list of entity descriptions based on a supported features list.""" + return [d for d in descriptions if entity_feature_key(d) in supported] + + +def entity_feature_key(entity_description: T) -> str: + """Returns the correct feature key for an entity description.""" + # This is needed because the description dataclasses don't allow methods/properties + return entity_description.feature_key_override or entity_description.key diff --git a/custom_components/gree_custom/select.py b/custom_components/gree_custom/select.py index a3047b5..8172dcf 100644 --- a/custom_components/gree_custom/select.py +++ b/custom_components/gree_custom/select.py @@ -2,43 +2,43 @@ from collections.abc import Callable import logging -from typing import TypeVar - -from attr import dataclass from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.const import CONF_MAC, EntityCategory +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .aiogree.api import GreeProp, HumidityControlMode, OperationMode, TemperatureUnits +from .aiogree.api import HumidityControlMode, OperationMode, TemperatureUnits from .aiogree.device import GreeDevice from .aiogree.errors import ( GreeContinuousDryUnavailable, GreeHumidityControlUnavailable, GreeSmartDryUnavailable, ) -from .const import ( - CONF_ADVANCED, - CONF_DEVICES, - CONF_DISABLE_AVAILABLE_CHECK, - CONF_FEATURES, - CONF_RESTORE_STATES, - DEFAULT_DISABLE_AVAILABLE_CHECK, - DEFAULT_RESTORE_STATES, - DEFAULT_SUPPORTED_FEATURES, - DOMAIN, - GATTR_FEAT_HUMIDITY, - GATTR_TEMP_UNITS, -) +from .const import DOMAIN, GATTR_FEAT_HUMIDITY, GATTR_TEMP_UNITS from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription +from .platform_helpers import ( + entity_feature_key, + filter_descriptions, + iter_platform_context, + supported_features, +) _LOGGER = logging.getLogger(__name__) -T = TypeVar("T") # T can be any type + +class GreeSelectDescription( + GreeEntityDescription, SelectEntityDescription, frozen_or_thawed=True +): + """Description of a Gree switch.""" + + options_func: Callable[[], list[str]] | None = None + value_func: Callable[[GreeDevice], str | None] + set_func: Callable[[GreeDevice, str], None] + updates_device: bool = True def _set_humidity_control_mode(device: GreeDevice, mode: str) -> None: @@ -61,6 +61,32 @@ def _set_humidity_control_mode(device: GreeDevice, mode: str) -> None: ) from err +SELECT_TYPES: list[GreeSelectDescription] = [ + GreeSelectDescription( + key=GATTR_TEMP_UNITS, + translation_key=GATTR_TEMP_UNITS, + entity_category=EntityCategory.CONFIG, + options=[f"º{member.name}" for member in TemperatureUnits], + value_func=lambda device: f"º{device.target_temperature_unit.name}", + set_func=lambda device, value: device.set_target_temperature_unit( + TemperatureUnits[value.replace("º", "")] + ), + updates_device=True, + ), + GreeSelectDescription( + key=GATTR_FEAT_HUMIDITY, + translation_key=GATTR_FEAT_HUMIDITY, + options=[member.name for member in HumidityControlMode], + value_func=lambda device: device.feature_humidity_control, + set_func=_set_humidity_control_mode, + additional_available_func=lambda device: ( + device.operation_mode in (OperationMode.cool, OperationMode.dry) + ), + updates_device=True, + ), +] + + async def async_setup_entry( hass: HomeAssistant, entry: GreeConfigEntry, @@ -70,97 +96,29 @@ async def async_setup_entry( entities: list[GreeSelect] = [] - for d in entry.data.get(CONF_DEVICES, []): - mac = d.get(CONF_MAC, "") - coordinator: GreeCoordinator = entry.runtime_data[mac] - if not coordinator: - _LOGGER.error( - "Cannot create Gree Selectors. No coordinator found for device '%s'", - mac, - ) - continue - - descriptions: list[GreeSelectDescription] = [] - - if coordinator.device.supports_property(GreeProp.TARGET_TEMPERATURE_UNIT): - descriptions.append( - GreeSelectDescription[GreeDevice]( - key=GATTR_TEMP_UNITS, - translation_key=GATTR_TEMP_UNITS, - entity_category=EntityCategory.CONFIG, - options=[f"º{member.name}" for member in TemperatureUnits], - value_func=lambda device: f"º{device.target_temperature_unit.name}", - set_func=lambda device, value: device.set_target_temperature_unit( - TemperatureUnits[value.replace("º", "")] - ), - updates_device=True, - ) - ) + for ctx in iter_platform_context(entry, "Selects"): + supported = supported_features( + ctx.device_config, + ctx.coordinator, + [entity_feature_key(description) for description in SELECT_TYPES], + ) - conf_supported_features = d.get(CONF_FEATURES, DEFAULT_SUPPORTED_FEATURES) - if ( - GATTR_FEAT_HUMIDITY in conf_supported_features - and coordinator.device.supports_property(GreeProp.FEATURE_HUMIDITY_CONTROL) - ): - descriptions.append( - GreeSelectDescription[GreeDevice]( - key=GATTR_FEAT_HUMIDITY, - translation_key=GATTR_FEAT_HUMIDITY, - options=[member.name for member in HumidityControlMode], - value_func=lambda device: device.feature_humidity_control, - set_func=_set_humidity_control_mode, - additional_available_func=lambda device: ( - device.operation_mode in (OperationMode.cool, OperationMode.dry) - ), - updates_device=True, - ) - ) + descriptions = filter_descriptions(SELECT_TYPES, supported) _LOGGER.debug( "Adding Select Entities for device '%s': %s", - coordinator.device.mac_address, + ctx.coordinator.device.mac_address, [d.key for d in descriptions], ) entities.extend( - GreeSelect( - description, - coordinator, - d.get(CONF_RESTORE_STATES, DEFAULT_RESTORE_STATES), - check_availability=( - not entry.data[CONF_ADVANCED].get( - CONF_DISABLE_AVAILABLE_CHECK, DEFAULT_DISABLE_AVAILABLE_CHECK - ) - ), - ) + GreeSelect(description, ctx.coordinator, False, ctx.check_availability) for description in descriptions ) async_add_entities(entities) -@dataclass(frozen=True, kw_only=True) -class GreeSelectDescription[T](GreeEntityDescription, SelectEntityDescription): - """Description of a Gree switch.""" - - additional_available_func = lambda _: True # noqa: E731 - device_class = None - entity_category = None - entity_registry_enabled_default = True - entity_registry_visible_default = True - force_update = False - icon = None - has_entity_name = True - name = None - translation_key = None - translation_placeholders = None - unit_of_measurement = None - options_func: Callable[[], list[str]] | None = None - value_func: Callable[[T], str | None] - set_func: Callable[[T, str], None] - updates_device: bool = True - - class GreeSelect(GreeEntity, SelectEntity, RestoreEntity): # pyright: ignore[reportIncompatibleVariableOverride] """A Gree select entity.""" diff --git a/custom_components/gree_custom/sensor.py b/custom_components/gree_custom/sensor.py index 8c728dd..67dcd27 100644 --- a/custom_components/gree_custom/sensor.py +++ b/custom_components/gree_custom/sensor.py @@ -1,7 +1,6 @@ """Gree Sensor Entity for Home Assistant.""" from collections.abc import Callable -from dataclasses import dataclass import logging from homeassistant.components.sensor import ( @@ -10,28 +9,68 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import CONF_MAC, PERCENTAGE, UnitOfTemperature +from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .aiogree.api import GreeProp from .aiogree.device import GreeDevice from .const import ( - CONF_ADVANCED, - CONF_DEVICES, - CONF_DISABLE_AVAILABLE_CHECK, - DEFAULT_DISABLE_AVAILABLE_CHECK, + CONF_TO_PROP_FEATURE_MAP, GATTR_HUMIDITY, GATTR_INDOOR_TEMPERATURE, GATTR_OUTDOOR_TEMPERATURE, ) from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription +from .platform_helpers import ( + entity_feature_key, + filter_descriptions, + iter_platform_context, +) _LOGGER = logging.getLogger(__name__) +class GreeSensorDescription( + GreeEntityDescription, SensorEntityDescription, frozen_or_thawed=True +): + """Description of a Gree temperature sensor.""" + + value_func: Callable[[GreeDevice], float | None] + + +SENSOR_TYPES: list[GreeSensorDescription] = [ + GreeSensorDescription( + key=GATTR_INDOOR_TEMPERATURE, + translation_key=GATTR_INDOOR_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=0, + value_func=lambda device: device.indoors_temperature_c, + ), + GreeSensorDescription( + key=GATTR_OUTDOOR_TEMPERATURE, + translation_key=GATTR_OUTDOOR_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=0, + value_func=lambda device: device.outdoors_temperature_c, + ), + GreeSensorDescription( + key=GATTR_HUMIDITY, + translation_key=GATTR_HUMIDITY, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=0, + value_func=lambda device: device.humidity, + ), +] + + async def async_setup_entry( hass: HomeAssistant, entry: GreeConfigEntry, @@ -41,84 +80,32 @@ async def async_setup_entry( entities: list[GreeSensor] = [] - for d in entry.data.get(CONF_DEVICES, []): - mac = d.get(CONF_MAC, "") - coordinator: GreeCoordinator = entry.runtime_data[mac] - if not coordinator: - _LOGGER.error( - "Cannot create Gree Sensors. No coordinator found for device '%s'", - mac, - ) - continue - - descriptions: list[GreeSensorDescription] = [] - if coordinator.device.supports_property(GreeProp.SENSOR_TEMPERATURE): - descriptions.append( - GreeSensorDescription( - key=GATTR_INDOOR_TEMPERATURE, - translation_key=GATTR_INDOOR_TEMPERATURE, - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - suggested_display_precision=0, - value_func=lambda device: device.indoors_temperature_c, - ) - ) - if coordinator.device.supports_property(GreeProp.SENSOR_OUTSIDE_TEMPERATURE): - descriptions.append( - GreeSensorDescription( - key=GATTR_OUTDOOR_TEMPERATURE, - translation_key=GATTR_OUTDOOR_TEMPERATURE, - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - suggested_display_precision=0, - value_func=lambda device: device.outdoors_temperature_c, - ) - ) - if coordinator.device.supports_property(GreeProp.SENSOR_HUMIDITY): - descriptions.append( - GreeSensorDescription( - key=GATTR_HUMIDITY, - translation_key=GATTR_HUMIDITY, - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - suggested_display_precision=0, - value_func=lambda device: device.humidity, - ) + for ctx in iter_platform_context(entry, "Sensors"): + # Sensors are checked directly, not on the entry config + supported = [ + key + for description in SENSOR_TYPES + if ctx.coordinator.device.supports_property( + CONF_TO_PROP_FEATURE_MAP.get(key := entity_feature_key(description)) ) + ] + + descriptions = filter_descriptions(SENSOR_TYPES, supported) _LOGGER.debug( "Adding Sensor Entities for device '%s': %s", - coordinator.device.mac_address, + ctx.coordinator.device.mac_address, [d.key for d in descriptions], ) entities.extend( - GreeSensor( - description, - coordinator, - restore_state=False, - check_availability=( - not entry.data[CONF_ADVANCED].get( - CONF_DISABLE_AVAILABLE_CHECK, DEFAULT_DISABLE_AVAILABLE_CHECK - ) - ), - ) + GreeSensor(description, ctx.coordinator, False, ctx.check_availability) for description in descriptions ) async_add_entities(entities) -@dataclass(frozen=True, kw_only=True) -class GreeSensorDescription(GreeEntityDescription, SensorEntityDescription): - """Description of a Gree temperature sensor.""" - - value_func: Callable[[GreeDevice], float | None] - - class GreeSensor(GreeEntity, SensorEntity, RestoreEntity): # pyright: ignore[reportIncompatibleVariableOverride] """A Gree Sensor.""" diff --git a/custom_components/gree_custom/switch.py b/custom_components/gree_custom/switch.py index b2c2a29..0e20849 100644 --- a/custom_components/gree_custom/switch.py +++ b/custom_components/gree_custom/switch.py @@ -1,7 +1,6 @@ """Gree Switch Entity for Home Assistant.""" from collections.abc import Callable -from dataclasses import dataclass import logging from typing import Any @@ -10,26 +9,17 @@ SwitchEntity, SwitchEntityDescription, ) -from homeassistant.const import CONF_MAC, EntityCategory +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .aiogree.api import GreeProp, OperationMode, SleepMode +from .aiogree.api import OperationMode, SleepMode from .aiogree.device import GreeDevice from .const import ( ATTR_AUTO_LIGHT, ATTR_AUTO_XFAN, - CONF_ADVANCED, - CONF_DEVICES, - CONF_DISABLE_AVAILABLE_CHECK, - CONF_FEATURES, - CONF_RESTORE_STATES, - CONF_TO_PROP_FEATURE_MAP, - DEFAULT_DISABLE_AVAILABLE_CHECK, - DEFAULT_RESTORE_STATES, - DEFAULT_SUPPORTED_FEATURES, GATTR_ANTI_DIRECT_BLOW, GATTR_BEEPER, GATTR_FEAT_ENERGY_SAVING, @@ -43,12 +33,19 @@ ) from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription +from .platform_helpers import ( + entity_feature_key, + filter_descriptions, + iter_platform_context, + supported_features, +) _LOGGER = logging.getLogger(__name__) -@dataclass(frozen=True, kw_only=True) -class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): +class GreeSwitchDescription( + GreeEntityDescription, SwitchEntityDescription, frozen_or_thawed=True +): """Description of a Gree switch.""" set_func: Callable[[GreeDevice, GreeCoordinator, bool], None] @@ -73,6 +70,15 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): value_func=lambda device, _: device.feature_x_fan, set_func=lambda device, _, value: device.set_feature_xfan(value), ), + GreeSwitchDescription( + feature_key_override=GATTR_FEAT_XFAN, + key=ATTR_AUTO_XFAN, + translation_key=ATTR_AUTO_XFAN, + value_func=lambda _, coordinator: coordinator.feature_auto_xfan, + set_func=lambda _, coordinator, value: coordinator.set_feature_auto_xfan(value), + updates_device=False, + entity_category=EntityCategory.CONFIG, + ), GreeSwitchDescription( key=GATTR_FEAT_SLEEP_MODE, translation_key=GATTR_FEAT_SLEEP_MODE, @@ -131,6 +137,17 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): set_func=lambda device, _, value: device.set_feature_light_sensor(value), entity_category=EntityCategory.CONFIG, ), + GreeSwitchDescription( + feature_key_override=GATTR_FEAT_LIGHT, + key=ATTR_AUTO_LIGHT, + translation_key=ATTR_AUTO_LIGHT, + value_func=(lambda _, coordinator: coordinator.feature_auto_light), + set_func=( + lambda _, coordinator, value: coordinator.set_feature_auto_light(value) + ), + updates_device=False, + entity_category=EntityCategory.CONFIG, + ), GreeSwitchDescription( key=GATTR_BEEPER, translation_key=GATTR_BEEPER, @@ -141,24 +158,6 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): ), ] -SWITCH_TYPE_AUTO_LIGHT = GreeSwitchDescription( - key=ATTR_AUTO_LIGHT, - translation_key=ATTR_AUTO_LIGHT, - value_func=(lambda _, coordinator: coordinator.feature_auto_light), - set_func=(lambda _, coordinator, value: coordinator.set_feature_auto_light(value)), - updates_device=False, - entity_category=EntityCategory.CONFIG, -) - -SWITCH_TYPE_AUTO_XFAN = GreeSwitchDescription( - key=ATTR_AUTO_XFAN, - translation_key=ATTR_AUTO_XFAN, - value_func=lambda _, coordinator: coordinator.feature_auto_xfan, - set_func=lambda _, coordinator, value: coordinator.set_feature_auto_xfan(value), - updates_device=False, - entity_category=EntityCategory.CONFIG, -) - async def async_setup_entry( hass: HomeAssistant, @@ -169,99 +168,43 @@ async def async_setup_entry( entities: list[GreeSwitch] = [] - for d in entry.data.get(CONF_DEVICES, []): - mac = d.get(CONF_MAC, "") - coordinator: GreeCoordinator = entry.runtime_data[mac] - if not coordinator: - _LOGGER.error( - "Cannot create Gree Switches. No coordinator found for device '%s'", - mac, - ) - continue - - descriptions: list[GreeSwitchDescription] = [] - - conf_restore_states: bool = d.get(CONF_RESTORE_STATES, DEFAULT_RESTORE_STATES) - conf_check_availability: bool = not entry.data[CONF_ADVANCED].get( - CONF_DISABLE_AVAILABLE_CHECK, DEFAULT_DISABLE_AVAILABLE_CHECK + for ctx in iter_platform_context(entry, "Switches"): + supported = supported_features( + ctx.device_config, + ctx.coordinator, + [entity_feature_key(description) for description in SWITCH_TYPES], ) - supported_features: list[str] = [] - - if not d.get(CONF_FEATURES): - _LOGGER.warning("Undefined supported features") - - conf_supported_features = d.get(CONF_FEATURES, DEFAULT_SUPPORTED_FEATURES) - - # Check features with device support before adding the entities - for feature in conf_supported_features: - if feature == GATTR_FEAT_SENSOR_LIGHT: - if coordinator.device.supports_property( - GreeProp.FEAT_SENSOR_LIGHT - ) and coordinator.device.supports_property(GreeProp.FEAT_LIGHT): - supported_features.append(GATTR_FEAT_SENSOR_LIGHT) - continue - - # For all other mapped features - prop = CONF_TO_PROP_FEATURE_MAP.get(feature) - if prop and coordinator.device.supports_property(prop): - supported_features.append(feature) - - descriptions.extend( - [ - description - for description in SWITCH_TYPES - if description.key in supported_features - ] - ) + descriptions = filter_descriptions(SWITCH_TYPES, supported) _LOGGER.debug( "Adding Switch Entities for device '%s': %s", - coordinator.device.mac_address, + ctx.coordinator.device.mac_address, [d.key for d in descriptions], ) entities.extend( - [ - GreeSwitch( - description, - coordinator, - restore_state=( - conf_restore_states - if description.key != GATTR_BEEPER # Always restore beeper - else True - ), - check_availability=( - conf_check_availability - if description.key != GATTR_BEEPER # Beeper is always available - else False - ), - ) - for description in descriptions - ] - ) - - # Add Auto Light if device supports Light - if GATTR_FEAT_LIGHT in supported_features: - entities.append( - GreeSwitch( - SWITCH_TYPE_AUTO_LIGHT, - coordinator, - restore_state=True, # Always restore Auto Light - check_availability=conf_check_availability, - ) - ) - - # Add XFan if device supports XFan - if GATTR_FEAT_XFAN in supported_features: - entities.append( - GreeSwitch( - SWITCH_TYPE_AUTO_XFAN, - coordinator, - restore_state=True, # Always restore Auto XFan - check_availability=conf_check_availability, - ) + GreeSwitch( + description, + ctx.coordinator, + restore_state=( + ctx.restore_state + if description.key + not in ( + GATTR_BEEPER, + ATTR_AUTO_LIGHT, + ATTR_AUTO_XFAN, + ) # Always restore these + else True + ), + check_availability=( + ctx.check_availability + if description.key != GATTR_BEEPER # Beeper is always available + else False + ), ) + for description in descriptions + ) async_add_entities(entities) From b77b420e272db3eb4084eb8ae8fb18e5815c978b Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Mon, 20 Jul 2026 15:30:06 +0100 Subject: [PATCH 07/10] Add humidity control to readme --- README.md | 4 ++-- manual-configuration.yaml | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e380df9..3668c86 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ The indoor _temperature_ (`current_temperature`) and _humidity_ (`current_humidi Depending on the device configuration, specific Gree AC model, and firmware version, the integration exposes various entities to configure additional features of your Gree AC unit. Entity availability depends on the current HVAC mode and status. These controls allow you to toggle special modes and adjust settings: -### Feature Switches +### Features - **Health**: Enables or disables the Health mode for air ionization and purification - **Power Save**: Enables or disables the power saving mode for energy efficiency. Only available in cooling mode @@ -154,7 +154,7 @@ Depending on the device configuration, specific Gree AC model, and firmware vers - **Fresh Air**: Enables or disables the fresh air circulation mode - **X-Fan**: Enables or disables the X-Fan mode that keeps the fan working for a few moments after turning the device off in cooling and dry modes, preventing condensation in the unit - **Anti Direct Blow**: Prevents direct air flow from blowing on people by adjusting the air deflector position - +- **Humidity Control**: Control the room humidity to a set target or inteligently. Only available in cooling and dry modes ### Configuration Controls diff --git a/manual-configuration.yaml b/manual-configuration.yaml index 110b4a7..39a4755 100644 --- a/manual-configuration.yaml +++ b/manual-configuration.yaml @@ -67,7 +67,7 @@ gree_custom: - "Center" - "RightCenter" - "Right" - features: # Supported device features | list | options = ["beeper", "air", "xfan", "sleep", "eightdegheat", "lights", "health", "anti_direct_blow", "powersave", "light_sensor", "faults"] | default = all options + features: # Supported device features | list | options = ["beeper", "air", "xfan", "sleep", "eightdegheat", "lights", "health", "anti_direct_blow", "powersave", "light_sensor", "faults", "humidity_control"] | default = all options - "beeper" - "air" - "xfan" @@ -79,6 +79,7 @@ gree_custom: - "powersave" - "light_sensor" - "faults" + - "humidity_control" target_temp_step: 1 # Number of degrees increase or decrease when changing the temperature | 0.5 < int < 5, 0.5 increments | default = 1 external_temperature_sensor: "None" # Sets a given temperature sensor as the sensor for the AC | str (Entity ID) | default = "None" external_humidity_sensor: "None" # Sets a given humidity sensor as the sensor for the AC | str (Entity ID) | default = "None" From 3dd1e2e053e9aede5582fe07082a8a7290fa27bd Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Mon, 20 Jul 2026 16:50:43 +0100 Subject: [PATCH 08/10] Fix entity support verification for entities not defined in device config Also move binary_sensor to the normalized code --- .../gree_custom/binary_sensor.py | 76 +++---------------- custom_components/gree_custom/entity.py | 3 + custom_components/gree_custom/number.py | 15 +--- .../gree_custom/platform_helpers.py | 47 +++++++----- custom_components/gree_custom/select.py | 16 ++-- custom_components/gree_custom/sensor.py | 28 ++----- custom_components/gree_custom/switch.py | 15 +--- 7 files changed, 63 insertions(+), 137 deletions(-) diff --git a/custom_components/gree_custom/binary_sensor.py b/custom_components/gree_custom/binary_sensor.py index f4988cf..72aa8e6 100644 --- a/custom_components/gree_custom/binary_sensor.py +++ b/custom_components/gree_custom/binary_sensor.py @@ -3,35 +3,24 @@ from collections.abc import Callable import logging -from attr import dataclass - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.const import CONF_MAC, EntityCategory +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .aiogree.device import GreeDevice -from .const import ( - CONF_ADVANCED, - CONF_DEVICES, - CONF_DISABLE_AVAILABLE_CHECK, - CONF_FEATURES, - CONF_TO_PROP_FEATURE_MAP, - DEFAULT_DISABLE_AVAILABLE_CHECK, - DEFAULT_SUPPORTED_FEATURES, - GATTR_FAULTS, -) +from .const import GATTR_FAULTS from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription +from .platform_helpers import iter_platform_context, supported_descriptions _LOGGER = logging.getLogger(__name__) -@dataclass(frozen=True, kw_only=True) class GreeBinarySensorDescription( GreeEntityDescription, BinarySensorEntityDescription, frozen_or_thawed=True ): @@ -47,14 +36,6 @@ class GreeBinarySensorDescription( device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, value_func=lambda device: device.has_hvac_error, - entity_registry_enabled_default=True, - entity_registry_visible_default=True, - force_update=False, - icon=None, - has_entity_name=True, - name=None, - translation_placeholders=None, - unit_of_measurement=None, ), ] @@ -67,59 +48,22 @@ async def async_setup_entry( """Set up binary sensors from a config entry.""" entities: list[GreeBinarySensor] = [] - - for d in entry.data.get(CONF_DEVICES, []): - mac = d.get(CONF_MAC, "") - coordinator: GreeCoordinator = entry.runtime_data[mac] - if not coordinator: - _LOGGER.error( - "Cannot create Gree Binary Sensors. No coordinator found for device '%s'", - mac, - ) - continue - - descriptions: list[GreeBinarySensorDescription] = [] - - conf_supported_features: list[str] = [] - supported_features: list[str] = [] - - if d.get(CONF_FEATURES, None) is None: - _LOGGER.warning("Undefined supported features") - - conf_supported_features = d.get(CONF_FEATURES, DEFAULT_SUPPORTED_FEATURES) - - # Check features with device support before addinig entities - for feature in conf_supported_features: - prop = CONF_TO_PROP_FEATURE_MAP.get(feature) - if prop and coordinator.device.supports_property(prop): - supported_features.append(feature) - - descriptions.extend( - [ - description - for description in SENSOR_TYPES - if description.key in supported_features - ] + for ctx in iter_platform_context(entry, "Binary Sensors"): + descriptions = supported_descriptions( + SENSOR_TYPES, + ctx.coordinator.device, + ctx.device_config, ) _LOGGER.debug( "Adding Binary Sensor Entities for device '%s': %s", - coordinator.device.mac_address, + ctx.coordinator.device.mac_address, [d.key for d in descriptions], ) entities.extend( [ - GreeBinarySensor( - description, - coordinator, - check_availability=( - not entry.data[CONF_ADVANCED].get( - CONF_DISABLE_AVAILABLE_CHECK, - DEFAULT_DISABLE_AVAILABLE_CHECK, - ) - ), - ) + GreeBinarySensor(description, ctx.coordinator, ctx.check_availability) for description in descriptions ] ) diff --git a/custom_components/gree_custom/entity.py b/custom_components/gree_custom/entity.py index 9e02ad6..aa8d6b5 100755 --- a/custom_components/gree_custom/entity.py +++ b/custom_components/gree_custom/entity.py @@ -81,6 +81,9 @@ def available(self): # pyright: ignore[reportIncompatibleVariableOverride] class GreeEntityDescription(EntityDescription, frozen_or_thawed=True): """Description of a Gree switch.""" + # Use this to override the feature that is checked with the device to evaluate support feature_key_override: str | None = None # Use this to conditionally block the entity availability independent of the device availability 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 diff --git a/custom_components/gree_custom/number.py b/custom_components/gree_custom/number.py index e9aa72e..a849587 100644 --- a/custom_components/gree_custom/number.py +++ b/custom_components/gree_custom/number.py @@ -19,12 +19,7 @@ from .const import GATTR_FEAT_HUMIDITY, GATTR_FEAT_HUMIDITY_TARGET from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription -from .platform_helpers import ( - entity_feature_key, - filter_descriptions, - iter_platform_context, - supported_features, -) +from .platform_helpers import iter_platform_context, supported_descriptions _LOGGER = logging.getLogger(__name__) @@ -83,14 +78,12 @@ async def async_setup_entry( entities: list[GreeNumber] = [] for ctx in iter_platform_context(entry, "Numbers"): - supported = supported_features( + descriptions = supported_descriptions( + NUMBER_TYPES, + ctx.coordinator.device, ctx.device_config, - ctx.coordinator, - [entity_feature_key(description) for description in NUMBER_TYPES], ) - descriptions = filter_descriptions(NUMBER_TYPES, supported) - _LOGGER.debug( "Adding Number Entities for device '%s': %s", ctx.coordinator.device.mac_address, diff --git a/custom_components/gree_custom/platform_helpers.py b/custom_components/gree_custom/platform_helpers.py index b53f85b..c72bb7e 100644 --- a/custom_components/gree_custom/platform_helpers.py +++ b/custom_components/gree_custom/platform_helpers.py @@ -8,6 +8,7 @@ from homeassistant.const import CONF_MAC from homeassistant.helpers.entity_platform import Any +from .aiogree.device import GreeDevice from .const import ( CONF_ADVANCED, CONF_DEVICES, @@ -72,33 +73,43 @@ def iter_platform_context( ) -def supported_features( - device_config: dict, coordinator: GreeCoordinator, subset: list[str] | None = None -) -> set[str]: - """Extracts supported features from a device config and device support.""" - features: list[str] = device_config.get( - CONF_FEATURES, - DEFAULT_SUPPORTED_FEATURES, +def supported_descriptions( + descriptions: Sequence[T], + device: GreeDevice, + device_config: dict | None = None, +) -> list[T]: + """Return the supported feature descriptions for a device. + + Args: + descriptions: `GreeEntityDescription` list of entity descriptions. + device: The device to check for property support, + device_config: Device configuration. If omitted, all ``descriptions`` are used. + """ + configured_features = ( + set(device_config.get(CONF_FEATURES, DEFAULT_SUPPORTED_FEATURES)) + if device_config is not None + else None ) - if subset is not None: - features = [feature for feature in features if feature in subset] + supported = [] - supported: set[str] = set() + for description in descriptions: + feature = entity_feature_key(description) + + if ( + configured_features is not None + and not description.auto_device_support + and feature not in configured_features + ): + continue - for feature in features: prop = CONF_TO_PROP_FEATURE_MAP.get(feature) - if prop and coordinator.device.supports_property(prop): - supported.add(feature) + if prop and device.supports_property(prop): + supported.append(description) return supported -def filter_descriptions(descriptions: Sequence[T], supported: set[str]) -> list[T]: - """Filters a list of entity descriptions based on a supported features list.""" - return [d for d in descriptions if entity_feature_key(d) in supported] - - def entity_feature_key(entity_description: T) -> str: """Returns the correct feature key for an entity description.""" # This is needed because the description dataclasses don't allow methods/properties diff --git a/custom_components/gree_custom/select.py b/custom_components/gree_custom/select.py index 8172dcf..853d9f6 100644 --- a/custom_components/gree_custom/select.py +++ b/custom_components/gree_custom/select.py @@ -20,12 +20,7 @@ from .const import DOMAIN, GATTR_FEAT_HUMIDITY, GATTR_TEMP_UNITS from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription -from .platform_helpers import ( - entity_feature_key, - filter_descriptions, - iter_platform_context, - supported_features, -) +from .platform_helpers import iter_platform_context, supported_descriptions _LOGGER = logging.getLogger(__name__) @@ -63,6 +58,7 @@ def _set_humidity_control_mode(device: GreeDevice, mode: str) -> None: SELECT_TYPES: list[GreeSelectDescription] = [ GreeSelectDescription( + auto_device_support=True, key=GATTR_TEMP_UNITS, translation_key=GATTR_TEMP_UNITS, entity_category=EntityCategory.CONFIG, @@ -97,14 +93,12 @@ async def async_setup_entry( entities: list[GreeSelect] = [] for ctx in iter_platform_context(entry, "Selects"): - supported = supported_features( + descriptions = supported_descriptions( + SELECT_TYPES, + ctx.coordinator.device, ctx.device_config, - ctx.coordinator, - [entity_feature_key(description) for description in SELECT_TYPES], ) - descriptions = filter_descriptions(SELECT_TYPES, supported) - _LOGGER.debug( "Adding Select Entities for device '%s': %s", ctx.coordinator.device.mac_address, diff --git a/custom_components/gree_custom/sensor.py b/custom_components/gree_custom/sensor.py index 67dcd27..4abd5c5 100644 --- a/custom_components/gree_custom/sensor.py +++ b/custom_components/gree_custom/sensor.py @@ -15,19 +15,10 @@ from homeassistant.helpers.restore_state import RestoreEntity from .aiogree.device import GreeDevice -from .const import ( - CONF_TO_PROP_FEATURE_MAP, - GATTR_HUMIDITY, - GATTR_INDOOR_TEMPERATURE, - GATTR_OUTDOOR_TEMPERATURE, -) +from .const import GATTR_HUMIDITY, GATTR_INDOOR_TEMPERATURE, GATTR_OUTDOOR_TEMPERATURE from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription -from .platform_helpers import ( - entity_feature_key, - filter_descriptions, - iter_platform_context, -) +from .platform_helpers import iter_platform_context, supported_descriptions _LOGGER = logging.getLogger(__name__) @@ -42,6 +33,7 @@ class GreeSensorDescription( SENSOR_TYPES: list[GreeSensorDescription] = [ GreeSensorDescription( + auto_device_support=True, key=GATTR_INDOOR_TEMPERATURE, translation_key=GATTR_INDOOR_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, @@ -51,6 +43,7 @@ class GreeSensorDescription( value_func=lambda device: device.indoors_temperature_c, ), GreeSensorDescription( + auto_device_support=True, key=GATTR_OUTDOOR_TEMPERATURE, translation_key=GATTR_OUTDOOR_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, @@ -60,6 +53,7 @@ class GreeSensorDescription( value_func=lambda device: device.outdoors_temperature_c, ), GreeSensorDescription( + auto_device_support=True, key=GATTR_HUMIDITY, translation_key=GATTR_HUMIDITY, device_class=SensorDeviceClass.HUMIDITY, @@ -82,15 +76,9 @@ async def async_setup_entry( for ctx in iter_platform_context(entry, "Sensors"): # Sensors are checked directly, not on the entry config - supported = [ - key - for description in SENSOR_TYPES - if ctx.coordinator.device.supports_property( - CONF_TO_PROP_FEATURE_MAP.get(key := entity_feature_key(description)) - ) - ] - - descriptions = filter_descriptions(SENSOR_TYPES, supported) + descriptions = supported_descriptions( + SENSOR_TYPES, ctx.coordinator.device, None + ) _LOGGER.debug( "Adding Sensor Entities for device '%s': %s", diff --git a/custom_components/gree_custom/switch.py b/custom_components/gree_custom/switch.py index 0e20849..aae091b 100644 --- a/custom_components/gree_custom/switch.py +++ b/custom_components/gree_custom/switch.py @@ -33,12 +33,7 @@ ) from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription -from .platform_helpers import ( - entity_feature_key, - filter_descriptions, - iter_platform_context, - supported_features, -) +from .platform_helpers import iter_platform_context, supported_descriptions _LOGGER = logging.getLogger(__name__) @@ -169,14 +164,12 @@ async def async_setup_entry( entities: list[GreeSwitch] = [] for ctx in iter_platform_context(entry, "Switches"): - supported = supported_features( + descriptions = supported_descriptions( + SWITCH_TYPES, + ctx.coordinator.device, ctx.device_config, - ctx.coordinator, - [entity_feature_key(description) for description in SWITCH_TYPES], ) - descriptions = filter_descriptions(SWITCH_TYPES, supported) - _LOGGER.debug( "Adding Switch Entities for device '%s': %s", ctx.coordinator.device.mac_address, From 7cf71bd6035f0f1aa02af9c5d5772de2695b50dc Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Tue, 21 Jul 2026 23:44:13 +0100 Subject: [PATCH 09/10] Remove uneccessary string argument in iter_platform_context --- custom_components/gree_custom/binary_sensor.py | 5 ++++- custom_components/gree_custom/climate.py | 4 +++- custom_components/gree_custom/diagnostics.py | 2 +- custom_components/gree_custom/number.py | 4 +++- custom_components/gree_custom/platform_helpers.py | 10 ++++------ custom_components/gree_custom/select.py | 4 +++- custom_components/gree_custom/sensor.py | 4 +++- custom_components/gree_custom/switch.py | 4 +++- 8 files changed, 24 insertions(+), 13 deletions(-) diff --git a/custom_components/gree_custom/binary_sensor.py b/custom_components/gree_custom/binary_sensor.py index 72aa8e6..e26626e 100644 --- a/custom_components/gree_custom/binary_sensor.py +++ b/custom_components/gree_custom/binary_sensor.py @@ -47,8 +47,11 @@ async def async_setup_entry( ) -> None: """Set up binary sensors from a config entry.""" + _LOGGER.debug("Setting up Binary Sensor Entities") + entities: list[GreeBinarySensor] = [] - for ctx in iter_platform_context(entry, "Binary Sensors"): + + for ctx in iter_platform_context(entry): descriptions = supported_descriptions( SENSOR_TYPES, ctx.coordinator.device, diff --git a/custom_components/gree_custom/climate.py b/custom_components/gree_custom/climate.py index 096bd64..26607ab 100644 --- a/custom_components/gree_custom/climate.py +++ b/custom_components/gree_custom/climate.py @@ -78,9 +78,11 @@ async def async_setup_entry( ) -> None: """Set up sensors from a config entry.""" + _LOGGER.debug("Setting up Climate Entities") + entities: list[GreeClimate] = [] - for ctx in iter_platform_context(entry, "Climates"): + for ctx in iter_platform_context(entry): hvac_modes: list[HVACMode] = [ HVACMode[mode.upper()] for mode in ( diff --git a/custom_components/gree_custom/diagnostics.py b/custom_components/gree_custom/diagnostics.py index e85b6ec..2af4071 100644 --- a/custom_components/gree_custom/diagnostics.py +++ b/custom_components/gree_custom/diagnostics.py @@ -47,7 +47,7 @@ async def async_get_device_diagnostics( mac = identifier break - coordinator = entry.runtime_data.get(mac, None) + coordinator: GreeCoordinator | None = entry.runtime_data.get(mac, None) return { "device": device.dict_repr, diff --git a/custom_components/gree_custom/number.py b/custom_components/gree_custom/number.py index a849587..59a5d3d 100644 --- a/custom_components/gree_custom/number.py +++ b/custom_components/gree_custom/number.py @@ -75,9 +75,11 @@ async def async_setup_entry( ) -> None: """Set up switches from a config entry.""" + _LOGGER.debug("Setting up Number Entities") + entities: list[GreeNumber] = [] - for ctx in iter_platform_context(entry, "Numbers"): + for ctx in iter_platform_context(entry): descriptions = supported_descriptions( NUMBER_TYPES, ctx.coordinator.device, diff --git a/custom_components/gree_custom/platform_helpers.py b/custom_components/gree_custom/platform_helpers.py index c72bb7e..771c9c0 100644 --- a/custom_components/gree_custom/platform_helpers.py +++ b/custom_components/gree_custom/platform_helpers.py @@ -41,7 +41,6 @@ class GreePlatformContext: def iter_platform_context( entry: GreeConfigEntry, - platform: str, ) -> Iterator[GreePlatformContext]: """Yield context for every configured device.""" @@ -56,8 +55,7 @@ def iter_platform_context( coordinator = entry.runtime_data.get(mac) if coordinator is None: _LOGGER.error( - "Cannot create Gree %s. No coordinator found for device '%s'", - platform, + "No coordinator found for device '%s'", mac, ) continue @@ -76,7 +74,7 @@ def iter_platform_context( def supported_descriptions( descriptions: Sequence[T], device: GreeDevice, - device_config: dict | None = None, + device_config: dict[str, Any] | None = None, ) -> list[T]: """Return the supported feature descriptions for a device. @@ -85,13 +83,13 @@ def supported_descriptions( device: The device to check for property support, device_config: Device configuration. If omitted, all ``descriptions`` are used. """ - configured_features = ( + configured_features: list[str] = ( set(device_config.get(CONF_FEATURES, DEFAULT_SUPPORTED_FEATURES)) if device_config is not None else None ) - supported = [] + supported: list[T] = [] for description in descriptions: feature = entity_feature_key(description) diff --git a/custom_components/gree_custom/select.py b/custom_components/gree_custom/select.py index 853d9f6..df57553 100644 --- a/custom_components/gree_custom/select.py +++ b/custom_components/gree_custom/select.py @@ -90,9 +90,11 @@ async def async_setup_entry( ) -> None: """Set up switches from a config entry.""" + _LOGGER.debug("Setting up Select Entities") + entities: list[GreeSelect] = [] - for ctx in iter_platform_context(entry, "Selects"): + for ctx in iter_platform_context(entry): descriptions = supported_descriptions( SELECT_TYPES, ctx.coordinator.device, diff --git a/custom_components/gree_custom/sensor.py b/custom_components/gree_custom/sensor.py index 4abd5c5..e5ecd12 100644 --- a/custom_components/gree_custom/sensor.py +++ b/custom_components/gree_custom/sensor.py @@ -72,9 +72,11 @@ async def async_setup_entry( ) -> None: """Set up sensors from a config entry.""" + _LOGGER.debug("Setting up Sensor Entities") + entities: list[GreeSensor] = [] - for ctx in iter_platform_context(entry, "Sensors"): + for ctx in iter_platform_context(entry): # Sensors are checked directly, not on the entry config descriptions = supported_descriptions( SENSOR_TYPES, ctx.coordinator.device, None diff --git a/custom_components/gree_custom/switch.py b/custom_components/gree_custom/switch.py index aae091b..f9db1e2 100644 --- a/custom_components/gree_custom/switch.py +++ b/custom_components/gree_custom/switch.py @@ -161,9 +161,11 @@ async def async_setup_entry( ) -> None: """Set up switches from a config entry.""" + _LOGGER.debug("Setting up Switch Entities") + entities: list[GreeSwitch] = [] - for ctx in iter_platform_context(entry, "Switches"): + for ctx in iter_platform_context(entry): descriptions = supported_descriptions( SWITCH_TYPES, ctx.coordinator.device, From 86b95de3ffcc99cc4093411a8dfa77f611fd22f4 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Wed, 22 Jul 2026 00:14:33 +0100 Subject: [PATCH 10/10] Improve aiogree docstrings --- custom_components/gree_custom/aiogree/api.py | 74 ++++++++++--------- .../gree_custom/aiogree/device.py | 9 ++- .../gree_custom/aiogree/transport.py | 2 +- custom_components/gree_custom/coordinator.py | 32 +++++--- 4 files changed, 65 insertions(+), 52 deletions(-) diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index 7e590a4..6ba9891 100644 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -16,7 +16,7 @@ class GreeProp(StrEnum): - """Enumeration of Gree device properties.""" + """Enumeration of device properties.""" # HVAC CONTROLS # power state of the device @@ -91,7 +91,7 @@ class GreeProp(StrEnum): class OtherProps(StrEnum): - """Enumeration of other Gree device properties.""" + """Enumeration of additional device properties.""" _UNKN_MODEL = "ModelType" _UNKN_ACStupPos = "ACStupPos" @@ -397,7 +397,7 @@ class VerticalSwingMode(IntEnum): @unique class SleepMode(IntEnum): - """Enumeration of sleep modes types.""" + """Enumeration of sleep mode types.""" disabled = 0 normal = 1 @@ -424,7 +424,7 @@ class GreeCommand(IntEnum): @dataclass class GreeDiscoveredDevice: - """Device discovered data.""" + """Information about a discovered Gree device.""" name: str host: str @@ -439,7 +439,7 @@ class GreeDiscoveredDevice: async def get_result_pack( json_data: dict, cipher: CipherBase, transport: GreeTransport ) -> dict: - """Get the result pack from the device (async).""" + """Send a request to the device and return the decoded response pack.""" try: recv_json = await transport.request_json(json_data) @@ -471,7 +471,7 @@ def get_gree_response_data( recv_json: dict, cipher: CipherBase, ) -> dict: - """Decodes a response from a gree device.""" + """Decode and decrypt a response from a Gree device.""" encoded_pack = recv_json.get("pack") tag = recv_json.get("tag") @@ -488,7 +488,7 @@ def gree_encrypt_pack( pack: dict, cipher: CipherBase, ) -> tuple[str, str | None]: - """Create an encrypted pack to send to the device.""" + """Encrypt a protocol pack for transmission to the device.""" if cipher is None: raise GreeError("Cipher must not be None") @@ -503,7 +503,7 @@ def gree_encrypt_pack( def gree_create_bind_pack(mac_addr: str, uid: int, cipher: CipherBase) -> dict: - """Create a bind pack to send to the device.""" + """Create a bind request pack.""" pack: dict = {} @@ -517,7 +517,7 @@ def gree_create_bind_pack(mac_addr: str, uid: int, cipher: CipherBase) -> dict: def gree_create_sub_bind_pack(mac_addr: str) -> dict: - """Create a bind pack to send to the device.""" + """Create a sub-device bind request pack.""" pack: dict = {"mac": mac_addr, "i": 1} @@ -526,7 +526,7 @@ def gree_create_sub_bind_pack(mac_addr: str) -> dict: def gree_create_status_pack(mac_addr: str, props: list[str]) -> dict: - """Create a status pack to send to the device.""" + """Create a status request pack.""" pack: dict = {"cols": props, "mac": mac_addr, "t": "status"} @@ -535,7 +535,7 @@ def gree_create_status_pack(mac_addr: str, props: list[str]) -> dict: def gree_create_set_pack(mac_addr: str, props: dict[GreeProp, int]) -> dict: - """Create a set pack to send to the device.""" + """Create a command pack to update device properties.""" pack: dict = { "opt": [prop.value for prop in props], @@ -556,7 +556,7 @@ def gree_create_payload( uid: int, tag: str | None, ) -> dict: - """Create the full payload to send to the device.""" + """Create a protocol payload containing an encrypted pack.""" payload: dict[str, Any] = { "cid": "app", @@ -581,10 +581,12 @@ async def gree_try_bind( key: str | None, transport: GreeTransport, ) -> tuple[str, EncryptionVersion]: - """Perform bind request to the device and return the valid version and key (async). + """Bind to the device and determine the correct encryption settings. - Performs the bind with the provided key or version. Falls back to generic keys. - If the provided key or version do not match the device, the function will return the correct device key and version. + Attempts binding using the provided encryption version and/or key when + available. If binding fails, falls back to the default encryption + versions and returns the encryption key and version accepted by the + device. """ ret_key: str = "" @@ -688,21 +690,15 @@ async def gree_get_status( cipher: CipherBase, transport: GreeTransport, ) -> tuple[dict[str, str], list[str]]: - """Get the status of the device by sending a status request to the device (async). Also returns the props not present. + """Retrieve the current values of the requested device properties. - Gree Protocol is a best-effort key/value response with no guaranteed completeness + Returns a mapping of property names to values, along with a list of + properties that were not returned by the device. - If a invalid prop is requested the response will not have it which is good - However, some "invalid" props are returned in the response with no data, making it impossible to know in a batch where they are - Note: Invalid != Unsupported - - Meaning: - - cols = what the device claims it is returning - dat = best-effort values, possibly incomplete - alignment between them is not guaranteed globally - - As such, it is only safe to batch props that are known to work. + The Gree protocol provides best-effort responses, meaning requested + properties may be omitted or returned without corresponding values. + This makes it impossible to know in a batch where they are. + Callers should therefore only batch properties known to be supported. """ _LOGGER.debug("Getting status for device '%s'", mac_addr) @@ -726,6 +722,12 @@ async def gree_get_status( except Exception as err: raise GreeProtocolError("Error getting device status") from err + # Gree protocol provides best-effort responses + # Meaning: + # cols = what the device claims it is returning + # dat = best-effort values, possibly incomplete + # alignment between them is not guaranteed globally + cols = result.get("cols") dat = result.get("dat") @@ -768,8 +770,10 @@ async def gree_set_status( cipher: CipherBase, transport: GreeTransport, ) -> dict[GreeProp, int]: - """Set the status of the device by sending a status request to the device (async).""" + """Update one or more device properties. + Returns the property values acknowledged by the device. + """ _LOGGER.debug("Trying to set device status") pack = gree_create_set_pack(mac_addr, props) @@ -825,7 +829,7 @@ async def gree_set_status( async def gree_get_device_info( transport: GreeTransport, cipher: CipherBase | None = None ) -> dict[str, str | dict | None]: - """Tries to retrive the device info.""" + """Retrieve device information from a scan response.""" data: dict = await get_result_pack( {"t": "scan"}, @@ -837,14 +841,14 @@ async def gree_get_device_info( info: dict[str, str | dict | None] = {} info["raw"] = data - info["firmware_version"], info["firmware_code"] = extract_version(data) + info["firmware_version"], info["firmware_code"] = _extract_fw_version(data) info["mac"] = data.get("mac", "") info["subdevices_count"] = data.get("subCnt", 0) return info -def extract_version(info: dict) -> tuple[str | None, str | None]: - """Finds the firmware info.""" +def _extract_fw_version(info: dict) -> tuple[str | None, str | None]: + """Extract the firmware version and device identifier from device information.""" hid = info.get("hid", "") ver_match = re.search(r"V([\d.]+)\.bin", hid) if ver_match: @@ -861,7 +865,7 @@ def extract_version(info: dict) -> tuple[str | None, str | None]: async def discover_gree_devices( broadcast_addresses: list[str], timeout: int ) -> list[GreeDiscoveredDevice]: - """Discovers gree devices in the network.""" + """Discover Gree devices on the specified broadcast networks.""" discovered_devices: list[GreeDiscoveredDevice] = [] @@ -945,7 +949,7 @@ async def discover_gree_devices( async def gree_get_sub_devices_list( mac_addr: str, uid: int, cipher: CipherBase, transport: GreeTransport ) -> list: - """Fetch the list of sub-devices for a Gree device.""" + """Retrieve the list of sub-devices exposed by a main controller device.""" try: pack = gree_create_sub_bind_pack(mac_addr) encrypted_pack, tag = gree_encrypt_pack( diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index d3c6cd4..9a8d47d 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -1022,8 +1022,8 @@ def feature_humidity_control(self) -> HumidityControlMode: def set_feature_humidity_control(self, mode: HumidityControlMode) -> None: """Sets the Humidy Control mode. - HumidityControlMode.smart_dry is only available under `Cool` mode. - HumidityControlMode.continuous_dry is only available under `Dry` mode. + `HumidityControlMode.smart_dry` is only available under `Cool` mode. + `HumidityControlMode.continuous_dry` is only available under `Dry` mode. """ if mode != HumidityControlMode.disabled and self.operation_mode not in ( @@ -1087,9 +1087,10 @@ def feature_humidity_control_target(self) -> int: def set_feature_humidity_control_target( self, humidity_target_percentage: int ) -> None: - """Sets the target humidity percentage. + """Sets the target humidity percentage (in multiples of 5). - The device only accepts multiples of 5 in a range from 40% to 80%. + Cool mode range: 40-80. + Dry mode range: 30-70. """ if self.feature_humidity_control is not HumidityControlMode.target_dry: diff --git a/custom_components/gree_custom/aiogree/transport.py b/custom_components/gree_custom/aiogree/transport.py index 5f155e1..8432bfc 100644 --- a/custom_components/gree_custom/aiogree/transport.py +++ b/custom_components/gree_custom/aiogree/transport.py @@ -143,7 +143,7 @@ def connection_lost(self, exc): async def async_udp_broadcast_request( broadcast_addresses: list[str], port: int, json_data: str, timeout: int ) -> dict[str, dict]: - """Sends an async UDP broadcast and waits for responses.""" + """Send a UDP broadcast and waits for responses.""" loop = asyncio.get_running_loop() responses: dict[str, dict] = {} diff --git a/custom_components/gree_custom/coordinator.py b/custom_components/gree_custom/coordinator.py index 251619d..bd5a8a9 100644 --- a/custom_components/gree_custom/coordinator.py +++ b/custom_components/gree_custom/coordinator.py @@ -18,6 +18,7 @@ _LOGGER = logging.getLogger(__name__) +# Home Assistant config entry containing Gree coordinators keyed by normalized MAC addresses ("xxxxxxxxxxxx"). type GreeConfigEntry = ConfigEntry[dict[str, GreeCoordinator]] @@ -31,7 +32,7 @@ def __init__( device: GreeDevice, scan_interval: int, ) -> None: - """Initialize coordinator.""" + """Initialize the coordinator for a Gree device.""" super().__init__( hass, _LOGGER, @@ -45,21 +46,20 @@ def __init__( self._feature_auto_light: bool = False async def _async_setup(self): - """Set up the coordinator. + """Bind to the device before the first coordinator refresh. - This is the place to set up your coordinator, - or to load data, that only needs to be loaded once. - - This method will be called automatically during - coordinator.async_config_entry_first_refresh. + This is called automatically by + `coordinator.async_config_entry_first_refresh()` and performs + one-time initialization required before regular updates begin. """ await self.device.bind_device() async def _async_update_data(self): - """Fetch data from API endpoint. + """Updates the device with he latest state. - This is the place to pre-process the data to lookup tables - so entities can quickly look up their data. + If communication fails due to a connection error, the coordinator + attempts to discover the device's new IP address and retries the + request once before reporting the update as failed. """ try: await self.device.fetch_device_status() @@ -83,7 +83,11 @@ async def _async_update_data(self): raise UpdateFailed("Error getting state from device") from err async def push_device_status(self): - """Pushes the transient state to the device.""" + """Push the current transient state to the device. + + If communication fails because the device IP has changed, attempt + to rediscover the device and retry the request once. + """ try: await self.device.push_device_status() except GreeConnectionError: @@ -94,7 +98,11 @@ async def push_device_status(self): await self.device.push_device_status() def get_coordinator_diagnostics(self) -> dict[str, Any]: - """Returns diagnostic data for the coordinator.""" + """Return diagnostic information for the coordinator. + + Includes device diagnostics along with coordinator-specific + configuration and feature flags. + """ data = self.device.gather_diagnostics() data["coordinator_props"] = { "auto_light": self.feature_auto_light,