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/custom_components/gree_custom/__init__.py b/custom_components/gree_custom/__init__.py index 70564f7..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 @@ -47,6 +45,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 613f415..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 @@ -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 @@ -62,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_CONTROL = "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 @@ -86,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" @@ -118,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" @@ -392,6 +395,26 @@ class VerticalSwingMode(IntEnum): swing_lower = 11 +@unique +class SleepMode(IntEnum): + """Enumeration of sleep mode types.""" + + disabled = 0 + normal = 1 + advanced = 2 + diy = 3 + + +@unique +class HumidityControlMode(IntEnum): + """Enumeration of the humidity control modes.""" + + disabled = 15 + target_dry = 0 + 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): """Enumeration of Gree commands.""" @@ -401,7 +424,7 @@ class GreeCommand(IntEnum): @dataclass class GreeDiscoveredDevice: - """Device discovered data.""" + """Information about a discovered Gree device.""" name: str host: str @@ -416,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) @@ -448,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") @@ -465,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") @@ -480,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 = {} @@ -494,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} @@ -503,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"} @@ -512,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], @@ -533,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", @@ -558,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 = "" @@ -665,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. - - Gree Protocol is a best-effort key/value response with no guaranteed completeness - - 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 + """Retrieve the current values of the requested device properties. - Meaning: + Returns a mapping of property names to values, along with a list of + properties that were not returned by the device. - 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) @@ -703,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") @@ -745,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) @@ -802,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"}, @@ -814,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: @@ -838,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] = [] @@ -922,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/const.py b/custom_components/gree_custom/aiogree/const.py index 9114aa2..ec7208e 100644 --- a/custom_components/gree_custom/aiogree/const.py +++ b/custom_components/gree_custom/aiogree/const.py @@ -6,5 +6,11 @@ MIN_TEMP_F = 61 MAX_TEMP_F = 86 +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 3fc12a1..9a8d47d 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -11,8 +11,10 @@ GreeDiscoveredDevice, GreeProp, HorizontalSwingMode, + HumidityControlMode, OperationMode, OtherProps, + SleepMode, TemperatureUnits, VerticalSwingMode, gree_get_device_info, @@ -22,10 +24,33 @@ gree_try_bind, ) from .cipher import CipherBase, get_cipher -from .const import DEFAULT_DEVICE_UID -from .errors import GreeBindingError, GreeConnectionError, GreeError, GreeProtocolError +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, + GreeContinuousDryUnavailable, + GreeEnergySavingUnavailable, + GreeError, + GreeHumidityControlTargetUnavailable, + GreeHumidityControlUnavailable, + GreeProtocolError, + GreeQuietIgnored, + GreeSleepUnavailable, + GreeSmartDryUnavailable, + GreeSmartHeatUnavailable, + GreeTurboIgnored, + GreeTurboUnavailable, +) 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, @@ -57,6 +82,7 @@ def __init__( uid: int = DEFAULT_DEVICE_UID, max_connection_attempts: int = 5, timeout: int = 10, + capabilities: list[GreeProp] | None = None, ) -> None: """Initialize the Gree device.""" @@ -88,6 +114,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 @@ -95,9 +123,14 @@ def __init__( self._raw_state: dict[GreeProp, int] = {} self._new_raw_state: dict[GreeProp, int] = {} + + 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 - self._uniqueid: str = self._mac_addr self._props_to_update: list[GreeProp] = list(GreeProp) # Don't poll the beeper state @@ -312,7 +345,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) @@ -327,7 +366,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) @@ -367,6 +406,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_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_CONTROL) + self._props_to_update.remove(GreeProp.FEATURE_HUMIDITY_TARGET) + 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_CONTROL, 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: @@ -473,7 +528,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 +541,12 @@ 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 + + supported = property in self._raw_state and property in self._capabilities + return supported or property in ( + GreeProp.BEEPER, + GreeProp.BEEPER_NEW, + ) @property def ip(self) -> str: @@ -618,6 +678,10 @@ def operation_mode(self) -> OperationMode: def set_operation_mode(self, mode: OperationMode): """Sets the device operation mode.""" + + # Force disable Humidity Control + self.set_feature_humidity_control(HumidityControlMode.disabled) + self._set_device_status({GreeProp.OP_MODE: mode}) @property @@ -626,7 +690,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 +763,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 +837,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 +899,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 +918,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 +946,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 +976,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 @@ -807,3 +1008,107 @@ 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_CONTROL, HumidityControlMode.disabled.value + ) + ) + + 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. + """ + + 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 == HumidityControlMode.smart_dry + and self.operation_mode is not OperationMode.cool + ): + raise GreeSmartDryUnavailable( + "Smart Dry is only available in Cool operation mode" + ) + + 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: + 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 # It's possible the device ignores this value in this mode + + case HumidityControlMode.continuous_dry: + target = 3 # It's possible the device ignores this value in this mode + + self._set_device_status( + { + GreeProp.FEATURE_HUMIDITY_CONTROL: 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) + return gree_get_target_humidity_p(raw_value) + + def set_feature_humidity_control_target( + self, humidity_target_percentage: int + ) -> None: + """Sets the target humidity percentage (in multiples of 5). + + Cool mode range: 40-80. + Dry mode range: 30-70. + """ + + if self.feature_humidity_control is not HumidityControlMode.target_dry: + raise GreeHumidityControlTargetUnavailable( + "Humidity Control with a target humidity is only available in Normal Dry mode" + ) + + 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( + { + GreeProp.FEATURE_HUMIDITY_TARGET: target, + } + ) diff --git a/custom_components/gree_custom/aiogree/errors.py b/custom_components/gree_custom/aiogree/errors.py index 196ac93..f288fd8 100644 --- a/custom_components/gree_custom/aiogree/errors.py +++ b/custom_components/gree_custom/aiogree/errors.py @@ -15,3 +15,47 @@ 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.""" + + +class GreeHumidityControlUnavailable(GreeUnsupportedState): + """Humidity Control is only available under Cool mode.""" + + +class GreeContinuousDryUnavailable(GreeUnsupportedState): + """Humidity Control Continuous 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 1142f41..e88f862 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 @@ -173,3 +173,40 @@ 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_humidity_percentage: int, min_val: int, max_val: int +) -> int: + """Calculates the prop value for a given humidity percentage.""" + + 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_val, + ) + desired_humidity_percentage = max_val + + 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_val, + ) + desired_humidity_percentage = min_val + + if desired_humidity_percentage % 5 != 0: + _LOGGER.warning( + "Humidity target %s is not a multiple of 5; rounding to the nearest multiple", + desired_humidity_percentage, + ) + desired_humidity_percentage = round(desired_humidity_percentage / 5) * 5 + + return int((desired_humidity_percentage - 15) / 5) + + +def gree_get_target_humidity_p(Dwet: int) -> int: + """Return a humidity percentage based on the device property value.""" + + return 5 * Dwet + 15 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/binary_sensor.py b/custom_components/gree_custom/binary_sensor.py index 3a2f3ab..e26626e 100644 --- a/custom_components/gree_custom/binary_sensor.py +++ b/custom_components/gree_custom/binary_sensor.py @@ -3,39 +3,29 @@ 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): +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] @@ -46,14 +36,6 @@ class GreeBinarySensorDescription(GreeEntityDescription, BinarySensorEntityDescr 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, ), ] @@ -65,60 +47,26 @@ async def async_setup_entry( ) -> None: """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] = [] + _LOGGER.debug("Setting up Binary Sensor Entities") - 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) + entities: list[GreeBinarySensor] = [] - descriptions.extend( - [ - description - for description in SENSOR_TYPES - if description.key in supported_features - ] + for ctx in iter_platform_context(entry): + 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/climate.py b/custom_components/gree_custom/climate.py index 6ddb280..26607ab 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,27 +31,21 @@ 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 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, - 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, @@ -67,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, @@ -98,29 +78,24 @@ async def async_setup_entry( ) -> None: """Set up sensors from a config entry.""" - entities: list[GreeClimate] = [] + _LOGGER.debug("Setting up Climate Entities") - 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 + entities: list[GreeClimate] = [] + for ctx in iter_platform_context(entry): 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, @@ -130,8 +105,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( @@ -144,8 +119,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( @@ -165,7 +140,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( @@ -174,20 +149,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), ) ) @@ -695,10 +672,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 +702,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 +715,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/config_flow.py b/custom_components/gree_custom/config_flow.py index 8501fe7..1b0d007 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, @@ -291,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): @@ -303,6 +300,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_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 f9dcbc5..f23a2e1 100755 --- a/custom_components/gree_custom/const.py +++ b/custom_components/gree_custom/const.py @@ -75,11 +75,15 @@ 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" GATTR_OUTDOOR_TEMPERATURE = "outdoor_temperature" -GATTR_HUMIDITY = "rooom_humidity" +GATTR_HUMIDITY = "room_humidity" GATTR_FAULTS = "faults" @@ -92,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, @@ -100,8 +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, } # HVAC modes - these come from Home Assistant and are standard 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, 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/entity.py b/custom_components/gree_custom/entity.py index f8c048a..aa8d6b5 100755 --- a/custom_components/gree_custom/entity.py +++ b/custom_components/gree_custom/entity.py @@ -1,9 +1,6 @@ """Base entity for Gree integration.""" -from __future__ import annotations - 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 @@ -81,16 +78,12 @@ 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 - + # 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] = field( - default=lambda _: True - ) + additional_available_func: Callable[[GreeDevice], bool] = lambda _: True + # True if the support can be evaluated with the device API only, false if support must be explicit in device config + auto_device_support: bool = False 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/manifest.json b/custom_components/gree_custom/manifest.json index 0e2aece..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.101" + "version": "4.0.0-alpha.103" } diff --git a/custom_components/gree_custom/number.py b/custom_components/gree_custom/number.py new file mode 100644 index 0000000..59a5d3d --- /dev/null +++ b/custom_components/gree_custom/number.py @@ -0,0 +1,165 @@ +"""Support for Gree number entities (e.g., target humidity control).""" + +from collections.abc import Callable +import logging + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +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 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 GATTR_FEAT_HUMIDITY, GATTR_FEAT_HUMIDITY_TARGET +from .coordinator import GreeConfigEntry, GreeCoordinator +from .entity import GreeEntity, GreeEntityDescription +from .platform_helpers import iter_platform_context, supported_descriptions + +_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, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up switches from a config entry.""" + + _LOGGER.debug("Setting up Number Entities") + + entities: list[GreeNumber] = [] + + for ctx in iter_platform_context(entry): + descriptions = supported_descriptions( + NUMBER_TYPES, + ctx.coordinator.device, + ctx.device_config, + ) + + _LOGGER.debug( + "Adding Number Entities for device '%s': %s", + ctx.coordinator.device.mac_address, + [d.key for d in descriptions], + ) + + entities.extend( + GreeNumber( + description, ctx.coordinator, ctx.restore_state, ctx.check_availability + ) + for description in descriptions + ) + + async_add_entities(entities) + + +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_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.""" + 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/platform_helpers.py b/custom_components/gree_custom/platform_helpers.py new file mode 100644 index 0000000..771c9c0 --- /dev/null +++ b/custom_components/gree_custom/platform_helpers.py @@ -0,0 +1,114 @@ +"""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 .aiogree.device import GreeDevice +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, +) -> 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( + "No coordinator found for device '%s'", + 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_descriptions( + descriptions: Sequence[T], + device: GreeDevice, + device_config: dict[str, Any] | 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: list[str] = ( + set(device_config.get(CONF_FEATURES, DEFAULT_SUPPORTED_FEATURES)) + if device_config is not None + else None + ) + + supported: list[T] = [] + + 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 + + prop = CONF_TO_PROP_FEATURE_MAP.get(feature) + if prop and device.supports_property(prop): + supported.append(description) + + return 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 c2b2fff..df57553 100644 --- a/custom_components/gree_custom/select.py +++ b/custom_components/gree_custom/select.py @@ -2,34 +2,85 @@ from collections.abc import Callable import logging -from typing import Generic, 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, TemperatureUnits +from .aiogree.api import HumidityControlMode, OperationMode, TemperatureUnits from .aiogree.device import GreeDevice -from .const import ( - CONF_ADVANCED, - CONF_DEVICES, - CONF_DISABLE_AVAILABLE_CHECK, - CONF_RESTORE_STATES, - DEFAULT_DISABLE_AVAILABLE_CHECK, - DEFAULT_RESTORE_STATES, - GATTR_TEMP_UNITS, +from .aiogree.errors import ( + GreeContinuousDryUnavailable, + GreeHumidityControlUnavailable, + GreeSmartDryUnavailable, ) +from .const import DOMAIN, GATTR_FEAT_HUMIDITY, GATTR_TEMP_UNITS from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription +from .platform_helpers import iter_platform_context, supported_descriptions _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: + 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 GreeSmartDryUnavailable as err: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="smart_dry_unavailable" + ) from err + + except GreeContinuousDryUnavailable as err: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="continuous_dry_unavailable" + ) from err + + +SELECT_TYPES: list[GreeSelectDescription] = [ + GreeSelectDescription( + auto_device_support=True, + 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( @@ -39,80 +90,31 @@ async def async_setup_entry( ) -> None: """Set up switches from a config entry.""" + _LOGGER.debug("Setting up Select Entities") + 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): + descriptions = supported_descriptions( + SELECT_TYPES, + ctx.coordinator.device, + ctx.device_config, + ) _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(GreeEntityDescription, SelectEntityDescription, Generic[T]): - """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.""" @@ -144,11 +146,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/sensor.py b/custom_components/gree_custom/sensor.py index 8c728dd..e5ecd12 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,62 @@ 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, - 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 iter_platform_context, supported_descriptions _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( + auto_device_support=True, + 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( + auto_device_support=True, + 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( + auto_device_support=True, + 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, @@ -39,86 +72,30 @@ async def async_setup_entry( ) -> None: """Set up sensors from a config entry.""" + _LOGGER.debug("Setting up Sensor Entities") + 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 are checked directly, not on the entry config + descriptions = supported_descriptions( + SENSOR_TYPES, ctx.coordinator.device, None + ) _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 4b53bc8..f9db1e2 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 +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,14 @@ ) 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 GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): +class GreeSwitchDescription( + GreeEntityDescription, SwitchEntityDescription, frozen_or_thawed=True +): """Description of a Gree switch.""" set_func: Callable[[GreeDevice, GreeCoordinator, bool], None] @@ -73,21 +65,34 @@ 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, 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 not SleepMode.disabled, + 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 +111,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), ), @@ -124,6 +132,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, @@ -134,24 +153,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, @@ -160,101 +161,45 @@ async def async_setup_entry( ) -> None: """Set up switches from a config 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] = [] + _LOGGER.debug("Setting up Switch Entities") - 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 - ) + entities: list[GreeSwitch] = [] - 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 - ] + for ctx in iter_platform_context(entry): + descriptions = supported_descriptions( + SWITCH_TYPES, + ctx.coordinator.device, + ctx.device_config, ) _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) diff --git a/custom_components/gree_custom/translations/en.json b/custom_components/gree_custom/translations/en.json index 17bf465..590fb21 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": { @@ -301,10 +314,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." + }, + "turbo_ignored": { + "message": "Turbo mode is ignored when Energy Saving or Smart Heat are enabled." }, - "quiet_availability": { - "message": "Quiet mode is only available in Dry and Cool modes." + "quiet_ignored": { + "message": "Quiet mode is ignored when Energy Saving or Smart Heat are enabled." }, "entity_unavailable": { "message": "The entity is unavailable." @@ -323,6 +339,15 @@ }, "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 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 1900450..bcf657e 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": "Secar", + "smart_dry": "Secagem Inteligente", + "continuous_dry": "Secagem Contínua" + } } }, "switch": { @@ -309,10 +322,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." @@ -331,6 +347,15 @@ }, "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 nos modos de Arrefecer e Secar." + }, + "continuous_dry_unavailable": { + "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": { 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"