From b5d00f7ea4e4d38af230d1b5435513cd240c1bc3 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Tue, 16 Sep 2025 21:20:32 +0100 Subject: [PATCH 001/113] Initial implementation of Gree climate integration component rewrite - Change config flow to: be multi step and allow setting the device features; allow to reconfigure an entry - Separate HA and Device API logic: device API is now declarative and handles device behaviour. HA entities expose the device - Implement a coordinator - Async device communication - More error handling --- custom_components/gree/__init__.py | 118 +- custom_components/gree/climate.py | 1330 ++++++----------- custom_components/gree/config_flow.py | 489 +++--- custom_components/gree/const.py | 251 +++- custom_components/gree/coordinator.py | 66 + custom_components/gree/entity.py | 61 +- custom_components/gree/gree_api.py | 622 ++++++++ custom_components/gree/gree_const.py | 15 + custom_components/gree/gree_device.py | 601 ++++++++ custom_components/gree/gree_device_api.py | 377 +++++ custom_components/gree/gree_helpers.py | 136 ++ custom_components/gree/gree_protocol.py | 311 ---- custom_components/gree/icons.json | 82 +- custom_components/gree/manifest.json | 3 +- custom_components/gree/number.py | 41 +- custom_components/gree/old_climate.py | 1185 +++++++++++++++ custom_components/gree/old_config_flow.py | 143 ++ custom_components/gree/old_gree_protocol.py | 91 ++ .../gree/{helpers.py => old_helpers.py} | 0 custom_components/gree/old_select.py | 160 ++ custom_components/gree/old_sensor.py | 117 ++ custom_components/gree/old_switch.py | 207 +++ custom_components/gree/select.py | 254 ++-- custom_components/gree/sensor.py | 164 +- custom_components/gree/switch.py | 367 ++--- custom_components/gree/translations/de.json | 4 + custom_components/gree/translations/en.json | 262 ++-- custom_components/gree/translations/he.json | 4 + custom_components/gree/translations/hu.json | 4 + custom_components/gree/translations/it.json | 4 + custom_components/gree/translations/pl.json | 4 + .../gree/translations/pt-BR.json | 4 + custom_components/gree/translations/ro.json | 4 + custom_components/gree/translations/ru.json | 4 + .../gree/translations/zh-Hans.json | 4 + 35 files changed, 5557 insertions(+), 1932 deletions(-) mode change 100644 => 100755 custom_components/gree/__init__.py mode change 100644 => 100755 custom_components/gree/const.py create mode 100644 custom_components/gree/coordinator.py mode change 100644 => 100755 custom_components/gree/entity.py create mode 100644 custom_components/gree/gree_api.py create mode 100644 custom_components/gree/gree_const.py create mode 100755 custom_components/gree/gree_device.py create mode 100644 custom_components/gree/gree_device_api.py create mode 100644 custom_components/gree/gree_helpers.py delete mode 100644 custom_components/gree/gree_protocol.py mode change 100644 => 100755 custom_components/gree/icons.json mode change 100644 => 100755 custom_components/gree/manifest.json mode change 100644 => 100755 custom_components/gree/number.py create mode 100755 custom_components/gree/old_climate.py create mode 100755 custom_components/gree/old_config_flow.py create mode 100755 custom_components/gree/old_gree_protocol.py rename custom_components/gree/{helpers.py => old_helpers.py} (100%) mode change 100644 => 100755 create mode 100755 custom_components/gree/old_select.py create mode 100644 custom_components/gree/old_sensor.py create mode 100755 custom_components/gree/old_switch.py mode change 100644 => 100755 custom_components/gree/translations/de.json mode change 100644 => 100755 custom_components/gree/translations/en.json mode change 100644 => 100755 custom_components/gree/translations/he.json mode change 100644 => 100755 custom_components/gree/translations/hu.json mode change 100644 => 100755 custom_components/gree/translations/it.json mode change 100644 => 100755 custom_components/gree/translations/pl.json mode change 100644 => 100755 custom_components/gree/translations/pt-BR.json mode change 100644 => 100755 custom_components/gree/translations/ro.json mode change 100644 => 100755 custom_components/gree/translations/ru.json mode change 100644 => 100755 custom_components/gree/translations/zh-Hans.json diff --git a/custom_components/gree/__init__.py b/custom_components/gree/__init__.py old mode 100644 new mode 100755 index a585893..1b45a53 --- a/custom_components/gree/__init__.py +++ b/custom_components/gree/__init__.py @@ -3,45 +3,62 @@ from __future__ import annotations # Standard library imports +import asyncio import logging # Third-party imports import voluptuous as vol -# Home Assistant imports from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, + CONF_TIMEOUT, Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType # Local imports from .const import ( + CONF_ADVANCED, CONF_DISABLE_AVAILABLE_CHECK, CONF_ENCRYPTION_KEY, CONF_ENCRYPTION_VERSION, CONF_FAN_MODES, CONF_HVAC_MODES, + CONF_MAX_ONLINE_ATTEMPTS, CONF_SWING_HORIZONTAL_MODES, CONF_SWING_MODES, CONF_TEMP_SENSOR_OFFSET, CONF_UID, + DEFAULT_ENCRYPTION_VERSION, DEFAULT_FAN_MODES, DEFAULT_HVAC_MODES, + DEFAULT_MAX_ONLINE_ATTEMPTS, DEFAULT_PORT, DEFAULT_SWING_HORIZONTAL_MODES, DEFAULT_SWING_MODES, + DEFAULT_TIMEOUT, DOMAIN, OPTION_KEYS, ) -PLATFORMS = [Platform.CLIMATE, Platform.SWITCH, Platform.NUMBER, Platform.SELECT, Platform.SENSOR] +# Home Assistant imports +from .coordinator import GreeConfigEntry, GreeCoordinator +from .gree_device import GreeDevice, GreeDeviceNotBoundError + +PLATFORMS = [ + Platform.CLIMATE, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, +] _LOGGER = logging.getLogger(__name__) # YAML configuration schema @@ -51,19 +68,31 @@ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_MAC): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Optional(CONF_ENCRYPTION_KEY): cv.string, vol.Optional(CONF_UID): cv.positive_int, vol.Optional(CONF_ENCRYPTION_VERSION, default=1): vol.In([1, 2]), - vol.Optional(CONF_HVAC_MODES, default=DEFAULT_HVAC_MODES): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_FAN_MODES, default=DEFAULT_FAN_MODES): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_SWING_MODES, default=DEFAULT_SWING_MODES): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_SWING_HORIZONTAL_MODES, default=DEFAULT_SWING_HORIZONTAL_MODES): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_HVAC_MODES, default=DEFAULT_HVAC_MODES): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_FAN_MODES, default=DEFAULT_FAN_MODES): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_SWING_MODES, default=DEFAULT_SWING_MODES): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional( + CONF_SWING_HORIZONTAL_MODES, default=DEFAULT_SWING_HORIZONTAL_MODES + ): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_MAX_ONLINE_ATTEMPTS, default=3): cv.positive_int, vol.Optional(CONF_DISABLE_AVAILABLE_CHECK, default=False): cv.boolean, vol.Optional(CONF_TEMP_SENSOR_OFFSET): cv.boolean, } ) -CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.All(cv.ensure_list, [CLIMATE_SCHEMA])}, extra=vol.ALLOW_EXTRA) +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.All(cv.ensure_list, [CLIMATE_SCHEMA])}, extra=vol.ALLOW_EXTRA +) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -83,11 +112,58 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool: """Set up Gree from a config entry.""" + + _LOGGER.debug("Setting up entry: %s\n%s", entry, entry.data) + + conf = entry.data + if conf is None or conf[CONF_ADVANCED] is None: + _LOGGER.error("Bad config entry, this should not happen") + return False + + host: str = conf[CONF_HOST] + + new_device = GreeDevice( + name=conf.get(CONF_NAME, "Gree HVAC"), + ip_addr=host, + mac_addr=str(conf.get(CONF_MAC, "")).replace(":", ""), + port=conf[CONF_ADVANCED].get(CONF_PORT, DEFAULT_PORT), + encryption_version=conf[CONF_ADVANCED].get( + CONF_ENCRYPTION_VERSION, DEFAULT_ENCRYPTION_VERSION + ), + encryption_key=conf[CONF_ADVANCED].get(CONF_ENCRYPTION_KEY, ""), + uid=conf[CONF_ADVANCED].get(CONF_UID, 0), + max_connection_attempts=conf.get( + CONF_MAX_ONLINE_ATTEMPTS, DEFAULT_MAX_ONLINE_ATTEMPTS + ), + ) + + try: + async with asyncio.timeout(30): + await new_device.bind_device() + _LOGGER.debug("Bound to device %s", host) + except TimeoutError as err: + _LOGGER.debug("Conection to %s timed out", host) + raise ConfigEntryNotReady from err + except GreeDeviceNotBoundError as err: + _LOGGER.debug("Failed to bind to device %s", host) + raise ConfigEntryNotReady from err + + coordinator = GreeCoordinator(hass, entry, new_device) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups( + entry, [Platform.SENSOR, Platform.SWITCH, Platform.CLIMATE, Platform.SELECT] + ) + return True + if DOMAIN not in hass.data: hass.data[DOMAIN] = {} - + _LOGGER.debug(entry) # Combine entry data with options combined_data = {**entry.data} for key, value in entry.options.items(): @@ -98,30 +174,38 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: combined_data.pop(key, None) else: combined_data[key] = value + _LOGGER.debug(hass.data[DOMAIN]) + _LOGGER.debug(combined_data) # Create the Gree device instance here and store it - from .climate import create_gree_device + from .climate_old import create_gree_device device = await create_gree_device(hass, combined_data) # Store both the config data and the device instance - hass.data[DOMAIN][entry.entry_id] = { + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { "config": combined_data, "device": device, + "coordinator": coordinator, + "gdevice": new_device, } - _LOGGER.debug("Setting up config entry %s with data: %s", entry.entry_id, combined_data) + _LOGGER.debug( + "Setting up config entry %s with data: %s", entry.entry_id, combined_data + ) entry.async_on_unload(entry.add_update_listener(_update_listener)) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR]) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unloaded: - _LOGGER.debug("Unloaded config entry %s", entry.entry_id) - hass.data[DOMAIN].pop(entry.entry_id) + unloaded = await hass.config_entries.async_unload_platforms( + entry, [Platform.SENSOR, Platform.SWITCH, Platform.CLIMATE, Platform.SELECT] + ) + # if unloaded: + # _LOGGER.debug("Unloaded config entry %s", entry.entry_id) + # hass.data[DOMAIN].pop(entry.entry_id) return unloaded diff --git a/custom_components/gree/climate.py b/custom_components/gree/climate.py index 5e248ad..ca401be 100644 --- a/custom_components/gree/climate.py +++ b/custom_components/gree/climate.py @@ -1,912 +1,532 @@ -""" -Gree Climate Entity for Home Assistant. +"""Gree Climate Entity for Home Assistant.""" -This module defines the climate (HVAC) unit for the Gree integration. -""" - -# Standard library imports -import base64 import logging -from datetime import timedelta - -# Third-party imports -try: - import simplejson -except ImportError: - import json as simplejson -from Crypto.Cipher import AES - -# Home Assistant imports -from homeassistant.components.climate import ClimateEntity, ClimateEntityFeature, HVACMode -from homeassistant.const import ( - ATTR_TEMPERATURE, - ATTR_UNIT_OF_MEASUREMENT, - CONF_HOST, - CONF_MAC, - CONF_NAME, - CONF_PORT, + +from attr import dataclass + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityDescription, + ClimateEntityFeature, + HVACMode, ) -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +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 homeassistant.helpers.typing import UNDEFINED +from homeassistant.util.unit_conversion import TemperatureConverter -# Local imports from .const import ( - DOMAIN, - DEFAULT_PORT, - DEFAULT_HVAC_MODES, + CONF_FAN_MODES, + CONF_HVAC_MODES, + CONF_SWING_HORIZONTAL_MODES, + CONF_SWING_MODES, DEFAULT_FAN_MODES, - DEFAULT_SWING_MODES, + DEFAULT_HVAC_MODES, DEFAULT_SWING_HORIZONTAL_MODES, - DEFAULT_TARGET_TEMP_STEP, - MIN_TEMP_C, - MIN_TEMP_F, + DEFAULT_SWING_MODES, + DOMAIN, + GATTR_FEAT_QUIET_MODE, + GATTR_FEAT_TURBO, + HVAC_MODES_GREE_TO_HA, + HVAC_MODES_HA_TO_GREE, MAX_TEMP_C, MAX_TEMP_F, - MODES_MAPPING, - TEMSEN_OFFSET, - CONF_HVAC_MODES, - CONF_FAN_MODES, - CONF_SWING_MODES, - CONF_SWING_HORIZONTAL_MODES, - CONF_ENCRYPTION_KEY, - CONF_UID, - CONF_ENCRYPTION_VERSION, - CONF_DISABLE_AVAILABLE_CHECK, - CONF_TEMP_SENSOR_OFFSET, + MIN_TEMP_C, + MIN_TEMP_F, + UNITS_GREE_TO_HA, ) -from .gree_protocol import Pad, FetchResult, GetDeviceKey, GetGCMCipher, EncryptGCM, GetDeviceKeyGCM -from .helpers import TempOffsetResolver, gree_f_to_c, gree_c_to_f, encode_temp_c, decode_temp_c - -REQUIREMENTS = ["pycryptodome"] +from .coordinator import GreeConfigEntry, GreeCoordinator +from .entity import GreeEntity, GreeEntityDescription +from .gree_api import FanSpeed, HorizontalSwingMode, VerticalSwingMode _LOGGER = logging.getLogger(__name__) -SUPPORT_FLAGS = ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF - - -async def create_gree_device(hass, config): - """Create a Gree device instance from config.""" - name = config.get(CONF_NAME, "Gree Climate") - ip_addr = config.get(CONF_HOST) - port = config.get(CONF_PORT, DEFAULT_PORT) - mac_addr = config.get(CONF_MAC).encode().replace(b":", b"") - - chm = config.get(CONF_HVAC_MODES) - hvac_modes = [getattr(HVACMode, mode.upper()) for mode in (chm if chm is not None else DEFAULT_HVAC_MODES)] - - cfm = config.get(CONF_FAN_MODES) - fan_modes = cfm if cfm is not None else DEFAULT_FAN_MODES - csm = config.get(CONF_SWING_MODES) - swing_modes = csm if csm is not None else DEFAULT_SWING_MODES - cshm = config.get(CONF_SWING_HORIZONTAL_MODES) - swing_horizontal_modes = cshm if cshm is not None else DEFAULT_SWING_HORIZONTAL_MODES - encryption_key = config.get(CONF_ENCRYPTION_KEY) - uid = config.get(CONF_UID) - encryption_version = config.get(CONF_ENCRYPTION_VERSION, 1) - disable_available_check = config.get(CONF_DISABLE_AVAILABLE_CHECK, False) - temp_sensor_offset = config.get(CONF_TEMP_SENSOR_OFFSET) - - return GreeClimate( - hass, - name, - ip_addr, - port, - mac_addr, - hvac_modes, - fan_modes, - swing_modes, - swing_horizontal_modes, - encryption_version, - disable_available_check, - encryption_key, - uid, - temp_sensor_offset, - ) +GATTR_CLIMATE = "hvac" + + +@dataclass(frozen=True, kw_only=True) +class GreeClimateDescription(GreeEntityDescription, ClimateEntityDescription): + """Description of a Gree Climate entity.""" + + 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, + entry: GreeConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensors from a config entry.""" + + coordinator = entry.runtime_data + + hvac_modes: list[HVACMode] = [ + HVACMode[mode.upper()] + for mode in ( + entry.data[CONF_HVAC_MODES] + if entry.data[CONF_HVAC_MODES] is not None + else DEFAULT_HVAC_MODES + ) + ] + fan_modes: list[str] = ( + entry.data[CONF_FAN_MODES] + if entry.data[CONF_FAN_MODES] is not None + else DEFAULT_FAN_MODES + ) -# from the remote control and gree app + swing_modes: list[str] = ( + entry.data[CONF_SWING_MODES] + if entry.data[CONF_SWING_MODES] is not None + else DEFAULT_SWING_MODES + ) -# update() interval -SCAN_INTERVAL = timedelta(seconds=60) + swing_horizontal_modes: list[str] = ( + entry.data[CONF_SWING_HORIZONTAL_MODES] + if entry.data[CONF_SWING_HORIZONTAL_MODES] is not None + else DEFAULT_SWING_HORIZONTAL_MODES + ) + if not hvac_modes: + _LOGGER.info( + "Climate Entity will not be created because no Climate options and features are available for the device" + ) + return + + async_add_entities( + [ + GreeClimate( + GreeClimateDescription( + key=GATTR_CLIMATE, + translation_key=GATTR_CLIMATE, + available_func=lambda device: device.available, + ), + coordinator, + hvac_modes, + fan_modes, + swing_modes, + swing_horizontal_modes, + ) + ] + ) -async def async_setup_entry(hass, entry, async_add_devices): - """Set up Gree climate from a config entry.""" - # Get the device that was created in __init__.py - entry_data = hass.data[DOMAIN][entry.entry_id] - device = entry_data["device"] - async_add_devices([device]) +class GreeClimate(GreeEntity, ClimateEntity, RestoreEntity): # pyright: ignore[reportIncompatibleVariableOverride] + """Climate Entity.""" + def __init__( + self, + description: GreeClimateDescription, + coordinator: GreeCoordinator, + hvac_modes: list[HVACMode], + fan_modes: list[str], + swing_modes: list[str], + swing_horizontal_modes: list[str], + restore_state: bool = True, + ) -> None: + """Initialize the Gree Climate entity.""" + super().__init__(coordinator, restore_state) + self.entity_description = description + + self._attr_unique_id = f"{self._device.name}_{description.key}" + self._attr_name = None # Main entity + + self._attr_precision = 1 + self._attr_target_temperature_step = 1 + + self._attr_hvac_modes = hvac_modes + + if hvac_modes and HVACMode.OFF in hvac_modes: + self._attr_supported_features |= ClimateEntityFeature.TURN_OFF + + if any(mode != HVACMode.OFF for mode in hvac_modes): + self._attr_supported_features |= ClimateEntityFeature.TURN_ON + + if any( + mode in hvac_modes for mode in (HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO) + ): + self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE + + if fan_modes: + self._attr_supported_features |= ClimateEntityFeature.FAN_MODE + self._attr_fan_modes = fan_modes + + if swing_modes: + self._attr_supported_features |= ClimateEntityFeature.SWING_MODE + self._attr_swing_modes = swing_modes + + if swing_horizontal_modes: + self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE + self._attr_swing_horizontal_modes = swing_horizontal_modes + + self.update_attributes() + + _LOGGER.debug("Initialized climate %s", self._attr_unique_id) + + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + _LOGGER.debug("Updating Climate Entity for %s", self._device.unique_id) + self.update_attributes() + + def update_attributes(self): + """Updates the entity attributes with the device values.""" + self._attr_available = self._device.available + + if ( + self._attr_supported_features + & ( + ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TARGET_TEMPERATURE + ) + or HVACMode.FAN_ONLY in self._attr_hvac_modes + ): + self._attr_hvac_mode = self.get_hvac_mode() + + if self._attr_supported_features & ClimateEntityFeature.FAN_MODE: + self._attr_fan_mode = self.get_fan_mode() + if self._attr_supported_features & ClimateEntityFeature.SWING_MODE: + self._attr_swing_mode = self.get_swing_mode() + if self._attr_supported_features & ClimateEntityFeature.SWING_HORIZONTAL_MODE: + self._attr_swing_horizontal_mode = self.get_swing_horizontal_mode() + + self._attr_temperature_unit = self.get_temp_units() + self._attr_target_temperature = self.get_current_target_temp() + self._attr_current_temperature = self.get_current_temp() + self._attr_current_humidity = self.get_current_himidty() + + if self._attr_supported_features & ClimateEntityFeature.TARGET_TEMPERATURE: + self._attr_max_temp = ( + MAX_TEMP_C + if self._attr_temperature_unit == UnitOfTemperature.CELSIUS + else MAX_TEMP_F + ) + + self._attr_min_temp = ( + MIN_TEMP_C + if self._attr_temperature_unit == UnitOfTemperature.CELSIUS + else MIN_TEMP_F + ) -async def async_unload_entry(hass, entry): - """Unload a config entry.""" - return True + async def async_turn_on(self): + """Turn on.""" + _LOGGER.debug("turn_on(%s)", self._device.unique_id) + if not self.available: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="entity_unavailable" + ) + try: + self._device.set_power_mode(True) + await self._device.update_device_status() -class GreeClimate(ClimateEntity): - # Language is retrieved from translation key - _attr_translation_key = "gree" + # notify coordinator listeners of state change so that dependent entities are updated immediately + self.coordinator.async_update_listeners() - def __init__( - self, - hass, - name, - ip_addr, - port, - mac_addr, - hvac_modes, - fan_modes, - swing_modes, - swing_horizontal_modes, - encryption_version, - disable_available_check, - encryption_key=None, - uid=None, - temp_sensor_offset=None, - ): - _LOGGER.info(f"{name}: Initializing Gree climate device") - - self.hass = hass - self._name = name - self._ip_addr = ip_addr - self._port = port - mac_addr_str = mac_addr.decode("utf-8").lower() - if "@" in mac_addr_str: - self._sub_mac_addr, self._mac_addr = mac_addr_str.split("@", 1) - else: - self._sub_mac_addr = self._mac_addr = mac_addr_str - self._unique_id = f"{DOMAIN}_{self._sub_mac_addr}" - self._device_online = None - self._disable_available_check = disable_available_check - - self._target_temperature = None - # Initialize target temperature step with default value (will be overridden by number entity when available) - self._target_temperature_step = DEFAULT_TARGET_TEMP_STEP - # Device uses a combination of Celsius + a set bit for Fahrenheit, so the integration needs to be aware of the units. - self._unit_of_measurement = hass.config.units.temperature_unit - _LOGGER.info(f"{self._name}: Unit of measurement: {self._unit_of_measurement}") - - self._hvac_modes = hvac_modes - self._hvac_mode = HVACMode.OFF - self._fan_modes = fan_modes - self._fan_mode = None - self._swing_modes = swing_modes - self._swing_mode = None - self._swing_horizontal_modes = swing_horizontal_modes - self._swing_horizontal_mode = None - - self._temp_sensor_offset = temp_sensor_offset - - # Store for external temp sensor entity (set by sensor entity) - self._external_temperature_sensor = None - - # Keep unsub callbacks for deregistering listeners - self._listeners: list = [] - - self._has_temp_sensor = None - self._has_anti_direct_blow = None - self._has_light_sensor = None - self._has_outside_temp_sensor = None - self._has_room_humidity_sensor = None - - self._current_temperature = None - self._current_anti_direct_blow = None - self._current_light_sensor = None - self._current_outside_temperature = None - self._current_room_humidity = None - - self._firstTimeRun = True - - self._enable_turn_on_off_backwards_compatibility = False - - self.encryption_version = encryption_version - self.CIPHER = None - - if encryption_key: - _LOGGER.info(f"{self._name}: Using configured encryption key: {encryption_key}") - self._encryption_key = encryption_key.encode("utf8") - if encryption_version == 1: - # Cipher to use to encrypt/decrypt - self.CIPHER = AES.new(self._encryption_key, AES.MODE_ECB) - elif self.encryption_version != 2: - _LOGGER.error(f"{self._name}: Encryption version {self.encryption_version} is not implemented") - else: - self._encryption_key = None - - if uid: - self._uid = uid - else: - self._uid = 0 - - self._acOptions = { - "Pow": None, - "Mod": None, - "SetTem": None, - "WdSpd": None, - "Air": None, - "Blo": None, - "Health": None, - "SwhSlp": None, - "Lig": None, - "SwingLfRig": None, - "SwUpDn": None, - "Quiet": None, - "Tur": None, - "StHt": None, - "TemUn": None, - "HeatCoolType": None, - "TemRec": None, - "SvSt": None, - "SlpMod": None, - } - self._optionsToFetch = ["Pow", "Mod", "SetTem", "WdSpd", "Air", "Blo", "Health", "SwhSlp", "Lig", "SwingLfRig", "SwUpDn", "Quiet", "Tur", "StHt", "TemUn", "HeatCoolType", "TemRec", "SvSt", "SlpMod"] - - # Initialize auto switches - self._auto_light = False - self._auto_xfan = False - - # Initialize beeper control - self._beeper_enabled = True # Default to beeper ON (silent mode OFF) - - # helper method to determine TemSen offset - self._process_temp_sensor = TempOffsetResolver() - - async def GreeGetValues(self, propertyNames): - plaintext = '{"cols":' + simplejson.dumps(propertyNames) + ',"mac":"' + str(self._sub_mac_addr) + '","t":"status"}' - if self.encryption_version == 1: - cipher = self.CIPHER - jsonPayloadToSend = '{"cid":"app","i":0,"pack":"' + base64.b64encode(cipher.encrypt(Pad(plaintext).encode("utf8"))).decode("utf-8") + '","t":"pack","tcid":"' + str(self._mac_addr) + '","uid":{}'.format(self._uid) + "}" - elif self.encryption_version == 2: - pack, tag = EncryptGCM(self._encryption_key, plaintext) - jsonPayloadToSend = '{"cid":"app","i":0,"pack":"' + pack + '","t":"pack","tcid":"' + str(self._mac_addr) + '","uid":{}'.format(self._uid) + ',"tag" : "' + tag + '"}' - cipher = GetGCMCipher(self._encryption_key) - result = await FetchResult(cipher, self._ip_addr, self._port, jsonPayloadToSend, encryption_version=self.encryption_version) - return result["dat"][0] if len(result["dat"]) == 1 else result["dat"] - - def SetAcOptions(self, acOptions, newOptionsToOverride, optionValuesToOverride=None): - if optionValuesToOverride is not None: - # Build a list of key-value pairs for a single log line - settings = [] - for key in newOptionsToOverride: - value = optionValuesToOverride[newOptionsToOverride.index(key)] - settings.append(f"{key}={value}") - acOptions[key] = value - _LOGGER.debug(f"{self._name}: Setting device options with retrieved values: {', '.join(settings)}") - else: - # Build a list of key-value pairs for a single log line - settings = [] - for key, value in newOptionsToOverride.items(): - settings.append(f"{key}={value}") - acOptions[key] = value - _LOGGER.debug(f"{self._name}: Overwriting device options with new settings: {', '.join(settings)}") - return acOptions - - async def SendStateToAc(self): - opt_list = ["Pow", "Mod", "SetTem", "WdSpd", "Air", "Blo", "Health", "SwhSlp", "Lig", "SwingLfRig", "SwUpDn", "Quiet", "Tur", "StHt", "TemUn", "HeatCoolType", "TemRec", "SvSt", "SlpMod", "AntiDirectBlow", "LigSen"] - - # Collect values from _acOptions - p_values = [self._acOptions.get(k) for k in opt_list] - - # Filter out empty ones - filtered_opt = [] - filtered_p = [] - for name, val in zip(opt_list, p_values): - if val not in ("", None): - filtered_opt.append(f'"{name}"') - filtered_p.append(str(val)) - - buzzer_command_value = 0 if self._beeper_enabled else 1 - filtered_opt.append('"Buzzer_ON_OFF"') - filtered_p.append(str(buzzer_command_value)) - _LOGGER.debug(f"{self._name}: Sending command with beeper {'enabled' if self._beeper_enabled else 'disabled'} (buzzer={buzzer_command_value})") - - statePackJson = '{"opt":[' + ",".join(filtered_opt) + '],"p":[' + ",".join(filtered_p) + '],"t":"cmd","sub":"' + self._sub_mac_addr + '"}' - - if self.encryption_version == 1: - cipher = self.CIPHER - sentJsonPayload = '{"cid":"app","i":0,"pack":"' + base64.b64encode(cipher.encrypt(Pad(statePackJson).encode("utf8"))).decode("utf-8") + '","t":"pack","tcid":"' + str(self._mac_addr) + '","uid":{}'.format(self._uid) + "}" - elif self.encryption_version == 2: - pack, tag = EncryptGCM(self._encryption_key, statePackJson) - sentJsonPayload = '{"cid":"app","i":0,"pack":"' + pack + '","t":"pack","tcid":"' + str(self._mac_addr) + '","uid":{}'.format(self._uid) + ',"tag":"' + tag + '"}' - cipher = GetGCMCipher(self._encryption_key) - result = await FetchResult(cipher, self._ip_addr, self._port, sentJsonPayload, encryption_version=self.encryption_version) - _LOGGER.debug(f"{self._name}: Command sent successfully: {str(result)}") - - def UpdateHATargetTemperature(self): - # Sync set temperature to HA. If 8℃ heating is active we set the temp in HA to 8℃ so that it shows the same as the AC display. - if self._acOptions["StHt"] and (int(self._acOptions["StHt"]) == 1): - self._target_temperature = 8 - _LOGGER.debug(f"{self._name}: Target temperature set to 8°C for 8°C heating mode") - else: - temp_c = decode_temp_c(SetTem=self._acOptions["SetTem"], TemRec=self._acOptions["TemRec"]) # takes care of 1/2 degrees - temp_f = gree_c_to_f(SetTem=self._acOptions["SetTem"], TemRec=self._acOptions["TemRec"]) - - if self._unit_of_measurement == "°C": - display_temp = temp_c - elif self._unit_of_measurement == "°F": - display_temp = temp_f - else: - display_temp = temp_c # default to deg c - _LOGGER.error(f"{self._name}: Unknown unit of measurement: {self._unit_of_measurement}") - - self._target_temperature = display_temp - - _LOGGER.debug(f"{self._name}: Target temperature set to {self._target_temperature}{self._unit_of_measurement}") - - def UpdateHAHvacMode(self): - # Sync current HVAC operation mode to HA - if self._acOptions["Pow"] == 0: - self._hvac_mode = HVACMode.OFF - else: - for key, value in MODES_MAPPING.get("Mod").items(): - if value == (self._acOptions["Mod"]): - self._hvac_mode = key - _LOGGER.debug(f"{self._name}: HVAC mode updated to {self._hvac_mode}") - - def UpdateHACurrentSwingMode(self): - # Sync current HVAC Swing mode state to HA - for key, value in MODES_MAPPING.get("SwUpDn").items(): - if value == (self._acOptions["SwUpDn"]): - self._swing_mode = key - _LOGGER.debug(f"{self._name}: Swing mode updated to {self._swing_mode}") - - def UpdateHACurrentSwingHorizontalMode(self): - # Sync current HVAC Horizontal Swing mode state to HA - for key, value in MODES_MAPPING.get("SwingLfRig").items(): - if value == (self._acOptions["SwingLfRig"]): - self._swing_horizontal_mode = key - _LOGGER.debug(f"{self._name}: Horizontal swing mode updated to {self._swing_horizontal_mode}") - - def UpdateHAFanMode(self): - # Sync current HVAC Fan mode state to HA - if int(self._acOptions["Tur"]) == 1: - turbo_index = self._fan_modes.index("turbo") - self._fan_mode = self._fan_modes[turbo_index] - elif int(self._acOptions["Quiet"]) >= 1: - quiet_index = self._fan_modes.index("quiet") - self._fan_mode = self._fan_modes[quiet_index] - else: - for key, value in MODES_MAPPING.get("WdSpd").items(): - if value == (self._acOptions["WdSpd"]): - self._fan_mode = key - _LOGGER.debug(f"{self._name}: Fan mode updated to {self._fan_mode}") - - def UpdateHACurrentTemperature(self): - # Use external temperature sensor if available - if self._external_temperature_sensor: - # Use external temperature sensor - external_sensor_state = self.hass.states.get(self._external_temperature_sensor) - if external_sensor_state and external_sensor_state.state not in ("unknown", "unavailable"): - try: - unit = external_sensor_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - _LOGGER.debug(f"{self._name}: Using external temperature sensor {self._external_temperature_sensor}: {external_sensor_state.state}{unit}") - self._current_temperature = self.hass.config.units.temperature(float(external_sensor_state.state), unit) - _LOGGER.debug(f"{self._name}: Current temperature from external sensor: {self._current_temperature}{self._unit_of_measurement}") - return - except (ValueError, TypeError) as ex: - _LOGGER.error(f"{self._name}: Unable to update from external temp sensor {self._external_temperature_sensor}: {ex}") - - # Use built-in AC temperature sensor if available - if self._has_temp_sensor: - _LOGGER.debug(f"{self._name}: Built-in temperature sensor reading: {self._acOptions['TemSen']}") - - if self._temp_sensor_offset is None: # user hasn't chosen an offset - # User hasn't set automaticaly, so try to determine the offset - temp_c = self._process_temp_sensor(self._acOptions["TemSen"]) - _LOGGER.debug("method UpdateHACurrentTemperature: User has not chosen an offset, using process_temp_sensor() to automatically determine offset.") - else: - # User set - if self._temp_sensor_offset is True: - temp_c = self._acOptions["TemSen"] - TEMSEN_OFFSET + # TODO: Turn Light on if auto light is on - elif self._temp_sensor_offset is False: - temp_c = self._acOptions["TemSen"] + await self.coordinator.async_request_refresh() + except Exception as err: + _LOGGER.exception("Error in '%s'", "async_turn_on") + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="generic" + ) from err - _LOGGER.debug(f"method UpdateHACurrentTemperature: User has chosen an offset ({self._temp_sensor_offset})") + self.async_write_ha_state() - temp_f = gree_c_to_f(SetTem=temp_c, TemRec=0) # Convert to Fahrenheit using TemRec bit + async def async_turn_off(self): + """Turn off.""" + _LOGGER.debug("turn_off(%s)", self._device.unique_id) - if self._unit_of_measurement == "°C": - self._current_temperature = temp_c - elif self._unit_of_measurement == "°F": - self._current_temperature = temp_f - else: - _LOGGER.error("Unknown unit of measurement: %s" % self._unit_of_measurement) + if not self.available: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="entity_unavailable" + ) + try: + self._device.set_power_mode(False) + await self._device.update_device_status() - _LOGGER.debug(f"{self._name}: UpdateHACurrentTemperature: HA current temperature set with device built-in temperature sensor state: {self._current_temperature}{self._unit_of_measurement}") + # notify coordinator listeners of state change so that dependent entities are updated immediately + self.coordinator.async_update_listeners() - def UpdateHAOutsideTemperature(self): - # Update outside temperature from built-in AC outside temperature sensor if available - if self._has_outside_temp_sensor: - _LOGGER.debug(f"{self._name}: UpdateHAOutsideTemperature: OutEnvTem: {self._acOptions['OutEnvTem']}") + # TODO: Turn Light off if auto light is on - if self._temp_sensor_offset is None: # user hasn't chosen an offset - # User hasn't set automatically, so try to determine the offset - temp_c = self._process_temp_sensor(self._acOptions["OutEnvTem"]) - _LOGGER.debug("method UpdateHAOutsideTemperature: User has not chosen an offset, using process_temp_sensor() to automatically determine offset.") - else: - # User set - if self._temp_sensor_offset is True: - temp_c = self._acOptions["OutEnvTem"] - TEMSEN_OFFSET - elif self._temp_sensor_offset is False: - temp_c = self._acOptions["OutEnvTem"] + await self.coordinator.async_request_refresh() + except Exception as err: + _LOGGER.exception("Error in '%s'", "async_turn_off") + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="generic" + ) from err - _LOGGER.debug(f"method UpdateHAOutsideTemperature: User has chosen an offset ({self._temp_sensor_offset})") + self.async_write_ha_state() - temp_f = gree_c_to_f(SetTem=temp_c, TemRec=0) # Convert to Fahrenheit using TemRec bit + def get_hvac_mode(self) -> HVACMode: + """Converts Gree Operation Modes to HA.""" + return ( + HVACMode.OFF + if not self._device.power_mode + else HVAC_MODES_GREE_TO_HA[self._device.operation_mode] + ) - if self._unit_of_measurement == "°C": - self._current_outside_temperature = temp_c - elif self._unit_of_measurement == "°F": - self._current_outside_temperature = temp_f - else: - _LOGGER.error("Unknown unit of measurement for outside temperature: %s" % self._unit_of_measurement) - - _LOGGER.debug(f"{self._name}: UpdateHAOutsideTemperature: HA outside temperature set with device built-in outside temperature sensor state: {self._current_outside_temperature}{self._unit_of_measurement}") - - def UpdateHARoomHumidity(self): - # Update room humidity from built-in AC room humidity sensor if available - if self._has_room_humidity_sensor: - _LOGGER.debug(f"{self._name}: UpdateHARoomHumidity: DwatSen: {self._acOptions['DwatSen']}") - self._current_room_humidity = self._acOptions["DwatSen"] - _LOGGER.debug(f"{self._name}: UpdateHARoomHumidity: HA room humidity set with device built-in room humidity sensor state: {self._current_room_humidity}%") - - def UpdateHAStateToCurrentACState(self): - self.UpdateHATargetTemperature() - self.UpdateHAHvacMode() - self.UpdateHACurrentSwingMode() - self.UpdateHACurrentSwingHorizontalMode() - self.UpdateHAFanMode() - self.UpdateHACurrentTemperature() - self.UpdateHAOutsideTemperature() - self.UpdateHARoomHumidity() - - async def SyncState(self, acOptions={}): - # Fetch current settings from HVAC - _LOGGER.debug(f"{self._name}: Starting device state sync") - - if self._has_temp_sensor is None: - _LOGGER.debug("Attempt to check whether device has an built-in temperature sensor") - try: - temp_sensor = await self.GreeGetValues(["TemSen"]) - except Exception: - _LOGGER.debug("Could not determine whether device has an built-in temperature sensor. Retrying at next update()") - else: - if temp_sensor: - self._has_temp_sensor = True - self._acOptions.update({"TemSen": None}) - self._optionsToFetch.append("TemSen") - _LOGGER.debug("Device has an built-in temperature sensor") - else: - self._has_temp_sensor = False - _LOGGER.debug("Device has no built-in temperature sensor") - - # Check if device has anti direct blow feature - if self._has_anti_direct_blow is None: - _LOGGER.debug("Attempt to check whether device has an anti direct blow feature") - try: - anti_direct_blow = await self.GreeGetValues(["AntiDirectBlow"]) - except Exception: - _LOGGER.debug("Could not determine whether device has an anti direct blow feature. Retrying at next update()") - else: - if anti_direct_blow: - self._has_anti_direct_blow = True - self._acOptions.update({"AntiDirectBlow": None}) - self._optionsToFetch.append("AntiDirectBlow") - _LOGGER.debug("Device has an anti direct blow feature") - else: - self._has_anti_direct_blow = False - _LOGGER.debug("Device has no anti direct blow feature") - - # Check if device has light sensor - if self._has_light_sensor is None: - _LOGGER.debug("Attempt to check whether device has a built-in light sensor") - try: - light_sensor = await self.GreeGetValues(["LigSen"]) - except Exception: - _LOGGER.debug("Could not determine whether device has a built-in light sensor. Retrying at next update()") - else: - if light_sensor: - self._has_light_sensor = True - self._acOptions.update({"LigSen": None}) - self._optionsToFetch.append("LigSen") - _LOGGER.debug("Device has a built-in light sensor") - else: - self._has_light_sensor = False - _LOGGER.debug("Device has no built-in light sensor") - - # Check if device has outside temperature sensor - if self._has_outside_temp_sensor is None: - _LOGGER.debug("Attempt to check whether device has an outside temperature sensor") - try: - outside_temp_sensor = await self.GreeGetValues(["OutEnvTem"]) - except Exception: - _LOGGER.debug("Could not determine whether device has an outside temperature sensor. Retrying at next update()") - else: - if outside_temp_sensor: - self._has_outside_temp_sensor = True - self._acOptions.update({"OutEnvTem": None}) - self._optionsToFetch.append("OutEnvTem") - _LOGGER.debug("Device has an outside temperature sensor") - else: - self._has_outside_temp_sensor = False - _LOGGER.debug("Device has no outside temperature sensor") - - # Check if device has room humidity sensor - if self._has_room_humidity_sensor is None: - _LOGGER.debug("Attempt to check whether device has a room humidity sensor") - try: - humidity_sensor = await self.GreeGetValues(["DwatSen"]) - except Exception: - _LOGGER.debug("Could not determine whether device has a room humidity sensor. Retrying at next update()") - else: - if humidity_sensor: - self._has_room_humidity_sensor = True - self._acOptions.update({"DwatSen": None}) - self._optionsToFetch.append("DwatSen") - _LOGGER.debug("Device has a room humidity sensor") - else: - self._has_room_humidity_sensor = False - _LOGGER.debug("Device has no room humidity sensor") + async def async_set_hvac_mode(self, hvac_mode: HVACMode): + """Set the HVAC Mode.""" + _LOGGER.debug("set_hvac_mode(%s, %s)", self._device.unique_id, hvac_mode) - optionsToFetch = self._optionsToFetch + if not self.available: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="entity_unavailable" + ) try: - currentValues = await self.GreeGetValues(optionsToFetch) - except Exception as e: - _LOGGER.warning(f"{self._name}: Failed to communicate with device {self._ip_addr}:{self._port}: {str(e)}") - if not self._disable_available_check: - _LOGGER.info(f"{self._name}: Device marked offline after failed communication") - self._device_online = False - else: - if not self._disable_available_check: - if not self._device_online: - self._device_online = True - # Set latest status from device - self._acOptions = self.SetAcOptions(self._acOptions, optionsToFetch, currentValues) - - # Overwrite status with our choices - if not (acOptions == {}): - self._acOptions = self.SetAcOptions(self._acOptions, acOptions) - - # If not the first (boot) run, update state towards the HVAC - if not (self._firstTimeRun): - if not (acOptions == {}): - # loop used to send changed settings from HA to HVAC - try: - await self.SendStateToAc() - except Exception as e: - _LOGGER.warning(f"{self._name}: Failed to send state to device {self._ip_addr}:{self._port}: {str(e)}") - # Mark device as offline if communication fails - if not self._disable_available_check: - _LOGGER.info(f"{self._name}: Device marked offline after failed send attempt") - self._device_online = False - else: - # loop used once for Gree Climate initialisation only - self._firstTimeRun = False - - # Update HA state to current HVAC state - self.UpdateHAStateToCurrentACState() - - _LOGGER.debug(f"{self._name}: Finished device state sync") - - @property - def should_poll(self): - _LOGGER.debug("should_poll()") - # Return the polling state. - return True - - @property - def available(self): - if self._disable_available_check: - return True - else: - if self._device_online: - _LOGGER.debug("available(): Device is online") - return True + if hvac_mode == HVACMode.OFF: + await self.async_turn_off() + # This will be called in the turn on + # await self._device.update_device_status() else: - _LOGGER.debug("available(): Device is offline") - return False - - async def async_update(self): - """Retrieve latest state.""" - _LOGGER.debug("async_update()") - if not self._encryption_key: - if self.encryption_version == 1: - key = await GetDeviceKey(self._mac_addr, self._ip_addr, self._port) - if key: - self._encryption_key = key - self.CIPHER = AES.new(self._encryption_key, AES.MODE_ECB) - await self.SyncState() - elif self.encryption_version == 2: - key = await GetDeviceKeyGCM(self._mac_addr, self._ip_addr, self._port) - if key: - self._encryption_key = key - self.CIPHER = GetGCMCipher(self._encryption_key) - await self.SyncState() - else: - _LOGGER.error("Encryption version %s is not implemented." % self.encryption_version) - else: - await self.SyncState() - - @property - def name(self): - _LOGGER.debug(f"{self._name}: name() = {self._name}") - # Return the name of the climate device. - return self._name - - @property - def temperature_unit(self): - _LOGGER.debug(f"{self._name}: temperature_unit() = {self._unit_of_measurement}") - # Return the unit of measurement. - return self._unit_of_measurement - - @property - def current_temperature(self): - _LOGGER.debug(f"{self._name}: current_temperature() = {self._current_temperature}") - # Return the current temperature. - return self._current_temperature - - @property - def min_temp(self): - if self._unit_of_measurement == "°C": - MIN_TEMP = MIN_TEMP_C - else: - MIN_TEMP = MIN_TEMP_F - - _LOGGER.debug(f"{self._name}: min_temp() = {MIN_TEMP}") - # Return the minimum temperature. - return MIN_TEMP - - @property - def max_temp(self): - if self._unit_of_measurement == "°C": - MAX_TEMP = MAX_TEMP_C - else: - MAX_TEMP = MAX_TEMP_F - - _LOGGER.debug(f"{self._name}: max_temp() = {MAX_TEMP}") - # Return the maximum temperature. - return MAX_TEMP - - @property - def target_temperature(self): - _LOGGER.debug(f"{self._name}: target_temperature() = {self._target_temperature}") - # Return the temperature we try to reach. - return self._target_temperature - - @property - def target_temperature_step(self): - _LOGGER.debug(f"{self._name}: target_temperature_step() = {self._target_temperature_step}") - return self._target_temperature_step - - @property - def hvac_mode(self): - _LOGGER.debug(f"{self._name}: hvac_mode() = {self._hvac_mode}") - # Return current operation mode ie. heat, cool, idle. - return self._hvac_mode - - @property - def swing_mode(self): - if self._swing_modes: - _LOGGER.debug(f"{self._name}: swing_mode() = {self._swing_mode}") - # get the current swing mode - return self._swing_mode - else: - return None - - @property - def swing_modes(self): - _LOGGER.debug(f"{self._name}: swing_modes() = {self._swing_modes}") - # get the list of available swing modes - return self._swing_modes - - @property - def swing_horizontal_mode(self): - if self._swing_horizontal_modes: - _LOGGER.debug(f"{self._name}: swing_horizontal_mode() = {self._swing_horizontal_mode}") - # get the current preset mode - return self._swing_horizontal_mode - else: - return None - - @property - def swing_horizontal_modes(self): - _LOGGER.debug(f"{self._name}: swing_horizontal_modes() = {self._swing_horizontal_modes}") - # get the list of available preset modes - return self._swing_horizontal_modes - - @property - def hvac_modes(self): - _LOGGER.debug(f"{self._name}: hvac_modes() = {self._hvac_modes}") - # get the list of available operation modes. - return self._hvac_modes - - @property - def fan_mode(self): - _LOGGER.debug(f"{self._name}: fan_mode() = {self._fan_mode}") - # Return the fan mode. - return self._fan_mode - - @property - def fan_modes(self): - _LOGGER.debug(f"{self._name}: fan_modes() = {self._fan_modes}") - # Return the list of available fan modes. - return self._fan_modes - - @property - def supported_features(self): - sf = SUPPORT_FLAGS - if self._swing_modes: - sf = sf | ClimateEntityFeature.SWING_MODE - if self._swing_horizontal_modes: - sf = sf | ClimateEntityFeature.SWING_HORIZONTAL_MODE - _LOGGER.debug(f"{self._name}: supported_features() = {sf}") - # Return the list of supported features. - return sf - - @property - def unique_id(self): - # Return unique_id - return self._unique_id - - @property - def device_info(self) -> DeviceInfo: - """Return device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self._mac_addr)}, - name=self._name, - manufacturer="Gree", - ) + self._device.set_operation_mode(HVAC_MODES_HA_TO_GREE[hvac_mode]) + await self.async_turn_on() - @property - def outside_temperature(self): - """Return the outside temperature if available.""" - if self._has_outside_temp_sensor: - _LOGGER.debug(f"{self._name}: outside_temperature() = {self._current_outside_temperature}") - return self._current_outside_temperature - return None + # This will be called in the turn on + # await self._device.update_device_status() - @property - def room_humidity(self): - """Return the current room humidity if available.""" - if self._has_room_humidity_sensor: - _LOGGER.debug(f"{self._name}: room_humidity() = {self._current_room_humidity}") - return self._current_room_humidity - return None + # TODO: Control X-FAN based on auto X-FAN - @property - def extra_state_attributes(self): - """Return additional state attributes.""" - attributes = {} + # notify coordinator listeners of state change so that dependent entities are updated immediately + self.coordinator.async_update_listeners() - if self.outside_temperature is not None: - attributes["outside_temperature"] = self.outside_temperature - attributes["outside_temperature_unit"] = self._unit_of_measurement + await self.coordinator.async_request_refresh() + except Exception as err: + _LOGGER.exception("Error in '%s'", "async_set_hvac_mode") + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="generic" + ) from err - if self.room_humidity is not None: - attributes["room_humidity"] = self.room_humidity - attributes["room_humidity_unit"] = "%" + self.async_write_ha_state() - return attributes if attributes else None + def get_fan_mode(self) -> str: + """Converts Gree Fan Modes to HA. Accounts for the 2 special modes.""" + if ( + GATTR_FEAT_QUIET_MODE in self._attr_hvac_modes + and self._device.feature_quiet + ): + return GATTR_FEAT_QUIET_MODE + + if GATTR_FEAT_TURBO in self._attr_hvac_modes and self._device.feature_turbo: + return GATTR_FEAT_TURBO + + return self._device.fan_speed.name + + async def async_set_fan_mode(self, fan_mode: str): + """Set new target fan mode.""" + _LOGGER.debug("set_fan_mode(%s, %s)", self._device.unique_id, fan_mode) + + if not self.available: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="entity_unavailable" + ) + + if self._attr_hvac_mode == HVACMode.OFF: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="change_while_device_off" + ) + + 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" + ) - async def async_set_temperature(self, **kwargs): - """Set new target temperature.""" - target_temperature = kwargs.get(ATTR_TEMPERATURE) - if target_temperature is not None: - # do nothing if temperature is none - if not (self._acOptions["Pow"] == 0): - # do nothing if HVAC is switched off + try: + self._device.set_feature_quiet(fan_mode == GATTR_FEAT_QUIET_MODE) + self._device.set_feature_turbo(fan_mode == GATTR_FEAT_TURBO) - if self._unit_of_measurement == "°C": - SetTem, TemRec = encode_temp_c(T=target_temperature) # takes care of 1/2 degrees - elif self._unit_of_measurement == "°F": - SetTem, TemRec = gree_f_to_c(desired_temp_f=target_temperature) - else: - _LOGGER.error("Unable to set temperature. Units not set to °C or °F") - return + if fan_mode not in (GATTR_FEAT_QUIET_MODE, GATTR_FEAT_TURBO): + self._device.set_fan_speed(FanSpeed[fan_mode]) - await self.SyncState({"SetTem": int(SetTem), "TemRec": int(TemRec)}) - _LOGGER.debug(f"{self._name}: async_set_temperature: Set Temp to {target_temperature}{self._unit_of_measurement} -> SyncState with SetTem={SetTem}, SyncState with TemRec={TemRec}") + await self._device.update_device_status() - self.async_write_ha_state() + # notify coordinator listeners of state change so that dependent entities are updated immediately + self.coordinator.async_update_listeners() - async def async_set_swing_mode(self, swing_mode): - """Set swing mode.""" - if not (self._acOptions["Pow"] == 0): - # do nothing if HVAC is switched off - try: - sw_up_dn = MODES_MAPPING.get("SwUpDn").get(swing_mode) - _LOGGER.info(f"{self._name}: SyncState with SwUpDn={sw_up_dn}") - await self.SyncState({"SwUpDn": sw_up_dn}) - self.async_write_ha_state() - except ValueError: - _LOGGER.error(f"Unknown swing mode: {swing_mode}") - return + await self.coordinator.async_request_refresh() + except Exception as err: + _LOGGER.exception("Error in '%s'", "async_set_fan_mode") + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="generic" + ) from err - async def async_set_swing_horizontal_mode(self, swing_horizontal_mode): - """Set horizontal swing mode.""" - if not (self._acOptions["Pow"] == 0): - # do nothing if HVAC is switched off - try: - swing_lf_rig = MODES_MAPPING.get("SwingLfRig").get(swing_horizontal_mode) - _LOGGER.info(f"{self._name}: SyncState with SwingLfRig={swing_lf_rig}") - await self.SyncState({"SwingLfRig": swing_lf_rig}) - self.async_write_ha_state() - except ValueError: - _LOGGER.error(f"Unknown preset mode: {swing_horizontal_mode}") - return - - async def async_set_fan_mode(self, fan): - """Set fan mode.""" - # Set the fan mode. - if not (self._acOptions["Pow"] == 0): - try: - wd_spd = MODES_MAPPING.get("WdSpd").get(fan) - - # Check if this is turbo mode - if fan == "turbo": - _LOGGER.info("Enabling turbo mode") - await self.SyncState({"Tur": 1, "Quiet": 0}) - # Check if this is quiet mode - elif fan == "quiet": - _LOGGER.info("Enabling quiet mode") - await self.SyncState({"Tur": 0, "Quiet": 1}) - else: - _LOGGER.info(f"{self._name}: Setting normal fan mode to {wd_spd}") - await self.SyncState({"WdSpd": str(wd_spd), "Tur": 0, "Quiet": 0}) - - self.async_write_ha_state() - except ValueError: - _LOGGER.error(f"Unknown fan mode: {fan}") - return - - async def async_set_hvac_mode(self, hvac_mode): - """Set new operation mode.""" - _LOGGER.info(f"{self._name}: async_set_hvac_mode(): {hvac_mode}") - c = {} - if hvac_mode == HVACMode.OFF: - c.update({"Pow": 0}) - if hasattr(self, "_auto_light") and self._auto_light: - c.update({"Lig": 0}) - else: - mod = MODES_MAPPING.get("Mod").get(hvac_mode) - c.update({"Pow": 1, "Mod": mod}) - if hasattr(self, "_auto_light") and self._auto_light: - c.update({"Lig": 1}) - if hasattr(self, "_auto_xfan") and self._auto_xfan: - if (hvac_mode == HVACMode.COOL) or (hvac_mode == HVACMode.DRY): - c.update({"Blo": 1}) - await self.SyncState(c) self.async_write_ha_state() - async def async_turn_on(self): - """Turn on.""" - _LOGGER.info("async_turn_on(): ") - # Turn on. - c = {"Pow": 1} - if hasattr(self, "_auto_light") and self._auto_light: - c.update({"Lig": 1}) - await self.SyncState(c) + def get_swing_mode(self) -> str: + """Converts Gree Swing Modes to HA.""" + return self._device.vertical_swing_mode.name + + async def async_set_swing_mode(self, swing_mode): + """Set new target swing operation.""" + _LOGGER.debug( + "async_set_swing_mode(%s, %s)", self._device.unique_id, swing_mode + ) + + if not self.available: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="entity_unavailable" + ) + + if self._attr_hvac_mode == HVACMode.OFF: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="change_while_device_off" + ) + + try: + self._device.set_vertical_swing_mode(VerticalSwingMode[swing_mode]) + await self._device.update_device_status() + + # notify coordinator listeners of state change so that dependent entities are updated immediately + self.coordinator.async_update_listeners() + + await self.coordinator.async_request_refresh() + except Exception as err: + _LOGGER.exception("Error in '%s'", "async_set_swing_mode") + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="generic" + ) from err + self.async_write_ha_state() - async def async_turn_off(self): - """Turn off.""" - _LOGGER.info("async_turn_off(): ") - # Turn off. - c = {"Pow": 0} - if hasattr(self, "_auto_light") and self._auto_light: - c.update({"Lig": 0}) - await self.SyncState(c) + def get_swing_horizontal_mode(self) -> str: + """Converts Gree Swing Horizontal Modes to HA.""" + return self._device.horizontal_swing_mode.name + + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode): + """Set new target horizontal swing operation.""" + _LOGGER.debug( + "async_set_swing_horizontal_mode(%s, %s)", + self._device.unique_id, + swing_horizontal_mode, + ) + + if not self.available: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="entity_unavailable" + ) + + if self._attr_hvac_mode == HVACMode.OFF: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="change_while_device_off" + ) + + try: + self._device.set_horizontal_swing_mode( + HorizontalSwingMode[swing_horizontal_mode] + ) + await self._device.update_device_status() + + # notify coordinator listeners of state change so that dependent entities are updated immediately + self.coordinator.async_update_listeners() + + await self.coordinator.async_request_refresh() + except Exception as err: + _LOGGER.exception("Error in '%s'", "async_set_swing_horizontal_mode") + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="generic" + ) from err + self.async_write_ha_state() - async def async_added_to_hass(self): - _LOGGER.info("Gree climate device added to hass()") - await self.async_update() + def get_temp_units(self) -> UnitOfTemperature: + """Returns the device units of temperature.""" + return UNITS_GREE_TO_HA[self._device.target_temperature_unit] + + def get_current_temp(self) -> float | None: + """Returns the current temperature of the room. Accounting for units.""" + + # TODO: Add external sensor support + + # Gree API always return current temperature in ºC + # so if we are dealing with ºF we convert to that first + if ( + self._device.has_indoor_temperature_sensor + and self._device.indoors_temperature_c is not None + ): + if self._attr_temperature_unit == UnitOfTemperature.FAHRENHEIT: + return TemperatureConverter.convert( + self._device.indoors_temperature_c, + UnitOfTemperature.CELSIUS, + UnitOfTemperature.FAHRENHEIT, + ) + return float(self._device.indoors_temperature_c) + # FIXME: When changing Units in HA Settings, the temp does not update + return None + + def get_current_himidty(self) -> float | None: + """Returns the current humidity of the room.""" + + # TODO: Add external sensor support + + # Gree API always return current temperature in ºC + # so if we are dealing with ºF we convert to that first + if self._device.has_humidity_sensor and self._device.humidity is not None: + return float(self._device.humidity) + + return None + + def get_current_target_temp(self) -> float | None: + """Returns the current target temperature set on the device.""" + # Device already return in the temperature_units + return self._device.target_temperature + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + temperature: float | None = kwargs.get(ATTR_TEMPERATURE) + _LOGGER.debug( + "async_set_temperature(%s, %s)", self._device.unique_id, temperature + ) + _LOGGER.debug(kwargs) + + if temperature is None: + _LOGGER.error("No temperature received to set as target") + return - async def async_will_remove_from_hass(self) -> None: - """Clean up when entity is removed.""" - for name, entity_id, unsub in self._listeners: - _LOGGER.debug("Deregistering %s listener for %s", name, entity_id) - unsub() - self._listeners.clear() + if not self.available: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="entity_unavailable" + ) + + try: + self._device.set_target_temperature(temperature) + await self._device.update_device_status() + + # notify coordinator listeners of state change so that dependent entities are updated immediately + self.coordinator.async_update_listeners() + + await self.coordinator.async_request_refresh() + except Exception as err: + _LOGGER.exception("Error in '%s'", "async_set_temperature") + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="generic" + ) from err + + self.async_write_ha_state() diff --git a/custom_components/gree/config_flow.py b/custom_components/gree/config_flow.py index 58ca7fc..51eb83c 100644 --- a/custom_components/gree/config_flow.py +++ b/custom_components/gree/config_flow.py @@ -1,291 +1,302 @@ -"""Config flow for Gree climate integration.""" +"""Config flow to configure the Gree integration.""" from __future__ import annotations -# Standard library imports +from collections.abc import Mapping import logging +from typing import Any -# Third-party imports import voluptuous as vol -# Home Assistant imports from homeassistant import config_entries -from homeassistant.const import ( - CONF_HOST, - CONF_MAC, - CONF_NAME, - CONF_PORT, -) -from homeassistant.core import callback -from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import selector +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT +from homeassistant.data_entry_flow import section +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.selector import selector -# Local imports from .const import ( + CONF_ADVANCED, CONF_DISABLE_AVAILABLE_CHECK, CONF_ENCRYPTION_KEY, CONF_ENCRYPTION_VERSION, CONF_FAN_MODES, + CONF_FEATURES, CONF_HVAC_MODES, + CONF_MAX_ONLINE_ATTEMPTS, CONF_SWING_HORIZONTAL_MODES, CONF_SWING_MODES, CONF_TEMP_SENSOR_OFFSET, CONF_UID, DEFAULT_FAN_MODES, DEFAULT_HVAC_MODES, + DEFAULT_MAX_ONLINE_ATTEMPTS, DEFAULT_PORT, + DEFAULT_SUPPORTED_FEATURES, DEFAULT_SWING_HORIZONTAL_MODES, DEFAULT_SWING_MODES, DOMAIN, - OPTION_KEYS, ) -from .gree_protocol import test_connection, discover_gree_devices, detect_device_encryption +from .coordinator import GreeConfigEntry +from .gree_const import DEFAULT_UID +from .gree_device import GreeDevice _LOGGER = logging.getLogger(__name__) +# C0:39:37:B1:22:80 + + +def build_main_schema(data: Mapping | None) -> vol.Schema | None: + """Builds the main option schema.""" + return vol.Schema( + { + vol.Required( + CONF_NAME, default="AC" if data is None else data.get(CONF_NAME, "") + ): str, + vol.Required( + CONF_HOST, + default="192.168.1.103" if data is None else data.get(CONF_HOST, ""), + ): str, + vol.Required( + CONF_MAC, + default="C0:39:37:B1:22:80" if data is None else data.get(CONF_MAC, ""), + ): str, + vol.Required(CONF_ADVANCED): section( + vol.Schema( + { + vol.Required( + CONF_PORT, + default=DEFAULT_PORT + if data is None or data[CONF_ADVANCED] is None + else data[CONF_ADVANCED].get(CONF_PORT, DEFAULT_PORT), + ): int, + vol.Required( + CONF_ENCRYPTION_VERSION, + default="Auto-Detect" + if data is None or data[CONF_ADVANCED] is None + else data[CONF_ADVANCED].get( + CONF_ENCRYPTION_VERSION, "Auto-Detect" + ), + ): vol.In(["Auto-Detect", "1", "2"]), + vol.Optional( + CONF_ENCRYPTION_KEY, + default="" + if data is None or data[CONF_ADVANCED] is None + else data[CONF_ADVANCED].get(CONF_ENCRYPTION_KEY, ""), + ): str, + vol.Required( + CONF_UID, + default=DEFAULT_UID + if data is None or data[CONF_ADVANCED] is None + else data[CONF_ADVANCED].get(CONF_UID, DEFAULT_UID), + ): int, + } + ), + {"collapsed": True}, + ), + } + ) + + +def build_device_schema(data: Mapping | None) -> vol.Schema | None: + """Builds the device option schema.""" + + return vol.Schema( + { + vol.Optional( + CONF_HVAC_MODES, + default=DEFAULT_HVAC_MODES + if data is None + else data.get(CONF_HVAC_MODES, DEFAULT_HVAC_MODES), + ): selector( + { + "select": { + "options": DEFAULT_HVAC_MODES, + "multiple": True, + "translation_key": CONF_HVAC_MODES, + } + } + ), + vol.Optional( + CONF_FAN_MODES, + default=DEFAULT_FAN_MODES + if data is None + else data.get(CONF_FAN_MODES, DEFAULT_FAN_MODES), + ): selector( + { + "select": { + "options": DEFAULT_FAN_MODES, + "multiple": True, + "translation_key": CONF_FAN_MODES, + } + } + ), + vol.Optional( + CONF_SWING_MODES, + default=DEFAULT_SWING_MODES + if data is None + else data.get(CONF_SWING_MODES, DEFAULT_SWING_MODES), + ): selector( + { + "select": { + "options": DEFAULT_SWING_MODES, + "multiple": True, + "translation_key": CONF_SWING_MODES, + } + } + ), + vol.Optional( + CONF_SWING_HORIZONTAL_MODES, + default=DEFAULT_SWING_HORIZONTAL_MODES + if data is None + else data.get( + CONF_SWING_HORIZONTAL_MODES, DEFAULT_SWING_HORIZONTAL_MODES + ), + ): selector( + { + "select": { + "options": DEFAULT_SWING_HORIZONTAL_MODES, + "multiple": True, + "translation_key": CONF_SWING_HORIZONTAL_MODES, + } + } + ), + vol.Optional( + CONF_FEATURES, + default=DEFAULT_SUPPORTED_FEATURES + if data is None + else data.get(CONF_FEATURES, DEFAULT_SUPPORTED_FEATURES), + ): selector( + { + "select": { + "options": DEFAULT_SUPPORTED_FEATURES, + "multiple": True, + "translation_key": CONF_FEATURES, + } + } + ), + vol.Optional( + CONF_MAX_ONLINE_ATTEMPTS, + default=DEFAULT_MAX_ONLINE_ATTEMPTS + if data is None + else data.get(CONF_MAX_ONLINE_ATTEMPTS, DEFAULT_MAX_ONLINE_ATTEMPTS), + ): cv.positive_int, + vol.Optional( + CONF_DISABLE_AVAILABLE_CHECK, + default=False + if data is None + else data.get(CONF_DISABLE_AVAILABLE_CHECK, False), + ): cv.boolean, + vol.Optional( + CONF_TEMP_SENSOR_OFFSET, + default=False if data is None else data.get(CONF_TEMP_SENSOR_OFFSET, 0), + ): cv.boolean, + } + ) + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for Gree climate.""" + """Handle a config flow from user.""" - VERSION = 1 + VERSION = 2 def __init__(self) -> None: - self._data: dict[str, any] = {} - self._discovered_devices: list[dict] = [] - self._selected_device: dict | None = None - - async def async_step_user(self, user_input: dict | None = None) -> FlowResult: - """Handle the initial step - show discovery or manual entry.""" - if user_input is not None: - if user_input.get("discovery") == "discover": - return await self.async_step_discovery() - else: - return await self.async_step_manual() - - # Show discovery vs manual choice - data_schema = vol.Schema( - { - vol.Required("discovery", default="discover"): selector.SelectSelector( - selector.SelectSelectorConfig( - options=["discover", "manual"], - translation_key="discovery_method", - ) - ) - } - ) - return self.async_show_form(step_id="user", data_schema=data_schema) - - async def async_step_discovery(self, user_input: dict | None = None) -> FlowResult: - """Handle device discovery.""" - if user_input is not None: - # User selected a discovered device - selected_device = user_input["device"] - - for device in self._discovered_devices: - device_id = f"{device['mac']}_{device['host']}" - if device_id == selected_device: - # Check if already configured - await self.async_set_unique_id(device["mac"]) - self._abort_if_unique_id_configured() - - # Store selected device for next step - self._selected_device = device - return await self.async_step_detect_encryption() - - # If no matching device found, something went wrong - go to manual - return await self.async_step_manual() - - # Discover devices - self._discovered_devices = await discover_gree_devices(self.hass) - - if not self._discovered_devices: - # No devices found, go to manual entry - return await self.async_step_manual() - - # Create device selection options - device_options = {} - for device in self._discovered_devices: - device_id = f"{device['mac']}_{device['host']}" - device_options[device_id] = f"IP: {device['host']}, MAC: {device['mac']}" - - data_schema = vol.Schema({vol.Required("device"): vol.In(device_options)}) - - return self.async_show_form(step_id="discovery", data_schema=data_schema, description_placeholders={"devices_found": str(len(self._discovered_devices))}) - - async def async_step_detect_encryption(self, user_input: dict | None = None) -> FlowResult: - """Detect encryption version and configure device.""" + """Initialize the config flow.""" + self._step_main_data: dict | None = None + self._device: GreeDevice | None = None + + async def async_step_user( + self, user_input: dict | None = None + ) -> config_entries.ConfigFlowResult: + """Handle the initial step.""" + errors = {} if user_input is not None: - # User entered device name, proceed with setup - device_name = user_input[CONF_NAME] - - # Create final configuration - self._data = { - CONF_NAME: device_name, - CONF_HOST: self._selected_device["host"], - CONF_MAC: self._selected_device["mac"], - CONF_PORT: self._selected_device["port"], - CONF_ENCRYPTION_KEY: "", - CONF_ENCRYPTION_VERSION: self._selected_device["encryption_version"], - } - - # Test the connection - is_connection_valid = await test_connection(self._data) - if not is_connection_valid: - return self.async_show_form( - step_id="detect_encryption", - data_schema=vol.Schema( - { - vol.Required(CONF_NAME, default=device_name): str, - } - ), - errors={"base": "cannot_connect"}, + try: + self._device = GreeDevice( + user_input[CONF_NAME], + user_input[CONF_HOST], + user_input[CONF_MAC], + user_input[CONF_ADVANCED][CONF_PORT], + int(user_input[CONF_ADVANCED][CONF_ENCRYPTION_VERSION]) + if user_input[CONF_ADVANCED][CONF_ENCRYPTION_VERSION] + != "Auto-Detect" + else 0, + user_input[CONF_ADVANCED][CONF_ENCRYPTION_KEY], + user_input[CONF_ADVANCED][CONF_UID], + max_connection_attempts=2, # Use fewer attempts for testing the device ) - - return self.async_create_entry(title=device_name, data=self._data) - - # Detect encryption version for selected device - mac_addr = self._selected_device["mac"] - ip_addr = self._selected_device["host"] - port = self._selected_device["port"] - - encryption_version = await detect_device_encryption(mac_addr, ip_addr, port) - - if encryption_version is None: - # Could not detect encryption, pre-fill manual form with discovered device info - self._data = { - CONF_NAME: self._selected_device["name"], - CONF_HOST: self._selected_device["host"], - CONF_MAC: self._selected_device["mac"], - CONF_PORT: self._selected_device["port"], - CONF_ENCRYPTION_KEY: "", - CONF_ENCRYPTION_VERSION: 1, # Default to version 1 - } - # Show manual form with error about encryption detection failure - return self.async_show_form( - step_id="manual", - data_schema=vol.Schema( + await self._device.fetch_device_status() + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception as err: # noqa: BLE001 + errors["base"] = "unknown: " + repr(err) + else: + self._step_main_data = user_input + self._step_main_data["advanced"].update( { - vol.Required(CONF_NAME, default=self._data.get(CONF_NAME, "")): str, - vol.Required(CONF_HOST, default=self._data.get(CONF_HOST, "")): str, - vol.Required(CONF_MAC, default=self._data.get(CONF_MAC, "")): str, - vol.Required(CONF_PORT, default=self._data.get(CONF_PORT, DEFAULT_PORT)): int, - vol.Optional(CONF_ENCRYPTION_KEY, default=self._data.get(CONF_ENCRYPTION_KEY, "")): str, - vol.Optional(CONF_UID): int, - vol.Optional(CONF_ENCRYPTION_VERSION, default=self._data.get(CONF_ENCRYPTION_VERSION, 1)): int, + CONF_ENCRYPTION_VERSION: self._device.encryption_version, + CONF_ENCRYPTION_KEY: self._device.encryption_key, } - ), - errors={"base": "cannot_connect"}, - ) + ) + await self.async_set_unique_id(format_mac(self._device.unique_id)) + self._abort_if_unique_id_configured() - # Store detected encryption version - self._selected_device["encryption_version"] = encryption_version + return await self.async_step_device_options() - # Show device naming form with detected info - data_schema = vol.Schema( - { - vol.Required(CONF_NAME, default=self._selected_device["name"]): str, - } + return self.async_show_form( + step_id="user", data_schema=build_main_schema(user_input), errors=errors ) - return self.async_show_form(step_id="detect_encryption", data_schema=data_schema) - - async def async_step_manual(self, user_input: dict | None = None) -> FlowResult: - """Handle manual device entry.""" - errors = {} - if user_input is not None: - self._data.update(user_input) - - # Check if already configured by MAC - await self.async_set_unique_id(self._data[CONF_MAC]) + async def async_step_device_options( + self, user_input: dict | None = None + ) -> config_entries.ConfigFlowResult: + """Second step: configure features/modes.""" + if ( + user_input is not None + and self._step_main_data is not None + and self._device is not None + ): + data = {**self._step_main_data, **user_input} + await self.async_set_unique_id(self._device.unique_id) self._abort_if_unique_id_configured() + _LOGGER.debug("New entry with config: %s", data) + return self.async_create_entry( + title=self._step_main_data[CONF_NAME], data=data + ) - is_connection_valid = await test_connection(self._data) - if not is_connection_valid: - errors["base"] = "cannot_connect" - else: - return self.async_create_entry(title=user_input[CONF_NAME], data=self._data) - - # Set defaults from user_input if present, else use hardcoded defaults - defaults = user_input or self._data - data_schema = vol.Schema( - { - vol.Required(CONF_NAME, default=defaults.get(CONF_NAME, "")): str, - vol.Required(CONF_HOST, default=defaults.get(CONF_HOST, "")): str, - vol.Required(CONF_MAC, default=defaults.get(CONF_MAC, "")): str, - vol.Required(CONF_PORT, default=defaults.get(CONF_PORT, DEFAULT_PORT)): int, - vol.Optional(CONF_ENCRYPTION_KEY, default=defaults.get(CONF_ENCRYPTION_KEY, "")): str, - vol.Optional(CONF_UID): int, - vol.Optional(CONF_ENCRYPTION_VERSION, default=defaults.get(CONF_ENCRYPTION_VERSION, 1)): int, - } + return self.async_show_form( + step_id="device_options", + data_schema=build_device_schema(user_input), ) - return self.async_show_form(step_id="manual", data_schema=data_schema, errors=errors) - - async def async_step_import(self, import_data: dict) -> FlowResult: - """Handle configuration via YAML import.""" - return await self.async_step_user(import_data) - - @staticmethod - @callback - def async_get_options_flow( - config_entry: config_entries.ConfigEntry, - ) -> config_entries.OptionsFlow: - return OptionsFlowHandler(config_entry) + async def async_step_import( + self, user_input: dict + ) -> config_entries.ConfigFlowResult: + """Handle import from configuration.yaml.""" + return await self.async_step_user(user_input) -class OptionsFlowHandler(config_entries.OptionsFlow): - """Handle an options flow for Gree climate.""" + async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None): + """Handle reconfiguration of an existing entry.""" + entry: GreeConfigEntry = self._get_reconfigure_entry() - def __init__(self, config_entry: config_entries.ConfigEntry) -> None: - self.config_entry = config_entry + _LOGGER.debug("Reconfiguring: %s", entry) + await self.async_set_unique_id(entry.unique_id) - async def async_step_init(self, user_input: dict | None = None) -> FlowResult: if user_input is not None: - _LOGGER.debug("Raw user options input: %s", user_input) - normalized_input: dict[str, str | None] = {} - # Only handle known option keys - for key in OPTION_KEYS: - if key in user_input: - value = user_input[key] - normalized_input[key] = value if value not in (None, "") else None - elif key in self.config_entry.options: - normalized_input[key] = None - _LOGGER.debug("Normalized options to save: %s", normalized_input) - result = self.async_create_entry(title="", data=normalized_input) - _LOGGER.debug("Creating entry with options: %s", normalized_input) - return result + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + entry, + data_updates=user_input, + ) - options = {key: value for key, value in self.config_entry.options.items() if key in OPTION_KEYS} - _LOGGER.debug("Current stored options: %s", options) - schema = vol.Schema( - { - vol.Optional( - CONF_HVAC_MODES, - description={"suggested_value": options.get(CONF_HVAC_MODES, DEFAULT_HVAC_MODES)}, - default=options.get(CONF_HVAC_MODES, DEFAULT_HVAC_MODES), - ): vol.Any(None, selector.SelectSelector(selector.SelectSelectorConfig(options=DEFAULT_HVAC_MODES, multiple=True, custom_value=True, translation_key=CONF_HVAC_MODES))), - vol.Optional( - CONF_FAN_MODES, - description={"suggested_value": options.get(CONF_FAN_MODES, DEFAULT_FAN_MODES)}, - default=options.get(CONF_FAN_MODES, DEFAULT_FAN_MODES), - ): vol.Any(None, selector.SelectSelector(selector.SelectSelectorConfig(options=DEFAULT_FAN_MODES, multiple=True, custom_value=True, translation_key=CONF_FAN_MODES))), - vol.Optional( - CONF_SWING_MODES, - description={"suggested_value": options.get(CONF_SWING_MODES, DEFAULT_SWING_MODES)}, - default=options.get(CONF_SWING_MODES, DEFAULT_SWING_MODES), - ): vol.Any(None, selector.SelectSelector(selector.SelectSelectorConfig(options=DEFAULT_SWING_MODES, multiple=True, custom_value=True, translation_key=CONF_SWING_MODES))), - vol.Optional( - CONF_SWING_HORIZONTAL_MODES, - description={"suggested_value": options.get(CONF_SWING_HORIZONTAL_MODES, DEFAULT_SWING_HORIZONTAL_MODES)}, - default=options.get(CONF_SWING_HORIZONTAL_MODES, DEFAULT_SWING_HORIZONTAL_MODES), - ): vol.Any(None, selector.SelectSelector(selector.SelectSelectorConfig(options=DEFAULT_SWING_HORIZONTAL_MODES, multiple=True, custom_value=True, translation_key=CONF_SWING_HORIZONTAL_MODES))), - vol.Optional( - CONF_DISABLE_AVAILABLE_CHECK, - default=options.get(CONF_DISABLE_AVAILABLE_CHECK, False), - ): bool, - vol.Optional( - CONF_TEMP_SENSOR_OFFSET, - description={"suggested_value": options.get(CONF_TEMP_SENSOR_OFFSET)}, - ): vol.Any(None, bool), - } + return self.async_show_form( + step_id="reconfigure", + data_schema=build_device_schema( + entry.data if entry.data is not None else user_input + ), ) - return self.async_show_form(step_id="init", data_schema=schema) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/custom_components/gree/const.py b/custom_components/gree/const.py old mode 100644 new mode 100755 index 4b6e7e1..4c5cfa0 --- a/custom_components/gree/const.py +++ b/custom_components/gree/const.py @@ -1,17 +1,37 @@ +"""Constants.""" + +from homeassistant.components.climate import HVACMode +from homeassistant.const import UnitOfTemperature + +from .gree_api import ( + FanSpeed, + HorizontalSwingMode, + OperationMode, + TemperatureUnits, + VerticalSwingMode, +) + DOMAIN = "gree" +CONF_ADVANCED = "advanced" +CONF_UID = "uid" +CONF_ENCRYPTION_KEY = "encryption_key" +CONF_ENCRYPTION_VERSION = "encryption_version" +CONF_DISABLE_AVAILABLE_CHECK = "disable_available_check" +CONF_MAX_ONLINE_ATTEMPTS = "max_online_attempts" + CONF_HVAC_MODES = "hvac_modes" -CONF_ENCRYPTION_KEY = 'encryption_key' -CONF_UID = 'uid' -CONF_FAN_MODES = 'fan_modes' -CONF_SWING_MODES = 'swing_modes' -CONF_SWING_HORIZONTAL_MODES = 'swing_horizontal_modes' -CONF_ENCRYPTION_VERSION = 'encryption_version' -CONF_DISABLE_AVAILABLE_CHECK = 'disable_available_check' -CONF_TEMP_SENSOR_OFFSET = 'temp_sensor_offset' +CONF_FAN_MODES = "fan_modes" +CONF_SWING_MODES = "swing_modes" +CONF_SWING_HORIZONTAL_MODES = "swing_horizontal_modes" +CONF_TEMP_SENSOR_OFFSET = "temp_sensor_offset" +CONF_FEATURES = "features" DEFAULT_PORT = 7000 +DEFAULT_TIMEOUT = 10 DEFAULT_TARGET_TEMP_STEP = 1 +DEFAULT_MAX_ONLINE_ATTEMPTS = 8 +DEFAULT_ENCRYPTION_VERSION = 0 MIN_TEMP_C = 16 MAX_TEMP_C = 30 @@ -21,12 +41,146 @@ TEMSEN_OFFSET = 40 +# OPTIONAL FEATURES/MODES +# use the device beeper on commands +GATTR_BEEPER = "beeper" +# controls the state of the fresh air valve (not available on all units) +GATTR_FEAT_FRESH_AIR = "feat_fresh_air" +# "Blow" or "X-Fan", this function keeps the fan running for a while after shutting down. Only usable in Dry and Cool mode +GATTR_FEAT_XFAN = "feat_xfan" +# sleep mode, which gradually changes the temperature in Cool, Heat and Dry mode +GATTR_FEAT_SLEEP_MODE = "feat_sleep" +# 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 +GATTR_FEAT_SMART_HEAT_8C = "feat_smart_heat" +# turns all indicators and the display on the unit on or off +GATTR_FEAT_LIGHT = "feat_lights" +# controls Health ("Cold plasma") mode +GATTR_FEAT_HEALTH = "feat_health" +# prevents the wind from blowing directly on people +GATTR_ANTI_DIRECT_BLOW = "feat_anti_direct_blow" +# energy saving mode +GATTR_FEAT_ENERGY_SAVING = "feat_energy_saving" +# use light sensor for unit display +GATTR_FEAT_SENSOR_LIGHT = "feat_light_sensor" +# Quiet mode which slows down the fan to its most quiet speed. Not available in Dry and Fan mode. +GATTR_FEAT_QUIET_MODE = "feat_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 = "feat_turbo" + +GATTR_TEMP_UNITS = "temperature_units" + # HVAC modes - these come from Home Assistant and are standard -DEFAULT_HVAC_MODES = ["auto", "cool", "dry", "fan_only", "heat", "off"] +DEFAULT_HVAC_MODES = [ + HVACMode.AUTO, + HVACMode.COOL, + HVACMode.DRY, + HVACMode.FAN_ONLY, + HVACMode.HEAT, + HVACMode.OFF, +] + +HVAC_MODES_HA_TO_GREE = { + HVACMode.AUTO: OperationMode.Auto, + HVACMode.COOL: OperationMode.Cool, + HVACMode.DRY: OperationMode.Dry, + HVACMode.FAN_ONLY: OperationMode.Fan, + HVACMode.HEAT: OperationMode.Heat, +} +HVAC_MODES_GREE_TO_HA = { + OperationMode.Auto: HVACMode.AUTO, + OperationMode.Cool: HVACMode.COOL, + OperationMode.Dry: HVACMode.DRY, + OperationMode.Fan: HVACMode.FAN_ONLY, + OperationMode.Heat: HVACMode.HEAT, +} -DEFAULT_FAN_MODES = ["auto", "low", "medium_low", "medium", "medium_high", "high", "turbo", "quiet"] -DEFAULT_SWING_MODES = ["default", "swing_full", "fixed_upmost", "fixed_middle_up", "fixed_middle", "fixed_middle_low", "fixed_lowest", "swing_downmost", "swing_middle_low", "swing_middle", "swing_middle_up", "swing_upmost"] -DEFAULT_SWING_HORIZONTAL_MODES = ["default", "swing_full", "fixed_leftmost", "fixed_middle_left", "fixed_middle", "fixed_middle_right", "fixed_rightmost"] +DEFAULT_FAN_MODES = [ + FanSpeed.Auto.name, + FanSpeed.Low.name, + FanSpeed.MediumLow.name, + FanSpeed.Medium.name, + FanSpeed.MediumHigh.name, + FanSpeed.High.name, + GATTR_FEAT_TURBO, # Special mode on Gree device + GATTR_FEAT_QUIET_MODE, # Special mode on Gree device +] + +# FAN_SPEED_HA_TO_GREE = { +# FanSpeed.Auto.name: FanSpeed.Auto, +# FanSpeed.Low.name: FanSpeed.Low, +# FanSpeed.MediumLow.name: FanSpeed.MediumLow, +# FanSpeed.Medium.name: FanSpeed.Medium, +# FanSpeed.MediumHigh.name: FanSpeed.MediumHigh, +# FanSpeed.High.name: FanSpeed.High, +# } + +DEFAULT_SWING_MODES = [ + VerticalSwingMode.Default.name, + VerticalSwingMode.FullSwing.name, + VerticalSwingMode.FixedUpper.name, + VerticalSwingMode.FixedUpperMiddle.name, + VerticalSwingMode.FixedMiddle.name, + VerticalSwingMode.FixedLowerMiddle.name, + VerticalSwingMode.FixedLower.name, + VerticalSwingMode.SwingLower.name, + VerticalSwingMode.SwingLowerMiddle.name, + VerticalSwingMode.SwingMiddle.name, + VerticalSwingMode.SwingUpperMiddle.name, + VerticalSwingMode.SwingUpper.name, +] + +# SWING_VERTICAL_MODE_HA_TO_GREE = { +# VerticalSwingMode.Default.name: VerticalSwingMode.Default, +# VerticalSwingMode.FullSwing.name: VerticalSwingMode.FullSwing, +# VerticalSwingMode.FixedUpper.name: VerticalSwingMode.FixedUpper, +# VerticalSwingMode.FixedUpperMiddle.name: VerticalSwingMode.FixedUpperMiddle, +# VerticalSwingMode.FixedMiddle.name: VerticalSwingMode.FixedMiddle, +# VerticalSwingMode.FixedLowerMiddle.name: VerticalSwingMode.FixedLowerMiddle, +# VerticalSwingMode.FixedLower.name: VerticalSwingMode.FixedLower, +# VerticalSwingMode.SwingLower.name: VerticalSwingMode.SwingLower, +# VerticalSwingMode.SwingLowerMiddle.name: VerticalSwingMode.SwingLowerMiddle, +# VerticalSwingMode.SwingMiddle.name: VerticalSwingMode.SwingMiddle, +# VerticalSwingMode.SwingUpperMiddle.name: VerticalSwingMode.SwingUpperMiddle, +# VerticalSwingMode.SwingUpper.name: VerticalSwingMode.SwingUpper, +# } + +DEFAULT_SWING_HORIZONTAL_MODES = [ + HorizontalSwingMode.Default.name, + HorizontalSwingMode.FullSwing.name, + HorizontalSwingMode.Left.name, + HorizontalSwingMode.LeftCenter.name, + HorizontalSwingMode.Center.name, + HorizontalSwingMode.RightCenter.name, + HorizontalSwingMode.Right.name, +] + +# SWING_HORIZONTAL_MODE_HA_TO_GREE = { +# HorizontalSwingMode.Default.name: HorizontalSwingMode.Default, +# HorizontalSwingMode.FullSwing.name: HorizontalSwingMode.FullSwing, +# HorizontalSwingMode.Left.name: HorizontalSwingMode.Left, +# HorizontalSwingMode.LeftCenter.name: HorizontalSwingMode.LeftCenter, +# HorizontalSwingMode.Center.name: HorizontalSwingMode.Center, +# HorizontalSwingMode.RightCenter.name: HorizontalSwingMode.RightCenter, +# HorizontalSwingMode.Right.name: HorizontalSwingMode.Right, +# } + +DEFAULT_SUPPORTED_FEATURES = [ + GATTR_BEEPER, + GATTR_FEAT_FRESH_AIR, + GATTR_FEAT_XFAN, + GATTR_FEAT_SLEEP_MODE, + GATTR_FEAT_SMART_HEAT_8C, + GATTR_FEAT_LIGHT, + GATTR_FEAT_HEALTH, + GATTR_ANTI_DIRECT_BLOW, + GATTR_FEAT_ENERGY_SAVING, + GATTR_FEAT_SENSOR_LIGHT, +] + +UNITS_GREE_TO_HA = { + TemperatureUnits.C: UnitOfTemperature.CELSIUS, + TemperatureUnits.F: UnitOfTemperature.FAHRENHEIT, +} # Keys that can be updated via the options flow OPTION_KEYS = { @@ -35,46 +189,41 @@ CONF_SWING_MODES, CONF_SWING_HORIZONTAL_MODES, CONF_DISABLE_AVAILABLE_CHECK, + CONF_MAX_ONLINE_ATTEMPTS, CONF_TEMP_SENSOR_OFFSET, } MODES_MAPPING = { - "Mod" : { - "auto" : 0, - "cool" : 1, - "dry" : 2, - "fan_only" : 3, - "heat" : 4 - }, - "WdSpd" : { - "auto" : 0, - "low" : 1, - "medium_low" : 2, - "medium" : 3, - "medium_high" : 4, - "high" : 5 - }, - "SwUpDn" : { - "default" : 0, - "swing_full" : 1, - "fixed_upmost" : 2, - "fixed_middle_up" : 3, - "fixed_middle" : 4, - "fixed_middle_low" : 5, - "fixed_lowest" : 6, - "swing_downmost" : 7, - "swing_middle_low" : 8, - "swing_middle" : 9, - "swing_middle_up" : 10, - "swing_upmost" : 11 - }, - "SwingLfRig" : { - "default" : 0, - "swing_full" : 1, - "fixed_leftmost" : 2, - "fixed_middle_left" : 3, - "fixed_middle" : 4, - "fixed_middle_right" : 5, - "fixed_rightmost" : 6 - } -} \ No newline at end of file + "Mod": {"auto": 0, "cool": 1, "dry": 2, "fan_only": 3, "heat": 4}, + "WdSpd": { + "auto": 0, + "low": 1, + "medium_low": 2, + "medium": 3, + "medium_high": 4, + "high": 5, + }, + "SwUpDn": { + "default": 0, + "swing_full": 1, + "fixed_upmost": 2, + "fixed_middle_up": 3, + "fixed_middle": 4, + "fixed_middle_low": 5, + "fixed_lowest": 6, + "swing_downmost": 7, + "swing_middle_low": 8, + "swing_middle": 9, + "swing_middle_up": 10, + "swing_upmost": 11, + }, + "SwingLfRig": { + "default": 0, + "swing_full": 1, + "fixed_leftmost": 2, + "fixed_middle_left": 3, + "fixed_middle": 4, + "fixed_middle_right": 5, + "fixed_rightmost": 6, + }, +} diff --git a/custom_components/gree/coordinator.py b/custom_components/gree/coordinator.py new file mode 100644 index 0000000..c736d18 --- /dev/null +++ b/custom_components/gree/coordinator.py @@ -0,0 +1,66 @@ +"""Data update coordinator for Gree integration.""" + +from asyncio import timeout +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import ( + DataUpdateCoordinator, + UpdateFailed, + timedelta, +) + +from .gree_device import GreeDevice, GreeDeviceNotBoundError + +_LOGGER = logging.getLogger(__name__) + +type GreeConfigEntry = ConfigEntry[GreeCoordinator] + + +class GreeCoordinator(DataUpdateCoordinator[None]): + """Gree device coordinator.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: GreeConfigEntry, + device: GreeDevice, + scan_interval: int = 30, + ) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Gree Coordinator " + device.unique_id, + config_entry=config_entry, + update_interval=timedelta(seconds=scan_interval), + always_update=True, + ) + self.device: GreeDevice = device + + async def _async_setup(self): + """Set up the coordinator. + + 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. + """ + await self.device.bind_device() + + async def _async_update_data(self): + """Fetch data from API endpoint. + + This is the place to pre-process the data to lookup tables + so entities can quickly look up their data. + """ + try: + async with timeout(10): + await self.device.fetch_device_status() + except GreeDeviceNotBoundError as err: + raise ConfigEntryAuthFailed("Failed to initiate Gree device") from err + except ValueError as err: + raise UpdateFailed("Error getting state from device") from err diff --git a/custom_components/gree/entity.py b/custom_components/gree/entity.py old mode 100644 new mode 100755 index e0f6968..3476eb6 --- a/custom_components/gree/entity.py +++ b/custom_components/gree/entity.py @@ -5,18 +5,53 @@ # Standard library imports from collections.abc import Callable from dataclasses import dataclass -from typing import Any +from typing import Any, Generic, TypeVar # Home Assistant imports +from config.custom_components.gree.coordinator import GreeCoordinator +from config.custom_components.gree.gree_device import GreeDevice from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity # Local imports from .const import DOMAIN +T = TypeVar("T") + + +class GreeEntity(CoordinatorEntity[GreeCoordinator]): + """Base Gree entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: GreeCoordinator, restore_state: bool) -> None: + """Initialize Gree entity.""" + super().__init__(coordinator) + self._device = coordinator.device + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, self._device.unique_id)}, + identifiers={(DOMAIN, self._device.unique_id)}, + name=self._device.name, + manufacturer="Gree", + ) + self.restore_state = restore_state + + +@dataclass(frozen=True, kw_only=True) +class GreeEntityDescription(EntityDescription): + """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 + + available_func: Callable[[GreeDevice], bool] + @dataclass -class GreeEntityDescription: +class OldGreeEntityDescription: """Describes Gree entity.""" property_key: str @@ -37,13 +72,13 @@ def __post_init__(self): icon_fn: Callable[[Any, object], str] = None -class GreeEntity(Entity): +class OldGreeEntity(Entity): """Base Gree entity.""" _attr_has_entity_name = True - entity_description: GreeEntityDescription + entity_description: OldGreeEntityDescription - def __init__(self, hass, entry, description: GreeEntityDescription) -> None: + def __init__(self, hass, entry, description: OldGreeEntityDescription) -> None: """Initialize Gree entity.""" # Get the device from the entry data entry_data = hass.data.get(DOMAIN, {}).get(entry.entry_id, {}) @@ -55,11 +90,15 @@ def _set_id(self) -> None: """Set entity ID and unique ID.""" if self.entity_description: if self.entity_description.icon_fn is not None: - self._attr_icon = self.entity_description.icon_fn(self.native_value, self._device) + self._attr_icon = self.entity_description.icon_fn( + self.native_value, self._device + ) elif self.entity_description.icon is not None: self._attr_icon = self.entity_description.icon - self._attr_unique_id = f"{self._device._mac_addr}_{self.entity_description.key}" + self._attr_unique_id = ( + f"{self._device._mac_addr}_{self.entity_description.key}" + ) @property def device_info(self) -> DeviceInfo: @@ -76,7 +115,11 @@ def available(self) -> bool: """Return if entity is available.""" if self.entity_description.available_fn: return self.entity_description.available_fn(self._device) - return self._device._device_online if hasattr(self._device, "_device_online") else True + return ( + self._device._device_online + if hasattr(self._device, "_device_online") + else True + ) @property def native_value(self) -> Any: diff --git a/custom_components/gree/gree_api.py b/custom_components/gree/gree_api.py new file mode 100644 index 0000000..a6bca9d --- /dev/null +++ b/custom_components/gree/gree_api.py @@ -0,0 +1,622 @@ +"""Contains the API to interface with the Gree device.""" + +import asyncio +import base64 +from enum import Enum, IntEnum, unique +import json +import logging +import socket + +import asyncio_dgram +from Crypto.Cipher import AES + +_LOGGER = logging.getLogger(__name__) + +GCM_IV = b"\x54\x40\x78\x44\x49\x67\x5a\x51\x6c\x5e\x63\x13" +GCM_ADD = b"qualcomm-test" + +GREE_GENERIC_DEVICE_KEY = "a3K8Bx%2r8Y7#xDh" +GREE_GENERIC_DEVICE_KEY_GCM = "{yxAHAY_Lm6pbC/<" + + +class GreeProp(Enum): + """Enumeration of Gree device properties.""" + + # HVAC CONTROLS + # power state of the device + POWER = "Pow" + # mode of operation + OP_MODE = "Mod" + # fan speed mode + FAN_SPEED = "WdSpd" + # target temperature + TARGET_TEMPERATURE = "SetTem" + # used to distinguish between Fahrenheit values + TARGET_TEMPERATURE_BIT = "TemRec" + # defines the unit of temperature for the target temperature + TARGET_TEMPERATURE_UNIT = "TemUn" + # the swing mode of the horizontal air blades (available on limited number of devices) + SWING_HORIZONTAL = "SwingLfRig" + # the swing mode of the vertical air blades + SWING_VERTICAL = "SwUpDn" + # Quiet mode which slows down the fan to its most quiet speed. Not available in Dry and Fan mode. + 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 + FEAT_TURBO_MODE = "Tur" + + # OPTIONAL FEATURES/MODES + # controls the state of the fresh air valve (not available on all units) + FEAT_FRESH_AIR = "Air" + # "Blow" or "X-Fan", this function keeps the fan running for a while after shutting down. Only usable in Dry and Cool mode + 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" + FEAT_SLEEP_MODE = "SlpMod" + # 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 + FEAT_SMART_HEAT_8C = "StHt" + # energy saving mode + FEAT_ENERGY_SAVING = "SvSt" + # prevents the wind from blowing directly on people + FEAT_ANTI_DIRECT_BLOW = "AntiDirectBlow" + # use light sensor for unit display + FEAT_SENSOR_LIGHT = "LigSen" + + # SENSORS + # indoor temperature sensor, used to read the current room temperature, if available + SENSOR_TEMPERATURE = "TemSen" + # outside temperature sensor, used to read the current outdooors temperature, if available + SENSOR_OUTSIDE_TEMPERATURE = "OutEnvTem" + # indoor humidity sensor, used to read the current room humidity, if available + SENSOR_HUMIDITY = "DwatSen" + + # OTHER + _UNKNOWN_HEAT_COOL_TYPE = "HeatCoolType" + # If set to 0 the unit will beep on every command + BEEPER = "Buzzer_ON_OFF" + + +@unique +class TemperatureUnits(IntEnum): + """Enumeration of temperature units.""" + + C = 0 + F = 1 + + +@unique +class OperationMode(IntEnum): + """Enumeration of HVAC modes.""" + + Auto = 0 + Cool = 1 + Dry = 2 + Fan = 3 + Heat = 4 + + +@unique +class FanSpeed(IntEnum): + """Enumeration of fan speeds.""" + + Auto = 0 + Low = 1 + MediumLow = 2 + Medium = 3 + MediumHigh = 4 + High = 5 + + +@unique +class HorizontalSwingMode(IntEnum): + """Enumeration of horizontal swing modes.""" + + Default = 0 + FullSwing = 1 + Left = 2 + LeftCenter = 3 + Center = 4 + RightCenter = 5 + Right = 6 + + +@unique +class VerticalSwingMode(IntEnum): + """Enumeration of vertical swing modes.""" + + Default = 0 + FullSwing = 1 + FixedUpper = 2 + FixedUpperMiddle = 3 + FixedMiddle = 4 + FixedLowerMiddle = 5 + FixedLower = 6 + SwingUpper = 7 + SwingUpperMiddle = 8 + SwingMiddle = 9 + SwingLowerMiddle = 10 + SwingLower = 11 + + +class GreeCommand(IntEnum): + """Enumeration of Gree commands.""" + + STATUS = 0 + BIND = 1 + + +propkey_to_enum = {prop.value: prop for prop in GreeProp} + + +def pad(s: str): + """Pads a string so its length becomes a multiple of 16. For PKCS#7 padding.""" + aesBlockSize = 16 + requiredPaddingSize = aesBlockSize - len(s) % aesBlockSize + return s + requiredPaddingSize * chr(requiredPaddingSize) + + +async def udp_request_async( + ip_addr: str, + port: int, + json_data: str, + timeout: float = 2.0, + max_retries: int = 8, +) -> str: + """Send a payload JSON data to the device and reads the response (async).""" + + for attempt in range(max_retries): + stream: asyncio_dgram.DatagramClient | None = None + try: + stream = await asyncio_dgram.connect((ip_addr, port)) + await stream.send(json_data.encode("utf-8")) + received_json, _ = await asyncio.wait_for(stream.recv(), timeout) + return received_json.decode("utf-8") + except Exception as err: # noqa: BLE001 + _LOGGER.warning( + "Error communicating with %s. Attempt %d/%d | %s", + ip_addr, + attempt + 1, + max_retries, + repr(err), + ) + # raise ValueError(f"Error communicating with {ip_addr}", ip_addr) from err + finally: + if stream: + try: + stream.close() + except Exception as cerr: # noqa: BLE001 + _LOGGER.warning( + "Error communicating with %s. Attempt %d/%d | %s", + ip_addr, + attempt + 1, + max_retries, + repr(cerr), + ) + + # Apply backoff before retrying + if attempt < max_retries - 1: + backoff = 0.5 + attempt * 0.3 # 0.5s, 0.8s, 1.1s, ... + await asyncio.sleep(backoff) + + raise ValueError( + f"Failed to communicate with device '{ip_addr}:{port}' after {max_retries} attempts" + ) + + +async def udp_request_blocking( + ip_addr: str, port: int, json_data: str, timeout: int = 2, max_retries: int = 8 +) -> str: + """Send a payload JSON data to the device and reads the response (blocking).""" + _LOGGER.debug("Fetching(%s, %s, %s, %s)", ip_addr, port, timeout, json_data) + + for attempt in range(max_retries): + clientSock = None + + try: + clientSock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + clientSock.settimeout(timeout) + + clientSock.sendto(bytes(json_data, "utf-8"), (ip_addr, port)) + + data, _ = await asyncio.wait_for( + asyncio.get_event_loop().run_in_executor( + None, clientSock.recvfrom, 64000 + ), + timeout=timeout, + ) + + return data.decode("utf-8") + + except Exception: # noqa: BLE001 + _LOGGER.warning( + "Error communicating with %s. Attempt %d/%d", + ip_addr, + attempt + 1, + max_retries, + ) + + finally: + if clientSock: + try: + clientSock.close() + except Exception: # noqa: BLE001 + _LOGGER.error("Error closing socket to %s", ip_addr) + + if attempt < max_retries - 1: + await asyncio.sleep( + 0.5 * (attempt * 0.3) + ) # 0.5s, 0.8s, 1.1s, 1.4s, 1.7s, 2.0s, 2.3s + + raise ValueError( + f"Failed to communicate with device '{ip_addr}' after multiple attempts" + ) + + +async def fetch_result( + ip_addr: str, + port: int, + json_data: str, + cipher, + encryption_version: int = 1, + max_connection_attempts: int = 8, +): + """Send a payload JSON data to the device and reads the response (async).""" + + _LOGGER.debug("Fetching data from %s", ip_addr) + + received_json: str = "" + + try: + received_json = await udp_request_async( + ip_addr, port, json_data, max_retries=max_connection_attempts + ) + except Exception as err: + raise ValueError(f"Error communicating with {ip_addr}", ip_addr) from err + + # try: + # received_json = await udp_request_blocking(ip_addr, port, json_data) + # except Exception as err: + # raise ValueError(f"Error communicating with {ip_addr}", ip_addr) from err + + data = json.loads(received_json) + + encodedPack = data["pack"] + pack = base64.b64decode(encodedPack) + decryptedPack = cipher.decrypt(pack) + + if encryption_version == 2: + tag = data["tag"] + _LOGGER.debug("Verifying tag: %s", tag) + cipher.verify(base64.b64decode(tag)) + + pack = decryptedPack.decode("utf-8") + replacedPack = pack.replace("\x0f", "").replace(pack[pack.rindex("}") + 1 :], "") + data["pack"] = json.loads(replacedPack) + + _LOGGER.debug("Got data from %s", ip_addr) + + return data + + +async def get_result_pack( + ip_addr: str, + port: int, + json_data: str, + cipher, + encryption_version: int = 1, + max_connection_attempts: int = 8, +): + """Get the result pack from the device (async).""" + + data = await fetch_result( + ip_addr, port, json_data, cipher, encryption_version, max_connection_attempts + ) + + if data is not None and data["pack"] is not None: + return data["pack"] + + raise ValueError("No pack received from device") + + +def get_cipher(key: str, encryption_version: int): + """Get AES cipher object based on encryption version.""" + + if encryption_version == 1: + return AES.new(key.encode("utf8"), AES.MODE_ECB) + + if encryption_version == 2: + return AES.new(key.encode("utf8"), AES.MODE_GCM, nonce=GCM_IV).update( + assoc_data=GCM_ADD + ) + + _LOGGER.error("Unsupported encryption version: %d", encryption_version) + return None + + +def gree_get_default_cipher(encryption_version: int): + """Get AES cipher object based on encryption version using default keys.""" + + if encryption_version == 1: + return get_cipher(GREE_GENERIC_DEVICE_KEY, encryption_version) + + if encryption_version == 2: + return get_cipher(GREE_GENERIC_DEVICE_KEY_GCM, encryption_version) + + _LOGGER.error("Unsupported encryption version: %d", encryption_version) + return None + + +def gree_encrypt_pack( + data: str, + cipher, + encryption_version: int, +) -> tuple[str, str]: + """Create an encrypted pack to send to the device.""" + + if cipher is None: + raise ValueError("Cipher must not be None") + + if encryption_version == 1: + encrypted_data = cipher.encrypt(pad(data).encode("utf-8")) + return ( + base64.b64encode(encrypted_data).decode("utf-8"), + "", + ) + + if encryption_version == 2: + encrypted_data, tag = cipher.encrypt_and_digest(data.encode("utf-8")) + return ( + base64.b64encode(encrypted_data).decode("utf-8"), + base64.b64encode(tag).decode("utf-8"), + ) + + raise ValueError(f"Unsupported encryption version: {encryption_version}") + + +def gree_create_bind_pack(mac_addr: str, uid: int, encryption_version: int) -> str: + """Create a bind pack to send to the device.""" + + pack: str = "" + + if encryption_version == 1: + pack = json.dumps({"mac": mac_addr, "t": "bind", "uid": uid}) + elif encryption_version == 2: + pack = json.dumps({"cid": mac_addr, "mac": mac_addr, "t": "bind", "uid": uid}) + + _LOGGER.debug("Bind Pack: %s", pack) + return pack + + +def gree_create_status_pack(mac_addr: str, props: list[str]) -> str: + """Create a status pack to send to the device.""" + + pack: str = json.dumps({"cols": props, "mac": mac_addr, "t": "status"}) + + _LOGGER.debug("Status Pack: %s", pack) + return pack + + +def gree_create_set_pack(props: dict[GreeProp, int]) -> str: + """Create a set pack to send to the device.""" + + pack: str = json.dumps( + {"opt": [prop.value for prop in props], "p": list(props.values()), "t": "cmd"} + ) + + _LOGGER.debug("Status Pack: %s", pack) + return pack + + +def gree_create_payload( + pack: str, + i_command: GreeCommand, + mac_addr: str, + uid: int, + encryption_version: int, + tag: str, +) -> str: + """Create the full payload to send to the device.""" + + payload: str = "" + + if encryption_version == 1: + payload = json.dumps( + { + "cid": "app", + "i": i_command.value, + "pack": pack, + "t": "pack", + "tcid": mac_addr, + "uid": uid, + } + ) + elif encryption_version == 2: + payload = json.dumps( + { + "cid": "app", + "i": i_command.value, + "pack": pack, + "t": "pack", + "tcid": mac_addr, + "uid": uid, + "tag": tag, + } + ) + + # _LOGGER.debug("Payload: %s", payload) + return payload + + +async def gree_get_device_key( + ip_addr: str, + mac_addr: str, + port: int, + uid: int, + encryption_version: int, + max_connection_attempts: int = 8, +) -> tuple[str, int]: + """Get the device key by sending a bind request to the device using a generic key (async).""" + + key = "" + error: Exception = ValueError("Unknown error getting device encryption key") + + for enc_version in [1, 2] if encryption_version == 0 else [encryption_version]: + _LOGGER.info("Trying to retrieve device encryption key v%d", enc_version) + pack, tag = gree_encrypt_pack( + gree_create_bind_pack(mac_addr, uid, enc_version), + gree_get_default_cipher(enc_version), + enc_version, + ) + jsonPayloadToSend = gree_create_payload( + pack, GreeCommand.BIND, mac_addr, uid, enc_version, tag + ) + + try: + result = await get_result_pack( + ip_addr, + port, + jsonPayloadToSend, + gree_get_default_cipher(enc_version), + enc_version, + max_connection_attempts, + ) + key = result.get("key", "") + except Exception as err: # noqa: BLE001 + error = err + _LOGGER.error( + "Error getting device encryption key with version %d: %s", + enc_version, + repr(err), + ) + # raise ValueError("Error getting device encryption key") from err + continue + + if key.strip() == "": + error = ValueError("Received empty encryption key from device") + continue + + _LOGGER.info( + "Fetched device encryption key with version %d with success", enc_version + ) + _LOGGER.debug("Fetched encryption key: %s", key) + + return key, enc_version + + raise ValueError("Error getting device encryption key") from error + + +async def gree_get_status( + ip_addr: str, + mac_addr: str, + port: int, + uid: int, + encryption_key: str, + encryption_version: int, + props: list[GreeProp], +) -> dict[GreeProp, int]: + """Get the status of the device by sending a status request to the device (async).""" + + _LOGGER.debug("Trying to get device status") + + status_values: dict[GreeProp, int] = {} + + pack, tag = gree_encrypt_pack( + gree_create_status_pack(mac_addr, [prop.value for prop in props]), + get_cipher(encryption_key, encryption_version), + encryption_version, + ) + jsonPayloadToSend = gree_create_payload( + pack, GreeCommand.STATUS, mac_addr, uid, encryption_version, tag + ) + + try: + result = await get_result_pack( + ip_addr, + port, + jsonPayloadToSend, + get_cipher(encryption_key, encryption_version), + encryption_version, + ) + except Exception as err: + raise ValueError("Error getting device status") from err + + if result["cols"] is None or result["dat"] is None: + raise ValueError("Error getting device status, no data received") + + cols = [propkey_to_enum[c] for c in result["cols"] if c in propkey_to_enum] + values = list(map(int, result["dat"])) + status_values = dict(zip(cols, values, strict=True)) + + _LOGGER.debug("Device status values: %s", status_values) + return status_values + + +async def gree_set_status( + ip_addr: str, + mac_addr: str, + port: int, + uid: int, + encryption_key: str, + encryption_version: int, + props: dict[GreeProp, int], +) -> dict[GreeProp, int]: + """Set the status of the device by sending a status request to the device (async).""" + + _LOGGER.debug("Trying to set device status") + + set_pack = gree_create_set_pack(props) + pack, tag = gree_encrypt_pack( + set_pack, + get_cipher(encryption_key, encryption_version), + encryption_version, + ) + + jsonPayloadToSend = gree_create_payload( + pack, GreeCommand.STATUS, mac_addr, uid, encryption_version, tag + ) + + try: + result = await get_result_pack( + ip_addr, + port, + jsonPayloadToSend, + get_cipher(encryption_key, encryption_version), + encryption_version, + ) + except Exception as err: + raise ValueError("Error getting device status") from err + + if result["r"] is None or result["r"] != 200: + raise ValueError(f"Error setting device status, response code: {result['r']}") + + options_set = [propkey_to_enum[c] for c in result["opt"] if c in propkey_to_enum] + if options_set is None or len(options_set) == 0: + raise ValueError("No options were set, something went wrong") + + values_set_1 = result.get("p", None) + values_set_2 = result.get("val", None) # this one is optional + + if values_set_1 is None: + raise ValueError("No values were set, something went wrong") + values_set_1 = list(map(int, values_set_1)) + + if values_set_2 is not None: + values_set_2 = list(map(int, values_set_2)) + if len(values_set_1) != len(values_set_2): + raise ValueError( + f"Wrong option values received: {values_set_1} {values_set_2}" + ) + + if len(values_set_1) != len(options_set): + raise ValueError( + f"Options and values set mismatch {options_set} {values_set_1}" + ) + + updated_props = dict(zip(options_set, values_set_1, strict=True)) + if updated_props != props: + _LOGGER.warning("Expected updated props %s but got %s", props, updated_props) + + return updated_props diff --git a/custom_components/gree/gree_const.py b/custom_components/gree/gree_const.py new file mode 100644 index 0000000..b0d6016 --- /dev/null +++ b/custom_components/gree/gree_const.py @@ -0,0 +1,15 @@ +"""Constants for Gree integration.""" + +DEFAULT_PORT = 7000 +DEFAULT_TIMEOUT = 30 +DEFAULT_TARGET_TEMP_STEP = 1 +DEFAULT_UID = 0 +DEFAULT_ENCRYPTION_VERSION = 1 + +MIN_TEMP_C = 16 +MAX_TEMP_C = 30 + +MIN_TEMP_F = 61 +MAX_TEMP_F = 86 + +TEMSEN_OFFSET = 40 diff --git a/custom_components/gree/gree_device.py b/custom_components/gree/gree_device.py new file mode 100755 index 0000000..a76add2 --- /dev/null +++ b/custom_components/gree/gree_device.py @@ -0,0 +1,601 @@ +"""Contains the API to interface with the Gree device.""" + +import logging + +from attr import dataclass + +from .gree_api import ( + FanSpeed, + GreeProp, + HorizontalSwingMode, + OperationMode, + TemperatureUnits, + VerticalSwingMode, + gree_get_device_key, + gree_get_status, + gree_set_status, +) +from .gree_const import DEFAULT_UID +from .gree_helpers import ( + TempOffsetResolver, + gree_get_target_temp_props_from_c, + gree_get_target_temp_props_from_f, + gree_get_target_temperature_c, + gree_get_target_temperature_f, +) + +_LOGGER = logging.getLogger(__name__) + + +class GreeDeviceNotBoundError(Exception): + """Raised when the device binding fails.""" + + +@dataclass +class GreeDeviceState: + """Data structure for Gree device state.""" + + power: bool = False + operation_mode: OperationMode = OperationMode.Auto + fan_speed: FanSpeed = FanSpeed.Auto + target_temperature: float = -1 + target_temperature_unit: TemperatureUnits = TemperatureUnits.C + horizontal_swing_mode: HorizontalSwingMode = HorizontalSwingMode.Default + vertical_swing_mode: VerticalSwingMode = VerticalSwingMode.Default + feature_fresh_air: bool = False + feature_x_fan: bool = False + feature_health: bool = False + feature_sleep: bool = False + feature_light: bool = False + feature_light_sensor: bool = False + feature_quiet: bool = False + feature_turbo: bool = False + feature_smart_heat: bool = False + feature_energy_saving: bool = False + feature_anti_direct_blow: bool = False + has_indoor_temperature_sensor: bool = False + indoors_temperature_c: int | None = None + has_outdoor_temperature_sensor: bool = False + outdoors_temperature_c: int | None = None + has_humidity_sensor: bool = False + humidity: int | None = None + has_light_sensor: bool = False + + +class GreeDevice: + """Representation of a Gree device.""" + + def __init__( + self, + name: str, + ip_addr: str, + mac_addr: str, + port: int, + encryption_version: int, + encryption_key: str, + uid: int = DEFAULT_UID, + max_connection_attempts: int = 8, + ) -> None: + """Initialize the Gree device.""" + + _LOGGER.info( + "Initialize the GREE Device API for: %s (%s:%d)", + mac_addr, + ip_addr, + port, + ) + _LOGGER.debug("Version: %s, Key: %s", encryption_version, encryption_key) + + self._name: str = name + self._ip_addr: str = ip_addr + self._port: int = port + self._mac_addr = self._mac_addr_sub = mac_addr.lower() + if "@" in mac_addr: + self._mac_addr_sub, self._mac_addr = mac_addr.lower().split("@", 1) + self._encryption_version: int = encryption_version + self._encryption_key: str = encryption_key + self._uid: int = uid + self._state: dict[GreeProp, int] = {} + self._new_state: dict[GreeProp, int] = {} + self._is_bound: bool = False + self._uniqueid: str = self._mac_addr + self._max_connection_attempts: int = max_connection_attempts + + self._props_to_update: list[GreeProp] = list(GreeProp) + self._props_to_update.remove( + GreeProp.BEEPER # We don't need to poll the beeper state + ) + + self._temp_processor_indoors: TempOffsetResolver | None = None + self._temp_processor_outdoors: TempOffsetResolver | None = None + self._beeper = False + + self.state: GreeDeviceState = GreeDeviceState() + + if encryption_version < 0 or encryption_version > 2: + _LOGGER.error("Unsupported encryption version, defaulting to 0") + self._encryption_version = 0 + + async def bind_device(self) -> bool: + """Setup the device (async).""" + + if not self._is_bound: + if not self._encryption_key.strip(): + _LOGGER.info("No encryption key provided") + try: + ( + self._encryption_key, + self._encryption_version, + ) = await gree_get_device_key( + self._ip_addr, + self._mac_addr, + self._port, + self._uid, + self._encryption_version, + max_connection_attempts=self._max_connection_attempts, + ) + self._is_bound = True + except Exception as e: + raise GreeDeviceNotBoundError("Device not bound") from e + else: + _LOGGER.info( + "Using the provided encryption key with version %d", + self._encryption_version, + ) + self._is_bound = True + + return self._is_bound + + async def fetch_device_status(self) -> GreeDeviceState: + """Get the device status (async).""" + + _LOGGER.debug("Trying to get device status") + + if not self._is_bound: + await self.bind_device() + + try: + self._state.update( + await gree_get_status( + self._ip_addr, + self._mac_addr, + self._port, + self._uid, + self._encryption_key, + self._encryption_version, + self._props_to_update, + ) + ) + except Exception as err: + raise ValueError("Error getting device status") from err + + self._update_state() + + self._remove_unsupported_props() + + return self.state + + async def update_device_status(self) -> GreeDeviceState: + """Send the new local device state to the device and updates local state if successfull.""" + if not self._is_bound: + await self.bind_device() + + # If there is no change in the properties, do nothing + has_updated_states = any( + self._state.get(k) != v for k, v in self._new_state.items() + ) + if not has_updated_states: + _LOGGER.debug("No changes in the properties, skipping update to device") + return self.state + + self._new_state[GreeProp.BEEPER] = 0 if self._beeper else 1 + + try: + self._state.update( + await gree_set_status( + self._ip_addr, + self._mac_addr, + self._port, + self._uid, + self._encryption_key, + self._encryption_version, + self._new_state, + ) + ) + self._new_state.clear() + except Exception as err: + raise ValueError("Error setting device status") from err + + self._update_state() + return self.state + + 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_state.update(props) + + def _update_state(self) -> None: + """Update the state from the internal state.""" + + self.state.power = self.power_mode + self.state.operation_mode = self.operation_mode + self.state.fan_speed = self.fan_speed + self.state.target_temperature = self.target_temperature + self.state.target_temperature_unit = self.target_temperature_unit + self.state.horizontal_swing_mode = self.horizontal_swing_mode + self.state.vertical_swing_mode = self.vertical_swing_mode + self.state.feature_fresh_air = self.feature_fresh_air + self.state.feature_x_fan = self.feature_x_fan + self.state.feature_health = self.feature_health + self.state.feature_sleep = self.feature_sleep + self.state.feature_light = self.feature_light + self.state.feature_light_sensor = self.feature_light_sensor + self.state.feature_quiet = self.feature_quiet + self.state.feature_turbo = self.feature_turbo + self.state.feature_smart_heat = self.feature_smart_heat + self.state.feature_energy_saving = self.feature_energy_saving + self.state.feature_anti_direct_blow = self.feature_anti_direct_blow + self.state.has_indoor_temperature_sensor = self.has_indoor_temperature_sensor + self.state.indoors_temperature_c = self.indoors_temperature_c + self.state.has_outdoor_temperature_sensor = self.has_outdoor_temperature_sensor + self.state.outdoors_temperature_c = self.outdoors_temperature_c + self.state.has_humidity_sensor = self.has_humidity_sensor + self.state.humidity = self.humidity + + def _remove_unsupported_props(self): + """Remove unsupported properties from the list to update.""" + if ( + GreeProp.SENSOR_TEMPERATURE in self._props_to_update + and not self.has_indoor_temperature_sensor + ): + self._props_to_update.remove(GreeProp.SENSOR_TEMPERATURE) + self._state.pop(GreeProp.SENSOR_TEMPERATURE, None) + _LOGGER.debug("No longer updating temperature sensor property") + + if ( + GreeProp.SENSOR_OUTSIDE_TEMPERATURE in self._props_to_update + and not self.has_outdoor_temperature_sensor + ): + self._props_to_update.remove(GreeProp.SENSOR_OUTSIDE_TEMPERATURE) + self._state.pop(GreeProp.SENSOR_OUTSIDE_TEMPERATURE, None) + _LOGGER.debug("No longer updating outside temperature sensor property") + + if ( + GreeProp.SENSOR_HUMIDITY in self._props_to_update + and not self.has_humidity_sensor + ): + self._props_to_update.remove(GreeProp.SENSOR_HUMIDITY) + self._state.pop(GreeProp.SENSOR_HUMIDITY, None) + _LOGGER.debug("No longer updating humidity sensor property") + + def _get_prop_raw(self, prop: GreeProp, default: int | None = None) -> int | None: + """Get the raw value of a property.""" + return self._state.get(prop, default) + + def LogDeviceInfo(self): + """Log basic device information.""" + + capabilities = [] + if self.has_indoor_temperature_sensor: + capabilities.append("Temperature Sensor") + if self.has_outdoor_temperature_sensor: + capabilities.append("Outside Temperature Sensor") + if self.has_humidity_sensor: + capabilities.append("Humidity Sensor") + + _LOGGER.info( + "Capabilities: %s", ", ".join(capabilities) if capabilities else "None" + ) + + _LOGGER.info( + "Indoor Temperature: %s ºC", + self.indoors_temperature_c if self.has_indoor_temperature_sensor else None, + ) + _LOGGER.info( + "Outddor Temperature: %s ºC", + self.indoors_temperature_c if self.has_indoor_temperature_sensor else None, + ) + _LOGGER.info( + "Target Temperature: %s º%s", + self.target_temperature, + self.target_temperature_unit.name, + ) + _LOGGER.info("Mode: %s", self.operation_mode.name) + + @property + def name(self) -> str: + """Returns the friendly name of the device.""" + return self._name + + @property + def encryption_key(self) -> str: + """Return the encryption key of the device.""" + return self._encryption_key + + @property + def encryption_version(self) -> int: + """Return the encryption version of the device.""" + return self._encryption_version + + @property + def unique_id(self) -> str: + """Return the unique ID of the device (MAC).""" + return self._uniqueid + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return bool(self._is_bound) + + @property + def beeper(self) -> bool: + """Return True if the device beeper is enabled.""" + return self._beeper + + def set_beeper(self, value: bool) -> None: + """Set the device beeper state.""" + self._beeper = value + + @property + def has_indoor_temperature_sensor(self) -> bool: + """Return True if the device has a temperature sensor.""" + return ( + GreeProp.SENSOR_TEMPERATURE in self._state + and self._get_prop_raw(GreeProp.SENSOR_TEMPERATURE, 0) != 0 + ) + + @property + def has_outdoor_temperature_sensor(self) -> bool: + """Return True if the device has an outdoor temperature sensor.""" + return ( + GreeProp.SENSOR_OUTSIDE_TEMPERATURE in self._state + and self._get_prop_raw(GreeProp.SENSOR_OUTSIDE_TEMPERATURE, 0) != 0 + ) + + @property + def has_humidity_sensor(self) -> bool: + """Return True if the device has an humidity sensor.""" + return ( + GreeProp.SENSOR_HUMIDITY in self._state + and self._get_prop_raw(GreeProp.SENSOR_HUMIDITY, 0) != 0 + ) + + @property + def indoors_temperature_c(self) -> int | None: + """Return the current temperature if available.""" + if self.has_indoor_temperature_sensor: + if self._temp_processor_indoors is None: + self._temp_processor_indoors = TempOffsetResolver() + + raw_c = self._get_prop_raw(GreeProp.SENSOR_TEMPERATURE, None) + return ( + int(self._temp_processor_indoors.evaluate(raw_c)) + if raw_c is not None + else None + ) + + return None + + @property + def outdoors_temperature_c(self) -> int | None: + """Return the current outside temperature if available.""" + if self.has_outdoor_temperature_sensor: + if self._temp_processor_outdoors is None: + self._temp_processor_outdoors = TempOffsetResolver() + + raw_c = self._get_prop_raw(GreeProp.SENSOR_OUTSIDE_TEMPERATURE, None) + return ( + int(self._temp_processor_outdoors.evaluate(raw_c)) + if raw_c is not None + else None + ) + + return None + + @property + def humidity(self) -> int | None: + """Return the current humidity if available.""" + if self.has_humidity_sensor: + return self._get_prop_raw(GreeProp.SENSOR_HUMIDITY, None) + return None + + @property + def power_mode(self) -> bool: + """Return the current power mode.""" + return self._get_prop_raw(GreeProp.POWER, 0) == 1 + + def set_power_mode(self, value: bool): + """Sets the device power mode.""" + self.set_device_status({GreeProp.POWER: 1 if value else 0}) + + @property + def operation_mode(self) -> OperationMode: + """Return the current operation mode.""" + return OperationMode( + self._get_prop_raw(GreeProp.OP_MODE, OperationMode.Auto.value) + ) + + def set_operation_mode(self, mode: OperationMode): + """Sets the device operation mode.""" + self.set_device_status({GreeProp.OP_MODE: mode}) + + @property + def fan_speed(self) -> FanSpeed: + """Return the current fan speed.""" + 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.""" + self.set_device_status({GreeProp.FAN_SPEED: speed}) + + @property + def vertical_swing_mode(self) -> VerticalSwingMode: + """Return the current vertical swing setting.""" + return VerticalSwingMode( + self._get_prop_raw(GreeProp.SWING_VERTICAL, VerticalSwingMode.Default.value) + ) + + def set_vertical_swing_mode(self, swing_mode: VerticalSwingMode): + """Sets the device vertical swing mode.""" + self.set_device_status({GreeProp.SWING_VERTICAL: swing_mode}) + + @property + def horizontal_swing_mode(self) -> HorizontalSwingMode: + """Return the current horizontal swing setting.""" + return HorizontalSwingMode( + self._get_prop_raw( + GreeProp.SWING_HORIZONTAL, HorizontalSwingMode.Default.value + ) + ) + + def set_horizontal_swing_mode(self, swing_mode: HorizontalSwingMode): + """Sets the device horizontal swing mode.""" + self.set_device_status({GreeProp.SWING_HORIZONTAL: swing_mode}) + + @property + def target_temperature_unit(self) -> TemperatureUnits: + """Return the units of the target temperature.""" + return TemperatureUnits( + self._get_prop_raw( + GreeProp.TARGET_TEMPERATURE_UNIT, TemperatureUnits.C.value + ) + ) + + def set_target_temperature_unit(self, units: TemperatureUnits): + """Sets the units of the target temperature.""" + self.set_device_status({GreeProp.TARGET_TEMPERATURE_UNIT: units}) + + @property + def target_temperature(self) -> float: + """Return the target temperature in target_temperature_unit.""" + + raw_c = self._get_prop_raw(GreeProp.TARGET_TEMPERATURE, 0) + tem_rec = self._get_prop_raw(GreeProp.TARGET_TEMPERATURE_BIT, 0) + + if raw_c is not None and tem_rec is not None: + if self.target_temperature_unit == TemperatureUnits.F: + return gree_get_target_temperature_f(raw_c, tem_rec) + if self.target_temperature_unit == TemperatureUnits.C: + return gree_get_target_temperature_c(raw_c, tem_rec) + return 0.0 + + def set_target_temperature(self, value: float) -> None: + """Sets the target temperature in target_temperature_unit.""" + + if self.target_temperature_unit == TemperatureUnits.F: + raw_c, tem_rec = gree_get_target_temp_props_from_f(value) + else: + raw_c, tem_rec = gree_get_target_temp_props_from_c(value) + + self.set_device_status( + { + GreeProp.TARGET_TEMPERATURE: raw_c, + GreeProp.TARGET_TEMPERATURE_BIT: tem_rec, + } + ) + + @property + def feature_light_sensor(self) -> bool: + """Return the light sensor state.""" + return self._get_prop_raw(GreeProp.FEAT_SENSOR_LIGHT, 0) != 0 + + def set_feature_light_sensor(self, value: bool) -> None: + """Set the light sensor state.""" + self.set_device_status({GreeProp.FEAT_SENSOR_LIGHT: 1 if value else 0}) + + @property + def feature_fresh_air(self) -> bool: + """Return the fresh air mode state.""" + return self._get_prop_raw(GreeProp.FEAT_FRESH_AIR, 0) == 1 + + def set_feature_fresh_air(self, value: bool) -> None: + """Set the fresh air mode state.""" + self.set_device_status({GreeProp.FEAT_FRESH_AIR: 1 if value else 0}) + + @property + def feature_x_fan(self) -> bool: + """Return the x-fan mode state.""" + return self._get_prop_raw(GreeProp.FEAT_XFAN, 0) == 1 + + def set_feature_xfan(self, value: bool) -> None: + """Set the x-fan mode state.""" + self.set_device_status({GreeProp.FEAT_XFAN: 1 if value else 0}) + + @property + def feature_health(self) -> bool: + """Return the health mode state.""" + return self._get_prop_raw(GreeProp.FEAT_HEALTH, 0) == 1 + + def set_feature_health(self, value: bool) -> None: + """Set the health mode state.""" + self.set_device_status({GreeProp.FEAT_HEALTH: 1 if value else 0}) + + @property + def feature_sleep(self) -> bool: + """Return the sleep mode state.""" + return ( + self._get_prop_raw(GreeProp.FEAT_SLEEP_MODE_SWING, 0) == 1 + or self._get_prop_raw(GreeProp.FEAT_SLEEP_MODE, 0) == 1 + ) + + 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, + } + ) + + @property + def feature_light(self) -> bool: + """Return the light state.""" + return self._get_prop_raw(GreeProp.FEAT_LIGHT, 0) == 1 + + def set_feature_light(self, value: bool) -> None: + """Set the light state.""" + self.set_device_status({GreeProp.FEAT_LIGHT: 1 if value else 0}) + + @property + def feature_quiet(self) -> bool: + """Return the quiet mode state.""" + return self._get_prop_raw(GreeProp.FEAT_QUIET_MODE, 0) == 1 + + def set_feature_quiet(self, value: bool) -> None: + """Set the quiet mode state.""" + self.set_device_status({GreeProp.FEAT_QUIET_MODE: 1 if value else 0}) + + @property + def feature_turbo(self) -> bool: + """Return the turbo mode state.""" + return self._get_prop_raw(GreeProp.FEAT_TURBO_MODE, 0) == 1 + + def set_feature_turbo(self, value: bool) -> None: + """Set the turbo mode state.""" + self.set_device_status({GreeProp.FEAT_TURBO_MODE: 1 if value else 0}) + + @property + def feature_smart_heat(self) -> bool: + """Return the smart heat (8ºC / anti-freeze) mode state.""" + return self._get_prop_raw(GreeProp.FEAT_SMART_HEAT_8C, 0) == 1 + + def set_feature_smart_heat(self, value: bool) -> None: + """Set the smart heat (8ºC / anti-freeze) mode state.""" + self.set_device_status({GreeProp.FEAT_SMART_HEAT_8C: 1 if value else 0}) + + @property + def feature_energy_saving(self) -> bool: + """Return the energy saving mode state.""" + return self._get_prop_raw(GreeProp.FEAT_ENERGY_SAVING, 0) == 1 + + def set_feature_energy_saving(self, value: bool) -> None: + """Set the energy saving mode state.""" + self.set_device_status({GreeProp.FEAT_ENERGY_SAVING: 1 if value else 0}) + + @property + def feature_anti_direct_blow(self) -> bool: + """Return the anti direct blow mode state.""" + return self._get_prop_raw(GreeProp.FEAT_ANTI_DIRECT_BLOW, None) == 1 + + 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}) diff --git a/custom_components/gree/gree_device_api.py b/custom_components/gree/gree_device_api.py new file mode 100644 index 0000000..50fab68 --- /dev/null +++ b/custom_components/gree/gree_device_api.py @@ -0,0 +1,377 @@ +"""Gree device API logic for Home Assistant integration.""" + +# Standard library imports +import base64 +import logging +import socket + +# Third-party imports +try: + import simplejson +except ImportError: + import json as simplejson +from Crypto.Cipher import AES + +from .const import * +from .old_helpers import TempOffsetResolver + +_LOGGER = logging.getLogger(__name__) + +GCM_IV = b"\x54\x40\x78\x44\x49\x67\x5a\x51\x6c\x5e\x63\x13" +GCM_ADD = b"qualcomm-test" +GENERIC_GREE_DEVICE_KEY = "a3K8Bx%2r8Y7#xDh" +GENERIC_GREE_DEVICE_KEY_GCM = "{yxAHAY_Lm6pbC/<" + +AC_OPTIONS_ALL = [ + "Pow", + "Mod", + "SetTem", + "WdSpd", + "Air", + "Blo", + "Health", + "SwhSlp", + "Lig", + "SwingLfRig", + "SwUpDn", + "Quiet", + "Tur", + "StHt", + "TemUn", + "HeatCoolType", + "TemRec", + "SvSt", + "SlpMod", + "TemSen", + "AntiDirectBlow", + "LigSen", +] + +AC_OPTIONS_MAPPING = { + # power state of the device + "Pow": {0: "off", 1: "on"}, + # mode of operation + "Mod": { + 0: "auto", + 1: "cool", + 2: "dry", + 3: "fan", + 4: "heat", + }, + # fan speed + "WdSpd": { + 0: "auto", + 1: "low", + 2: "medium-low", # not available on 3-speed units + 3: "medium", + 4: "medium-high", # not available on 3-speed units + 5: "high", + }, + # controls the state of the fresh air valve (not available on all units) + "Air": {0: "off", 1: "on"}, + # "Blow" or "X-Fan", this function keeps the fan running for a while after shutting down. Only usable in Dry and Cool mode + "Blo": {0: "off", 1: "on"}, + # controls Health ("Cold plasma") mode, only for devices equipped with "anion generator", which absorbs dust and kills bacteria + "Health": {0: "off", 1: "on"}, + # sleep mode, which gradually changes the temperature in Cool, Heat and Dry mode + "SwhSlp": {0: "off", 1: "on"}, + "SlpMod": {0: "off", 1: "on"}, + # turns all indicators and the display on the unit on or off + "Lig": {0: "off", 1: "on"}, + # controls the swing mode of the horizontal air blades (available on limited number of devices) + "SwingLfRig": { + 0: "default", + 1: "swing_full", + 2: "fixed_leftmost", + 3: "fixed_middle_left", + 4: "fixed_middle", + 5: "fixed_middle_right", + 6: "fixed_rightmost", + }, + # controls the swing mode of the vertical air blades + "SwUpDn": { + 0: "default", + 1: "swing_full", + 2: "fixed_upmost", + 3: "fixed_middle_up", + 4: "fixed_middle", + 5: "fixed_middle_low", + 6: "fixed_lowest", + 7: "swing_downmost", + 8: "swing_middle_low", + 9: "swing_middle", + 10: "swing_middle_up", + 11: "swing_upmost", + }, + # controls the Quiet mode which slows down the fan to its most quiet speed. Not available in Dry and Fan mode. + "Quiet": {0: "off", 1: "on"}, + # sets fan speed to the maximum. Fan speed cannot be changed while active and only available in Dry and Cool mode + "Tur": {0: "off", 1: "on"}, + # 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 + "StHt": {0: "off", 1: "on"}, + # used to distinguish between Fahrenheit values + "TemRec": {0: "low", 1: "high"}, + # energy saving mode + "SvSt": {0: "off", 1: "on"}, + # defines the unit of temperature + "TemUn": {0: "celcius", 1: "fahrenheit"}, + # unknown function + "AntiDirectBlow": {0: "off", 1: "on"}, + # controls if the light sensor is used (available on limited number of devices) + "LigSen": {0: "off", 1: "on"}, +} + + +def Pad(s: str): + """Pads a string so its length becomes a multiple of 16. For PKCS#7 padding.""" + aesBlockSize = 16 + requiredPaddingSize = aesBlockSize - len(s) % aesBlockSize + return s + requiredPaddingSize * chr(requiredPaddingSize) + + +def FetchResult(ip_addr, port, timeout, json_data, cipher, encryption_version=1): + """Sends a payload JSON data to the device and reads the response pack.""" + + _LOGGER.debug( + "Fetching data from %s with requested payload: %s", ip_addr, json_data + ) + + clientSock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + clientSock.settimeout(timeout) + clientSock.sendto(bytes(json_data, "utf-8"), (ip_addr, port)) + + data, _ = clientSock.recvfrom(64000) + receivedJson = simplejson.loads(data) + clientSock.close() + + pack = receivedJson["pack"] + base64decodedPack = base64.b64decode(pack) + decryptedPack = cipher.decrypt(base64decodedPack) + + if encryption_version == 2: + tag = receivedJson["tag"] + cipher.verify(base64.b64decode(tag)) + + decodedPack = decryptedPack.decode("utf-8") + replacedPack = decodedPack.replace("\x0f", "").replace( + decodedPack[decodedPack.rindex("}") + 1 :], "" + ) + loadedJsonPack = simplejson.loads(replacedPack) + + _LOGGER.debug(f"Got data from {ip_addr} with: {loadedJsonPack}") + return loadedJsonPack + + +def GetCipher(key: str, encryption_version: int): + # _LOGGER.debug(f"Version: {encryption_version}, Key: {key}, Key Length: {len(key)}") + if encryption_version == 1: + cipher = AES.new(key.encode("utf8"), AES.MODE_ECB) + return cipher + elif encryption_version == 2: + cipher = AES.new(key.encode("utf8"), AES.MODE_GCM, nonce=GCM_IV) + cipher.update(assoc_data=GCM_ADD) + return cipher + + +def GetDefaultCipher(encryption_version: int): + if encryption_version == 1: + cipher = GetCipher(GENERIC_GREE_DEVICE_KEY, encryption_version) + return cipher + elif encryption_version == 2: + cipher = GetCipher(GENERIC_GREE_DEVICE_KEY_GCM, encryption_version) + return cipher + else: + _LOGGER.error(f"Unsupported encryption version: {encryption_version}") + return None + + +def CreateEncryptedPack(data: str, cipher, encryption_version: int) -> tuple[str, str]: + if encryption_version == 1: + encrypted_data = cipher.encrypt(Pad(data).encode("utf8")) + return ( + base64.b64encode(encrypted_data).decode("utf-8"), + "", + ) + elif encryption_version == 2: + encrypted_data, tag = cipher.encrypt_and_digest(data.encode("utf8")) + return ( + base64.b64encode(encrypted_data).decode("utf-8"), + base64.b64encode(tag).decode("utf-8"), + ) + else: + return ("", "") + + +def CreateBindPack(mac_addr: str, uid: int, encryption_version: int) -> str: + pack = "" + if encryption_version == 1: + pack = simplejson.dumps({"mac": mac_addr, "t": "bind", "uid": uid}) + elif encryption_version == 2: + pack = simplejson.dumps( + {"cid": mac_addr, "mac": mac_addr, "t": "bind", "uid": uid} + ) + + _LOGGER.debug(f"Bind Pack: {pack}") + return pack + + +def CreateStatusPak(mac_addr: str) -> str: + pack = simplejson.dumps({"cols": AC_OPTIONS_ALL, "mac": mac_addr, "t": "status"}) + _LOGGER.debug(f"Status Pack: {pack}") + return pack + + +def CreatePayload( + pack: str, + i_command: int, + mac_addr: str, + uid: int, + encryption_version: int, + tag: str, +): + payload: str = "" + if encryption_version == 1: + payload = simplejson.dumps( + { + "cid": "app", + "i": i_command, + "pack": pack, + "t": "pack", + "tcid": mac_addr, + "uid": uid, + } + ) + elif encryption_version == 2: + payload = simplejson.dumps( + { + "cid": "app", + "i": i_command, + "pack": pack, + "t": "pack", + "tcid": mac_addr, + "uid": uid, + "tag": tag, + } + ) + + _LOGGER.debug(f"Payload: {payload}") + return payload + + +def GetFahrenheitValueToSend(fahrenheit: int) -> tuple[int, int]: + TemSet = round((fahrenheit - 32.0) * 5.0 / 9.0) + TemRec = (int)((((fahrenheit - 32.0) * 5.0 / 9.0) - TemSet) > 0) + return (TemSet, TemRec) + + +class GreeDeviceAPI: + def __init__( + self, + ip_addr: str, + mac_addr: str, + port: int = DEFAULT_PORT, + timeout: int = DEFAULT_TIMEOUT, + encryption_version: int = 1, + encryption_key: str = "", + uid: int = 0, + temp_offset=TEMSEN_OFFSET, + ): + _LOGGER.info( + f"Initialize the GREE Device API for: {mac_addr} ({ip_addr}:{port})" + ) + _LOGGER.debug(f"Version: {encryption_version}, Key: {encryption_key}") + + self.ip_addr: str = ip_addr + self.port: int = port + self.mac_addr: str = mac_addr + self.timeout: int = timeout + self.encryption_version: int = encryption_version + self.encryption_key: str = encryption_key + self.uid: int = uid + + if encryption_version < 1 or encryption_version > 2: + _LOGGER.error("Unsupported encryption version, defaulting to 1") + self.encryption_version = 1 + + if not encryption_key.strip(): + _LOGGER.info("No encryption key provided") + self.GetDeviceKey() + + self.temp_processor = TempOffsetResolver(offset=temp_offset) + + self.GetDeviceStatus() + + def GetDeviceKey(self) -> str: + _LOGGER.info("Trying to retrieve device encryption key") + + self.encryption_key = "" + + pack, tag = CreateEncryptedPack( + CreateBindPack(self.mac_addr, self.uid, self.encryption_version), + GetDefaultCipher(self.encryption_version), + self.encryption_version, + ) + jsonPayloadToSend = CreatePayload( + pack, 1, self.mac_addr, self.uid, self.encryption_version, tag + ) + + try: + key = FetchResult( + self.ip_addr, + self.port, + self.timeout, + jsonPayloadToSend, + GetDefaultCipher(self.encryption_version), + self.encryption_version, + )["key"] + except Exception: + _LOGGER.exception("Error getting device encryption key") + else: + _LOGGER.info("Fetched device encryption key with success") + _LOGGER.debug(f"Fetched encryption key: {key}") + self.encryption_key = key + + return self.encryption_key + + def GetDeviceStatus(self) -> dict[str, int]: + _LOGGER.debug("Trying to get device status") + + pack, tag = CreateEncryptedPack( + CreateStatusPak(self.mac_addr), + GetCipher(self.encryption_key, self.encryption_version), + self.encryption_version, + ) + jsonPayloadToSend = CreatePayload( + pack, 0, self.mac_addr, self.uid, self.encryption_version, tag + ) + + self.status_values: dict[str, int] = {} + + try: + result = FetchResult( + self.ip_addr, + self.port, + self.timeout, + jsonPayloadToSend, + GetCipher(self.encryption_key, self.encryption_version), + self.encryption_version, + ) + cols = list(map(str, result["cols"])) + values = list(map(int, result["dat"])) + status_values = dict(zip(cols, values)) + except Exception: + _LOGGER.error("Error getting device status") + else: + _LOGGER.debug(f"Fetched device status: {status_values}") + self.device_status = status_values + + # Update variables + self.has_temp_sensor = ( + "TemSen" in self.device_status and self.device_status["TemSen"] != 0 + ) + if self.has_temp_sensor and self.temp_processor is None: + self.temp_processor(self.device_status["TemSen"]) + + self.temperature_unit = AC_OPTIONS_MAPPING["TemUn"][self.device_status["TemUn"]] + + return self.device_status diff --git a/custom_components/gree/gree_helpers.py b/custom_components/gree/gree_helpers.py new file mode 100644 index 0000000..1393985 --- /dev/null +++ b/custom_components/gree/gree_helpers.py @@ -0,0 +1,136 @@ +"""Helpers for the Gree integration.""" + +from .gree_const import TEMSEN_OFFSET + + +class TempOffsetResolver: + """Detect whether this sensor reports temperatures in °C or in (°C + 40).""" + + # Continues to check, and bases decision on historical min and max raw values + # since there are extreme cases which would result in a switch. + # Two running values are stored (min & max raw). + # + # Note: This could be simplified by just using 40C as a max point + # for the unoffset case and a min point for the offset case. But + # this doesn't account for the marginal cases around 40C as well. + # + # Example: + + # if raw < 40: + # return raw + # else: + # return raw - 40 + + def __init__( + self, + indoor_min: float = -15.0, # coldest plausible indoor °C + indoor_max: float = 40.0, # hottest plausible indoor °C + offset: float = TEMSEN_OFFSET, # device's fixed offset + margin: float = 2.0, # tolerance before "impossible": + ) -> None: + """Initialize the resolver.""" + self._lo_lim = indoor_min - margin + self._hi_lim = indoor_max + margin + self._offset = offset + + self._min_raw: float | None = None + self._max_raw: float | None = None + self._has_offset: bool | None = None # undecided until True/False + + def evaluate(self, raw: float) -> float: + """Evaluate the raw temperature and return corrected value.""" + if self._min_raw is None or raw < self._min_raw: + self._min_raw = raw + if self._max_raw is None or raw > self._max_raw: + self._max_raw = raw + self._check() # evaluate every time, so it can change it's mind as needed + return raw - self._offset if self._has_offset else raw + + def _check(self) -> None: + if self._min_raw is None or self._max_raw is None: + return # not enough data yet + + lo, hi = self._min_raw, self._max_raw + 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 + self._has_offset = penalty_off < penalty_no + + def _penalty(self, lo: float, hi: float) -> float: + pen = 0.0 + if lo < self._lo_lim: + pen += self._lo_lim - lo + if hi > self._hi_lim: + pen += hi - self._hi_lim + return pen + + +def gree_get_target_temp_props_from_f(desired_temp_f: float) -> tuple[int, int]: + """Get SetTem and TemRec for a given Fahrenheit temperature.""" + # See: https://github.com/tomikaa87/gree-remote + + SetTem = round((desired_temp_f - 32.0) * 5.0 / 9.0) + TemRec = (int)((((desired_temp_f - 32.0) * 5.0 / 9.0) - SetTem) > -0.001) + + return SetTem, TemRec + + +def gree_get_target_temp_props_from_c(desired_temp_c: float) -> tuple[int, int]: + """Get SetTem and TemRec for a given 1/2 degree Celsius temperature.""" + + # 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) + + # 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)) + + # 2) The low bit of n says ".5" (odd) versus ".0" (even): + TemRec = n & 1 + + # 3) Shifting right by 1 gives floor(n/2), i.e. the integer °C of that nearest half‐step: + SetTem = n >> 1 + + return SetTem, TemRec + + +def gree_get_target_temperature_f(SetTem: int, TemRec: int) -> float: + """Convert SetTem and TemRec back to the Fahrenheit temperature.""" + + # Convert SetTem back to the minimum and maximum Fahrenheit before rounding + # We consider the worst case scenario: SetTem could be the result of rounding from any value in a range + # If TemRec is 1, it indicates the value was closer to the upper range of the rounding + # If TemRec is 0, it indicates the value was closer to the lower range + + if TemRec == 1: + # SetTem is closer to its higher bound, so we consider SetTem as the lower limit + min_celsius = SetTem + max_celsius = SetTem + 0.4999 # Just below the next rounding threshold + else: + # SetTem is closer to its lower bound, so we consider SetTem-1 as the potential lower limit + min_celsius = SetTem - 0.4999 # Just above the previous rounding threshold + max_celsius = SetTem + + # Convert these Celsius values back to Fahrenheit + min_fahrenheit = (min_celsius * 9.0 / 5.0) + 32.0 + max_fahrenheit = (max_celsius * 9.0 / 5.0) + 32.0 + + return round((min_fahrenheit + max_fahrenheit) / 2.0) + + +def gree_get_target_temperature_c(SetTem: int, TemRec: int) -> float: + """Convert SetTem and TemRec back to the Celsius temperature.""" + + # Given: + # SetTem = the "rounded-down" integer (⌊T⌋ or for negatives, floor(T)) + # TemRec = 0 or 1, where 1 means "there was a 0.5" + # Returns the original temperature as a float. + + return SetTem + (0.5 if TemRec else 0.0) diff --git a/custom_components/gree/gree_protocol.py b/custom_components/gree/gree_protocol.py deleted file mode 100644 index 5e11c93..0000000 --- a/custom_components/gree/gree_protocol.py +++ /dev/null @@ -1,311 +0,0 @@ -""" -Gree protocol/network logic for Home Assistant integration. -""" - -# Standard library imports -import asyncio -import base64 -import logging -import socket -import time - -# Third-party imports -try: - import simplejson -except ImportError: - import json as simplejson -from Crypto.Cipher import AES - -# Home Assistant imports -from homeassistant.const import CONF_HOST, CONF_PORT, CONF_MAC -from homeassistant.components.network import async_get_ipv4_broadcast_addresses - -# Local imports -from .const import ( - CONF_ENCRYPTION_VERSION, - CONF_ENCRYPTION_KEY, -) - -_LOGGER = logging.getLogger(__name__) - -GCM_IV = b"\x54\x40\x78\x44\x49\x67\x5a\x51\x6c\x5e\x63\x13" -GCM_ADD = b"qualcomm-test" -GENERIC_GREE_DEVICE_KEY = "a3K8Bx%2r8Y7#xDh" -GENERIC_GREE_DEVICE_KEY_GCM = b"{yxAHAY_Lm6pbC/<" - - -async def FetchResult(cipher, ip_addr, port, json_data, encryption_version=1, max_retries=8): - """Send a request to a Gree device and fetch the result, with retries and timeouts.""" - - _LOGGER.debug(f"Fetching device at: {ip_addr}:{port}, data sent: {json_data})") - - timeout = 2 - - for attempt in range(max_retries): - clientSock = None - try: - clientSock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - clientSock.settimeout(timeout) - - # Send data to device - clientSock.sendto(bytes(json_data, "utf-8"), (ip_addr, port)) - - # Receive response with event loop yielding - data, _ = await asyncio.wait_for(asyncio.get_event_loop().run_in_executor(None, clientSock.recvfrom, 64000), timeout=timeout) - - # Parse and decrypt response - received_json = simplejson.loads(data) - pack = received_json["pack"] - decoded_pack = base64.b64decode(pack) - decrypted_pack = cipher.decrypt(decoded_pack) - - if encryption_version == 2: - tag = received_json["tag"] - cipher.verify(base64.b64decode(tag)) - - # Clean up response data - decoded_text = decrypted_pack.decode("utf-8") - # Remove null bytes and trailing data after last } - clean_text = decoded_text.replace("\x0f", "") - last_brace = clean_text.rindex("}") - clean_text = clean_text[: last_brace + 1] - - result = simplejson.loads(clean_text) - - _LOGGER.debug(f"Successfully received response on attempt {attempt + 1}") - return result - - except Exception as e: - if attempt == max_retries - 1: - error_msg = f"{type(e).__name__}: {str(e)}" if str(e) else f"{type(e).__name__}" - _LOGGER.error(f"All {max_retries} attempts failed for {ip_addr}:{port}. Error: {error_msg}") - raise - - finally: - if clientSock: - try: - clientSock.close() - except Exception as e: - _LOGGER.debug(f"Error closing socket: {str(e)}") - - # Progressive backoff before retry - if attempt < max_retries - 1: - await asyncio.sleep(0.5 + (attempt * 0.3)) # 0.5s, 0.8s, 1.1s, 1.4s, 1.7s, 2.0s, 2.3s - - -def Pad(s): - aesBlockSize = 16 - return s + (aesBlockSize - len(s) % aesBlockSize) * chr(aesBlockSize - len(s) % aesBlockSize) - - -async def test_connection(config): - """Test connection to a Gree device.""" - - ip_addr = config[CONF_HOST] - port = config[CONF_PORT] - encryption_version = config[CONF_ENCRYPTION_VERSION] - encryption_key = config[CONF_ENCRYPTION_KEY] - - mac_addr = config.get(CONF_MAC).encode().replace(b":", b"").decode("utf-8").lower() - if "@" in mac_addr: - mac_addr = mac_addr.split("@", 1)[0] - - _LOGGER.debug(f"test_connection: host={ip_addr}, port={port}, mac={mac_addr}, encryption_version={encryption_version}, encryption_key={encryption_key}") - - try: - if encryption_version == 1: - key = await GetDeviceKey(mac_addr, ip_addr, port) - else: - key = await GetDeviceKeyGCM(mac_addr, ip_addr, port) - _LOGGER.debug(f"test_connection: Got device key: {key}") - return key is not None - except Exception as e: - _LOGGER.error(f"Gree device at {ip_addr} is unreachable: {type(e).__name__}: {e}", exc_info=True) - return False - - -async def GetDeviceKey(mac_addr, ip_addr, port, max_retries=8): - _LOGGER.debug("Retrieving HVAC encryption key") - cipher = AES.new(GENERIC_GREE_DEVICE_KEY.encode("utf8"), AES.MODE_ECB) - pack = base64.b64encode(cipher.encrypt(Pad(f'{{"mac":"{mac_addr}","t":"bind","uid":0}}').encode("utf8"))).decode("utf-8") - jsonPayloadToSend = f'{{"cid": "app","i": 1,"pack": "{pack}","t":"pack","tcid":"{mac_addr}","uid": 0}}' - try: - result = await FetchResult(cipher, ip_addr, port, jsonPayloadToSend, max_retries=max_retries) - _LOGGER.debug(f"GetDeviceKey: FetchResult: {result}") - key = result["key"].encode("utf8") - except Exception: - _LOGGER.debug("Error getting device encryption key!") - return None - else: - _LOGGER.debug(f"Fetched device encryption key: {str(key)}") - return key - - -def GetGCMCipher(key): - cipher = AES.new(key, AES.MODE_GCM, nonce=GCM_IV) - cipher.update(GCM_ADD) - return cipher - - -def EncryptGCM(key, plaintext): - cipher = GetGCMCipher(key) - encrypted_data, tag = cipher.encrypt_and_digest(plaintext.encode("utf8")) - pack = base64.b64encode(encrypted_data).decode("utf-8") - tag = base64.b64encode(tag).decode("utf-8") - return (pack, tag) - - -async def GetDeviceKeyGCM(mac_addr, ip_addr, port, max_retries=8): - _LOGGER.debug("Retrieving HVAC encryption key (GCM)") - plaintext = f'{{"cid":"{mac_addr}", "mac":"{mac_addr}","t":"bind","uid":0}}' - pack, tag = EncryptGCM(GENERIC_GREE_DEVICE_KEY_GCM, plaintext) - jsonPayloadToSend = f'{{"cid": "app","i": 1,"pack": "{pack}","t":"pack","tcid":"{mac_addr}","uid": 0, "tag" : "{tag}"}}' - try: - result = await FetchResult(GetGCMCipher(GENERIC_GREE_DEVICE_KEY_GCM), ip_addr, port, jsonPayloadToSend, encryption_version=2, max_retries=max_retries) - _LOGGER.debug(f"GetDeviceKeyGCM: FetchResult: {result}") - key = result["key"].encode("utf8") - except Exception: - _LOGGER.debug("Error getting device encryption key!") - return None - else: - _LOGGER.debug(f"Fetched device encryption key: {str(key)}") - return key - - -async def discover_gree_devices(hass, timeout=5): - """Discover Gree devices on the local network using UDP broadcast.""" - _LOGGER.debug("Starting Gree device discovery...") - - BROADCAST_PORT = 7000 - DISCOVERY_MESSAGE = b'{"t":"scan"}' - - # Set up UDP socket for broadcast - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) - sock.settimeout(timeout) - sock.bind(("", 0)) - - devices = [] - - try: - # Default broadcast addresses to try - broadcast_addresses = [ - "255.255.255.255", # Limited broadcast - "192.168.255.255", # /16 broadcast for 192.168.x.x networks - "10.255.255.255", # /8 broadcast for 10.x.x.x networks - "172.31.255.255", # /12 broadcast for 172.16-31.x.x networks - ] - - # Get broadcast addresses from Home Assistant's network helper - try: - ha_broadcast_addresses = await async_get_ipv4_broadcast_addresses(hass) - ha_broadcast_strings = [str(addr) for addr in ha_broadcast_addresses] - broadcast_addresses.extend(ha_broadcast_strings) - _LOGGER.debug(f"Found broadcast addresses from HA: {ha_broadcast_strings}") - except Exception as e: - _LOGGER.debug(f"Could not get HA broadcast addresses: {e}") - - # Remove duplicates - broadcast_addresses = list(dict.fromkeys(broadcast_addresses)) - - # Send to all broadcast addresses - for broadcast_addr in broadcast_addresses: - try: - _LOGGER.debug(f"Sending discovery to {broadcast_addr}") - sock.sendto(DISCOVERY_MESSAGE, (broadcast_addr, BROADCAST_PORT)) - except Exception as e: - _LOGGER.debug(f"Failed to send to {broadcast_addr}: {e}") - - _LOGGER.debug("Sent discovery packets, waiting for replies...") - - start = time.time() - while time.time() - start < timeout: - try: - data, addr = sock.recvfrom(1024) - try: - # Try to parse as JSON and decrypt if possible - response = simplejson.loads(data.decode(errors="ignore")) - if "pack" in response: - pack = response["pack"] - decoded_pack = base64.b64decode(pack) - - # Discovery responses typically use level 1 encryption (ECB mode) - # But we need to test which encryption the device actually uses for communication - pack_json = None - - try: - cipher = AES.new(GENERIC_GREE_DEVICE_KEY.encode("utf-8"), AES.MODE_ECB) - decrypted_pack = cipher.decrypt(decoded_pack) - # Remove null bytes and trailing data after last } - decoded_text = decrypted_pack.decode("utf-8", errors="ignore").replace("\x0f", "") - last_brace = decoded_text.rfind("}") - if last_brace != -1: - clean_text = decoded_text[: last_brace + 1] - else: - clean_text = decoded_text - pack_json = simplejson.loads(clean_text) - _LOGGER.debug(f"Decrypted discovery response from {addr}") - except Exception as e: - _LOGGER.debug(f"Could not decrypt discovery response from {addr}: {e}") - continue - - # If we successfully decrypted and got device info - if pack_json and pack_json.get("t") == "dev": - mac_addr = pack_json.get("mac", "") - if not mac_addr: - _LOGGER.debug(f"No MAC address in response from {addr}") - continue - - # Just collect basic device info for now - encryption detection happens later - device_info = { - "name": pack_json.get("name", "") or f"Gree {mac_addr[-4:]}", - "host": addr[0], - "port": BROADCAST_PORT, - "mac": mac_addr, - "brand": pack_json.get("brand", "gree"), - "model": pack_json.get("model", "gree"), - "version": pack_json.get("ver", ""), - } - devices.append(device_info) - _LOGGER.debug(f"Discovered Gree device: {device_info}") - else: - _LOGGER.debug(f"Invalid or missing device info from {addr}") - else: - _LOGGER.debug(f"Received response without pack from {addr}: {response}") - except Exception as e: - _LOGGER.debug(f"Could not parse response from {addr}: {e}") - except socket.timeout: - break - finally: - sock.close() - - _LOGGER.debug(f"Discovery completed, found {len(devices)} devices") - return devices - - -async def detect_device_encryption(mac_addr, ip_addr, port): - """Test which encryption version a device uses for communication.""" - _LOGGER.debug(f"Detecting encryption version for device {mac_addr} at {ip_addr}:{port}") - - # Test encryption version 1 first - try: - _LOGGER.debug(f"Testing encryption version 1 for device {mac_addr}") - key = await GetDeviceKey(mac_addr, ip_addr, port, max_retries=1) - if key: - _LOGGER.debug(f"Device {mac_addr} uses encryption version 1") - return 1 - except Exception as e: - _LOGGER.debug(f"Encryption version 1 failed for device {mac_addr}: {e}") - - # Test encryption version 2 - try: - _LOGGER.debug(f"Testing encryption version 2 for device {mac_addr}") - key = await GetDeviceKeyGCM(mac_addr, ip_addr, port, max_retries=1) - if key: - _LOGGER.debug(f"Device {mac_addr} uses encryption version 2") - return 2 - except Exception as e: - _LOGGER.debug(f"Encryption version 2 failed for device {mac_addr}: {e}") - - _LOGGER.error(f"Could not determine encryption version for device {mac_addr}") - return None diff --git a/custom_components/gree/icons.json b/custom_components/gree/icons.json old mode 100644 new mode 100755 index dc1f10e..ea12824 --- a/custom_components/gree/icons.json +++ b/custom_components/gree/icons.json @@ -1,33 +1,89 @@ { "entity": { "climate": { - "gree": { + "hvac": { "state_attributes": { "fan_mode": { "default": "mdi:fan", "state": { - "auto": "mdi:fan-auto", - "low": "mdi:fan-chevron-down", - "medium_low": "mdi:fan-minus", - "medium": "mdi:fan", - "medium_high": "mdi:fan-plus", - "high": "mdi:fan-chevron-up", - "turbo": "mdi:weather-windy", - "quiet": "mdi:sleep" + "Auto": "mdi:fan-auto", + "Low": "mdi:fan-chevron-down", + "MediumLow": "mdi:fan-minus", + "Medium": "mdi:fan", + "MediumHigh": "mdi:fan-plus", + "High": "mdi:fan-chevron-up", + "feat_turbo": "mdi:weather-windy", + "feat_quiet": "mdi:sleep" } }, "swing_horizontal_mode": { "default": "mdi:arrow-oscillating", "state": { - "default": "mdi:arrow-oscillating-off", - "swing_full": "mdi:arrow-oscillating" + "Default": "mdi:arrow-oscillating-off", + "FulLSwing": "mdi:arrow-oscillating", + "Left": "mdi:arrow-left", + "LeftCenter": "mdi:arrow-bottom-left", + "Center": "mdi:arrow-down", + "RightCenter": "mdi:arrow-bottom-right", + "Right": "mdi:arrow-left" } }, "swing_mode": { - "default": "mdi:arrow-up-down" + "default": "mdi:arrow-up-down", + "state": { + "Default": "mdi:arrow-up-down", + "FullSwing": "mdi:arrow-up-down", + "FixedUpper": "mdi:arrow-up", + "FixedUpperMiddle": "mdi:arrow-top-left", + "FixedMiddle": "mdi:arrow-left", + "FixedLowerMiddle": "mdi:arrow-bottom-left", + "FixedLower": "mdi:arrow-down", + "SwingLower": "mdi:arrow-up-down", + "SwingLowerMiddle": "mdi:arrow-up-down", + "SwingMiddle": "mdi:arrow-up-down", + "SwingUpperMiddle": "mdi:arrow-up-down", + "SwingUpper": "mdi:arrow-up-down" + } } } } + }, + "select": { + "temperature_units": { + "default": "mdi:thermometer-alert" + } + }, + "switch": { + "feat_fresh_air": { + "default": "mdi:air-filter" + }, + "feat_xfan": { + "default": "mdi:fan" + }, + "feat_sleep": { + "default": "mdi:sleep" + }, + "feat_smart_heat": { + "default": "mdi:thermometer-low" + }, + "feat_health": { + "default": "mdi:shield-check" + }, + "feat_anti_direct_blow": { + "default": "mdi:weather-windy" + }, + "feat_energy_saving": { + "default": "mdi:leaf" + }, + "feat_lights": { + "default": "mdi:lightbulb" + }, + "feat_light_sensor": { + "default": "mdi:brightness-auto" + }, + "beeper": { + "default": "mdi:volume-high" + } } } -} \ No newline at end of file +} diff --git a/custom_components/gree/manifest.json b/custom_components/gree/manifest.json old mode 100644 new mode 100755 index b5bc812..bb32620 --- a/custom_components/gree/manifest.json +++ b/custom_components/gree/manifest.json @@ -9,7 +9,8 @@ ], "requirements": [ "pycryptodome", - "aiofiles" + "aiofiles", + "asyncio_dgram" ], "config_flow": true } diff --git a/custom_components/gree/number.py b/custom_components/gree/number.py old mode 100644 new mode 100755 index 2a87c39..4e79336 --- a/custom_components/gree/number.py +++ b/custom_components/gree/number.py @@ -21,13 +21,13 @@ # Local imports from .const import DEFAULT_TARGET_TEMP_STEP -from .entity import GreeEntity, GreeEntityDescription +from .entity import OldGreeEntity, OldGreeEntityDescription _LOGGER = logging.getLogger(__name__) @dataclass -class GreeNumberEntityDescription(GreeEntityDescription, NumberEntityDescription): +class GreeNumberEntityDescription(OldGreeEntityDescription, NumberEntityDescription): set_fn: Callable[[object, float], None] = None restore_state: bool = False @@ -40,7 +40,9 @@ class GreeNumberEntityDescription(GreeEntityDescription, NumberEntityDescription native_max_value=5.0, native_step=0.1, mode=NumberMode.SLIDER, - value_fn=lambda device: getattr(device, "_target_temperature_step", DEFAULT_TARGET_TEMP_STEP), + value_fn=lambda device: getattr( + device, "_target_temperature_step", DEFAULT_TARGET_TEMP_STEP + ), set_fn=lambda device, value: setattr(device, "_target_temperature_step", value), entity_category=EntityCategory.CONFIG, restore_state=True, @@ -54,10 +56,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Gree number entities based on a config entry.""" - async_add_entities(GreeNumberEntity(hass, entry, description) for description in NUMBERS) + async_add_entities( + GreeNumberEntity(hass, entry, description) for description in NUMBERS + ) -class GreeNumberEntity(GreeEntity, NumberEntity, RestoreEntity): +class GreeNumberEntity(OldGreeEntity, NumberEntity, RestoreEntity): """Defines a Gree number entity.""" entity_description: GreeNumberEntityDescription @@ -71,12 +75,23 @@ async def async_added_to_hass(self): await super().async_added_to_hass() if self.entity_description.restore_state: last_state = await self.async_get_last_state() - if last_state is not None and last_state.state not in ["unknown", "unavailable"]: + if last_state is not None and last_state.state not in [ + "unknown", + "unavailable", + ]: try: value = float(last_state.state) # Validate the value is within the entity's range - if self.entity_description.native_min_value <= value <= self.entity_description.native_max_value: - setattr(self._device, f"_{self.entity_description.property_key}", value) + if ( + self.entity_description.native_min_value + <= value + <= self.entity_description.native_max_value + ): + setattr( + self._device, + f"_{self.entity_description.property_key}", + value, + ) self._attr_native_value = value self._restored = True except (ValueError, TypeError): @@ -86,12 +101,18 @@ async def async_added_to_hass(self): @property def native_value(self): if self.entity_description.restore_state: - return getattr(self, "_attr_native_value", self.entity_description.value_fn(self._device)) + return getattr( + self, + "_attr_native_value", + self.entity_description.value_fn(self._device), + ) return self.entity_description.value_fn(self._device) async def async_set_native_value(self, value: float) -> None: if self.entity_description.set_fn: - await self.hass.async_add_executor_job(self.entity_description.set_fn, self._device, value) + await self.hass.async_add_executor_job( + self.entity_description.set_fn, self._device, value + ) if self.entity_description.restore_state: self._attr_native_value = value self.async_write_ha_state() diff --git a/custom_components/gree/old_climate.py b/custom_components/gree/old_climate.py new file mode 100755 index 0000000..1441a3f --- /dev/null +++ b/custom_components/gree/old_climate.py @@ -0,0 +1,1185 @@ +""" +Gree Climate Entity for Home Assistant. + +This module defines the climate (HVAC) unit for the Gree integration. +""" + +# Standard library imports +import base64 +import logging +from datetime import timedelta + +# Third-party imports +try: + import simplejson +except ImportError: + import json as simplejson +from Crypto.Cipher import AES + +# Home Assistant imports +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.const import ( + ATTR_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT, + CONF_HOST, + CONF_MAC, + CONF_NAME, + CONF_PORT, + CONF_TIMEOUT, +) +from homeassistant.helpers.device_registry import DeviceInfo + +# Local imports +from .const import ( + DOMAIN, + DEFAULT_PORT, + DEFAULT_TIMEOUT, + DEFAULT_HVAC_MODES, + DEFAULT_FAN_MODES, + DEFAULT_SWING_MODES, + DEFAULT_SWING_HORIZONTAL_MODES, + DEFAULT_TARGET_TEMP_STEP, + MIN_TEMP_C, + MIN_TEMP_F, + MAX_TEMP_C, + MAX_TEMP_F, + MODES_MAPPING, + TEMSEN_OFFSET, + CONF_HVAC_MODES, + CONF_FAN_MODES, + CONF_SWING_MODES, + CONF_SWING_HORIZONTAL_MODES, + CONF_ENCRYPTION_KEY, + CONF_UID, + CONF_ENCRYPTION_VERSION, + CONF_DISABLE_AVAILABLE_CHECK, + CONF_MAX_ONLINE_ATTEMPTS, + CONF_TEMP_SENSOR_OFFSET, +) +from .gree_protocol import ( + Pad, + FetchResult, + GetDeviceKey, + GetGCMCipher, + EncryptGCM, + GetDeviceKeyGCM, +) +from .old_helpers import ( + TempOffsetResolver, + gree_f_to_c, + gree_c_to_f, + encode_temp_c, + decode_temp_c, +) + +REQUIREMENTS = ["pycryptodome"] + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_FLAGS = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF +) + + +async def create_gree_device(hass, config): + """Create a Gree device instance from config.""" + name = config.get(CONF_NAME, "Gree Climate") + ip_addr = config.get(CONF_HOST) + port = config.get(CONF_PORT, DEFAULT_PORT) + mac_addr = str(config.get(CONF_MAC)).encode().replace(b":", b"") + timeout = config.get(CONF_TIMEOUT, DEFAULT_TIMEOUT) + + chm = config.get(CONF_HVAC_MODES) + hvac_modes = [ + getattr(HVACMode, mode.upper()) + for mode in (chm if chm is not None else DEFAULT_HVAC_MODES) + ] + + cfm = config.get(CONF_FAN_MODES) + fan_modes = cfm if cfm is not None else DEFAULT_FAN_MODES + csm = config.get(CONF_SWING_MODES) + swing_modes = csm if csm is not None else DEFAULT_SWING_MODES + cshm = config.get(CONF_SWING_HORIZONTAL_MODES) + swing_horizontal_modes = ( + cshm if cshm is not None else DEFAULT_SWING_HORIZONTAL_MODES + ) + encryption_key = config.get(CONF_ENCRYPTION_KEY) + uid = config.get(CONF_UID) + encryption_version = config.get(CONF_ENCRYPTION_VERSION, 1) + disable_available_check = config.get(CONF_DISABLE_AVAILABLE_CHECK, False) + max_online_attempts = config.get(CONF_MAX_ONLINE_ATTEMPTS, 3) + temp_sensor_offset = config.get(CONF_TEMP_SENSOR_OFFSET) + + return GreeClimate( + hass, + name, + ip_addr, + port, + mac_addr, + timeout, + hvac_modes, + fan_modes, + swing_modes, + swing_horizontal_modes, + encryption_version, + disable_available_check, + max_online_attempts, + encryption_key, + uid, + temp_sensor_offset, + ) + + +# from the remote control and gree app + +# update() interval +SCAN_INTERVAL = timedelta(seconds=60) + + +async def async_setup_entry(hass, entry, async_add_devices): + """Set up Gree climate from a config entry.""" + # Get the device that was created in __init__.py + entry_data = hass.data[DOMAIN][entry.entry_id] + device = entry_data["device"] + + async_add_devices([device]) + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + return True + + +class GreeClimate(ClimateEntity): + # Language is retrieved from translation key + _attr_translation_key = "gree" + + def __init__( + self, + hass, + name, + ip_addr, + port, + mac_addr, + timeout, + hvac_modes, + fan_modes, + swing_modes, + swing_horizontal_modes, + encryption_version, + disable_available_check, + max_online_attempts, + encryption_key=None, + uid=None, + temp_sensor_offset=None, + ): + _LOGGER.info("Initialize the GREE climate device") + self.hass = hass + self._name = name + self._ip_addr = ip_addr + self._port = port + mac_addr_str: str = mac_addr.decode("utf-8").lower() + if "@" in mac_addr_str: + self._sub_mac_addr, self._mac_addr = mac_addr_str.split("@", 1) + else: + self._sub_mac_addr = self._mac_addr = mac_addr_str + self._timeout = timeout + self._unique_id = f"{DOMAIN}_{self._mac_addr}" + self._device_online = None + self._online_attempts = 0 + self._max_online_attempts = max_online_attempts + self._disable_available_check = disable_available_check + + self._target_temperature = None + # Initialize target temperature step with default value (will be overridden by number entity when available) + self._target_temperature_step = DEFAULT_TARGET_TEMP_STEP + # Device uses a combination of Celsius + a set bit for Fahrenheit, so the integration needs to be aware of the units. + self._unit_of_measurement = hass.config.units.temperature_unit + _LOGGER.info("Unit of measurement: %s", self._unit_of_measurement) + + self._hvac_modes = hvac_modes + self._hvac_mode = HVACMode.OFF + self._fan_modes = fan_modes + self._fan_mode = None + self._swing_modes = swing_modes + self._swing_mode = None + self._swing_horizontal_modes = swing_horizontal_modes + self._swing_horizontal_mode = None + + self._temp_sensor_offset = temp_sensor_offset + + # Store for external temp sensor entity (set by sensor entity) + self._external_temperature_sensor = None + + # Keep unsub callbacks for deregistering listeners + self._listeners: list = [] + + self._has_temp_sensor = None + self._has_anti_direct_blow = None + self._has_light_sensor = None + self._has_outside_temp_sensor = None + self._has_room_humidity_sensor = None + + self._current_temperature = None + self._current_anti_direct_blow = None + self._current_light_sensor = None + self._current_outside_temperature = None + self._current_room_humidity = None + + self._firstTimeRun = True + + self._enable_turn_on_off_backwards_compatibility = False + + self.encryption_version = encryption_version + self.CIPHER = None + + if encryption_key: + _LOGGER.info("Using configured encryption key: {}".format(encryption_key)) + self._encryption_key = encryption_key.encode("utf8") + if encryption_version == 1: + # Cipher to use to encrypt/decrypt + self.CIPHER = AES.new(self._encryption_key, AES.MODE_ECB) + elif self.encryption_version != 2: + _LOGGER.error( + "Encryption version %s is not implemented." + % self.encryption_version + ) + else: + self._encryption_key = None + + if uid: + self._uid = uid + else: + self._uid = 0 + + self._acOptions = { + "Pow": None, + "Mod": None, + "SetTem": None, + "WdSpd": None, + "Air": None, + "Blo": None, + "Health": None, + "SwhSlp": None, + "Lig": None, + "SwingLfRig": None, + "SwUpDn": None, + "Quiet": None, + "Tur": None, + "StHt": None, + "TemUn": None, + "HeatCoolType": None, + "TemRec": None, + "SvSt": None, + "SlpMod": None, + } + self._optionsToFetch = [ + "Pow", + "Mod", + "SetTem", + "WdSpd", + "Air", + "Blo", + "Health", + "SwhSlp", + "Lig", + "SwingLfRig", + "SwUpDn", + "Quiet", + "Tur", + "StHt", + "TemUn", + "HeatCoolType", + "TemRec", + "SvSt", + "SlpMod", + ] + + # Initialize auto switches + self._auto_light = False + self._auto_xfan = False + + # Initialize beeper control + self._beeper_enabled = True # Default to beeper ON (silent mode OFF) + + # helper method to determine TemSen offset + self._process_temp_sensor = TempOffsetResolver() + + def GreeGetValues(self, propertyNames): + plaintext = ( + '{"cols":' + + simplejson.dumps(propertyNames) + + ',"mac":"' + + str(self._sub_mac_addr) + + '","t":"status"}' + ) + if self.encryption_version == 1: + cipher = self.CIPHER + jsonPayloadToSend = ( + '{"cid":"app","i":0,"pack":"' + + base64.b64encode( + cipher.encrypt(Pad(plaintext).encode("utf8")) + ).decode("utf-8") + + '","t":"pack","tcid":"' + + str(self._mac_addr) + + '","uid":{}'.format(self._uid) + + "}" + ) + elif self.encryption_version == 2: + pack, tag = EncryptGCM(self._encryption_key, plaintext) + jsonPayloadToSend = ( + '{"cid":"app","i":0,"pack":"' + + pack + + '","t":"pack","tcid":"' + + str(self._mac_addr) + + '","uid":{}'.format(self._uid) + + ',"tag" : "' + + tag + + '"}' + ) + cipher = GetGCMCipher(self._encryption_key) + dat = FetchResult( + cipher, + self._ip_addr, + self._port, + self._timeout, + jsonPayloadToSend, + encryption_version=self.encryption_version, + )["dat"] + return dat[0] if len(dat) == 1 else dat + + def SetAcOptions( + self, acOptions, newOptionsToOverride, optionValuesToOverride=None + ): + if optionValuesToOverride is not None: + _LOGGER.debug("Setting acOptions with retrieved HVAC values") + for key in newOptionsToOverride: + _LOGGER.debug( + "Setting %s: %s" + % (key, optionValuesToOverride[newOptionsToOverride.index(key)]) + ) + acOptions[key] = optionValuesToOverride[newOptionsToOverride.index(key)] + _LOGGER.debug("Done setting acOptions") + else: + _LOGGER.debug("Overwriting acOptions with new settings") + for key, value in newOptionsToOverride.items(): + _LOGGER.debug("Overwriting %s: %s" % (key, value)) + acOptions[key] = value + _LOGGER.debug("Done overwriting acOptions") + return acOptions + + def SendStateToAc(self, timeout): + opt_list = [ + "Pow", + "Mod", + "SetTem", + "WdSpd", + "Air", + "Blo", + "Health", + "SwhSlp", + "Lig", + "SwingLfRig", + "SwUpDn", + "Quiet", + "Tur", + "StHt", + "TemUn", + "HeatCoolType", + "TemRec", + "SvSt", + "SlpMod", + "AntiDirectBlow", + "LigSen", + ] + + # Collect values from _acOptions + p_values = [self._acOptions.get(k) for k in opt_list] + + # Filter out empty ones + filtered_opt = [] + filtered_p = [] + for name, val in zip(opt_list, p_values): + if val not in ("", None): + filtered_opt.append(f'"{name}"') + filtered_p.append(str(val)) + + buzzer_command_value = 0 if self._beeper_enabled else 1 + filtered_opt.append('"Buzzer_ON_OFF"') + filtered_p.append(str(buzzer_command_value)) + _LOGGER.debug( + f"Sending with Buzzer_ON_OFF={buzzer_command_value} (Beeper is {'ENABLED' if self._beeper_enabled else 'DISABLED'})" + ) + + statePackJson = ( + '{"opt":[' + + ",".join(filtered_opt) + + '],"p":[' + + ",".join(filtered_p) + + '],"t":"cmd","sub":"' + + self._sub_mac_addr + + '"}' + ) + + if self.encryption_version == 1: + cipher = self.CIPHER + sentJsonPayload = ( + '{"cid":"app","i":0,"pack":"' + + base64.b64encode( + cipher.encrypt(Pad(statePackJson).encode("utf8")) + ).decode("utf-8") + + '","t":"pack","tcid":"' + + str(self._mac_addr) + + '","uid":{}'.format(self._uid) + + "}" + ) + elif self.encryption_version == 2: + pack, tag = EncryptGCM(self._encryption_key, statePackJson) + sentJsonPayload = ( + '{"cid":"app","i":0,"pack":"' + + pack + + '","t":"pack","tcid":"' + + str(self._mac_addr) + + '","uid":{}'.format(self._uid) + + ',"tag":"' + + tag + + '"}' + ) + cipher = GetGCMCipher(self._encryption_key) + receivedJsonPayload = FetchResult( + cipher, + self._ip_addr, + self._port, + timeout, + sentJsonPayload, + encryption_version=self.encryption_version, + ) + _LOGGER.debug("Done sending state to HVAC: " + str(receivedJsonPayload)) + + def UpdateHATargetTemperature(self): + # Sync set temperature to HA. If 8℃ heating is active we set the temp in HA to 8℃ so that it shows the same as the AC display. + if self._acOptions["StHt"] and (int(self._acOptions["StHt"]) == 1): + self._target_temperature = 8 + _LOGGER.info( + "HA target temp set according to HVAC state to 8℃ since 8℃ heating mode is active" + ) + else: + temp_c = decode_temp_c( + SetTem=self._acOptions["SetTem"], TemRec=self._acOptions["TemRec"] + ) # takes care of 1/2 degrees + temp_f = gree_c_to_f( + SetTem=self._acOptions["SetTem"], TemRec=self._acOptions["TemRec"] + ) + + if self._unit_of_measurement == "°C": + display_temp = temp_c + elif self._unit_of_measurement == "°F": + display_temp = temp_f + else: + display_temp = temp_c # default to deg c + _LOGGER.error( + "Unknown unit of measurement: %s" % self._unit_of_measurement + ) + + self._target_temperature = display_temp + + _LOGGER.info( + f"UpdateHATargetTemperature: HA target temp set to: {self._target_temperature} {self._unit_of_measurement}. Device commands: SetTem: {self._acOptions['SetTem']}, TemRec: {self._acOptions['TemRec']}" + ) + + def UpdateHAHvacMode(self): + # Sync current HVAC operation mode to HA + if self._acOptions["Pow"] == 0: + self._hvac_mode = HVACMode.OFF + else: + for key, value in MODES_MAPPING.get("Mod").items(): + if value == (self._acOptions["Mod"]): + self._hvac_mode = key + _LOGGER.debug( + "HA operation mode set according to HVAC state to: " + str(self._hvac_mode) + ) + + def UpdateHACurrentSwingMode(self): + # Sync current HVAC Swing mode state to HA + for key, value in MODES_MAPPING.get("SwUpDn").items(): + if value == (self._acOptions["SwUpDn"]): + self._swing_mode = key + _LOGGER.debug( + "HA swing mode set according to HVAC state to: " + str(self._swing_mode) + ) + + def UpdateHACurrentSwingHorizontalMode(self): + # Sync current HVAC Horizontal Swing mode state to HA + for key, value in MODES_MAPPING.get("SwingLfRig").items(): + if value == (self._acOptions["SwingLfRig"]): + self._swing_horizontal_mode = key + _LOGGER.debug( + "HA horizontal swing mode set according to HVAC state to: " + + str(self._swing_horizontal_mode) + ) + + def UpdateHAFanMode(self): + # Sync current HVAC Fan mode state to HA + if int(self._acOptions["Tur"]) == 1: + turbo_index = self._fan_modes.index("turbo") + self._fan_mode = self._fan_modes[turbo_index] + elif int(self._acOptions["Quiet"]) >= 1: + quiet_index = self._fan_modes.index("quiet") + self._fan_mode = self._fan_modes[quiet_index] + else: + for key, value in MODES_MAPPING.get("WdSpd").items(): + if value == (self._acOptions["WdSpd"]): + self._fan_mode = key + _LOGGER.debug( + "HA fan mode set according to HVAC state to: " + str(self._fan_mode) + ) + + def UpdateHACurrentTemperature(self): + # Use external temperature sensor if available + if self._external_temperature_sensor: + # Use external temperature sensor + external_sensor_state = self.hass.states.get( + self._external_temperature_sensor + ) + if external_sensor_state and external_sensor_state.state not in ( + "unknown", + "unavailable", + ): + try: + unit = external_sensor_state.attributes.get( + ATTR_UNIT_OF_MEASUREMENT + ) + _LOGGER.debug( + f"Using external temperature sensor: {self._external_temperature_sensor}, value: {external_sensor_state.state}, unit: {unit}" + ) + self._current_temperature = self.hass.config.units.temperature( + float(external_sensor_state.state), unit + ) + _LOGGER.debug( + f"External temperature: {self._current_temperature} {self._unit_of_measurement}" + ) + return + except (ValueError, TypeError) as ex: + _LOGGER.error( + "Unable to update from external temp sensor %s: %s", + self._external_temperature_sensor, + ex, + ) + + # Use built-in AC temperature sensor if available + if self._has_temp_sensor: + _LOGGER.debug( + "method UpdateHACurrentTemperature: TemSen: " + + str(self._acOptions["TemSen"]) + ) + + if self._temp_sensor_offset is None: # user hasn't chosen an offset + # User hasn't set automaticaly, so try to determine the offset + temp_c = self._process_temp_sensor(self._acOptions["TemSen"]) + _LOGGER.debug( + "method UpdateHACurrentTemperature: User has not chosen an offset, using process_temp_sensor() to automatically determine offset." + ) + else: + # User set + if self._temp_sensor_offset is True: + temp_c = self._acOptions["TemSen"] - TEMSEN_OFFSET + + elif self._temp_sensor_offset is False: + temp_c = self._acOptions["TemSen"] + + _LOGGER.debug( + f"method UpdateHACurrentTemperature: User has chosen an offset ({self._temp_sensor_offset})" + ) + + temp_f = gree_c_to_f( + SetTem=temp_c, TemRec=0 + ) # Convert to Fahrenheit using TemRec bit + + if self._unit_of_measurement == "°C": + self._current_temperature = temp_c + elif self._unit_of_measurement == "°F": + self._current_temperature = temp_f + else: + _LOGGER.error( + "Unknown unit of measurement: %s" % self._unit_of_measurement + ) + + _LOGGER.debug( + "method UpdateHACurrentTemperature: HA current temperature set with device built-in temperature sensor state : " + + str(self._current_temperature) + + str(self._unit_of_measurement) + ) + + def UpdateHAOutsideTemperature(self): + # Update outside temperature from built-in AC outside temperature sensor if available + if self._has_outside_temp_sensor: + _LOGGER.debug( + "method UpdateHAOutsideTemperature: OutEnvTem: " + + str(self._acOptions["OutEnvTem"]) + ) + + if self._temp_sensor_offset is None: # user hasn't chosen an offset + # User hasn't set automatically, so try to determine the offset + temp_c = self._process_temp_sensor(self._acOptions["OutEnvTem"]) + _LOGGER.debug( + "method UpdateHAOutsideTemperature: User has not chosen an offset, using process_temp_sensor() to automatically determine offset." + ) + else: + # User set + if self._temp_sensor_offset is True: + temp_c = self._acOptions["OutEnvTem"] - TEMSEN_OFFSET + elif self._temp_sensor_offset is False: + temp_c = self._acOptions["OutEnvTem"] + + _LOGGER.debug( + f"method UpdateHAOutsideTemperature: User has chosen an offset ({self._temp_sensor_offset})" + ) + + temp_f = gree_c_to_f( + SetTem=temp_c, TemRec=0 + ) # Convert to Fahrenheit using TemRec bit + + if self._unit_of_measurement == "°C": + self._current_outside_temperature = temp_c + elif self._unit_of_measurement == "°F": + self._current_outside_temperature = temp_f + else: + _LOGGER.error( + "Unknown unit of measurement for outside temperature: %s" + % self._unit_of_measurement + ) + + _LOGGER.debug( + "method UpdateHAOutsideTemperature: HA outside temperature set with device built-in outside temperature sensor state : " + + str(self._current_outside_temperature) + + str(self._unit_of_measurement) + ) + + def UpdateHARoomHumidity(self): + # Update room humidity from built-in AC room humidity sensor if available + if self._has_room_humidity_sensor: + _LOGGER.debug( + "method UpdateHARoomHumidity: DwatSen: " + + str(self._acOptions["DwatSen"]) + ) + self._current_room_humidity = self._acOptions["DwatSen"] + _LOGGER.debug( + "method UpdateHARoomHumidity: HA room humidity set with device built-in room humidity sensor state : " + + str(self._current_room_humidity) + + "%" + ) + + def UpdateHAStateToCurrentACState(self): + self.UpdateHATargetTemperature() + self.UpdateHAHvacMode() + if self._swing_modes: + self.UpdateHACurrentSwingMode() + if self._swing_horizontal_modes: + self.UpdateHACurrentSwingHorizontalMode() + self.UpdateHAFanMode() + self.UpdateHACurrentTemperature() + self.UpdateHAOutsideTemperature() + self.UpdateHARoomHumidity() + + def SyncState(self, acOptions={}): + # Fetch current settings from HVAC + _LOGGER.debug("Starting SyncState") + + if self._has_temp_sensor is None: + _LOGGER.debug( + "Attempt to check whether device has an built-in temperature sensor" + ) + try: + temp_sensor = self.GreeGetValues(["TemSen"]) + except Exception: + _LOGGER.debug( + "Could not determine whether device has an built-in temperature sensor. Retrying at next update()" + ) + else: + if temp_sensor: + self._has_temp_sensor = True + self._acOptions.update({"TemSen": None}) + self._optionsToFetch.append("TemSen") + _LOGGER.debug("Device has an built-in temperature sensor") + else: + self._has_temp_sensor = False + _LOGGER.debug("Device has no built-in temperature sensor") + + # Check if device has anti direct blow feature + if self._has_anti_direct_blow is None: + _LOGGER.debug( + "Attempt to check whether device has an anti direct blow feature" + ) + try: + anti_direct_blow = self.GreeGetValues(["AntiDirectBlow"]) + except Exception: + _LOGGER.debug( + "Could not determine whether device has an anti direct blow feature. Retrying at next update()" + ) + else: + if anti_direct_blow: + self._has_anti_direct_blow = True + self._acOptions.update({"AntiDirectBlow": None}) + self._optionsToFetch.append("AntiDirectBlow") + _LOGGER.debug("Device has an anti direct blow feature") + else: + self._has_anti_direct_blow = False + _LOGGER.debug("Device has no anti direct blow feature") + + # Check if device has light sensor + if self._has_light_sensor is None: + _LOGGER.debug("Attempt to check whether device has a built-in light sensor") + try: + light_sensor = self.GreeGetValues(["LigSen"]) + except Exception: + _LOGGER.debug( + "Could not determine whether device has a built-in light sensor. Retrying at next update()" + ) + else: + if light_sensor: + self._has_light_sensor = True + self._acOptions.update({"LigSen": None}) + self._optionsToFetch.append("LigSen") + _LOGGER.debug("Device has a built-in light sensor") + else: + self._has_light_sensor = False + _LOGGER.debug("Device has no built-in light sensor") + + # Check if device has outside temperature sensor + if self._has_outside_temp_sensor is None: + _LOGGER.debug( + "Attempt to check whether device has an outside temperature sensor" + ) + try: + outside_temp_sensor = self.GreeGetValues(["OutEnvTem"]) + except Exception: + _LOGGER.debug( + "Could not determine whether device has an outside temperature sensor. Retrying at next update()" + ) + else: + if outside_temp_sensor: + self._has_outside_temp_sensor = True + self._acOptions.update({"OutEnvTem": None}) + self._optionsToFetch.append("OutEnvTem") + _LOGGER.debug("Device has an outside temperature sensor") + else: + self._has_outside_temp_sensor = False + _LOGGER.debug("Device has no outside temperature sensor") + + # Check if device has room humidity sensor + if self._has_room_humidity_sensor is None: + _LOGGER.debug("Attempt to check whether device has a room humidity sensor") + try: + humidity_sensor = self.GreeGetValues(["DwatSen"]) + except Exception: + _LOGGER.debug( + "Could not determine whether device has a room humidity sensor. Retrying at next update()" + ) + else: + if humidity_sensor: + self._has_room_humidity_sensor = True + self._acOptions.update({"DwatSen": None}) + self._optionsToFetch.append("DwatSen") + _LOGGER.debug("Device has a room humidity sensor") + else: + self._has_room_humidity_sensor = False + _LOGGER.debug("Device has no room humidity sensor") + + optionsToFetch = self._optionsToFetch + + try: + currentValues = self.GreeGetValues(optionsToFetch) + except Exception: + _LOGGER.info("Could not connect with device. ") + if not self._disable_available_check: + self._online_attempts += 1 + if self._online_attempts == self._max_online_attempts: + _LOGGER.info( + "Could not connect with device %s times. Set it as offline." + % self._max_online_attempts + ) + self._device_online = False + self._online_attempts = 0 + else: + if not self._disable_available_check: + if not self._device_online: + self._device_online = True + self._online_attempts = 0 + # Set latest status from device + self._acOptions = self.SetAcOptions( + self._acOptions, optionsToFetch, currentValues + ) + + # Overwrite status with our choices + if not (acOptions == {}): + self._acOptions = self.SetAcOptions(self._acOptions, acOptions) + + # Initialize the receivedJsonPayload variable (for return) + receivedJsonPayload = "" + + # If not the first (boot) run, update state towards the HVAC + if not (self._firstTimeRun): + if not (acOptions == {}): + # loop used to send changed settings from HA to HVAC + self.SendStateToAc(self._timeout) + else: + # loop used once for Gree Climate initialisation only + self._firstTimeRun = False + + # Update HA state to current HVAC state + self.UpdateHAStateToCurrentACState() + + _LOGGER.debug("Finished SyncState") + return receivedJsonPayload + + @property + def should_poll(self): + _LOGGER.debug("should_poll()") + # Return the polling state. + return True + + @property + def available(self): + if self._disable_available_check: + return True + else: + if self._device_online: + _LOGGER.info("available(): Device is online") + return True + else: + _LOGGER.info("available(): Device is offline") + return False + + def update(self): + _LOGGER.debug("update()") + if not self._encryption_key: + if self.encryption_version == 1: + key = GetDeviceKey( + self._mac_addr, self._ip_addr, self._port, self._timeout + ) + if key: + self._encryption_key = key + self.CIPHER = AES.new(self._encryption_key, AES.MODE_ECB) + self.SyncState() + elif self.encryption_version == 2: + key = GetDeviceKeyGCM( + self._mac_addr, self._ip_addr, self._port, self._timeout + ) + if key: + self._encryption_key = key + self.CIPHER = GetGCMCipher(self._encryption_key) + self.SyncState() + else: + _LOGGER.error( + "Encryption version %s is not implemented." + % self.encryption_version + ) + else: + self.SyncState() + + @property + def name(self): + _LOGGER.debug("name(): " + str(self._name)) + # Return the name of the climate device. + return self._name + + @property + def temperature_unit(self): + _LOGGER.debug("temperature_unit(): " + str(self._unit_of_measurement)) + # Return the unit of measurement. + return self._unit_of_measurement + + @property + def current_temperature(self): + _LOGGER.debug("current_temperature(): " + str(self._current_temperature)) + # Return the current temperature. + return self._current_temperature + + @property + def min_temp(self): + if self._unit_of_measurement == "°C": + MIN_TEMP = MIN_TEMP_C + else: + MIN_TEMP = MIN_TEMP_F + + _LOGGER.debug("min_temp(): " + str(MIN_TEMP)) + # Return the minimum temperature. + return MIN_TEMP + + @property + def max_temp(self): + if self._unit_of_measurement == "°C": + MAX_TEMP = MAX_TEMP_C + else: + MAX_TEMP = MAX_TEMP_F + + _LOGGER.debug("max_temp(): " + str(MAX_TEMP)) + # Return the maximum temperature. + return MAX_TEMP + + @property + def target_temperature(self): + _LOGGER.debug("target_temperature(): " + str(self._target_temperature)) + # Return the temperature we try to reach. + return self._target_temperature + + @property + def target_temperature_step(self): + _LOGGER.debug( + "target_temperature_step(): " + str(self._target_temperature_step) + ) + return self._target_temperature_step + + @property + def hvac_mode(self): + _LOGGER.debug("hvac_mode(): " + str(self._hvac_mode)) + # Return current operation mode ie. heat, cool, idle. + return self._hvac_mode + + @property + def swing_mode(self): + if self._swing_modes: + _LOGGER.debug("swing_mode(): " + str(self._swing_mode)) + # get the current swing mode + return self._swing_mode + else: + return None + + @property + def swing_modes(self): + _LOGGER.debug("swing_modes(): " + str(self._swing_modes)) + # get the list of available swing modes + return self._swing_modes + + @property + def swing_horizontal_mode(self): + if self._swing_horizontal_modes: + _LOGGER.debug( + "swing_horizontal_mode(): " + str(self._swing_horizontal_mode) + ) + # get the current preset mode + return self._swing_horizontal_mode + else: + return None + + @property + def swing_horizontal_modes(self): + _LOGGER.debug("swing_horizontal_modes(): " + str(self._swing_horizontal_modes)) + # get the list of available preset modes + return self._swing_horizontal_modes + + @property + def hvac_modes(self): + _LOGGER.debug("hvac_modes(): " + str(self._hvac_modes)) + # Return the list of available operation modes. + return self._hvac_modes + + @property + def fan_mode(self): + _LOGGER.debug("fan_mode(): " + str(self._fan_mode)) + # Return the fan mode. + return self._fan_mode + + @property + def fan_modes(self): + _LOGGER.debug("fan_list(): " + str(self._fan_modes)) + # Return the list of available fan modes. + return self._fan_modes + + @property + def supported_features(self): + sf = SUPPORT_FLAGS + if self._swing_modes: + sf = sf | ClimateEntityFeature.SWING_MODE + if self._swing_horizontal_modes: + sf = sf | ClimateEntityFeature.SWING_HORIZONTAL_MODE + _LOGGER.debug("supported_features(): " + str(sf)) + # Return the list of supported features. + return sf + + @property + def unique_id(self): + # Return unique_id + return self._unique_id + + @property + def device_info(self) -> DeviceInfo: + """Return device information.""" + return DeviceInfo( + identifiers={(DOMAIN, self._mac_addr)}, + name=self._name, + manufacturer="Gree", + ) + + @property + def outside_temperature(self): + """Return the outside temperature if available.""" + if self._has_outside_temp_sensor: + _LOGGER.debug( + "outside_temperature(): " + str(self._current_outside_temperature) + ) + return self._current_outside_temperature + return None + + @property + def room_humidity(self): + """Return the current room humidity if available.""" + if self._has_room_humidity_sensor: + _LOGGER.debug("room_humidity(): " + str(self._current_room_humidity)) + return self._current_room_humidity + return None + + @property + def extra_state_attributes(self): + """Return additional state attributes.""" + attributes = {} + + if self.outside_temperature is not None: + attributes["outside_temperature"] = self.outside_temperature + attributes["outside_temperature_unit"] = self._unit_of_measurement + + if self.room_humidity is not None: + attributes["room_humidity"] = self.room_humidity + attributes["humidity_unit"] = "%" + + return attributes if attributes else None + + def set_temperature(self, **kwargs): + s = kwargs.get(ATTR_TEMPERATURE) + + _LOGGER.info("set_temperature(): " + str(s) + str(self._unit_of_measurement)) + # Set new target temperatures. + if s is not None: + # do nothing if temperature is none + if not (self._acOptions["Pow"] == 0): + # do nothing if HVAC is switched off + + if self._unit_of_measurement == "°C": + SetTem, TemRec = encode_temp_c(T=s) # takes care of 1/2 degrees + elif self._unit_of_measurement == "°F": + SetTem, TemRec = gree_f_to_c(desired_temp_f=s) + else: + _LOGGER.error( + "Unable to set temperature. Units not set to °C or °F" + ) + return + + self.SyncState({"SetTem": int(SetTem), "TemRec": int(TemRec)}) + _LOGGER.debug( + "method set_temperature: Set Temp to " + + str(s) + + str(self._unit_of_measurement) + + " -> SyncState with SetTem=" + + str(SetTem) + + ", SyncState with TemRec=" + + str(TemRec) + ) + + self.schedule_update_ha_state() + + def set_swing_mode(self, swing_mode): + _LOGGER.info("Set swing mode(): " + str(swing_mode)) + # set the swing mode + if not (self._acOptions["Pow"] == 0): + # do nothing if HVAC is switched off + try: + sw_up_dn = MODES_MAPPING.get("SwUpDn").get(swing_mode) + _LOGGER.info("SyncState with SwUpDn=" + str(sw_up_dn)) + self.SyncState({"SwUpDn": sw_up_dn}) + self.schedule_update_ha_state() + except ValueError: + _LOGGER.error(f"Unknown swing mode: {swing_mode}") + return + + def set_swing_horizontal_mode(self, swing_horizontal_mode): + if not (self._acOptions["Pow"] == 0): + # do nothing if HVAC is switched off + try: + swing_lf_rig = MODES_MAPPING.get("SwingLfRig").get( + swing_horizontal_mode + ) + _LOGGER.info("SyncState with SwingLfRig=" + str(swing_lf_rig)) + self.SyncState({"SwingLfRig": swing_lf_rig}) + self.schedule_update_ha_state() + except ValueError: + _LOGGER.error(f"Unknown preset mode: {swing_horizontal_mode}") + return + + def set_fan_mode(self, fan): + _LOGGER.info("set_fan_mode(): " + str(fan)) + # Set the fan mode. + if not (self._acOptions["Pow"] == 0): + try: + wd_spd = MODES_MAPPING.get("WdSpd").get(fan) + + # Check if this is turbo mode + if fan == "turbo": + _LOGGER.info("Enabling turbo mode") + self.SyncState({"Tur": 1, "Quiet": 0}) + # Check if this is quiet mode + elif fan == "quiet": + _LOGGER.info("Enabling quiet mode") + self.SyncState({"Tur": 0, "Quiet": 1}) + else: + _LOGGER.info("Setting normal fan mode to " + str(wd_spd)) + self.SyncState({"WdSpd": str(wd_spd), "Tur": 0, "Quiet": 0}) + + self.schedule_update_ha_state() + except ValueError: + _LOGGER.error(f"Unknown fan mode: {fan}") + return + + def set_hvac_mode(self, hvac_mode): + _LOGGER.info("set_hvac_mode(): " + str(hvac_mode)) + # Set new operation mode. + c = {} + if hvac_mode == HVACMode.OFF: + c.update({"Pow": 0}) + if hasattr(self, "_auto_light") and self._auto_light: + c.update({"Lig": 0}) + else: + mod = MODES_MAPPING.get("Mod").get(hvac_mode) + c.update({"Pow": 1, "Mod": mod}) + if hasattr(self, "_auto_light") and self._auto_light: + c.update({"Lig": 1}) + if hasattr(self, "_auto_xfan") and self._auto_xfan: + if (hvac_mode == HVACMode.COOL) or (hvac_mode == HVACMode.DRY): + c.update({"Blo": 1}) + self.SyncState(c) + self.schedule_update_ha_state() + + def turn_on(self): + _LOGGER.info("turn_on(): ") + # Turn on. + c = {"Pow": 1} + if hasattr(self, "_auto_light") and self._auto_light: + c.update({"Lig": 1}) + self.SyncState(c) + self.schedule_update_ha_state() + + def turn_off(self): + _LOGGER.info("turn_off(): ") + # Turn off. + c = {"Pow": 0} + if hasattr(self, "_auto_light") and self._auto_light: + c.update({"Lig": 0}) + self.SyncState(c) + self.schedule_update_ha_state() + + async def async_added_to_hass(self): + _LOGGER.info("Gree climate device added to hass()") + self.update() + + async def async_will_remove_from_hass(self) -> None: + """Clean up when entity is removed.""" + for name, entity_id, unsub in self._listeners: + _LOGGER.debug("Deregistering %s listener for %s", name, entity_id) + unsub() + self._listeners.clear() diff --git a/custom_components/gree/old_config_flow.py b/custom_components/gree/old_config_flow.py new file mode 100755 index 0000000..9155a1d --- /dev/null +++ b/custom_components/gree/old_config_flow.py @@ -0,0 +1,143 @@ +"""Config flow for Gree climate integration.""" + +from __future__ import annotations + +# Standard library imports +import logging + +# Third-party imports +import voluptuous as vol + +# Home Assistant imports +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, CONF_TIMEOUT +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import selector + +# Local imports +from .const import ( + CONF_DISABLE_AVAILABLE_CHECK, + CONF_ENCRYPTION_KEY, + CONF_ENCRYPTION_VERSION, + CONF_FAN_MODES, + CONF_HVAC_MODES, + CONF_MAX_ONLINE_ATTEMPTS, + CONF_SWING_HORIZONTAL_MODES, + CONF_SWING_MODES, + CONF_TEMP_SENSOR_OFFSET, + CONF_UID, + DEFAULT_FAN_MODES, + DEFAULT_HVAC_MODES, + DEFAULT_PORT, + DEFAULT_SWING_HORIZONTAL_MODES, + DEFAULT_SWING_MODES, + DEFAULT_TIMEOUT, + DOMAIN, + OPTION_KEYS, +) + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Gree climate.""" + + VERSION = 1 + + def __init__(self) -> None: + self._data: dict[str, any] = {} + + async def async_step_user(self, user_input: dict | None = None) -> FlowResult: + """Handle the initial step.""" + if user_input is not None: + self._data.update(user_input) + return self.async_create_entry(title=user_input.get(CONF_NAME) or "Gree Climate", data=self._data) + + data_schema = vol.Schema( + { + vol.Required(CONF_NAME): str, + vol.Required(CONF_HOST): str, + vol.Required(CONF_MAC): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): int, + vol.Optional(CONF_ENCRYPTION_KEY): str, + vol.Optional(CONF_UID): int, + vol.Optional(CONF_ENCRYPTION_VERSION, default=1): int, + } + ) + return self.async_show_form(step_id="user", data_schema=data_schema) + + async def async_step_import(self, import_data: dict) -> FlowResult: + """Handle configuration via YAML import.""" + return await self.async_step_user(import_data) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle an options flow for Gree climate.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + self.config_entry = config_entry + + async def async_step_init(self, user_input: dict | None = None) -> FlowResult: + if user_input is not None: + _LOGGER.debug("Raw user options input: %s", user_input) + normalized_input: dict[str, str | None] = {} + # Only handle known option keys + for key in OPTION_KEYS: + if key in user_input: + value = user_input[key] + normalized_input[key] = value if value not in (None, "") else None + elif key in self.config_entry.options: + normalized_input[key] = None + _LOGGER.debug("Normalized options to save: %s", normalized_input) + result = self.async_create_entry(title="", data=normalized_input) + _LOGGER.debug("Creating entry with options: %s", normalized_input) + return result + + options = {key: value for key, value in self.config_entry.options.items() if key in OPTION_KEYS} + _LOGGER.debug("Current stored options: %s", options) + schema = vol.Schema( + { + vol.Optional( + CONF_HVAC_MODES, + description={"suggested_value": options.get(CONF_HVAC_MODES, DEFAULT_HVAC_MODES)}, + default=options.get(CONF_HVAC_MODES, DEFAULT_HVAC_MODES), + ): vol.Any(None, selector.SelectSelector(selector.SelectSelectorConfig(options=DEFAULT_HVAC_MODES, multiple=True, custom_value=True, translation_key=CONF_HVAC_MODES))), + vol.Optional( + CONF_FAN_MODES, + description={"suggested_value": options.get(CONF_FAN_MODES, DEFAULT_FAN_MODES)}, + default=options.get(CONF_FAN_MODES, DEFAULT_FAN_MODES), + ): vol.Any(None, selector.SelectSelector(selector.SelectSelectorConfig(options=DEFAULT_FAN_MODES, multiple=True, custom_value=True, translation_key=CONF_FAN_MODES))), + vol.Optional( + CONF_SWING_MODES, + description={"suggested_value": options.get(CONF_SWING_MODES, DEFAULT_SWING_MODES)}, + default=options.get(CONF_SWING_MODES, DEFAULT_SWING_MODES), + ): vol.Any(None, selector.SelectSelector(selector.SelectSelectorConfig(options=DEFAULT_SWING_MODES, multiple=True, custom_value=True, translation_key=CONF_SWING_MODES))), + vol.Optional( + CONF_SWING_HORIZONTAL_MODES, + description={"suggested_value": options.get(CONF_SWING_HORIZONTAL_MODES, DEFAULT_SWING_HORIZONTAL_MODES)}, + default=options.get(CONF_SWING_HORIZONTAL_MODES, DEFAULT_SWING_HORIZONTAL_MODES), + ): vol.Any(None, selector.SelectSelector(selector.SelectSelectorConfig(options=DEFAULT_SWING_HORIZONTAL_MODES, multiple=True, custom_value=True, translation_key=CONF_SWING_HORIZONTAL_MODES))), + vol.Optional( + CONF_DISABLE_AVAILABLE_CHECK, + default=options.get(CONF_DISABLE_AVAILABLE_CHECK, False), + ): bool, + vol.Optional( + CONF_MAX_ONLINE_ATTEMPTS, + default=options.get(CONF_MAX_ONLINE_ATTEMPTS, 3), + ): int, + vol.Optional( + CONF_TEMP_SENSOR_OFFSET, + description={"suggested_value": options.get(CONF_TEMP_SENSOR_OFFSET)}, + ): vol.Any(None, bool), + } + ) + return self.async_show_form(step_id="init", data_schema=schema) diff --git a/custom_components/gree/old_gree_protocol.py b/custom_components/gree/old_gree_protocol.py new file mode 100755 index 0000000..13ce5a2 --- /dev/null +++ b/custom_components/gree/old_gree_protocol.py @@ -0,0 +1,91 @@ +""" +Gree protocol/network logic for Home Assistant integration. +""" + +# Standard library imports +import base64 +import logging +import socket + +# Third-party imports +try: + import simplejson +except ImportError: + import json as simplejson +from Crypto.Cipher import AES + +_LOGGER = logging.getLogger(__name__) + +GCM_IV = b"\x54\x40\x78\x44\x49\x67\x5a\x51\x6c\x5e\x63\x13" +GCM_ADD = b"qualcomm-test" +GENERIC_GREE_DEVICE_KEY = "a3K8Bx%2r8Y7#xDh" +GENERIC_GREE_DEVICE_KEY_GCM = b"{yxAHAY_Lm6pbC/<" + + +def Pad(s): + aesBlockSize = 16 + return s + (aesBlockSize - len(s) % aesBlockSize) * chr(aesBlockSize - len(s) % aesBlockSize) + + +def FetchResult(cipher, ip_addr, port, timeout, json_data, encryption_version=1): + _LOGGER.debug("Fetching(%s, %s, %s, %s)" % (ip_addr, port, timeout, json_data)) + clientSock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + clientSock.settimeout(timeout) + clientSock.sendto(bytes(json_data, "utf-8"), (ip_addr, port)) + data, addr = clientSock.recvfrom(64000) + receivedJson = simplejson.loads(data) + clientSock.close() + pack = receivedJson["pack"] + base64decodedPack = base64.b64decode(pack) + decryptedPack = cipher.decrypt(base64decodedPack) + if encryption_version == 2: + tag = receivedJson["tag"] + cipher.verify(base64.b64decode(tag)) + decodedPack = decryptedPack.decode("utf-8") + replacedPack = decodedPack.replace("\x0f", "").replace(decodedPack[decodedPack.rindex("}") + 1 :], "") + loadedJsonPack = simplejson.loads(replacedPack) + return loadedJsonPack + + +def GetDeviceKey(mac_addr, ip_addr, port, timeout): + _LOGGER.info("Retrieving HVAC encryption key") + cipher = AES.new(GENERIC_GREE_DEVICE_KEY.encode("utf8"), AES.MODE_ECB) + pack = base64.b64encode(cipher.encrypt(Pad('{"mac":"' + str(mac_addr) + '","t":"bind","uid":0}').encode("utf8"))).decode("utf-8") + jsonPayloadToSend = '{"cid": "app","i": 1,"pack": "' + pack + '","t":"pack","tcid":"' + str(mac_addr) + '","uid": 0}' + try: + key = FetchResult(cipher, ip_addr, port, timeout, jsonPayloadToSend)["key"].encode("utf8") + except Exception: + _LOGGER.info("Error getting device encryption key!") + return None + else: + _LOGGER.info("Fetched device encryption key: %s" % str(key)) + return key + + +def GetGCMCipher(key): + cipher = AES.new(key, AES.MODE_GCM, nonce=GCM_IV) + cipher.update(GCM_ADD) + return cipher + + +def EncryptGCM(key, plaintext): + cipher = GetGCMCipher(key) + encrypted_data, tag = cipher.encrypt_and_digest(plaintext.encode("utf8")) + pack = base64.b64encode(encrypted_data).decode("utf-8") + tag = base64.b64encode(tag).decode("utf-8") + return (pack, tag) + + +def GetDeviceKeyGCM(mac_addr, ip_addr, port, timeout): + _LOGGER.info("Retrieving HVAC encryption key (GCM)") + plaintext = '{"cid":"' + str(mac_addr) + '", "mac":"' + str(mac_addr) + '","t":"bind","uid":0}' + pack, tag = EncryptGCM(GENERIC_GREE_DEVICE_KEY_GCM, plaintext) + jsonPayloadToSend = '{"cid": "app","i": 1,"pack": "' + pack + '","t":"pack","tcid":"' + str(mac_addr) + '","uid": 0, "tag" : "' + tag + '"}' + try: + key = FetchResult(GetGCMCipher(GENERIC_GREE_DEVICE_KEY_GCM), ip_addr, port, timeout, jsonPayloadToSend, encryption_version=2)["key"].encode("utf8") + except Exception: + _LOGGER.info("Error getting device encryption key!") + return None + else: + _LOGGER.info("Fetched device encryption key: %s" % str(key)) + return key diff --git a/custom_components/gree/helpers.py b/custom_components/gree/old_helpers.py old mode 100644 new mode 100755 similarity index 100% rename from custom_components/gree/helpers.py rename to custom_components/gree/old_helpers.py diff --git a/custom_components/gree/old_select.py b/custom_components/gree/old_select.py new file mode 100755 index 0000000..1481b83 --- /dev/null +++ b/custom_components/gree/old_select.py @@ -0,0 +1,160 @@ +"""Support for Gree select entities (e.g., external temperature sensor selection).""" + +from __future__ import annotations + +# Standard library imports +import logging +from collections.abc import Callable +from dataclasses import dataclass + +# Home Assistant imports +from homeassistant.components.select import ( + SelectEntity, + SelectEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity + +# Local imports +from .entity import OldGreeEntity, OldGreeEntityDescription + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class GreeSelectEntityDescription(OldGreeEntityDescription, SelectEntityDescription): + """Describes Gree select entity.""" + + set_fn: Callable[[object, str], None] = None + restore_state: bool = False + options_fn: Callable[[object], list[str]] = None + + +def get_temperature_sensor_options(hass: HomeAssistant) -> list[str]: + """Get list of available temperature sensor entities.""" + options = ["None"] # Always include "None" as first option + + # Get all entities from the registry + for state in hass.states.async_all(): + # Look for temperature sensors + if state.entity_id.startswith("sensor."): + # Check for explicit device_class + if state.attributes.get("device_class") == "temperature": + options.append(state.entity_id) + # Also check for temperature units as fallback for helpers/combined sensors + elif state.attributes.get("unit_of_measurement") in ["°C", "°F", "K"]: + options.append(state.entity_id) + + return options + + +SELECTS: tuple[GreeSelectEntityDescription, ...] = ( + GreeSelectEntityDescription( + property_key="external_temperature_sensor", + icon="mdi:thermometer-lines", + options=[], # Will be populated dynamically + value_fn=lambda device: getattr(device, "_external_temperature_sensor", "None"), + set_fn=lambda device, value: setattr( + device, "_external_temperature_sensor", None if value == "None" else value + ), + entity_category=EntityCategory.CONFIG, + restore_state=True, + options_fn=lambda hass: get_temperature_sensor_options(hass), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Gree select entities based on a config entry.""" + async_add_entities( + GreeSelectEntity(hass, entry, description) for description in SELECTS + ) + + +class GreeSelectEntity(OldGreeEntity, SelectEntity, RestoreEntity): + """Defines a Gree select entity.""" + + entity_description: GreeSelectEntityDescription + + def __init__( + self, hass: HomeAssistant, entry, description: GreeSelectEntityDescription + ) -> None: + super().__init__(hass, entry, description) + self._hass = hass + # Initialize with no external sensor configured + self._device._external_temperature_sensor = None + # Set up options dynamically + if description.options_fn: + self._attr_options = description.options_fn(hass) + else: + self._attr_options = description.options or ["None"] + + async def async_added_to_hass(self) -> None: + """Restore state when entity is added to hass.""" + await super().async_added_to_hass() + + # Refresh options when entity is added + if self.entity_description.options_fn: + self._attr_options = self.entity_description.options_fn(self._hass) + + if self.entity_description.restore_state: + restored = await self.async_get_last_state() + if restored and restored.state not in ("unknown", "unavailable"): + # Restore the external temperature sensor entity ID + if restored.state in self._attr_options: + if self.entity_description.set_fn: + self.entity_description.set_fn(self._device, restored.state) + _LOGGER.debug( + "Restored %s state: %s", self.entity_id, restored.state + ) + else: + _LOGGER.warning( + "Restored state %s not in current options, resetting to None", + restored.state, + ) + if self.entity_description.set_fn: + self.entity_description.set_fn(self._device, "None") + + @property + def current_option(self) -> str: + """Return the current selected option.""" + if self.entity_description.value_fn: + value = self.entity_description.value_fn(self._device) + return value if value in self._attr_options else "None" + return "None" + + async def async_select_option(self, option: str) -> None: + """Select an option.""" + if option not in self._attr_options: + _LOGGER.error("Option %s not available in %s", option, self._attr_options) + return + + if self.entity_description.set_fn: + self.entity_description.set_fn(self._device, option) + self.async_write_ha_state() + _LOGGER.info( + "Selected %s: %s", self.entity_description.property_key, option + ) + + async def async_update(self) -> None: + """Update the entity.""" + # Refresh available temperature sensors periodically + if self.entity_description.options_fn: + new_options = self.entity_description.options_fn(self._hass) + if new_options != self._attr_options: + self._attr_options = new_options + _LOGGER.debug( + "Updated temperature sensor options: %s", self._attr_options + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return True diff --git a/custom_components/gree/old_sensor.py b/custom_components/gree/old_sensor.py new file mode 100644 index 0000000..26a75e2 --- /dev/null +++ b/custom_components/gree/old_sensor.py @@ -0,0 +1,117 @@ +"""Support for Gree sensors.""" + +import logging +from homeassistant.components.sensor import ( + SensorEntity, + SensorDeviceClass, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, +) +from homeassistant.helpers.device_registry import DeviceInfo + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Gree sensors from a config entry.""" + # Get the device that was created in __init__.py + entry_data = hass.data[DOMAIN][entry.entry_id] + device = entry_data["device"] + + sensors = [] + + sensors.append(GreeOutsideTemperatureSensor(device)) + _LOGGER.debug("Added outside temperature sensor") + + sensors.append(GreeRoomHumiditySensor(device)) + _LOGGER.debug("Added room humidity sensor") + + if sensors: + async_add_entities(sensors) + _LOGGER.info(f"Added {len(sensors)} Gree sensors") + + +class GreeOutsideTemperatureSensor(SensorEntity): + """Gree outside temperature sensor.""" + + def __init__(self, device): + """Initialize the sensor.""" + self._device = device + self._attr_name = f"{device.name} Outside Temperature" + self._attr_unique_id = f"{device.unique_id}_outside_temp" + self._attr_device_class = SensorDeviceClass.TEMPERATURE + self._attr_state_class = SensorStateClass.MEASUREMENT + self._attr_native_unit_of_measurement = device.temperature_unit + self._attr_suggested_display_precision = 0 + + @property + def device_info(self) -> DeviceInfo: + """Return device information.""" + return DeviceInfo( + identifiers={(DOMAIN, self._device._mac_addr)}, + name=self._device.name, + manufacturer="Gree", + ) + + @property + def native_value(self): + """Return the state of the sensor.""" + # Only return value if sensor is detected and available + if self._device._has_outside_temp_sensor: + return self._device.outside_temperature + return None + + @property + def available(self): + """Return True if entity is available.""" + return self._device.available and self._device._has_outside_temp_sensor + + def update(self): + """Update the sensor.""" + # The climate entity handles the actual data fetching + pass + + +class GreeRoomHumiditySensor(SensorEntity): + """Gree room humidity sensor.""" + + def __init__(self, device): + """Initialize the sensor.""" + self._device = device + self._attr_name = f"{device.name} Room Humidity" + self._attr_unique_id = f"{device.unique_id}_room_humidity" + self._attr_device_class = SensorDeviceClass.HUMIDITY + self._attr_state_class = SensorStateClass.MEASUREMENT + self._attr_native_unit_of_measurement = PERCENTAGE + self._attr_suggested_display_precision = 0 + + @property + def device_info(self) -> DeviceInfo: + """Return device information.""" + return DeviceInfo( + identifiers={(DOMAIN, self._device._mac_addr)}, + name=self._device.name, + manufacturer="Gree", + ) + + @property + def native_value(self): + """Return the state of the sensor.""" + # Only return value if sensor is detected and available + if self._device._has_room_humidity_sensor: + return self._device.room_humidity + return None + + @property + def available(self): + """Return True if entity is available.""" + return self._device.available and self._device._has_room_humidity_sensor + + def update(self): + """Update the sensor.""" + # The climate entity handles the actual data fetching + pass diff --git a/custom_components/gree/old_switch.py b/custom_components/gree/old_switch.py new file mode 100755 index 0000000..dd4c143 --- /dev/null +++ b/custom_components/gree/old_switch.py @@ -0,0 +1,207 @@ +"""Support for Gree switches.""" + +from __future__ import annotations + +# Standard library imports +import logging +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +# Home Assistant imports +from homeassistant.components.climate import HVACMode +from homeassistant.components.switch import ( + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity + +# Local imports +from .entity import OldGreeEntity, OldGreeEntityDescription + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class GreeSwitchEntityDescription(OldGreeEntityDescription, SwitchEntityDescription): + """Describes Gree Switch entity.""" + + set_fn: Callable[[object, bool], None] = None + restore_state: bool = False + """Whether to restore the state of the switch on startup.""" + + +SWITCHES: tuple[GreeSwitchEntityDescription, ...] = ( + GreeSwitchEntityDescription( + property_key="xfan", + icon="mdi:fan", + value_fn=lambda device: device._acOptions.get("Blo") == 1, + set_fn=lambda device, value: device.SyncState({"Blo": 1 if value else 0}), + ), + GreeSwitchEntityDescription( + property_key="lights", + icon="mdi:lightbulb", + value_fn=lambda device: device._acOptions.get("Lig") == 1, + set_fn=lambda device, value: device.SyncState({"Lig": 1 if value else 0}), + ), + GreeSwitchEntityDescription( + property_key="health", + icon="mdi:shield-check", + value_fn=lambda device: device._acOptions.get("Health") == 1, + set_fn=lambda device, value: device.SyncState({"Health": 1 if value else 0}), + ), + GreeSwitchEntityDescription( + property_key="powersave", + icon="mdi:leaf", + value_fn=lambda device: device._acOptions.get("SvSt") == 1, + set_fn=lambda device, value: device.SyncState({"SvSt": 1 if value else 0}), + exists_fn=lambda description, device: HVACMode.COOL in device._hvac_modes, + available_fn=lambda device: device._hvac_mode == HVACMode.COOL, + ), + GreeSwitchEntityDescription( + property_key="eightdegheat", + icon="mdi:thermometer-low", + value_fn=lambda device: device._acOptions.get("StHt") == 1, + set_fn=lambda device, value: device.SyncState({"StHt": 1 if value else 0}), + exists_fn=lambda description, device: HVACMode.HEAT in device._hvac_modes, + available_fn=lambda device: device._hvac_mode == HVACMode.HEAT, + ), + GreeSwitchEntityDescription( + property_key="sleep", + icon="mdi:sleep", + value_fn=lambda device: device._acOptions.get("SwhSlp") == 1 + and device._acOptions.get("SlpMod") == 1, + set_fn=lambda device, value: device.SyncState( + {"SwhSlp": 1 if value else 0, "SlpMod": 1 if value else 0} + ), + available_fn=lambda device: device._hvac_mode in (HVACMode.COOL, HVACMode.HEAT), + ), + GreeSwitchEntityDescription( + property_key="air", + icon="mdi:air-filter", + value_fn=lambda device: device._acOptions.get("Air") == 1, + set_fn=lambda device, value: device.SyncState({"Air": 1 if value else 0}), + ), + GreeSwitchEntityDescription( + property_key="anti_direct_blow", + icon="mdi:weather-windy", + value_fn=lambda device: device._acOptions.get("AntiDirectBlow") == 1, + set_fn=lambda device, value: device.SyncState( + {"AntiDirectBlow": 1 if value else 0} + ), + available_fn=lambda device: getattr(device, "_has_anti_direct_blow", False), + ), + GreeSwitchEntityDescription( + property_key="light_sensor", + icon="mdi:lightbulb-on", + value_fn=lambda device: device._acOptions.get("LigSen") + == 0, # LigSen=0 means sensor is active + set_fn=lambda device, value: device.SyncState( + {"Lig": 1, "LigSen": 0} if value else {"LigSen": 1} + ), + available_fn=lambda device: getattr(device, "_has_light_sensor", False), + ), + # These entities are not kept in the climate device + GreeSwitchEntityDescription( + property_key="auto_xfan", + icon="mdi:fan-auto", + value_fn=lambda device: getattr(device, "_auto_xfan", False), + set_fn=lambda device, value: setattr(device, "_auto_xfan", value), + restore_state=True, + entity_category=EntityCategory.CONFIG, + ), + GreeSwitchEntityDescription( + property_key="auto_light", + icon="mdi:lightbulb-auto", + value_fn=lambda device: getattr(device, "_auto_light", False), + set_fn=lambda device, value: setattr(device, "_auto_light", value), + restore_state=True, + entity_category=EntityCategory.CONFIG, + ), + GreeSwitchEntityDescription( + property_key="beeper", + icon="mdi:volume-high", + value_fn=lambda device: getattr(device, "_beeper_enabled", True), + set_fn=lambda device, value: setattr(device, "_beeper_enabled", value), + restore_state=True, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Gree switch based on a config entry.""" + async_add_entities( + GreeSwitchEntity(hass, entry, description) for description in SWITCHES + ) + + +class GreeSwitchEntity(OldGreeEntity, SwitchEntity, RestoreEntity): + """Defines a Gree Switch entity.""" + + entity_description: GreeSwitchEntityDescription + + def __init__( + self, + hass, + entry, + description: GreeSwitchEntityDescription, + ) -> None: + super().__init__(hass, entry, description) + self._attr_is_on = bool(self.native_value) + self._restored = False + + async def async_added_to_hass(self): + await super().async_added_to_hass() + # Restore state if applicable + if self.entity_description.restore_state: + last_state = await self.async_get_last_state() + if last_state is not None: + value = last_state.state == "on" + setattr(self._device, f"_{self.entity_description.property_key}", value) + self._attr_is_on = value + self._restored = True + + @property + def native_value(self): + if self.entity_description.restore_state: + return getattr(self, "_attr_is_on", False) + return super().native_value + + @property + def is_on(self) -> bool: + return bool(self.native_value) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the switch.""" + if not self.available: + raise HomeAssistantError("Entity unavailable") + + if self.entity_description.set_fn: + await self.hass.async_add_executor_job( + self.entity_description.set_fn, self._device, True + ) + if self.entity_description.restore_state: + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the switch.""" + if not self.available: + raise HomeAssistantError("Entity unavailable") + + if self.entity_description.set_fn: + await self.hass.async_add_executor_job( + self.entity_description.set_fn, self._device, False + ) + if self.entity_description.restore_state: + self._attr_is_on = False + self.async_write_ha_state() diff --git a/custom_components/gree/select.py b/custom_components/gree/select.py index 4971ac9..ffd265a 100644 --- a/custom_components/gree/select.py +++ b/custom_components/gree/select.py @@ -1,139 +1,175 @@ """Support for Gree select entities (e.g., external temperature sensor selection).""" -from __future__ import annotations - -# Standard library imports -import logging from collections.abc import Callable -from dataclasses import dataclass - -# Home Assistant imports -from homeassistant.components.select import ( - SelectEntity, - SelectEntityDescription, -) -from homeassistant.config_entries import ConfigEntry +import logging +from typing import Generic, TypeVar + +from attr import dataclass + +from config.custom_components.gree.gree_device import GreeDevice +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import EntityCategory +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import UNDEFINED -# Local imports +from .const import GATTR_TEMP_UNITS +from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription +from .gree_api import TemperatureUnits _LOGGER = logging.getLogger(__name__) +T = TypeVar("T") # T can be any type -@dataclass -class GreeSelectEntityDescription(GreeEntityDescription, SelectEntityDescription): - """Describes Gree select entity.""" - - set_fn: Callable[[object, str], None] = None - restore_state: bool = False - options_fn: Callable[[object], list[str]] = None - - -def get_temperature_sensor_options(hass: HomeAssistant) -> list[str]: - """Get list of available temperature sensor entities.""" - options = ["None"] # Always include "None" as first option - - # Get all entities from the registry - for state in hass.states.async_all(): - # Look for temperature sensors - if state.entity_id.startswith("sensor."): - # Check for explicit device_class - if state.attributes.get("device_class") == "temperature": - options.append(state.entity_id) - # Also check for temperature units as fallback for helpers/combined sensors - elif state.attributes.get("unit_of_measurement") in ["°C", "°F", "K"]: - options.append(state.entity_id) - - return options +@dataclass(frozen=True, kw_only=True) +class GreeSelectDescription(Generic[T], GreeEntityDescription, SelectEntityDescription): + """Description of a Gree switch.""" -SELECTS: tuple[GreeSelectEntityDescription, ...] = ( - GreeSelectEntityDescription( - property_key="external_temperature_sensor", - icon="mdi:thermometer-lines", - options=[], # Will be populated dynamically - value_fn=lambda device: getattr(device, "_external_temperature_sensor", "None"), - set_fn=lambda device, value: setattr(device, "_external_temperature_sensor", None if value == "None" else value), - entity_category=EntityCategory.CONFIG, - restore_state=True, - options_fn=lambda hass: get_temperature_sensor_options(hass), - ), -) + 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 + options_func: Callable[[], list[str]] | None = None + value_func: Callable[[T], str] + set_func: Callable[[T, str], None] + updates_device: bool = True async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GreeConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up Gree select entities based on a config entry.""" - async_add_entities(GreeSelectEntity(hass, entry, description) for description in SELECTS) + """Set up switches from a config entry.""" + + coordinator = entry.runtime_data + descriptions: list[GreeSelectDescription] = [] + + descriptions.append( + GreeSelectDescription[GreeDevice]( + key=GATTR_TEMP_UNITS, + translation_key=GATTR_TEMP_UNITS, + entity_category=EntityCategory.CONFIG, + options=[member.name for member in TemperatureUnits], + available_func=lambda device: device.available, + value_func=lambda device: device.target_temperature_unit.name, + set_func=lambda device, value: device.set_target_temperature_unit( + TemperatureUnits[value] + ), + updates_device=True, + ) + ) + + _LOGGER.debug("Adding Select Entities: %s", [desc.key for desc in descriptions]) + + async_add_entities( + [GreeSelectEntity(description, coordinator) for description in descriptions] + ) + + +class GreeSelectEntity(GreeEntity, SelectEntity, RestoreEntity): # pyright: ignore[reportIncompatibleVariableOverride] + """A Gree select entity.""" + + entity_description: GreeSelectDescription + + def __init__( + self, + description: GreeSelectDescription, + coordinator: GreeCoordinator, + restore_state: bool = True, + ) -> None: + """Initialize select.""" + super().__init__(coordinator, restore_state) + + self.entity_description = description # pyright: ignore[reportIncompatibleVariableOverride] + self._attr_unique_id = f"{self._device.name}_{description.key}" - -class GreeSelectEntity(GreeEntity, SelectEntity, RestoreEntity): - """Defines a Gree select entity.""" - - entity_description: GreeSelectEntityDescription - - def __init__(self, hass: HomeAssistant, entry, description: GreeSelectEntityDescription) -> None: - super().__init__(hass, entry, description) - self._hass = hass - # Initialize with no external sensor configured - self._device._external_temperature_sensor = None # Set up options dynamically - if description.options_fn: - self._attr_options = description.options_fn(hass) + if description.options_func: + self._attr_options = description.options_func() else: self._attr_options = description.options or ["None"] - async def async_added_to_hass(self) -> None: - """Restore state when entity is added to hass.""" - await super().async_added_to_hass() - - # Refresh options when entity is added - if self.entity_description.options_fn: - self._attr_options = self.entity_description.options_fn(self._hass) + self._attr_current_option = self.entity_description.value_func(self._device) - # Restore the last selected state if available - if self.entity_description.restore_state: - restored = await self.async_get_last_state() - if restored and self.entity_description.set_fn: - self.entity_description.set_fn(self._device, restored.state) - _LOGGER.debug("Restored %s state: %s", self.entity_id, restored.state) + _LOGGER.debug("Initialized select %s", self._attr_unique_id) @property - def current_option(self) -> str: - """Return the current selected option.""" - if self.entity_description.value_fn: - value = self.entity_description.value_fn(self._device) - return value or "None" - return "None" + def current_option(self) -> str: # pyright: ignore[reportIncompatibleVariableOverride] + """Return the selected entity option to represent the entity state.""" + return self.entity_description.value_func(self._device) - async def async_select_option(self, option: str) -> None: - """Select an option.""" - if option not in self._attr_options: - _LOGGER.error("Option %s not available in %s", option, self._attr_options) - return - - if self.entity_description.set_fn: - self.entity_description.set_fn(self._device, option) - self.async_write_ha_state() - _LOGGER.info("Selected %s: %s", self.entity_description.property_key, option) - - async def async_update(self) -> None: - """Update the entity.""" - # Refresh available temperature sensors periodically - if self.entity_description.options_fn: - new_options = self.entity_description.options_fn(self._hass) - if new_options != self._attr_options: - self._attr_options = new_options - _LOGGER.debug("Updated temperature sensor options: %s", 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 available(self) -> bool: - """Return if entity is available.""" - return True + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + _LOGGER.debug( + "async_select_option(%s, %s, %s -> %s)", + self._device.unique_id, + self.entity_description.key, + self.current_option, + option, + ) + + try: + self.entity_description.set_func(self._device, option) + + if self.entity_description.updates_device: + await self._device.update_device_status() + + # notify coordinator listeners of state change so that dependent entities are updated immediately + self.coordinator.async_update_listeners() + + await self.coordinator.async_request_refresh() + except Exception as err: + _LOGGER.debug( + "Error in async_select_option(%s, %s, %s -> %s)", + self._device.unique_id, + self.entity_description.key, + self.current_option, + option, + ) + raise HomeAssistantError( + "Failed to select a different temperature unit." + ) from err + + self.async_write_ha_state() + + async def async_added_to_hass(self): + """Handle entity which will be added.""" + await super().async_added_to_hass() + # Restore last HA state to device if applicable + if self.restore_state: + last_state = await self.async_get_last_state() + if last_state is not None: + _LOGGER.debug( + "Restoring state for %s: %s", self.entity_id, last_state.state + ) + if last_state.state not in ("unknown", "unavailable"): + try: + self.entity_description.set_func(self._device, last_state.state) + + if self.entity_description.updates_device: + await self._device.update_device_status() + + self._attr_current_option = last_state.state + except Exception as err: # noqa: BLE001 + _LOGGER.error( + "Failed to restore state for %s: %s", + self.entity_id, + repr(err), + ) diff --git a/custom_components/gree/sensor.py b/custom_components/gree/sensor.py index b9b3d1a..db2f1bb 100644 --- a/custom_components/gree/sensor.py +++ b/custom_components/gree/sensor.py @@ -1,95 +1,153 @@ -"""Support for Gree sensors.""" +"""Gree Sensor Entity for Home Assistant.""" -from __future__ import annotations - -# Standard library imports -import logging +from collections.abc import Callable from dataclasses import dataclass +import logging -# Home Assistant imports from homeassistant.components.sensor import ( + SensorDeviceClass, SensorEntity, SensorEntityDescription, - SensorDeviceClass, SensorStateClass, ) -from homeassistant.const import ( - PERCENTAGE, -) - +from homeassistant.config_entries import ConfigEntry +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 -# Local imports -from .const import DOMAIN +from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription +from .gree_device import GreeDevice _LOGGER = logging.getLogger(__name__) +GATTR_INDOOR_TEMPERATURE = "indoor_temperature" +GATTR_OUTDOOR_TEMPERATURE = "outdoor_temperature" +GATTR_HUMIDITY = "humidity" + -@dataclass -class GreeSensorEntityDescription(GreeEntityDescription, SensorEntityDescription): - """Describes Gree Sensor entity.""" +@dataclass(frozen=True, kw_only=True) +class GreeSensorDescription(GreeEntityDescription, SensorEntityDescription): + """Description of a Gree temperature sensor.""" - pass + value_func: Callable[[GreeDevice], float | None] -SENSORS: tuple[GreeSensorEntityDescription, ...] = ( - GreeSensorEntityDescription( - property_key="outside_temperature", +SENSOR_TYPES: tuple[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_fn=lambda device: device.outside_temperature if device._has_outside_temp_sensor else None, - available_fn=lambda device: device.available and device._has_outside_temp_sensor, + value_func=lambda device: device.indoors_temperature_c, + available_func=lambda device: device.has_indoor_temperature_sensor, ), - GreeSensorEntityDescription( - property_key="room_humidity", + 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, + available_func=lambda device: device.has_outdoor_temperature_sensor, + ), + 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_fn=lambda device: device.room_humidity if device._has_room_humidity_sensor else None, - available_fn=lambda device: device.available and device._has_room_humidity_sensor, + value_func=lambda device: device.humidity, + available_func=lambda device: device.has_humidity_sensor, ), ) -async def async_setup_entry(hass, entry, async_add_entities): - """Set up Gree sensors from a config entry.""" - # Get the device that was created in __init__.py - entry_data = hass.data[DOMAIN][entry.entry_id] - device = entry_data["device"] +async def async_setup_entry( + hass: HomeAssistant, + entry: GreeConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensors from a config entry.""" + + coordinator = entry.runtime_data sensors = [] - for description in SENSORS: - if description.exists_fn(description, device): - sensors.append(GreeSensor(hass, entry, description)) - _LOGGER.debug(f"Added {description.property_key} sensor") + if coordinator.device.has_indoor_temperature_sensor: + sensors.append(GATTR_INDOOR_TEMPERATURE) + if coordinator.device.has_outdoor_temperature_sensor: + sensors.append(GATTR_OUTDOOR_TEMPERATURE) + if coordinator.device.has_humidity_sensor: + sensors.append(GATTR_HUMIDITY) - if sensors: - async_add_entities(sensors) - _LOGGER.info(f"Added {len(sensors)} Gree sensors") + _LOGGER.debug("Adding Sensor Entities: %s", sensors) + entities = [ + GreeSensor(description, coordinator, restore_state=True) + for description in SENSOR_TYPES + if description.key in sensors + ] -class GreeSensor(GreeEntity, SensorEntity): - """Gree sensor entity.""" + async_add_entities(entities) - entity_description: GreeSensorEntityDescription - def __init__(self, hass, entry, description: GreeSensorEntityDescription) -> None: - """Initialize Gree sensor.""" - super().__init__(hass, entry, description) +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + return True - # Set temperature unit for temperature sensors - if description.device_class == SensorDeviceClass.TEMPERATURE: - self._attr_native_unit_of_measurement = self._device.temperature_unit - @property - def native_value(self): - """Return the native value of the sensor.""" - return self.entity_description.value_fn(self._device) +class GreeSensor(GreeEntity, SensorEntity, RestoreEntity): # pyright: ignore[reportIncompatibleVariableOverride] + """A Gree Sensor.""" + + entity_description: GreeSensorDescription + + def __init__( + self, + description: GreeSensorDescription, + coordinator: GreeCoordinator, + restore_state: bool = True, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, restore_state) + + self.entity_description = description # pyright: ignore[reportIncompatibleVariableOverride] + self._attr_unique_id = f"{self._device.name}_{description.key}" + _LOGGER.debug("Initialized sensor %s", self._attr_unique_id) @property - def available(self) -> bool: + def available(self): # pyright: ignore[reportIncompatibleVariableOverride] """Return True if entity is available.""" - return self.entity_description.available_fn(self._device) + return self._device.available and self.entity_description.available_func( + self._device + ) + + @property + def native_value(self): # pyright: ignore[reportIncompatibleVariableOverride] + """Return the state of the sensor.""" + return self.entity_description.value_func(self._device) + + async def async_added_to_hass(self): + """Handle entity which will be added.""" + await super().async_added_to_hass() + # Restore last HA state to device if applicable + if self.restore_state: + last_state = await self.async_get_last_state() + if last_state is not None: + _LOGGER.debug( + "Restoring state for %s: %s", self.entity_id, last_state.state + ) + if last_state.state not in (None, "unknown", "unavailable"): + try: + self._attr_native_value = float(last_state.state) + except ValueError as err: + _LOGGER.error( + "Failed to restore state for %s: %s", + self.entity_id, + repr(err), + ) diff --git a/custom_components/gree/switch.py b/custom_components/gree/switch.py index e9ebc3b..248471e 100644 --- a/custom_components/gree/switch.py +++ b/custom_components/gree/switch.py @@ -1,246 +1,257 @@ -"""Support for Gree switches.""" +"""Gree Switch Entity for Home Assistant.""" -from __future__ import annotations - -# Standard library imports -import logging from collections.abc import Callable from dataclasses import dataclass +import logging from typing import Any -# Home Assistant imports -from homeassistant.components.climate import HVACMode from homeassistant.components.switch import ( + SwitchDeviceClass, SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -# Local imports +from .const import ( + CONF_FEATURES, + DEFAULT_SUPPORTED_FEATURES, + GATTR_ANTI_DIRECT_BLOW, + GATTR_BEEPER, + GATTR_FEAT_ENERGY_SAVING, + GATTR_FEAT_FRESH_AIR, + GATTR_FEAT_HEALTH, + GATTR_FEAT_LIGHT, + GATTR_FEAT_SENSOR_LIGHT, + GATTR_FEAT_SLEEP_MODE, + GATTR_FEAT_SMART_HEAT_8C, + GATTR_FEAT_XFAN, +) +from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription +from .gree_api import OperationMode +from .gree_device import GreeDevice _LOGGER = logging.getLogger(__name__) -@dataclass -class GreeSwitchEntityDescription(GreeEntityDescription, SwitchEntityDescription): - """Describes Gree Switch entity.""" - - set_fn: Callable[[object, bool], None] = None - restore_state: bool = False - """Whether to restore the state of the switch on startup.""" - - -async def _set_xfan(device, value: bool) -> None: - await device.SyncState({"Blo": 1 if value else 0}) - - -async def _set_lights(device, value: bool) -> None: - await device.SyncState({"Lig": 1 if value else 0}) - - -async def _set_health(device, value: bool) -> None: - await device.SyncState({"Health": 1 if value else 0}) - - -async def _set_powersave(device, value: bool) -> None: - await device.SyncState({"SvSt": 1 if value else 0}) - - -async def _set_eightdegheat(device, value: bool) -> None: - await device.SyncState({"StHt": 1 if value else 0}) - - -async def _set_sleep(device, value: bool) -> None: - await device.SyncState({"SwhSlp": 1 if value else 0, "SlpMod": 1 if value else 0}) - - -async def _set_air(device, value: bool) -> None: - await device.SyncState({"Air": 1 if value else 0}) - - -async def _set_anti_direct_blow(device, value: bool) -> None: - await device.SyncState({"AntiDirectBlow": 1 if value else 0}) - - -async def _set_light_sensor(device, value: bool) -> None: - if value: - await device.SyncState({"Lig": 1, "LigSen": 0}) - else: - await device.SyncState({"LigSen": 1}) - - -async def _set_auto_xfan(device, value: bool) -> None: - setattr(device, "_auto_xfan", value) - +@dataclass(frozen=True, kw_only=True) +class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): + """Description of a Gree switch.""" -async def _set_auto_light(device, value: bool) -> None: - setattr(device, "_auto_light", value) + set_func: Callable[[GreeDevice, bool], None] + device_class = SwitchDeviceClass.SWITCH + value_func: Callable[[GreeDevice], bool] + updates_device: bool = True -async def _set_beeper(device, value: bool) -> None: - setattr(device, "_beeper_enabled", value) - - -SWITCHES: tuple[GreeSwitchEntityDescription, ...] = ( - GreeSwitchEntityDescription( - property_key="xfan", - icon="mdi:fan", - value_fn=lambda device: device._acOptions.get("Blo") == 1, - set_fn=_set_xfan, - ), - GreeSwitchEntityDescription( - property_key="lights", - icon="mdi:lightbulb", - value_fn=lambda device: device._acOptions.get("Lig") == 1, - set_fn=_set_lights, - ), - GreeSwitchEntityDescription( - property_key="health", - icon="mdi:shield-check", - value_fn=lambda device: device._acOptions.get("Health") == 1, - set_fn=_set_health, +SWITCH_TYPES: tuple[GreeSwitchDescription, ...] = ( + GreeSwitchDescription( + key=GATTR_FEAT_FRESH_AIR, + translation_key=GATTR_FEAT_FRESH_AIR, + available_func=lambda device: device.available, + value_func=lambda device: device.feature_fresh_air, + set_func=lambda device, value: device.set_feature_fresh_air(value), ), - GreeSwitchEntityDescription( - property_key="powersave", - icon="mdi:leaf", - value_fn=lambda device: device._acOptions.get("SvSt") == 1, - set_fn=_set_powersave, - exists_fn=lambda description, device: HVACMode.COOL in device._hvac_modes, - available_fn=lambda device: device._hvac_mode == HVACMode.COOL, + GreeSwitchDescription( + key=GATTR_FEAT_XFAN, + translation_key=GATTR_FEAT_XFAN, + available_func=lambda device: device.available + and device.operation_mode in [OperationMode.Cool, OperationMode.Dry], + value_func=lambda device: device.feature_x_fan, + set_func=lambda device, value: device.set_feature_xfan(value), ), - GreeSwitchEntityDescription( - property_key="eightdegheat", - icon="mdi:thermometer-low", - value_fn=lambda device: device._acOptions.get("StHt") == 1, - set_fn=_set_eightdegheat, - exists_fn=lambda description, device: HVACMode.HEAT in device._hvac_modes, - available_fn=lambda device: device._hvac_mode == HVACMode.HEAT, + GreeSwitchDescription( + key=GATTR_FEAT_SLEEP_MODE, + translation_key=GATTR_FEAT_SLEEP_MODE, + available_func=lambda device: device.available + and device.operation_mode + in [OperationMode.Cool, OperationMode.Dry, OperationMode.Heat], + value_func=lambda device: device.feature_sleep, + set_func=lambda device, value: device.set_feature_sleep(value), ), - GreeSwitchEntityDescription( - property_key="sleep", - icon="mdi:sleep", - value_fn=lambda device: device._acOptions.get("SwhSlp") == 1 and device._acOptions.get("SlpMod") == 1, - set_fn=_set_sleep, - available_fn=lambda device: device._hvac_mode in (HVACMode.COOL, HVACMode.HEAT), + GreeSwitchDescription( + key=GATTR_FEAT_SMART_HEAT_8C, + translation_key=GATTR_FEAT_SMART_HEAT_8C, + available_func=lambda device: device.available, + value_func=lambda device: device.feature_smart_heat, + set_func=lambda device, value: device.set_feature_smart_heat(value), ), - GreeSwitchEntityDescription( - property_key="air", - icon="mdi:air-filter", - value_fn=lambda device: device._acOptions.get("Air") == 1, - set_fn=_set_air, + GreeSwitchDescription( + key=GATTR_FEAT_HEALTH, + translation_key=GATTR_FEAT_HEALTH, + available_func=lambda device: device.available, + value_func=lambda device: device.feature_health, + set_func=lambda device, value: device.set_feature_health(value), ), - GreeSwitchEntityDescription( - property_key="anti_direct_blow", - icon="mdi:weather-windy", - value_fn=lambda device: device._acOptions.get("AntiDirectBlow") == 1, - set_fn=_set_anti_direct_blow, - available_fn=lambda device: getattr(device, "_has_anti_direct_blow", False), + GreeSwitchDescription( + key=GATTR_ANTI_DIRECT_BLOW, + translation_key=GATTR_ANTI_DIRECT_BLOW, + available_func=lambda device: device.available, + value_func=lambda device: device.feature_anti_direct_blow, + set_func=lambda device, value: device.set_feature_anti_direct_blow(value), ), - GreeSwitchEntityDescription( - property_key="light_sensor", - icon="mdi:lightbulb-on", - value_fn=lambda device: device._acOptions.get("LigSen") == 0, # LigSen=0 means sensor is active - set_fn=_set_light_sensor, - available_fn=lambda device: getattr(device, "_has_light_sensor", False), + GreeSwitchDescription( + key=GATTR_FEAT_ENERGY_SAVING, + translation_key=GATTR_FEAT_ENERGY_SAVING, + available_func=lambda device: device.available, + value_func=lambda device: device.feature_energy_saving, + set_func=lambda device, value: device.set_feature_energy_saving(value), ), - # These entities are not kept in the climate device - GreeSwitchEntityDescription( - property_key="auto_xfan", - icon="mdi:fan-auto", - value_fn=lambda device: getattr(device, "_auto_xfan", False), - set_fn=_set_auto_xfan, - restore_state=True, + GreeSwitchDescription( + key=GATTR_FEAT_LIGHT, + translation_key=GATTR_FEAT_LIGHT, + available_func=lambda device: device.available, + value_func=lambda device: device.feature_light, + set_func=lambda device, value: device.set_feature_light(value), entity_category=EntityCategory.CONFIG, ), - GreeSwitchEntityDescription( - property_key="auto_light", - icon="mdi:lightbulb-auto", - value_fn=lambda device: getattr(device, "_auto_light", False), - set_fn=_set_auto_light, - restore_state=True, + GreeSwitchDescription( + key=GATTR_FEAT_SENSOR_LIGHT, + translation_key=GATTR_FEAT_SENSOR_LIGHT, + available_func=lambda device: device.available and device.feature_light, + value_func=lambda device: device.feature_light_sensor, + set_func=lambda device, value: device.set_feature_light_sensor(value), entity_category=EntityCategory.CONFIG, ), - GreeSwitchEntityDescription( - property_key="beeper", - icon="mdi:volume-high", - value_fn=lambda device: getattr(device, "_beeper_enabled", True), - set_fn=_set_beeper, - restore_state=True, + GreeSwitchDescription( + key=GATTR_BEEPER, + translation_key=GATTR_BEEPER, + available_func=lambda device: device.available, + value_func=lambda device: device.beeper, + set_func=lambda device, value: device.set_beeper(value), + entity_category=EntityCategory.CONFIG, + updates_device=False, # Local entity ), ) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GreeConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up Gree switch based on a config entry.""" - async_add_entities(GreeSwitchEntity(hass, entry, description) for description in SWITCHES) + """Set up switches from a config entry.""" + + coordinator = entry.runtime_data + supported_features: list[str] + if entry.data[CONF_FEATURES] is None: + _LOGGER.warning("Undefined supported features") + supported_features = DEFAULT_SUPPORTED_FEATURES + else: + supported_features = entry.data[CONF_FEATURES] -class GreeSwitchEntity(GreeEntity, SwitchEntity, RestoreEntity): + _LOGGER.debug("Adding Switch Entities: %s", supported_features) + + entities = [ + GreeSwitch(description, coordinator, restore_state=True) + for description in SWITCH_TYPES + if description.key in supported_features + ] + + async_add_entities(entities) + + +class GreeSwitch(GreeEntity, SwitchEntity, RestoreEntity): # pyright: ignore[reportIncompatibleVariableOverride] """Defines a Gree Switch entity.""" - entity_description: GreeSwitchEntityDescription + entity_description: GreeSwitchDescription def __init__( self, - hass, - entry, - description: GreeSwitchEntityDescription, + description: GreeSwitchDescription, + coordinator: GreeCoordinator, + restore_state: bool = True, ) -> None: - super().__init__(hass, entry, description) - self._attr_is_on = bool(self.native_value) - self._restored = False + """Initialize switch.""" + super().__init__(coordinator, restore_state) - async def async_added_to_hass(self): - await super().async_added_to_hass() - # Restore state if applicable - if self.entity_description.restore_state: - last_state = await self.async_get_last_state() - if last_state is not None: - value = last_state.state == "on" - await self.entity_description.set_fn(self._device, value) - self._attr_is_on = value - self._restored = True + self.entity_description = description # pyright: ignore[reportIncompatibleVariableOverride] + self._attr_unique_id = f"{self._device.name}_{description.key}" + _LOGGER.debug("Initialized sensor %s", self._attr_unique_id) @property - def native_value(self): - if self.entity_description.restore_state: - return getattr(self, "_attr_is_on", False) - return super().native_value + def available(self): # pyright: ignore[reportIncompatibleVariableOverride] + """Return True if entity is available.""" + return self.entity_description.available_func(self._device) @property - def is_on(self) -> bool: - return bool(self.native_value) + def is_on(self) -> bool | None: # pyright: ignore[reportIncompatibleVariableOverride] + """Return true if the switch is on.""" + return self.entity_description.value_func(self._device) + + async def async_added_to_hass(self): + """Handle entity which will be added.""" + await super().async_added_to_hass() + # Restore last HA state to device if applicable + if self.restore_state: + last_state = await self.async_get_last_state() + if last_state is not None: + _LOGGER.debug( + "Restoring state for %s: %s", self.entity_id, last_state.state + ) + if last_state.state in ("on", "off"): + value: bool = last_state.state == "on" + try: + self.entity_description.set_func(self._device, value) + + if self.entity_description.updates_device: + await self._device.update_device_status() + + self._attr_is_on = value + except Exception as err: # noqa: BLE001 + _LOGGER.error( + "Failed to restore state for %s: %s", + self.entity_id, + repr(err), + ) async def async_turn_on(self, **kwargs: Any) -> None: - """Turn on the switch.""" + """Turn the switch on.""" if not self.available: raise HomeAssistantError("Entity unavailable") - if self.entity_description.set_fn: - await self.entity_description.set_fn(self._device, True) + try: + self.entity_description.set_func(self._device, True) + + if self.entity_description.updates_device: + await self._device.update_device_status() + + # notify coordinator listeners of state change so that dependent entities are updated immediately + self.coordinator.async_update_listeners() + + if self.entity_description.key not in [ + GATTR_BEEPER + ]: # ignore HA-only dependent entities + await self.coordinator.async_request_refresh() + except Exception as err: + raise HomeAssistantError("Failed to turn on switch") from err - if self.entity_description.restore_state: - self._attr_is_on = True self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: - """Turn off the switch.""" + """Turn the switch on.""" if not self.available: raise HomeAssistantError("Entity unavailable") - if self.entity_description.set_fn: - await self.entity_description.set_fn(self._device, False) + try: + self.entity_description.set_func(self._device, False) + + if self.entity_description.updates_device: + await self._device.update_device_status() + + # notify coordinator listeners of state change so that dependent entities are updated immediately + self.coordinator.async_update_listeners() + + if self.entity_description.key not in [ + GATTR_BEEPER + ]: # ignore HA-only dependent entities + await self.coordinator.async_request_refresh() + except Exception as err: + raise HomeAssistantError("Failed to turn off switch") from err - if self.entity_description.restore_state: - self._attr_is_on = False self.async_write_ha_state() diff --git a/custom_components/gree/translations/de.json b/custom_components/gree/translations/de.json old mode 100644 new mode 100755 index 86b4b98..242bf0a --- a/custom_components/gree/translations/de.json +++ b/custom_components/gree/translations/de.json @@ -9,6 +9,7 @@ "host": "IP-Adresse", "port": "Port", "mac": "MAC-Adresse", + "timeout": "Zeitüberschreitung", "encryption_key": "Verschlüsselungsschlüssel", "uid": "UID", "encryption_version": "Verschlüsselungsversion" @@ -20,6 +21,7 @@ "host": "IP-Adresse", "port": "Port", "mac": "MAC-Adresse", + "timeout": "Zeitüberschreitung", "hvac_modes" : "HVAC-Modi", "fan_modes" : "Lüftermodi", "swing_modes" : "Vertikale Swing-Modi", @@ -28,6 +30,7 @@ "uid": "UID", "encryption_version": "Verschlüsselungsversion", "disable_available_check": "Verfügbarkeitsprüfung deaktivieren", + "max_online_attempts": "Maximale Online-Versuche", "temp_sensor_offset": "Temperatursensor-Offset" } }, @@ -41,6 +44,7 @@ "swing_modes" : "Vertikale Swing-Modi", "swing_horizontal_modes" : "Horizontale Swing-Modi", "disable_available_check": "Verfügbarkeitsprüfung deaktivieren", + "max_online_attempts": "Maximale Online-Versuche", "temp_sensor_offset": "Temperatursensor-Offset" } } diff --git a/custom_components/gree/translations/en.json b/custom_components/gree/translations/en.json old mode 100644 new mode 100755 index c980ec4..2c8d983 --- a/custom_components/gree/translations/en.json +++ b/custom_components/gree/translations/en.json @@ -38,10 +38,39 @@ "host": "IP Address", "port": "Port", "mac": "MAC Address", + "timeout": "Timeout", "encryption_key": "Encryption Key", "uid": "UID", "encryption_version": "Encryption Version" } + }, + "device_options": { + "title": "Configure the device features", + "description": "The Gree API doesn't have a reliable method of getting a the supported features of a device. Please use the options bellow to the best of your knowledge about your device.", + "data": { + "hvac_modes": "HVAC Modes", + "fan_modes": "Fan Speeds", + "swing_modes": "Vertical Swing Modes", + "swing_horizontal_modes": "Horizontal Swing Modes", + "features": "Device Features and Modes", + "disable_available_check": "Disable Available Check", + "max_online_attempts": "Max Online Attempts", + "temp_sensor_offset": "Temperature Sensor Offset" + } + }, + "reconfigure": { + "title": "Configure the device features", + "description": "The Gree API doesn't have a reliable method of getting a the supported features of a device. Please use the options bellow to the best of your knowledge about your device.", + "data": { + "hvac_modes": "HVAC Modes", + "fan_modes": "Fan Speeds", + "swing_modes": "Vertical Swing Modes", + "swing_horizontal_modes": "Horizontal Swing Modes", + "features": "Device Features and Modes", + "disable_available_check": "Disable Available Check", + "max_online_attempts": "Max Online Attempts", + "temp_sensor_offset": "Temperature Sensor Offset" + } } }, "data": { @@ -49,14 +78,16 @@ "host": "IP Address", "port": "Port", "mac": "MAC Address", - "hvac_modes" : "HVAC Modes", - "fan_modes" : "Fan Modes", - "swing_modes" : "Vertical Swing Modes", - "swing_horizontal_modes" : "Horizontal Swing Modes", + "timeout": "Timeout", + "hvac_modes": "HVAC Modes", + "fan_modes": "Fan Modes", + "swing_modes": "Vertical Swing Modes", + "swing_horizontal_modes": "Horizontal Swing Modes", "encryption_key": "Encryption Key", "uid": "UID", "encryption_version": "Encryption Version", "disable_available_check": "Disable Available Check", + "max_online_attempts": "Max Online Attempts", "temp_sensor_offset": "Temperature Sensor Offset" } }, @@ -65,11 +96,12 @@ "init": { "title": "Gree Climate Options", "data": { - "hvac_modes" : "HVAC Modes", - "fan_modes" : "Fan Modes", - "swing_modes" : "Vertical Swing Modes", - "swing_horizontal_modes" : "Horizontal Swing Modes", + "hvac_modes": "HVAC Modes", + "fan_modes": "Fan Modes", + "swing_modes": "Vertical Swing Modes", + "swing_horizontal_modes": "Horizontal Swing Modes", "disable_available_check": "Disable Available Check", + "max_online_attempts": "Max Online Attempts", "temp_sensor_offset": "Temperature Sensor Offset" } } @@ -94,85 +126,116 @@ }, "fan_modes": { "options": { - "auto": "Auto", - "low": "Low", - "medium_low": "Medium-Low", - "medium": "Medium", - "medium_high": "Medium-High", - "high": "High", - "turbo": "Turbo", - "quiet": "Quiet" + "Auto": "Auto", + "Low": "Low", + "MediumLow": "Medium-Low", + "Medium": "Medium", + "MediumHigh": "Medium-High", + "High": "High", + "feat_turbo": "Turbo", + "feat_quiet": "Quiet" } }, "swing_modes": { "options": { - "default": "Default", - "swing_full": "Swing in full range", - "fixed_upmost": "Fixed in the upmost position", - "fixed_middle_up": "Fixed in the middle-up position", - "fixed_middle": "Fixed in the middle position", - "fixed_middle_low": "Fixed in the middle-low position", - "fixed_lowest": "Fixed in the lowest position", - "swing_downmost": "Swing in the downmost region", - "swing_middle_low": "Swing in the middle-low region", - "swing_middle": "Swing in the middle region", - "swing_middle_up": "Swing in the middle-up region", - "swing_upmost": "Swing in the upmost region" + "Default": "Default", + "FullSwing": "Swing in full range", + "FixedUpper": "Fixed in the upmost position", + "FixedUpperMiddle": "Fixed in the middle-up position", + "FixedMiddle": "Fixed in the middle position", + "FixedLowerMiddle": "Fixed in the middle-low position", + "FixedLower": "Fixed in the lowest position", + "SwingLower": "Swing in the downmost region", + "SwingLowerMiddle": "Swing in the middle-low region", + "SwingMiddle": "Swing in the middle region", + "SwingUpperMiddle": "Swing in the middle-up region", + "SwingUpper": "Swing in the upmost region" } }, "swing_horizontal_modes": { "options": { - "default": "Default", - "swing_full": "Full swing", - "fixed_leftmost": "Fixed in the leftmost position", - "fixed_middle_left": "Fixed in the middle-left position", - "fixed_middle": "Fixed in the middle position", - "fixed_middle_right": "Fixed in the middle-right position", - "fixed_rightmost": "Fixed in the rightmost position" + "Default": "Default", + "FullSwing": "Swing in full range", + "Left": "Fixed in the leftmost position", + "LeftCenter": "Fixed in the middle-left position", + "Center": "Fixed in the middle position", + "RightCenter": "Fixed in the middle-right position", + "Right": "Fixed in the rightmost position" + } + }, + "features": { + "options": { + "beeper": "Beeper", + "feat_fresh_air": "Fresh Air", + "feat_xfan": "X-Fan", + "feat_sleep": "Sleep", + "feat_smart_heat": "8ºC Smart Heat", + "feat_lights": "Display Light", + "feat_health": "Health", + "feat_anti_direct_blow": "Anti Direct Blow", + "feat_energy_saving": "Energy Saving", + "feat_light_sensor": "Display Auto Brightness" } } }, "entity": { + "sensor": { + "indoor_temperature": { + "name": "Indoor Temperature", + "description": "The temperature reported by the HVAC indoors unit" + }, + "outdoor_temperature": { + "name": "Outdoor Temperature", + "description": "The temperature reported by the HVAC outdoors unit" + }, + "humidity": { + "name": "Indoor Humidity", + "description": "The humidity reported by the HVAC indoors unit" + } + }, "climate": { - "gree": { + "hvac": { "state_attributes": { + "default": "Fan Speed", "fan_mode": { "state": { - "auto": "Auto", - "low": "Low", - "medium_low": "Medium-Low", - "medium": "Medium", - "medium_high": "Medium-High", - "high": "High", - "turbo": "Turbo", - "quiet": "Quiet" + "Auto": "Auto", + "Low": "Low", + "MediumLow": "Medium-Low", + "Medium": "Medium", + "MediumHigh": "Medium-High", + "High": "High", + "feat_turbo": "Turbo", + "feat_quiet": "Quiet" } }, "swing_mode": { + "default": "Vertical Swing", "state": { - "default": "Default", - "swing_full": "Swing in full range", - "fixed_upmost": "Fixed in the upmost position", - "fixed_middle_up": "Fixed in the middle-up position", - "fixed_middle": "Fixed in the middle position", - "fixed_middle_low": "Fixed in the middle-low position", - "fixed_lowest": "Fixed in the lowest position", - "swing_downmost": "Swing in the downmost region", - "swing_middle_low": "Swing in the middle-low region", - "swing_middle": "Swing in the middle region", - "swing_middle_up": "Swing in the middle-up region", - "swing_upmost": "Swing in the upmost region" + "Default": "Default", + "FullSwing": "Swing in full range", + "FixedUpper": "Fixed in the upmost position", + "FixedUpperMiddle": "Fixed in the middle-up position", + "FixedMiddle": "Fixed in the middle position", + "FixedLowerMiddle": "Fixed in the middle-low position", + "FixedLower": "Fixed in the lowest position", + "SwingLower": "Swing in the downmost region", + "SwingLowerMiddle": "Swing in the middle-low region", + "SwingMiddle": "Swing in the middle region", + "SwingUpperMiddle": "Swing in the middle-up region", + "SwingUpper": "Swing in the upmost region" } }, "swing_horizontal_mode": { + "default": "Horizontal Swing", "state": { - "default": "Default", - "swing_full": "Swing in full range", - "fixed_leftmost": "Fixed in the leftmost position", - "fixed_middle_left": "Fixed in the middle-left position", - "fixed_middle": "Fixed in the middle position", - "fixed_middle_right": "Fixed in the middle-right position", - "fixed_rightmost": "Fixed in the rightmost position" + "Default": "Default", + "FullSwing": "Swing in full range", + "Left": "Fixed in the leftmost position", + "LeftCenter": "Fixed in the middle-left position", + "Center": "Fixed in the middle position", + "RightCenter": "Fixed in the middle-right position", + "Right": "Fixed in the rightmost position" } } } @@ -188,6 +251,10 @@ "external_temperature_sensor": { "name": "External Temperature Sensor", "description": "Select a temperature sensor entity to use instead of the built-in AC sensor. Choose 'None' to use the built-in sensor." + }, + "temperature_units": { + "name": "Temperature Units", + "description": "Select the temperature units used by the device." } }, "sensor": { @@ -201,48 +268,48 @@ } }, "switch": { - "xfan": { - "name": "X-Fan", - "description": "Enables or disables the X-Fan mode for extra drying when turning off." + "auto_light": { + "name": "Auto Display Light", + "description": "Automatically controls the display lights based on HVAC operations. When enabled, lights will turn on/off with the AC unit." + }, + "auto_xfan": { + "name": "Auto X-Fan", + "description": "Automatically controls the X-Fan mode based on HVAC operations. When enabled, X-Fan will automatically turn on in cooling and dry modes." }, - "lights": { - "name": "Lights", + "feat_lights": { + "name": "Display Light", "description": "Controls the display lights on the air conditioner unit." }, - "health": { + "feat_xfan": { + "name": "X-Fan", + "description": "Enables or disables the X-Fan mode for extra drying when turning off." + }, + "feat_health": { "name": "Health", "description": "Enables or disables the Health mode for air ionization and purification." }, - "powersave": { + "feat_energy_saving": { "name": "Power Save", "description": "Enables or disables the power saving mode for energy efficiency. Only available in cooling mode." }, - "eightdegheat": { - "name": "8°C Heat", + "feat_smart_heat": { + "name": "Smart Heat 8ºC", "description": "Enables or disables the 8°C heating mode for frost protection. Only available in heating mode." }, - "sleep": { + "feat_sleep": { "name": "Sleep", "description": "Enables or disables the sleep mode for comfortable overnight operation. Only available in cooling or heating mode." }, - "air": { - "name": "Air", + "feat_fresh_air": { + "name": "Fresh Air", "description": "Enables or disables the fresh air circulation mode." }, - "auto_xfan": { - "name": "Auto X-Fan", - "description": "Automatically controls the X-Fan mode based on HVAC operations. When enabled, X-Fan will automatically turn on in cooling and dry modes." - }, - "auto_light": { - "name": "Auto Light", - "description": "Automatically controls the display lights based on HVAC operations. When enabled, lights will turn on/off with the AC unit." - }, - "anti_direct_blow": { + "feat_anti_direct_blow": { "name": "Anti Direct Blow", "description": "Prevents direct air flow from blowing on people by adjusting the air deflector position." }, - "light_sensor": { - "name": "Light Sensor", + "feat_light_sensor": { + "name": "Display Auto Brightness", "description": "Enables or disables light sensor for automatic brightness. Requires lights to be enabled." }, "beeper": { @@ -250,5 +317,28 @@ "description": "Controls the beeper sounds from the air conditioner unit. When enabled, the unit will make sounds for button presses and status changes." } } + }, + "exceptions": { + "change_while_device_off": { + "message": "You cannot change this while the device is off." + }, + "turbo_availability": { + "message": "Trubo mode is not available in Dry and Fan-only modes." + }, + "quiet_availability": { + "message": "Quiet mode is only available in Dry and Cool modes." + }, + "entity_unavailable": { + "message": "The entity is unavailable." + }, + "generic": { + "message": "There was a problem performing the requested change, please consult the integration log." + } + }, + "state": { + "temperature_units": { + "C": "Celsius", + "F": "Fahrenheit" + } } } diff --git a/custom_components/gree/translations/he.json b/custom_components/gree/translations/he.json old mode 100644 new mode 100755 index 97e0483..841efa0 --- a/custom_components/gree/translations/he.json +++ b/custom_components/gree/translations/he.json @@ -9,6 +9,7 @@ "host": "כתובת IP", "port": "פורט", "mac": "כתובת MAC", + "timeout": "זמן קצוב להתחברות", "encryption_key": "מפתח הצפנה", "uid": "UID", "encryption_version": "גרסת הצפנה" @@ -20,6 +21,7 @@ "host": "כתובת IP", "port": "פורט", "mac": "כתובת MAC", + "timeout": "פסק זמן", "hvac_modes" : "מצבי HVAC", "fan_modes" : "מצבי מאוורר", "swing_modes" : "מצבי נדנוד אנכי", @@ -28,6 +30,7 @@ "uid": "UID", "encryption_version": "גרסת הצפנה", "disable_available_check": "השבת בדיקת זמינות", + "max_online_attempts": "ניסיונות מקסימליים להתחברות", "temp_sensor_offset": "היסט חיישן טמפרטורה" } }, @@ -41,6 +44,7 @@ "swing_modes" : "מצבי נדנוד אנכי", "swing_horizontal_modes" : "מצבי נדנוד אופקי", "disable_available_check": "השבת בדיקת זמינות", + "max_online_attempts": "ניסיונות מקסימליים להתחברות", "temp_sensor_offset": "היסט חיישן טמפרטורה" } } diff --git a/custom_components/gree/translations/hu.json b/custom_components/gree/translations/hu.json old mode 100644 new mode 100755 index cee62af..e1ce659 --- a/custom_components/gree/translations/hu.json +++ b/custom_components/gree/translations/hu.json @@ -9,6 +9,7 @@ "host": "IP-cím", "port": "Port", "mac": "MAC-cím", + "timeout": "Időtúllépés", "encryption_key": "Titkosítási kulcs", "uid": "UID", "encryption_version": "Titkosítási verzió" @@ -20,6 +21,7 @@ "host": "IP-cím", "port": "Port", "mac": "MAC-cím", + "timeout": "Időtúllépés", "hvac_modes" : "HVAC módok", "fan_modes" : "Ventilátor módok", "swing_modes" : "Függőleges lengési módok", @@ -28,6 +30,7 @@ "uid": "UID", "encryption_version": "Titkosítási verzió", "disable_available_check": "Elérhetőség ellenőrzésének letiltása", + "max_online_attempts": "Maximális online próbálkozások", "temp_sensor_offset": "Hőmérséklet érzékelő eltolás" } }, @@ -41,6 +44,7 @@ "swing_modes" : "Függőleges lengési módok", "swing_horizontal_modes" : "Vízszintes lengési módok", "disable_available_check": "Elérhetőség ellenőrzésének letiltása", + "max_online_attempts": "Maximális online próbálkozások", "temp_sensor_offset": "Hőmérséklet érzékelő eltolás" } } diff --git a/custom_components/gree/translations/it.json b/custom_components/gree/translations/it.json old mode 100644 new mode 100755 index 3f1cabf..d0ee2da --- a/custom_components/gree/translations/it.json +++ b/custom_components/gree/translations/it.json @@ -9,6 +9,7 @@ "host": "Indirizzo IP", "port": "Porta", "mac": "Indirizzo MAC", + "timeout": "Timeout", "encryption_key": "Chiave di crittografia", "uid": "UID", "encryption_version": "Versione crittografia" @@ -20,6 +21,7 @@ "host": "Indirizzo IP", "port": "Porta", "mac": "Indirizzo MAC", + "timeout": "Timeout", "hvac_modes" : "Modalità HVAC", "fan_modes" : "Modalità ventola", "swing_modes" : "Modalità oscillazione verticale", @@ -28,6 +30,7 @@ "uid": "UID", "encryption_version": "Versione crittografia", "disable_available_check": "Disabilita controllo disponibilità", + "max_online_attempts": "Tentativi massimi online", "temp_sensor_offset": "Offset sensore temperatura" } }, @@ -41,6 +44,7 @@ "swing_modes" : "Modalità oscillazione verticale", "swing_horizontal_modes" : "Modalità oscillazione orizzontale", "disable_available_check": "Disabilita controllo disponibilità", + "max_online_attempts": "Tentativi massimi online", "temp_sensor_offset": "Offset sensore temperatura" } } diff --git a/custom_components/gree/translations/pl.json b/custom_components/gree/translations/pl.json old mode 100644 new mode 100755 index d8f94dd..0590457 --- a/custom_components/gree/translations/pl.json +++ b/custom_components/gree/translations/pl.json @@ -9,6 +9,7 @@ "host": "Adres IP", "port": "Port", "mac": "Adres MAC", + "timeout": "Limit czasu odpowiedzi", "encryption_key": "Klucz szyfrowania", "uid": "UID", "encryption_version": "Wersja szyfrowania" @@ -20,6 +21,7 @@ "host": "Adres IP", "port": "Port", "mac": "Adres MAC", + "timeout": "Limit czasu odpowiedzi", "hvac_modes" : "Tryby pracy", "fan_modes" : "Tryby wentylatora", "swing_modes" : "Tryby pionowego ruchu", @@ -28,6 +30,7 @@ "uid": "UID", "encryption_version": "Wersja szyfrowania", "disable_available_check": "Wyłącz sprawdzanie dostępności", + "max_online_attempts": "Maksymalna liczba prób połączenia", "temp_sensor_offset": "Offset czujnika temperatury" } }, @@ -41,6 +44,7 @@ "swing_modes" : "Tryby pionowego ruchu", "swing_horizontal_modes" : "Tryby poziomego ruchu", "disable_available_check": "Wyłącz sprawdzanie dostępności", + "max_online_attempts": "Maksymalna liczba prób połączenia", "temp_sensor_offset": "Offset czujnika temperatury" } } diff --git a/custom_components/gree/translations/pt-BR.json b/custom_components/gree/translations/pt-BR.json old mode 100644 new mode 100755 index 00f83f2..48decb3 --- a/custom_components/gree/translations/pt-BR.json +++ b/custom_components/gree/translations/pt-BR.json @@ -9,6 +9,7 @@ "host": "Endereço IP", "port": "Porta", "mac": "Endereço MAC", + "timeout": "Tempo de Espera (Timeout)", "encryption_key": "Chave de Criptografia", "uid": "UID", "encryption_version": "Versão da Criptografia" @@ -20,6 +21,7 @@ "host": "Endereço IP", "port": "Porta", "mac": "Endereço MAC", + "timeout": "Tempo de Espera (Timeout)", "hvac_modes" : "Climatização", "fan_modes" : "Ventilação", "swing_modes" : "Oscilação Vertical", @@ -28,6 +30,7 @@ "uid": "UID", "encryption_version": "Versão da Criptografia", "disable_available_check": "Desativar Verificação de Disponibilidade", + "max_online_attempts": "Máximo de Tentativas de Conexão", "temp_sensor_offset": "Ajuste do Sensor de Temperatura" } }, @@ -41,6 +44,7 @@ "swing_modes" : "Oscilação Vertical", "swing_horizontal_modes" : "Oscilação Horizontal", "disable_available_check": "Desativar Verificação de Disponibilidade", + "max_online_attempts": "Máximo de Tentativas de Conexão", "temp_sensor_offset": "Ajuste do Sensor de Temperatura" } } diff --git a/custom_components/gree/translations/ro.json b/custom_components/gree/translations/ro.json old mode 100644 new mode 100755 index b26a209..c096732 --- a/custom_components/gree/translations/ro.json +++ b/custom_components/gree/translations/ro.json @@ -9,6 +9,7 @@ "host": "Adresă IP", "port": "Port", "mac": "Adresă MAC", + "timeout": "Timp de așteptare", "encryption_key": "Cheie de criptare", "uid": "UID", "encryption_version": "Versiune criptare" @@ -20,6 +21,7 @@ "host": "Adresă IP", "port": "Port", "mac": "Adresă MAC", + "timeout": "Timp de așteptare", "hvac_modes" : "Moduri HVAC", "fan_modes" : "Moduri ventilator", "swing_modes" : "Moduri balansare verticală", @@ -28,6 +30,7 @@ "uid": "UID", "encryption_version": "Versiune criptare", "disable_available_check": "Dezactivează verificarea disponibilității", + "max_online_attempts": "Număr maxim de încercări online", "temp_sensor_offset": "Offset senzor temperatură" } }, @@ -41,6 +44,7 @@ "swing_modes" : "Moduri balansare verticală", "swing_horizontal_modes" : "Moduri balansare orizontală", "disable_available_check": "Dezactivează verificarea disponibilității", + "max_online_attempts": "Număr maxim de încercări online", "temp_sensor_offset": "Offset senzor temperatură" } } diff --git a/custom_components/gree/translations/ru.json b/custom_components/gree/translations/ru.json old mode 100644 new mode 100755 index a162792..cc0aa51 --- a/custom_components/gree/translations/ru.json +++ b/custom_components/gree/translations/ru.json @@ -9,6 +9,7 @@ "host": "IP-адрес", "port": "Порт", "mac": "MAC-адрес", + "timeout": "Тайм-аут", "encryption_key": "Ключ шифрования", "uid": "UID", "encryption_version": "Версия шифрования" @@ -20,6 +21,7 @@ "host": "IP-адрес", "port": "Порт", "mac": "MAC-адрес", + "timeout": "Тайм-аут", "hvac_modes" : "Режимы HVAC", "fan_modes" : "Режимы вентилятора", "swing_modes" : "Режимы вертикального качания", @@ -28,6 +30,7 @@ "uid": "UID", "encryption_version": "Версия шифрования", "disable_available_check": "Отключить проверку доступности", + "max_online_attempts": "Максимум попыток онлайн", "temp_sensor_offset": "Смещение датчика температуры" } }, @@ -41,6 +44,7 @@ "swing_modes" : "Режимы вертикального качания", "swing_horizontal_modes" : "Режимы горизонтального качания", "disable_available_check": "Отключить проверку доступности", + "max_online_attempts": "Максимум попыток онлайн", "temp_sensor_offset": "Смещение датчика температуры" } } diff --git a/custom_components/gree/translations/zh-Hans.json b/custom_components/gree/translations/zh-Hans.json old mode 100644 new mode 100755 index 00ba01f..ec50026 --- a/custom_components/gree/translations/zh-Hans.json +++ b/custom_components/gree/translations/zh-Hans.json @@ -9,6 +9,7 @@ "host": "IP 地址", "port": "端口", "mac": "MAC 地址", + "timeout": "超时", "encryption_key": "加密密钥", "uid": "UID", "encryption_version": "加密版本" @@ -20,6 +21,7 @@ "host": "IP 地址", "port": "端口", "mac": "MAC 地址", + "timeout": "超时", "hvac_modes": "空调模式", "fan_modes": "风速模式", "swing_modes": "垂直扫风模式", @@ -28,6 +30,7 @@ "uid": "UID", "encryption_version": "加密版本", "disable_available_check": "禁用可用性检查", + "max_online_attempts": "最大在线尝试次数", "temp_sensor_offset": "温度传感器偏移" } }, @@ -41,6 +44,7 @@ "swing_modes": "垂直扫风模式", "swing_horizontal_modes": "水平扫风模式", "disable_available_check": "禁用可用性检查", + "max_online_attempts": "最大在线尝试次数", "temp_sensor_offset": "温度传感器偏移" } } From 187ef3cdbb11b2cfcc970c980a100916779c71b2 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Wed, 17 Sep 2025 00:20:29 +0100 Subject: [PATCH 002/113] Add support for external temperature and humidity sensors --- custom_components/gree/climate.py | 94 ++++++++++++++-- custom_components/gree/config_flow.py | 118 ++++++++++++-------- custom_components/gree/const.py | 3 + custom_components/gree/select.py | 17 +-- custom_components/gree/translations/en.json | 8 +- 5 files changed, 172 insertions(+), 68 deletions(-) diff --git a/custom_components/gree/climate.py b/custom_components/gree/climate.py index ca401be..60abf6d 100644 --- a/custom_components/gree/climate.py +++ b/custom_components/gree/climate.py @@ -10,8 +10,14 @@ ClimateEntityFeature, HVACMode, ) -from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature -from homeassistant.core import HomeAssistant +from homeassistant.const import ( + ATTR_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -19,6 +25,8 @@ from homeassistant.util.unit_conversion import TemperatureConverter from .const import ( + ATTR_EXTERNAL_HUMIDITY_SENSOR, + ATTR_EXTERNAL_TEMPERATURE_SENSOR, CONF_FAN_MODES, CONF_HVAC_MODES, CONF_SWING_HORIZONTAL_MODES, @@ -119,6 +127,12 @@ async def async_setup_entry( fan_modes, swing_modes, swing_horizontal_modes, + external_temperature_sensor_id=entry.data.get( + ATTR_EXTERNAL_TEMPERATURE_SENSOR + ), + external_humidity_sensor_id=entry.data.get( + ATTR_EXTERNAL_HUMIDITY_SENSOR + ), ) ] ) @@ -136,14 +150,18 @@ def __init__( swing_modes: list[str], swing_horizontal_modes: list[str], restore_state: bool = True, + external_temperature_sensor_id: str | None = None, + external_humidity_sensor_id: str | None = None, ) -> None: """Initialize the Gree Climate entity.""" super().__init__(coordinator, restore_state) self.entity_description = description - self._attr_unique_id = f"{self._device.name}_{description.key}" self._attr_name = None # Main entity + self._external_temperature_sensor = external_temperature_sensor_id + self._external_humidity_sensor = external_humidity_sensor_id + self._attr_precision = 1 self._attr_target_temperature_step = 1 @@ -206,7 +224,7 @@ def update_attributes(self): self._attr_temperature_unit = self.get_temp_units() self._attr_target_temperature = self.get_current_target_temp() self._attr_current_temperature = self.get_current_temp() - self._attr_current_humidity = self.get_current_himidty() + self._attr_current_humidity = self.get_current_humidty() if self._attr_supported_features & ClimateEntityFeature.TARGET_TEMPERATURE: self._attr_max_temp = ( @@ -221,6 +239,9 @@ def update_attributes(self): else MIN_TEMP_F ) + if self.hass: + self.async_write_ha_state() + async def async_turn_on(self): """Turn on.""" _LOGGER.debug("turn_on(%s)", self._device.unique_id) @@ -463,7 +484,34 @@ def get_temp_units(self) -> UnitOfTemperature: def get_current_temp(self) -> float | None: """Returns the current temperature of the room. Accounting for units.""" - # TODO: Add external sensor support + # Use external temperature sensor if available + if self._external_temperature_sensor and self.hass: + external_state = self.hass.states.get(self._external_temperature_sensor) + + if external_state and external_state.state not in ( + STATE_UNKNOWN, + STATE_UNAVAILABLE, + ): + try: + unit: str = external_state.attributes.get( + ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature.CELSIUS + ) + value = float(external_state.state) + + except (ValueError, TypeError) as ex: + _LOGGER.error( + "Unable to update from external temp sensor %s: %s", + self._external_temperature_sensor, + ex, + ) + else: + _LOGGER.debug( + "Using external temperature sensor: %s, value: %s, unit: %s", + self._external_temperature_sensor, + value, + unit, + ) + return self.hass.config.units.temperature(value, unit) # Gree API always return current temperature in ºC # so if we are dealing with ºF we convert to that first @@ -478,16 +526,40 @@ def get_current_temp(self) -> float | None: UnitOfTemperature.FAHRENHEIT, ) return float(self._device.indoors_temperature_c) - # FIXME: When changing Units in HA Settings, the temp does not update + + # FIXME: When changing Units in HA Settings, the temp does not update + return None - def get_current_himidty(self) -> float | None: + def get_current_humidty(self) -> float | None: """Returns the current humidity of the room.""" - # TODO: Add external sensor support - - # Gree API always return current temperature in ºC - # so if we are dealing with ºF we convert to that first + # Use external humidity sensor if available + if self._external_humidity_sensor and self.hass: + external_state = self.hass.states.get(self._external_humidity_sensor) + + if external_state and external_state.state not in ( + STATE_UNKNOWN, + STATE_UNAVAILABLE, + ): + try: + value = float(external_state.state) + + except (ValueError, TypeError) as ex: + _LOGGER.error( + "Unable to update from humidity temp sensor %s: %s", + self._external_humidity_sensor, + ex, + ) + else: + _LOGGER.debug( + "Using external humidity sensor: %s, value: %s", + self._external_humidity_sensor, + value, + ) + return value + + # Gree API always return current humidity in % if self._device.has_humidity_sensor and self._device.humidity is not None: return float(self._device.humidity) diff --git a/custom_components/gree/config_flow.py b/custom_components/gree/config_flow.py index 51eb83c..e4e59ae 100644 --- a/custom_components/gree/config_flow.py +++ b/custom_components/gree/config_flow.py @@ -7,16 +7,26 @@ from typing import Any import voluptuous as vol +from voluptuous.schema_builder import UNDEFINED from homeassistant import config_entries +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import section from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac -from homeassistant.helpers.selector import selector +from homeassistant.helpers.selector import ( + EntitySelector, + EntitySelectorConfig, + SelectSelector, + SelectSelectorConfig, +) from .const import ( + ATTR_EXTERNAL_HUMIDITY_SENSOR, + ATTR_EXTERNAL_TEMPERATURE_SENSOR, CONF_ADVANCED, CONF_DISABLE_AVAILABLE_CHECK, CONF_ENCRYPTION_KEY, @@ -44,8 +54,6 @@ _LOGGER = logging.getLogger(__name__) -# C0:39:37:B1:22:80 - def build_main_schema(data: Mapping | None) -> vol.Schema | None: """Builds the main option schema.""" @@ -99,7 +107,9 @@ def build_main_schema(data: Mapping | None) -> vol.Schema | None: ) -def build_device_schema(data: Mapping | None) -> vol.Schema | None: +def build_options_schema( + hass: HomeAssistant, data: Mapping | None +) -> vol.Schema | None: """Builds the device option schema.""" return vol.Schema( @@ -109,42 +119,36 @@ def build_device_schema(data: Mapping | None) -> vol.Schema | None: default=DEFAULT_HVAC_MODES if data is None else data.get(CONF_HVAC_MODES, DEFAULT_HVAC_MODES), - ): selector( - { - "select": { - "options": DEFAULT_HVAC_MODES, - "multiple": True, - "translation_key": CONF_HVAC_MODES, - } - } + ): SelectSelector( + config=SelectSelectorConfig( + options=DEFAULT_HVAC_MODES, + multiple=True, + translation_key=CONF_HVAC_MODES, + ) ), vol.Optional( CONF_FAN_MODES, default=DEFAULT_FAN_MODES if data is None else data.get(CONF_FAN_MODES, DEFAULT_FAN_MODES), - ): selector( - { - "select": { - "options": DEFAULT_FAN_MODES, - "multiple": True, - "translation_key": CONF_FAN_MODES, - } - } + ): SelectSelector( + config=SelectSelectorConfig( + options=DEFAULT_FAN_MODES, + multiple=True, + translation_key=CONF_FAN_MODES, + ) ), vol.Optional( CONF_SWING_MODES, default=DEFAULT_SWING_MODES if data is None else data.get(CONF_SWING_MODES, DEFAULT_SWING_MODES), - ): selector( - { - "select": { - "options": DEFAULT_SWING_MODES, - "multiple": True, - "translation_key": CONF_SWING_MODES, - } - } + ): SelectSelector( + config=SelectSelectorConfig( + options=DEFAULT_SWING_MODES, + multiple=True, + translation_key=CONF_SWING_MODES, + ) ), vol.Optional( CONF_SWING_HORIZONTAL_MODES, @@ -153,28 +157,24 @@ def build_device_schema(data: Mapping | None) -> vol.Schema | None: else data.get( CONF_SWING_HORIZONTAL_MODES, DEFAULT_SWING_HORIZONTAL_MODES ), - ): selector( - { - "select": { - "options": DEFAULT_SWING_HORIZONTAL_MODES, - "multiple": True, - "translation_key": CONF_SWING_HORIZONTAL_MODES, - } - } + ): SelectSelector( + config=SelectSelectorConfig( + options=DEFAULT_SWING_HORIZONTAL_MODES, + multiple=True, + translation_key=CONF_SWING_HORIZONTAL_MODES, + ) ), vol.Optional( CONF_FEATURES, default=DEFAULT_SUPPORTED_FEATURES if data is None else data.get(CONF_FEATURES, DEFAULT_SUPPORTED_FEATURES), - ): selector( - { - "select": { - "options": DEFAULT_SUPPORTED_FEATURES, - "multiple": True, - "translation_key": CONF_FEATURES, - } - } + ): SelectSelector( + config=SelectSelectorConfig( + options=DEFAULT_SUPPORTED_FEATURES, + multiple=True, + translation_key=CONF_FEATURES, + ) ), vol.Optional( CONF_MAX_ONLINE_ATTEMPTS, @@ -192,6 +192,30 @@ def build_device_schema(data: Mapping | None) -> vol.Schema | None: CONF_TEMP_SENSOR_OFFSET, default=False if data is None else data.get(CONF_TEMP_SENSOR_OFFSET, 0), ): cv.boolean, + vol.Optional( + ATTR_EXTERNAL_TEMPERATURE_SENSOR, + default=UNDEFINED + if data is None + else data.get(ATTR_EXTERNAL_TEMPERATURE_SENSOR, UNDEFINED), + ): EntitySelector( + config=EntitySelectorConfig( + multiple=False, + domain="sensor", + device_class=SensorDeviceClass.TEMPERATURE, + ) + ), + vol.Optional( + ATTR_EXTERNAL_HUMIDITY_SENSOR, + default=UNDEFINED + if data is None + else data.get(ATTR_EXTERNAL_HUMIDITY_SENSOR, UNDEFINED), + ): EntitySelector( + config=EntitySelectorConfig( + multiple=False, + domain="sensor", + device_class=SensorDeviceClass.HUMIDITY, + ) + ), } ) @@ -267,7 +291,7 @@ async def async_step_device_options( return self.async_show_form( step_id="device_options", - data_schema=build_device_schema(user_input), + data_schema=build_options_schema(self.hass, user_input), ) async def async_step_import( @@ -292,8 +316,8 @@ async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None) return self.async_show_form( step_id="reconfigure", - data_schema=build_device_schema( - entry.data if entry.data is not None else user_input + data_schema=build_options_schema( + self.hass, entry.data if entry.data is not None else user_input ), ) diff --git a/custom_components/gree/const.py b/custom_components/gree/const.py index 4c5cfa0..7e6c59c 100755 --- a/custom_components/gree/const.py +++ b/custom_components/gree/const.py @@ -69,6 +69,9 @@ GATTR_TEMP_UNITS = "temperature_units" +ATTR_EXTERNAL_TEMPERATURE_SENSOR = "external_temperature_sensor" +ATTR_EXTERNAL_HUMIDITY_SENSOR = "external_humidity_sensor" + # HVAC modes - these come from Home Assistant and are standard DEFAULT_HVAC_MODES = [ HVACMode.AUTO, diff --git a/custom_components/gree/select.py b/custom_components/gree/select.py index ffd265a..5908220 100644 --- a/custom_components/gree/select.py +++ b/custom_components/gree/select.py @@ -26,7 +26,7 @@ @dataclass(frozen=True, kw_only=True) -class GreeSelectDescription(Generic[T], GreeEntityDescription, SelectEntityDescription): +class GreeSelectDescription(GreeEntityDescription, SelectEntityDescription, Generic[T]): """Description of a Gree switch.""" device_class = None @@ -41,7 +41,7 @@ class GreeSelectDescription(Generic[T], GreeEntityDescription, SelectEntityDescr translation_placeholders = None unit_of_measurement = None options_func: Callable[[], list[str]] | None = None - value_func: Callable[[T], str] + value_func: Callable[[T], str | None] set_func: Callable[[T, str], None] updates_device: bool = True @@ -102,19 +102,19 @@ def __init__( self._attr_options = description.options or ["None"] self._attr_current_option = self.entity_description.value_func(self._device) - _LOGGER.debug("Initialized select %s", self._attr_unique_id) - - @property - def current_option(self) -> str: # pyright: ignore[reportIncompatibleVariableOverride] - """Return the selected entity option to represent the entity state.""" - return self.entity_description.value_func(self._device) + _LOGGER.debug("Options: %s", 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.""" + return self.entity_description.value_func(self._device) + async def async_select_option(self, option: str) -> None: """Change the selected option.""" _LOGGER.debug( @@ -152,6 +152,7 @@ async def async_select_option(self, option: str) -> None: async def async_added_to_hass(self): """Handle entity which will be added.""" await super().async_added_to_hass() + # Restore last HA state to device if applicable if self.restore_state: last_state = await self.async_get_last_state() diff --git a/custom_components/gree/translations/en.json b/custom_components/gree/translations/en.json index 2c8d983..1c1156c 100755 --- a/custom_components/gree/translations/en.json +++ b/custom_components/gree/translations/en.json @@ -55,7 +55,9 @@ "features": "Device Features and Modes", "disable_available_check": "Disable Available Check", "max_online_attempts": "Max Online Attempts", - "temp_sensor_offset": "Temperature Sensor Offset" + "temp_sensor_offset": "Temperature Sensor Offset", + "external_temperature_sensor": "External Temperature Sensor", + "external_humidity_sensor": "External Humidity Sensor" } }, "reconfigure": { @@ -69,7 +71,9 @@ "features": "Device Features and Modes", "disable_available_check": "Disable Available Check", "max_online_attempts": "Max Online Attempts", - "temp_sensor_offset": "Temperature Sensor Offset" + "temp_sensor_offset": "Temperature Sensor Offset", + "external_temperature_sensor": "External Temperature Sensor", + "external_humidity_sensor": "External Humidity Sensor" } } }, From 11881181e1693b9018cad7aa08f5a75779e4771c Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Wed, 17 Sep 2025 00:21:15 +0100 Subject: [PATCH 003/113] Make manifest compliant with HA requirements --- custom_components/gree/manifest.json | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/custom_components/gree/manifest.json b/custom_components/gree/manifest.json index bb32620..6db2b26 100755 --- a/custom_components/gree/manifest.json +++ b/custom_components/gree/manifest.json @@ -4,13 +4,8 @@ "version": "3.3.0", "documentation": "https://github.com/RobHofmann/HomeAssistant-GreeClimateComponent", "dependencies": [], - "codeowners": [ - "@robhofmann" - ], - "requirements": [ - "pycryptodome", - "aiofiles", - "asyncio_dgram" - ], - "config_flow": true + "codeowners": ["@robhofmann"], + "requirements": ["pycryptodome", "aiofiles", "asyncio_dgram"], + "config_flow": true, + "integration_type": "hub" } From c71392f58d4976aa4b4aaef240a7e2989aa31220 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Wed, 17 Sep 2025 21:35:46 +0100 Subject: [PATCH 004/113] Cleanup old code in preparation for backporting features --- custom_components/gree/__init__.py | 106 +- custom_components/gree/climate.py | 6 +- custom_components/gree/config_flow.py | 2 +- custom_components/gree/const.py | 91 +- custom_components/gree/entity.py | 84 +- custom_components/gree/gree_const.py | 14 - custom_components/gree/gree_device.py | 2 +- custom_components/gree/gree_device_api.py | 377 ------ custom_components/gree/gree_helpers.py | 2 +- custom_components/gree/number.py | 118 -- custom_components/gree/old_climate.py | 1185 ------------------- custom_components/gree/old_config_flow.py | 143 --- custom_components/gree/old_gree_protocol.py | 91 -- custom_components/gree/old_helpers.py | 131 -- custom_components/gree/old_select.py | 160 --- custom_components/gree/old_sensor.py | 117 -- custom_components/gree/old_switch.py | 207 ---- 17 files changed, 19 insertions(+), 2817 deletions(-) delete mode 100644 custom_components/gree/gree_device_api.py delete mode 100755 custom_components/gree/number.py delete mode 100755 custom_components/gree/old_climate.py delete mode 100755 custom_components/gree/old_config_flow.py delete mode 100755 custom_components/gree/old_gree_protocol.py delete mode 100755 custom_components/gree/old_helpers.py delete mode 100755 custom_components/gree/old_select.py delete mode 100644 custom_components/gree/old_sensor.py delete mode 100755 custom_components/gree/old_switch.py diff --git a/custom_components/gree/__init__.py b/custom_components/gree/__init__.py index 1b45a53..9b86e57 100755 --- a/custom_components/gree/__init__.py +++ b/custom_components/gree/__init__.py @@ -7,45 +7,23 @@ import logging # Third-party imports -import voluptuous as vol - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_HOST, - CONF_MAC, - CONF_NAME, - CONF_PORT, - CONF_TIMEOUT, - Platform, -) +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType # Local imports from .const import ( CONF_ADVANCED, - CONF_DISABLE_AVAILABLE_CHECK, CONF_ENCRYPTION_KEY, CONF_ENCRYPTION_VERSION, - CONF_FAN_MODES, - CONF_HVAC_MODES, CONF_MAX_ONLINE_ATTEMPTS, - CONF_SWING_HORIZONTAL_MODES, - CONF_SWING_MODES, - CONF_TEMP_SENSOR_OFFSET, CONF_UID, DEFAULT_ENCRYPTION_VERSION, - DEFAULT_FAN_MODES, - DEFAULT_HVAC_MODES, DEFAULT_MAX_ONLINE_ATTEMPTS, DEFAULT_PORT, - DEFAULT_SWING_HORIZONTAL_MODES, - DEFAULT_SWING_MODES, - DEFAULT_TIMEOUT, DOMAIN, - OPTION_KEYS, ) # Home Assistant imports @@ -54,46 +32,12 @@ PLATFORMS = [ Platform.CLIMATE, - Platform.NUMBER, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] _LOGGER = logging.getLogger(__name__) -# YAML configuration schema -CLIMATE_SCHEMA = vol.Schema( - { - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_MAC): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, - vol.Optional(CONF_ENCRYPTION_KEY): cv.string, - vol.Optional(CONF_UID): cv.positive_int, - vol.Optional(CONF_ENCRYPTION_VERSION, default=1): vol.In([1, 2]), - vol.Optional(CONF_HVAC_MODES, default=DEFAULT_HVAC_MODES): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Optional(CONF_FAN_MODES, default=DEFAULT_FAN_MODES): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Optional(CONF_SWING_MODES, default=DEFAULT_SWING_MODES): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Optional( - CONF_SWING_HORIZONTAL_MODES, default=DEFAULT_SWING_HORIZONTAL_MODES - ): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_MAX_ONLINE_ATTEMPTS, default=3): cv.positive_int, - vol.Optional(CONF_DISABLE_AVAILABLE_CHECK, default=False): cv.boolean, - vol.Optional(CONF_TEMP_SENSOR_OFFSET): cv.boolean, - } -) - -CONFIG_SCHEMA = vol.Schema( - {DOMAIN: vol.All(cv.ensure_list, [CLIMATE_SCHEMA])}, extra=vol.ALLOW_EXTRA -) - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Gree component from yaml.""" @@ -156,57 +100,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool entry.runtime_data = coordinator - await hass.config_entries.async_forward_entry_setups( - entry, [Platform.SENSOR, Platform.SWITCH, Platform.CLIMATE, Platform.SELECT] - ) - return True - - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} - _LOGGER.debug(entry) - # Combine entry data with options - combined_data = {**entry.data} - for key, value in entry.options.items(): - if key not in OPTION_KEYS: - _LOGGER.debug("Ignoring unexpected option key %s", key) - continue - if value is None: - combined_data.pop(key, None) - else: - combined_data[key] = value - _LOGGER.debug(hass.data[DOMAIN]) - _LOGGER.debug(combined_data) - - # Create the Gree device instance here and store it - from .climate_old import create_gree_device - - device = await create_gree_device(hass, combined_data) - - # Store both the config data and the device instance - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - "config": combined_data, - "device": device, - "coordinator": coordinator, - "gdevice": new_device, - } - - _LOGGER.debug( - "Setting up config entry %s with data: %s", entry.entry_id, combined_data - ) - entry.async_on_unload(entry.add_update_listener(_update_listener)) - await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR]) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unloaded = await hass.config_entries.async_unload_platforms( - entry, [Platform.SENSOR, Platform.SWITCH, Platform.CLIMATE, Platform.SELECT] - ) - # if unloaded: - # _LOGGER.debug("Unloaded config entry %s", entry.entry_id) - # hass.data[DOMAIN].pop(entry.entry_id) - return unloaded + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: diff --git a/custom_components/gree/climate.py b/custom_components/gree/climate.py index 60abf6d..e8ee411 100644 --- a/custom_components/gree/climate.py +++ b/custom_components/gree/climate.py @@ -17,7 +17,7 @@ STATE_UNKNOWN, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -224,7 +224,7 @@ def update_attributes(self): self._attr_temperature_unit = self.get_temp_units() self._attr_target_temperature = self.get_current_target_temp() self._attr_current_temperature = self.get_current_temp() - self._attr_current_humidity = self.get_current_humidty() + self._attr_current_humidity = self.get_current_humidity() if self._attr_supported_features & ClimateEntityFeature.TARGET_TEMPERATURE: self._attr_max_temp = ( @@ -531,7 +531,7 @@ def get_current_temp(self) -> float | None: return None - def get_current_humidty(self) -> float | None: + def get_current_humidity(self) -> float | None: """Returns the current humidity of the room.""" # Use external humidity sensor if available diff --git a/custom_components/gree/config_flow.py b/custom_components/gree/config_flow.py index e4e59ae..23ff19b 100644 --- a/custom_components/gree/config_flow.py +++ b/custom_components/gree/config_flow.py @@ -46,10 +46,10 @@ DEFAULT_SUPPORTED_FEATURES, DEFAULT_SWING_HORIZONTAL_MODES, DEFAULT_SWING_MODES, + DEFAULT_UID, DOMAIN, ) from .coordinator import GreeConfigEntry -from .gree_const import DEFAULT_UID from .gree_device import GreeDevice _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/gree/const.py b/custom_components/gree/const.py index 7e6c59c..d6e832a 100755 --- a/custom_components/gree/const.py +++ b/custom_components/gree/const.py @@ -1,4 +1,4 @@ -"""Constants.""" +"""Constants for the Gree integration.""" from homeassistant.components.climate import HVACMode from homeassistant.const import UnitOfTemperature @@ -11,6 +11,12 @@ VerticalSwingMode, ) +MIN_TEMP_C = 16 +MAX_TEMP_C = 30 + +MIN_TEMP_F = 61 +MAX_TEMP_F = 86 + DOMAIN = "gree" CONF_ADVANCED = "advanced" @@ -28,10 +34,11 @@ CONF_FEATURES = "features" DEFAULT_PORT = 7000 -DEFAULT_TIMEOUT = 10 +DEFAULT_TIMEOUT = 30 DEFAULT_TARGET_TEMP_STEP = 1 DEFAULT_MAX_ONLINE_ATTEMPTS = 8 DEFAULT_ENCRYPTION_VERSION = 0 +DEFAULT_UID = 0 MIN_TEMP_C = 16 MAX_TEMP_C = 30 @@ -108,15 +115,6 @@ GATTR_FEAT_QUIET_MODE, # Special mode on Gree device ] -# FAN_SPEED_HA_TO_GREE = { -# FanSpeed.Auto.name: FanSpeed.Auto, -# FanSpeed.Low.name: FanSpeed.Low, -# FanSpeed.MediumLow.name: FanSpeed.MediumLow, -# FanSpeed.Medium.name: FanSpeed.Medium, -# FanSpeed.MediumHigh.name: FanSpeed.MediumHigh, -# FanSpeed.High.name: FanSpeed.High, -# } - DEFAULT_SWING_MODES = [ VerticalSwingMode.Default.name, VerticalSwingMode.FullSwing.name, @@ -132,21 +130,6 @@ VerticalSwingMode.SwingUpper.name, ] -# SWING_VERTICAL_MODE_HA_TO_GREE = { -# VerticalSwingMode.Default.name: VerticalSwingMode.Default, -# VerticalSwingMode.FullSwing.name: VerticalSwingMode.FullSwing, -# VerticalSwingMode.FixedUpper.name: VerticalSwingMode.FixedUpper, -# VerticalSwingMode.FixedUpperMiddle.name: VerticalSwingMode.FixedUpperMiddle, -# VerticalSwingMode.FixedMiddle.name: VerticalSwingMode.FixedMiddle, -# VerticalSwingMode.FixedLowerMiddle.name: VerticalSwingMode.FixedLowerMiddle, -# VerticalSwingMode.FixedLower.name: VerticalSwingMode.FixedLower, -# VerticalSwingMode.SwingLower.name: VerticalSwingMode.SwingLower, -# VerticalSwingMode.SwingLowerMiddle.name: VerticalSwingMode.SwingLowerMiddle, -# VerticalSwingMode.SwingMiddle.name: VerticalSwingMode.SwingMiddle, -# VerticalSwingMode.SwingUpperMiddle.name: VerticalSwingMode.SwingUpperMiddle, -# VerticalSwingMode.SwingUpper.name: VerticalSwingMode.SwingUpper, -# } - DEFAULT_SWING_HORIZONTAL_MODES = [ HorizontalSwingMode.Default.name, HorizontalSwingMode.FullSwing.name, @@ -157,16 +140,6 @@ HorizontalSwingMode.Right.name, ] -# SWING_HORIZONTAL_MODE_HA_TO_GREE = { -# HorizontalSwingMode.Default.name: HorizontalSwingMode.Default, -# HorizontalSwingMode.FullSwing.name: HorizontalSwingMode.FullSwing, -# HorizontalSwingMode.Left.name: HorizontalSwingMode.Left, -# HorizontalSwingMode.LeftCenter.name: HorizontalSwingMode.LeftCenter, -# HorizontalSwingMode.Center.name: HorizontalSwingMode.Center, -# HorizontalSwingMode.RightCenter.name: HorizontalSwingMode.RightCenter, -# HorizontalSwingMode.Right.name: HorizontalSwingMode.Right, -# } - DEFAULT_SUPPORTED_FEATURES = [ GATTR_BEEPER, GATTR_FEAT_FRESH_AIR, @@ -184,49 +157,3 @@ TemperatureUnits.C: UnitOfTemperature.CELSIUS, TemperatureUnits.F: UnitOfTemperature.FAHRENHEIT, } - -# Keys that can be updated via the options flow -OPTION_KEYS = { - CONF_HVAC_MODES, - CONF_FAN_MODES, - CONF_SWING_MODES, - CONF_SWING_HORIZONTAL_MODES, - CONF_DISABLE_AVAILABLE_CHECK, - CONF_MAX_ONLINE_ATTEMPTS, - CONF_TEMP_SENSOR_OFFSET, -} - -MODES_MAPPING = { - "Mod": {"auto": 0, "cool": 1, "dry": 2, "fan_only": 3, "heat": 4}, - "WdSpd": { - "auto": 0, - "low": 1, - "medium_low": 2, - "medium": 3, - "medium_high": 4, - "high": 5, - }, - "SwUpDn": { - "default": 0, - "swing_full": 1, - "fixed_upmost": 2, - "fixed_middle_up": 3, - "fixed_middle": 4, - "fixed_middle_low": 5, - "fixed_lowest": 6, - "swing_downmost": 7, - "swing_middle_low": 8, - "swing_middle": 9, - "swing_middle_up": 10, - "swing_upmost": 11, - }, - "SwingLfRig": { - "default": 0, - "swing_full": 1, - "fixed_leftmost": 2, - "fixed_middle_left": 3, - "fixed_middle": 4, - "fixed_middle_right": 5, - "fixed_rightmost": 6, - }, -} diff --git a/custom_components/gree/entity.py b/custom_components/gree/entity.py index 3476eb6..221e0c9 100755 --- a/custom_components/gree/entity.py +++ b/custom_components/gree/entity.py @@ -5,20 +5,17 @@ # Standard library imports from collections.abc import Callable from dataclasses import dataclass -from typing import Any, Generic, TypeVar # Home Assistant imports from config.custom_components.gree.coordinator import GreeCoordinator from config.custom_components.gree.gree_device import GreeDevice from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription +from homeassistant.helpers.entity import DeviceInfo, EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity # Local imports from .const import DOMAIN -T = TypeVar("T") - class GreeEntity(CoordinatorEntity[GreeCoordinator]): """Base Gree entity.""" @@ -48,82 +45,3 @@ class GreeEntityDescription(EntityDescription): # restore_state: bool = True available_func: Callable[[GreeDevice], bool] - - -@dataclass -class OldGreeEntityDescription: - """Describes Gree entity.""" - - property_key: str - """Fills key and translation_key.""" - key: str = None - translation_key: str = None - - def __post_init__(self): - self.key = self.property_key - self.translation_key = self.property_key - - name: str = None - icon: str = None - entity_category: str = None - exists_fn: Callable[[object, object], bool] = lambda description, device: True - value_fn: Callable[[object], Any] = None - available_fn: Callable[[object], bool] = lambda device: True - icon_fn: Callable[[Any, object], str] = None - - -class OldGreeEntity(Entity): - """Base Gree entity.""" - - _attr_has_entity_name = True - entity_description: OldGreeEntityDescription - - def __init__(self, hass, entry, description: OldGreeEntityDescription) -> None: - """Initialize Gree entity.""" - # Get the device from the entry data - entry_data = hass.data.get(DOMAIN, {}).get(entry.entry_id, {}) - self._device = entry_data.get("device") - self.entity_description = description - self._set_id() - - def _set_id(self) -> None: - """Set entity ID and unique ID.""" - if self.entity_description: - if self.entity_description.icon_fn is not None: - self._attr_icon = self.entity_description.icon_fn( - self.native_value, self._device - ) - elif self.entity_description.icon is not None: - self._attr_icon = self.entity_description.icon - - self._attr_unique_id = ( - f"{self._device._mac_addr}_{self.entity_description.key}" - ) - - @property - def device_info(self) -> DeviceInfo: - """Return device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self._device._mac_addr)}, - name=self._device._name, - manufacturer="Gree", - connections={(CONNECTION_NETWORK_MAC, self._device._mac_addr)}, - ) - - @property - def available(self) -> bool: - """Return if entity is available.""" - if self.entity_description.available_fn: - return self.entity_description.available_fn(self._device) - return ( - self._device._device_online - if hasattr(self._device, "_device_online") - else True - ) - - @property - def native_value(self) -> Any: - """Return the native value of the entity.""" - if self.entity_description.value_fn: - return self.entity_description.value_fn(self._device) - return None diff --git a/custom_components/gree/gree_const.py b/custom_components/gree/gree_const.py index b0d6016..e9bb8e7 100644 --- a/custom_components/gree/gree_const.py +++ b/custom_components/gree/gree_const.py @@ -1,15 +1 @@ """Constants for Gree integration.""" - -DEFAULT_PORT = 7000 -DEFAULT_TIMEOUT = 30 -DEFAULT_TARGET_TEMP_STEP = 1 -DEFAULT_UID = 0 -DEFAULT_ENCRYPTION_VERSION = 1 - -MIN_TEMP_C = 16 -MAX_TEMP_C = 30 - -MIN_TEMP_F = 61 -MAX_TEMP_F = 86 - -TEMSEN_OFFSET = 40 diff --git a/custom_components/gree/gree_device.py b/custom_components/gree/gree_device.py index a76add2..886066a 100755 --- a/custom_components/gree/gree_device.py +++ b/custom_components/gree/gree_device.py @@ -4,6 +4,7 @@ from attr import dataclass +from .const import DEFAULT_UID from .gree_api import ( FanSpeed, GreeProp, @@ -15,7 +16,6 @@ gree_get_status, gree_set_status, ) -from .gree_const import DEFAULT_UID from .gree_helpers import ( TempOffsetResolver, gree_get_target_temp_props_from_c, diff --git a/custom_components/gree/gree_device_api.py b/custom_components/gree/gree_device_api.py deleted file mode 100644 index 50fab68..0000000 --- a/custom_components/gree/gree_device_api.py +++ /dev/null @@ -1,377 +0,0 @@ -"""Gree device API logic for Home Assistant integration.""" - -# Standard library imports -import base64 -import logging -import socket - -# Third-party imports -try: - import simplejson -except ImportError: - import json as simplejson -from Crypto.Cipher import AES - -from .const import * -from .old_helpers import TempOffsetResolver - -_LOGGER = logging.getLogger(__name__) - -GCM_IV = b"\x54\x40\x78\x44\x49\x67\x5a\x51\x6c\x5e\x63\x13" -GCM_ADD = b"qualcomm-test" -GENERIC_GREE_DEVICE_KEY = "a3K8Bx%2r8Y7#xDh" -GENERIC_GREE_DEVICE_KEY_GCM = "{yxAHAY_Lm6pbC/<" - -AC_OPTIONS_ALL = [ - "Pow", - "Mod", - "SetTem", - "WdSpd", - "Air", - "Blo", - "Health", - "SwhSlp", - "Lig", - "SwingLfRig", - "SwUpDn", - "Quiet", - "Tur", - "StHt", - "TemUn", - "HeatCoolType", - "TemRec", - "SvSt", - "SlpMod", - "TemSen", - "AntiDirectBlow", - "LigSen", -] - -AC_OPTIONS_MAPPING = { - # power state of the device - "Pow": {0: "off", 1: "on"}, - # mode of operation - "Mod": { - 0: "auto", - 1: "cool", - 2: "dry", - 3: "fan", - 4: "heat", - }, - # fan speed - "WdSpd": { - 0: "auto", - 1: "low", - 2: "medium-low", # not available on 3-speed units - 3: "medium", - 4: "medium-high", # not available on 3-speed units - 5: "high", - }, - # controls the state of the fresh air valve (not available on all units) - "Air": {0: "off", 1: "on"}, - # "Blow" or "X-Fan", this function keeps the fan running for a while after shutting down. Only usable in Dry and Cool mode - "Blo": {0: "off", 1: "on"}, - # controls Health ("Cold plasma") mode, only for devices equipped with "anion generator", which absorbs dust and kills bacteria - "Health": {0: "off", 1: "on"}, - # sleep mode, which gradually changes the temperature in Cool, Heat and Dry mode - "SwhSlp": {0: "off", 1: "on"}, - "SlpMod": {0: "off", 1: "on"}, - # turns all indicators and the display on the unit on or off - "Lig": {0: "off", 1: "on"}, - # controls the swing mode of the horizontal air blades (available on limited number of devices) - "SwingLfRig": { - 0: "default", - 1: "swing_full", - 2: "fixed_leftmost", - 3: "fixed_middle_left", - 4: "fixed_middle", - 5: "fixed_middle_right", - 6: "fixed_rightmost", - }, - # controls the swing mode of the vertical air blades - "SwUpDn": { - 0: "default", - 1: "swing_full", - 2: "fixed_upmost", - 3: "fixed_middle_up", - 4: "fixed_middle", - 5: "fixed_middle_low", - 6: "fixed_lowest", - 7: "swing_downmost", - 8: "swing_middle_low", - 9: "swing_middle", - 10: "swing_middle_up", - 11: "swing_upmost", - }, - # controls the Quiet mode which slows down the fan to its most quiet speed. Not available in Dry and Fan mode. - "Quiet": {0: "off", 1: "on"}, - # sets fan speed to the maximum. Fan speed cannot be changed while active and only available in Dry and Cool mode - "Tur": {0: "off", 1: "on"}, - # 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 - "StHt": {0: "off", 1: "on"}, - # used to distinguish between Fahrenheit values - "TemRec": {0: "low", 1: "high"}, - # energy saving mode - "SvSt": {0: "off", 1: "on"}, - # defines the unit of temperature - "TemUn": {0: "celcius", 1: "fahrenheit"}, - # unknown function - "AntiDirectBlow": {0: "off", 1: "on"}, - # controls if the light sensor is used (available on limited number of devices) - "LigSen": {0: "off", 1: "on"}, -} - - -def Pad(s: str): - """Pads a string so its length becomes a multiple of 16. For PKCS#7 padding.""" - aesBlockSize = 16 - requiredPaddingSize = aesBlockSize - len(s) % aesBlockSize - return s + requiredPaddingSize * chr(requiredPaddingSize) - - -def FetchResult(ip_addr, port, timeout, json_data, cipher, encryption_version=1): - """Sends a payload JSON data to the device and reads the response pack.""" - - _LOGGER.debug( - "Fetching data from %s with requested payload: %s", ip_addr, json_data - ) - - clientSock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - clientSock.settimeout(timeout) - clientSock.sendto(bytes(json_data, "utf-8"), (ip_addr, port)) - - data, _ = clientSock.recvfrom(64000) - receivedJson = simplejson.loads(data) - clientSock.close() - - pack = receivedJson["pack"] - base64decodedPack = base64.b64decode(pack) - decryptedPack = cipher.decrypt(base64decodedPack) - - if encryption_version == 2: - tag = receivedJson["tag"] - cipher.verify(base64.b64decode(tag)) - - decodedPack = decryptedPack.decode("utf-8") - replacedPack = decodedPack.replace("\x0f", "").replace( - decodedPack[decodedPack.rindex("}") + 1 :], "" - ) - loadedJsonPack = simplejson.loads(replacedPack) - - _LOGGER.debug(f"Got data from {ip_addr} with: {loadedJsonPack}") - return loadedJsonPack - - -def GetCipher(key: str, encryption_version: int): - # _LOGGER.debug(f"Version: {encryption_version}, Key: {key}, Key Length: {len(key)}") - if encryption_version == 1: - cipher = AES.new(key.encode("utf8"), AES.MODE_ECB) - return cipher - elif encryption_version == 2: - cipher = AES.new(key.encode("utf8"), AES.MODE_GCM, nonce=GCM_IV) - cipher.update(assoc_data=GCM_ADD) - return cipher - - -def GetDefaultCipher(encryption_version: int): - if encryption_version == 1: - cipher = GetCipher(GENERIC_GREE_DEVICE_KEY, encryption_version) - return cipher - elif encryption_version == 2: - cipher = GetCipher(GENERIC_GREE_DEVICE_KEY_GCM, encryption_version) - return cipher - else: - _LOGGER.error(f"Unsupported encryption version: {encryption_version}") - return None - - -def CreateEncryptedPack(data: str, cipher, encryption_version: int) -> tuple[str, str]: - if encryption_version == 1: - encrypted_data = cipher.encrypt(Pad(data).encode("utf8")) - return ( - base64.b64encode(encrypted_data).decode("utf-8"), - "", - ) - elif encryption_version == 2: - encrypted_data, tag = cipher.encrypt_and_digest(data.encode("utf8")) - return ( - base64.b64encode(encrypted_data).decode("utf-8"), - base64.b64encode(tag).decode("utf-8"), - ) - else: - return ("", "") - - -def CreateBindPack(mac_addr: str, uid: int, encryption_version: int) -> str: - pack = "" - if encryption_version == 1: - pack = simplejson.dumps({"mac": mac_addr, "t": "bind", "uid": uid}) - elif encryption_version == 2: - pack = simplejson.dumps( - {"cid": mac_addr, "mac": mac_addr, "t": "bind", "uid": uid} - ) - - _LOGGER.debug(f"Bind Pack: {pack}") - return pack - - -def CreateStatusPak(mac_addr: str) -> str: - pack = simplejson.dumps({"cols": AC_OPTIONS_ALL, "mac": mac_addr, "t": "status"}) - _LOGGER.debug(f"Status Pack: {pack}") - return pack - - -def CreatePayload( - pack: str, - i_command: int, - mac_addr: str, - uid: int, - encryption_version: int, - tag: str, -): - payload: str = "" - if encryption_version == 1: - payload = simplejson.dumps( - { - "cid": "app", - "i": i_command, - "pack": pack, - "t": "pack", - "tcid": mac_addr, - "uid": uid, - } - ) - elif encryption_version == 2: - payload = simplejson.dumps( - { - "cid": "app", - "i": i_command, - "pack": pack, - "t": "pack", - "tcid": mac_addr, - "uid": uid, - "tag": tag, - } - ) - - _LOGGER.debug(f"Payload: {payload}") - return payload - - -def GetFahrenheitValueToSend(fahrenheit: int) -> tuple[int, int]: - TemSet = round((fahrenheit - 32.0) * 5.0 / 9.0) - TemRec = (int)((((fahrenheit - 32.0) * 5.0 / 9.0) - TemSet) > 0) - return (TemSet, TemRec) - - -class GreeDeviceAPI: - def __init__( - self, - ip_addr: str, - mac_addr: str, - port: int = DEFAULT_PORT, - timeout: int = DEFAULT_TIMEOUT, - encryption_version: int = 1, - encryption_key: str = "", - uid: int = 0, - temp_offset=TEMSEN_OFFSET, - ): - _LOGGER.info( - f"Initialize the GREE Device API for: {mac_addr} ({ip_addr}:{port})" - ) - _LOGGER.debug(f"Version: {encryption_version}, Key: {encryption_key}") - - self.ip_addr: str = ip_addr - self.port: int = port - self.mac_addr: str = mac_addr - self.timeout: int = timeout - self.encryption_version: int = encryption_version - self.encryption_key: str = encryption_key - self.uid: int = uid - - if encryption_version < 1 or encryption_version > 2: - _LOGGER.error("Unsupported encryption version, defaulting to 1") - self.encryption_version = 1 - - if not encryption_key.strip(): - _LOGGER.info("No encryption key provided") - self.GetDeviceKey() - - self.temp_processor = TempOffsetResolver(offset=temp_offset) - - self.GetDeviceStatus() - - def GetDeviceKey(self) -> str: - _LOGGER.info("Trying to retrieve device encryption key") - - self.encryption_key = "" - - pack, tag = CreateEncryptedPack( - CreateBindPack(self.mac_addr, self.uid, self.encryption_version), - GetDefaultCipher(self.encryption_version), - self.encryption_version, - ) - jsonPayloadToSend = CreatePayload( - pack, 1, self.mac_addr, self.uid, self.encryption_version, tag - ) - - try: - key = FetchResult( - self.ip_addr, - self.port, - self.timeout, - jsonPayloadToSend, - GetDefaultCipher(self.encryption_version), - self.encryption_version, - )["key"] - except Exception: - _LOGGER.exception("Error getting device encryption key") - else: - _LOGGER.info("Fetched device encryption key with success") - _LOGGER.debug(f"Fetched encryption key: {key}") - self.encryption_key = key - - return self.encryption_key - - def GetDeviceStatus(self) -> dict[str, int]: - _LOGGER.debug("Trying to get device status") - - pack, tag = CreateEncryptedPack( - CreateStatusPak(self.mac_addr), - GetCipher(self.encryption_key, self.encryption_version), - self.encryption_version, - ) - jsonPayloadToSend = CreatePayload( - pack, 0, self.mac_addr, self.uid, self.encryption_version, tag - ) - - self.status_values: dict[str, int] = {} - - try: - result = FetchResult( - self.ip_addr, - self.port, - self.timeout, - jsonPayloadToSend, - GetCipher(self.encryption_key, self.encryption_version), - self.encryption_version, - ) - cols = list(map(str, result["cols"])) - values = list(map(int, result["dat"])) - status_values = dict(zip(cols, values)) - except Exception: - _LOGGER.error("Error getting device status") - else: - _LOGGER.debug(f"Fetched device status: {status_values}") - self.device_status = status_values - - # Update variables - self.has_temp_sensor = ( - "TemSen" in self.device_status and self.device_status["TemSen"] != 0 - ) - if self.has_temp_sensor and self.temp_processor is None: - self.temp_processor(self.device_status["TemSen"]) - - self.temperature_unit = AC_OPTIONS_MAPPING["TemUn"][self.device_status["TemUn"]] - - return self.device_status diff --git a/custom_components/gree/gree_helpers.py b/custom_components/gree/gree_helpers.py index 1393985..0453d47 100644 --- a/custom_components/gree/gree_helpers.py +++ b/custom_components/gree/gree_helpers.py @@ -1,6 +1,6 @@ """Helpers for the Gree integration.""" -from .gree_const import TEMSEN_OFFSET +TEMSEN_OFFSET = 40 class TempOffsetResolver: diff --git a/custom_components/gree/number.py b/custom_components/gree/number.py deleted file mode 100755 index 4e79336..0000000 --- a/custom_components/gree/number.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Support for Gree number entities (e.g., target temperature step).""" - -from __future__ import annotations - -# Standard library imports -import logging -from collections.abc import Callable -from dataclasses import dataclass - -# Home Assistant imports -from homeassistant.components.number import ( - NumberEntity, - NumberEntityDescription, - NumberMode, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import EntityCategory -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity - -# Local imports -from .const import DEFAULT_TARGET_TEMP_STEP -from .entity import OldGreeEntity, OldGreeEntityDescription - -_LOGGER = logging.getLogger(__name__) - - -@dataclass -class GreeNumberEntityDescription(OldGreeEntityDescription, NumberEntityDescription): - set_fn: Callable[[object, float], None] = None - restore_state: bool = False - - -NUMBERS: tuple[GreeNumberEntityDescription, ...] = ( - GreeNumberEntityDescription( - property_key="target_temp_step", - icon="mdi:arrow-expand-vertical", - native_min_value=0.1, - native_max_value=5.0, - native_step=0.1, - mode=NumberMode.SLIDER, - value_fn=lambda device: getattr( - device, "_target_temperature_step", DEFAULT_TARGET_TEMP_STEP - ), - set_fn=lambda device, value: setattr(device, "_target_temperature_step", value), - entity_category=EntityCategory.CONFIG, - restore_state=True, - ), -) - - -async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up Gree number entities based on a config entry.""" - async_add_entities( - GreeNumberEntity(hass, entry, description) for description in NUMBERS - ) - - -class GreeNumberEntity(OldGreeEntity, NumberEntity, RestoreEntity): - """Defines a Gree number entity.""" - - entity_description: GreeNumberEntityDescription - - def __init__(self, hass, entry, description: GreeNumberEntityDescription) -> None: - super().__init__(hass, entry, description) - self._attr_native_value = self.native_value - self._restored = False - - async def async_added_to_hass(self): - await super().async_added_to_hass() - if self.entity_description.restore_state: - last_state = await self.async_get_last_state() - if last_state is not None and last_state.state not in [ - "unknown", - "unavailable", - ]: - try: - value = float(last_state.state) - # Validate the value is within the entity's range - if ( - self.entity_description.native_min_value - <= value - <= self.entity_description.native_max_value - ): - setattr( - self._device, - f"_{self.entity_description.property_key}", - value, - ) - self._attr_native_value = value - self._restored = True - except (ValueError, TypeError): - # If conversion fails, use default value - pass - - @property - def native_value(self): - if self.entity_description.restore_state: - return getattr( - self, - "_attr_native_value", - self.entity_description.value_fn(self._device), - ) - return self.entity_description.value_fn(self._device) - - async def async_set_native_value(self, value: float) -> None: - if self.entity_description.set_fn: - await self.hass.async_add_executor_job( - self.entity_description.set_fn, self._device, value - ) - if self.entity_description.restore_state: - self._attr_native_value = value - self.async_write_ha_state() diff --git a/custom_components/gree/old_climate.py b/custom_components/gree/old_climate.py deleted file mode 100755 index 1441a3f..0000000 --- a/custom_components/gree/old_climate.py +++ /dev/null @@ -1,1185 +0,0 @@ -""" -Gree Climate Entity for Home Assistant. - -This module defines the climate (HVAC) unit for the Gree integration. -""" - -# Standard library imports -import base64 -import logging -from datetime import timedelta - -# Third-party imports -try: - import simplejson -except ImportError: - import json as simplejson -from Crypto.Cipher import AES - -# Home Assistant imports -from homeassistant.components.climate import ( - ClimateEntity, - ClimateEntityFeature, - HVACMode, -) -from homeassistant.const import ( - ATTR_TEMPERATURE, - ATTR_UNIT_OF_MEASUREMENT, - CONF_HOST, - CONF_MAC, - CONF_NAME, - CONF_PORT, - CONF_TIMEOUT, -) -from homeassistant.helpers.device_registry import DeviceInfo - -# Local imports -from .const import ( - DOMAIN, - DEFAULT_PORT, - DEFAULT_TIMEOUT, - DEFAULT_HVAC_MODES, - DEFAULT_FAN_MODES, - DEFAULT_SWING_MODES, - DEFAULT_SWING_HORIZONTAL_MODES, - DEFAULT_TARGET_TEMP_STEP, - MIN_TEMP_C, - MIN_TEMP_F, - MAX_TEMP_C, - MAX_TEMP_F, - MODES_MAPPING, - TEMSEN_OFFSET, - CONF_HVAC_MODES, - CONF_FAN_MODES, - CONF_SWING_MODES, - CONF_SWING_HORIZONTAL_MODES, - CONF_ENCRYPTION_KEY, - CONF_UID, - CONF_ENCRYPTION_VERSION, - CONF_DISABLE_AVAILABLE_CHECK, - CONF_MAX_ONLINE_ATTEMPTS, - CONF_TEMP_SENSOR_OFFSET, -) -from .gree_protocol import ( - Pad, - FetchResult, - GetDeviceKey, - GetGCMCipher, - EncryptGCM, - GetDeviceKeyGCM, -) -from .old_helpers import ( - TempOffsetResolver, - gree_f_to_c, - gree_c_to_f, - encode_temp_c, - decode_temp_c, -) - -REQUIREMENTS = ["pycryptodome"] - -_LOGGER = logging.getLogger(__name__) - -SUPPORT_FLAGS = ( - ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.TURN_ON - | ClimateEntityFeature.TURN_OFF -) - - -async def create_gree_device(hass, config): - """Create a Gree device instance from config.""" - name = config.get(CONF_NAME, "Gree Climate") - ip_addr = config.get(CONF_HOST) - port = config.get(CONF_PORT, DEFAULT_PORT) - mac_addr = str(config.get(CONF_MAC)).encode().replace(b":", b"") - timeout = config.get(CONF_TIMEOUT, DEFAULT_TIMEOUT) - - chm = config.get(CONF_HVAC_MODES) - hvac_modes = [ - getattr(HVACMode, mode.upper()) - for mode in (chm if chm is not None else DEFAULT_HVAC_MODES) - ] - - cfm = config.get(CONF_FAN_MODES) - fan_modes = cfm if cfm is not None else DEFAULT_FAN_MODES - csm = config.get(CONF_SWING_MODES) - swing_modes = csm if csm is not None else DEFAULT_SWING_MODES - cshm = config.get(CONF_SWING_HORIZONTAL_MODES) - swing_horizontal_modes = ( - cshm if cshm is not None else DEFAULT_SWING_HORIZONTAL_MODES - ) - encryption_key = config.get(CONF_ENCRYPTION_KEY) - uid = config.get(CONF_UID) - encryption_version = config.get(CONF_ENCRYPTION_VERSION, 1) - disable_available_check = config.get(CONF_DISABLE_AVAILABLE_CHECK, False) - max_online_attempts = config.get(CONF_MAX_ONLINE_ATTEMPTS, 3) - temp_sensor_offset = config.get(CONF_TEMP_SENSOR_OFFSET) - - return GreeClimate( - hass, - name, - ip_addr, - port, - mac_addr, - timeout, - hvac_modes, - fan_modes, - swing_modes, - swing_horizontal_modes, - encryption_version, - disable_available_check, - max_online_attempts, - encryption_key, - uid, - temp_sensor_offset, - ) - - -# from the remote control and gree app - -# update() interval -SCAN_INTERVAL = timedelta(seconds=60) - - -async def async_setup_entry(hass, entry, async_add_devices): - """Set up Gree climate from a config entry.""" - # Get the device that was created in __init__.py - entry_data = hass.data[DOMAIN][entry.entry_id] - device = entry_data["device"] - - async_add_devices([device]) - - -async def async_unload_entry(hass, entry): - """Unload a config entry.""" - return True - - -class GreeClimate(ClimateEntity): - # Language is retrieved from translation key - _attr_translation_key = "gree" - - def __init__( - self, - hass, - name, - ip_addr, - port, - mac_addr, - timeout, - hvac_modes, - fan_modes, - swing_modes, - swing_horizontal_modes, - encryption_version, - disable_available_check, - max_online_attempts, - encryption_key=None, - uid=None, - temp_sensor_offset=None, - ): - _LOGGER.info("Initialize the GREE climate device") - self.hass = hass - self._name = name - self._ip_addr = ip_addr - self._port = port - mac_addr_str: str = mac_addr.decode("utf-8").lower() - if "@" in mac_addr_str: - self._sub_mac_addr, self._mac_addr = mac_addr_str.split("@", 1) - else: - self._sub_mac_addr = self._mac_addr = mac_addr_str - self._timeout = timeout - self._unique_id = f"{DOMAIN}_{self._mac_addr}" - self._device_online = None - self._online_attempts = 0 - self._max_online_attempts = max_online_attempts - self._disable_available_check = disable_available_check - - self._target_temperature = None - # Initialize target temperature step with default value (will be overridden by number entity when available) - self._target_temperature_step = DEFAULT_TARGET_TEMP_STEP - # Device uses a combination of Celsius + a set bit for Fahrenheit, so the integration needs to be aware of the units. - self._unit_of_measurement = hass.config.units.temperature_unit - _LOGGER.info("Unit of measurement: %s", self._unit_of_measurement) - - self._hvac_modes = hvac_modes - self._hvac_mode = HVACMode.OFF - self._fan_modes = fan_modes - self._fan_mode = None - self._swing_modes = swing_modes - self._swing_mode = None - self._swing_horizontal_modes = swing_horizontal_modes - self._swing_horizontal_mode = None - - self._temp_sensor_offset = temp_sensor_offset - - # Store for external temp sensor entity (set by sensor entity) - self._external_temperature_sensor = None - - # Keep unsub callbacks for deregistering listeners - self._listeners: list = [] - - self._has_temp_sensor = None - self._has_anti_direct_blow = None - self._has_light_sensor = None - self._has_outside_temp_sensor = None - self._has_room_humidity_sensor = None - - self._current_temperature = None - self._current_anti_direct_blow = None - self._current_light_sensor = None - self._current_outside_temperature = None - self._current_room_humidity = None - - self._firstTimeRun = True - - self._enable_turn_on_off_backwards_compatibility = False - - self.encryption_version = encryption_version - self.CIPHER = None - - if encryption_key: - _LOGGER.info("Using configured encryption key: {}".format(encryption_key)) - self._encryption_key = encryption_key.encode("utf8") - if encryption_version == 1: - # Cipher to use to encrypt/decrypt - self.CIPHER = AES.new(self._encryption_key, AES.MODE_ECB) - elif self.encryption_version != 2: - _LOGGER.error( - "Encryption version %s is not implemented." - % self.encryption_version - ) - else: - self._encryption_key = None - - if uid: - self._uid = uid - else: - self._uid = 0 - - self._acOptions = { - "Pow": None, - "Mod": None, - "SetTem": None, - "WdSpd": None, - "Air": None, - "Blo": None, - "Health": None, - "SwhSlp": None, - "Lig": None, - "SwingLfRig": None, - "SwUpDn": None, - "Quiet": None, - "Tur": None, - "StHt": None, - "TemUn": None, - "HeatCoolType": None, - "TemRec": None, - "SvSt": None, - "SlpMod": None, - } - self._optionsToFetch = [ - "Pow", - "Mod", - "SetTem", - "WdSpd", - "Air", - "Blo", - "Health", - "SwhSlp", - "Lig", - "SwingLfRig", - "SwUpDn", - "Quiet", - "Tur", - "StHt", - "TemUn", - "HeatCoolType", - "TemRec", - "SvSt", - "SlpMod", - ] - - # Initialize auto switches - self._auto_light = False - self._auto_xfan = False - - # Initialize beeper control - self._beeper_enabled = True # Default to beeper ON (silent mode OFF) - - # helper method to determine TemSen offset - self._process_temp_sensor = TempOffsetResolver() - - def GreeGetValues(self, propertyNames): - plaintext = ( - '{"cols":' - + simplejson.dumps(propertyNames) - + ',"mac":"' - + str(self._sub_mac_addr) - + '","t":"status"}' - ) - if self.encryption_version == 1: - cipher = self.CIPHER - jsonPayloadToSend = ( - '{"cid":"app","i":0,"pack":"' - + base64.b64encode( - cipher.encrypt(Pad(plaintext).encode("utf8")) - ).decode("utf-8") - + '","t":"pack","tcid":"' - + str(self._mac_addr) - + '","uid":{}'.format(self._uid) - + "}" - ) - elif self.encryption_version == 2: - pack, tag = EncryptGCM(self._encryption_key, plaintext) - jsonPayloadToSend = ( - '{"cid":"app","i":0,"pack":"' - + pack - + '","t":"pack","tcid":"' - + str(self._mac_addr) - + '","uid":{}'.format(self._uid) - + ',"tag" : "' - + tag - + '"}' - ) - cipher = GetGCMCipher(self._encryption_key) - dat = FetchResult( - cipher, - self._ip_addr, - self._port, - self._timeout, - jsonPayloadToSend, - encryption_version=self.encryption_version, - )["dat"] - return dat[0] if len(dat) == 1 else dat - - def SetAcOptions( - self, acOptions, newOptionsToOverride, optionValuesToOverride=None - ): - if optionValuesToOverride is not None: - _LOGGER.debug("Setting acOptions with retrieved HVAC values") - for key in newOptionsToOverride: - _LOGGER.debug( - "Setting %s: %s" - % (key, optionValuesToOverride[newOptionsToOverride.index(key)]) - ) - acOptions[key] = optionValuesToOverride[newOptionsToOverride.index(key)] - _LOGGER.debug("Done setting acOptions") - else: - _LOGGER.debug("Overwriting acOptions with new settings") - for key, value in newOptionsToOverride.items(): - _LOGGER.debug("Overwriting %s: %s" % (key, value)) - acOptions[key] = value - _LOGGER.debug("Done overwriting acOptions") - return acOptions - - def SendStateToAc(self, timeout): - opt_list = [ - "Pow", - "Mod", - "SetTem", - "WdSpd", - "Air", - "Blo", - "Health", - "SwhSlp", - "Lig", - "SwingLfRig", - "SwUpDn", - "Quiet", - "Tur", - "StHt", - "TemUn", - "HeatCoolType", - "TemRec", - "SvSt", - "SlpMod", - "AntiDirectBlow", - "LigSen", - ] - - # Collect values from _acOptions - p_values = [self._acOptions.get(k) for k in opt_list] - - # Filter out empty ones - filtered_opt = [] - filtered_p = [] - for name, val in zip(opt_list, p_values): - if val not in ("", None): - filtered_opt.append(f'"{name}"') - filtered_p.append(str(val)) - - buzzer_command_value = 0 if self._beeper_enabled else 1 - filtered_opt.append('"Buzzer_ON_OFF"') - filtered_p.append(str(buzzer_command_value)) - _LOGGER.debug( - f"Sending with Buzzer_ON_OFF={buzzer_command_value} (Beeper is {'ENABLED' if self._beeper_enabled else 'DISABLED'})" - ) - - statePackJson = ( - '{"opt":[' - + ",".join(filtered_opt) - + '],"p":[' - + ",".join(filtered_p) - + '],"t":"cmd","sub":"' - + self._sub_mac_addr - + '"}' - ) - - if self.encryption_version == 1: - cipher = self.CIPHER - sentJsonPayload = ( - '{"cid":"app","i":0,"pack":"' - + base64.b64encode( - cipher.encrypt(Pad(statePackJson).encode("utf8")) - ).decode("utf-8") - + '","t":"pack","tcid":"' - + str(self._mac_addr) - + '","uid":{}'.format(self._uid) - + "}" - ) - elif self.encryption_version == 2: - pack, tag = EncryptGCM(self._encryption_key, statePackJson) - sentJsonPayload = ( - '{"cid":"app","i":0,"pack":"' - + pack - + '","t":"pack","tcid":"' - + str(self._mac_addr) - + '","uid":{}'.format(self._uid) - + ',"tag":"' - + tag - + '"}' - ) - cipher = GetGCMCipher(self._encryption_key) - receivedJsonPayload = FetchResult( - cipher, - self._ip_addr, - self._port, - timeout, - sentJsonPayload, - encryption_version=self.encryption_version, - ) - _LOGGER.debug("Done sending state to HVAC: " + str(receivedJsonPayload)) - - def UpdateHATargetTemperature(self): - # Sync set temperature to HA. If 8℃ heating is active we set the temp in HA to 8℃ so that it shows the same as the AC display. - if self._acOptions["StHt"] and (int(self._acOptions["StHt"]) == 1): - self._target_temperature = 8 - _LOGGER.info( - "HA target temp set according to HVAC state to 8℃ since 8℃ heating mode is active" - ) - else: - temp_c = decode_temp_c( - SetTem=self._acOptions["SetTem"], TemRec=self._acOptions["TemRec"] - ) # takes care of 1/2 degrees - temp_f = gree_c_to_f( - SetTem=self._acOptions["SetTem"], TemRec=self._acOptions["TemRec"] - ) - - if self._unit_of_measurement == "°C": - display_temp = temp_c - elif self._unit_of_measurement == "°F": - display_temp = temp_f - else: - display_temp = temp_c # default to deg c - _LOGGER.error( - "Unknown unit of measurement: %s" % self._unit_of_measurement - ) - - self._target_temperature = display_temp - - _LOGGER.info( - f"UpdateHATargetTemperature: HA target temp set to: {self._target_temperature} {self._unit_of_measurement}. Device commands: SetTem: {self._acOptions['SetTem']}, TemRec: {self._acOptions['TemRec']}" - ) - - def UpdateHAHvacMode(self): - # Sync current HVAC operation mode to HA - if self._acOptions["Pow"] == 0: - self._hvac_mode = HVACMode.OFF - else: - for key, value in MODES_MAPPING.get("Mod").items(): - if value == (self._acOptions["Mod"]): - self._hvac_mode = key - _LOGGER.debug( - "HA operation mode set according to HVAC state to: " + str(self._hvac_mode) - ) - - def UpdateHACurrentSwingMode(self): - # Sync current HVAC Swing mode state to HA - for key, value in MODES_MAPPING.get("SwUpDn").items(): - if value == (self._acOptions["SwUpDn"]): - self._swing_mode = key - _LOGGER.debug( - "HA swing mode set according to HVAC state to: " + str(self._swing_mode) - ) - - def UpdateHACurrentSwingHorizontalMode(self): - # Sync current HVAC Horizontal Swing mode state to HA - for key, value in MODES_MAPPING.get("SwingLfRig").items(): - if value == (self._acOptions["SwingLfRig"]): - self._swing_horizontal_mode = key - _LOGGER.debug( - "HA horizontal swing mode set according to HVAC state to: " - + str(self._swing_horizontal_mode) - ) - - def UpdateHAFanMode(self): - # Sync current HVAC Fan mode state to HA - if int(self._acOptions["Tur"]) == 1: - turbo_index = self._fan_modes.index("turbo") - self._fan_mode = self._fan_modes[turbo_index] - elif int(self._acOptions["Quiet"]) >= 1: - quiet_index = self._fan_modes.index("quiet") - self._fan_mode = self._fan_modes[quiet_index] - else: - for key, value in MODES_MAPPING.get("WdSpd").items(): - if value == (self._acOptions["WdSpd"]): - self._fan_mode = key - _LOGGER.debug( - "HA fan mode set according to HVAC state to: " + str(self._fan_mode) - ) - - def UpdateHACurrentTemperature(self): - # Use external temperature sensor if available - if self._external_temperature_sensor: - # Use external temperature sensor - external_sensor_state = self.hass.states.get( - self._external_temperature_sensor - ) - if external_sensor_state and external_sensor_state.state not in ( - "unknown", - "unavailable", - ): - try: - unit = external_sensor_state.attributes.get( - ATTR_UNIT_OF_MEASUREMENT - ) - _LOGGER.debug( - f"Using external temperature sensor: {self._external_temperature_sensor}, value: {external_sensor_state.state}, unit: {unit}" - ) - self._current_temperature = self.hass.config.units.temperature( - float(external_sensor_state.state), unit - ) - _LOGGER.debug( - f"External temperature: {self._current_temperature} {self._unit_of_measurement}" - ) - return - except (ValueError, TypeError) as ex: - _LOGGER.error( - "Unable to update from external temp sensor %s: %s", - self._external_temperature_sensor, - ex, - ) - - # Use built-in AC temperature sensor if available - if self._has_temp_sensor: - _LOGGER.debug( - "method UpdateHACurrentTemperature: TemSen: " - + str(self._acOptions["TemSen"]) - ) - - if self._temp_sensor_offset is None: # user hasn't chosen an offset - # User hasn't set automaticaly, so try to determine the offset - temp_c = self._process_temp_sensor(self._acOptions["TemSen"]) - _LOGGER.debug( - "method UpdateHACurrentTemperature: User has not chosen an offset, using process_temp_sensor() to automatically determine offset." - ) - else: - # User set - if self._temp_sensor_offset is True: - temp_c = self._acOptions["TemSen"] - TEMSEN_OFFSET - - elif self._temp_sensor_offset is False: - temp_c = self._acOptions["TemSen"] - - _LOGGER.debug( - f"method UpdateHACurrentTemperature: User has chosen an offset ({self._temp_sensor_offset})" - ) - - temp_f = gree_c_to_f( - SetTem=temp_c, TemRec=0 - ) # Convert to Fahrenheit using TemRec bit - - if self._unit_of_measurement == "°C": - self._current_temperature = temp_c - elif self._unit_of_measurement == "°F": - self._current_temperature = temp_f - else: - _LOGGER.error( - "Unknown unit of measurement: %s" % self._unit_of_measurement - ) - - _LOGGER.debug( - "method UpdateHACurrentTemperature: HA current temperature set with device built-in temperature sensor state : " - + str(self._current_temperature) - + str(self._unit_of_measurement) - ) - - def UpdateHAOutsideTemperature(self): - # Update outside temperature from built-in AC outside temperature sensor if available - if self._has_outside_temp_sensor: - _LOGGER.debug( - "method UpdateHAOutsideTemperature: OutEnvTem: " - + str(self._acOptions["OutEnvTem"]) - ) - - if self._temp_sensor_offset is None: # user hasn't chosen an offset - # User hasn't set automatically, so try to determine the offset - temp_c = self._process_temp_sensor(self._acOptions["OutEnvTem"]) - _LOGGER.debug( - "method UpdateHAOutsideTemperature: User has not chosen an offset, using process_temp_sensor() to automatically determine offset." - ) - else: - # User set - if self._temp_sensor_offset is True: - temp_c = self._acOptions["OutEnvTem"] - TEMSEN_OFFSET - elif self._temp_sensor_offset is False: - temp_c = self._acOptions["OutEnvTem"] - - _LOGGER.debug( - f"method UpdateHAOutsideTemperature: User has chosen an offset ({self._temp_sensor_offset})" - ) - - temp_f = gree_c_to_f( - SetTem=temp_c, TemRec=0 - ) # Convert to Fahrenheit using TemRec bit - - if self._unit_of_measurement == "°C": - self._current_outside_temperature = temp_c - elif self._unit_of_measurement == "°F": - self._current_outside_temperature = temp_f - else: - _LOGGER.error( - "Unknown unit of measurement for outside temperature: %s" - % self._unit_of_measurement - ) - - _LOGGER.debug( - "method UpdateHAOutsideTemperature: HA outside temperature set with device built-in outside temperature sensor state : " - + str(self._current_outside_temperature) - + str(self._unit_of_measurement) - ) - - def UpdateHARoomHumidity(self): - # Update room humidity from built-in AC room humidity sensor if available - if self._has_room_humidity_sensor: - _LOGGER.debug( - "method UpdateHARoomHumidity: DwatSen: " - + str(self._acOptions["DwatSen"]) - ) - self._current_room_humidity = self._acOptions["DwatSen"] - _LOGGER.debug( - "method UpdateHARoomHumidity: HA room humidity set with device built-in room humidity sensor state : " - + str(self._current_room_humidity) - + "%" - ) - - def UpdateHAStateToCurrentACState(self): - self.UpdateHATargetTemperature() - self.UpdateHAHvacMode() - if self._swing_modes: - self.UpdateHACurrentSwingMode() - if self._swing_horizontal_modes: - self.UpdateHACurrentSwingHorizontalMode() - self.UpdateHAFanMode() - self.UpdateHACurrentTemperature() - self.UpdateHAOutsideTemperature() - self.UpdateHARoomHumidity() - - def SyncState(self, acOptions={}): - # Fetch current settings from HVAC - _LOGGER.debug("Starting SyncState") - - if self._has_temp_sensor is None: - _LOGGER.debug( - "Attempt to check whether device has an built-in temperature sensor" - ) - try: - temp_sensor = self.GreeGetValues(["TemSen"]) - except Exception: - _LOGGER.debug( - "Could not determine whether device has an built-in temperature sensor. Retrying at next update()" - ) - else: - if temp_sensor: - self._has_temp_sensor = True - self._acOptions.update({"TemSen": None}) - self._optionsToFetch.append("TemSen") - _LOGGER.debug("Device has an built-in temperature sensor") - else: - self._has_temp_sensor = False - _LOGGER.debug("Device has no built-in temperature sensor") - - # Check if device has anti direct blow feature - if self._has_anti_direct_blow is None: - _LOGGER.debug( - "Attempt to check whether device has an anti direct blow feature" - ) - try: - anti_direct_blow = self.GreeGetValues(["AntiDirectBlow"]) - except Exception: - _LOGGER.debug( - "Could not determine whether device has an anti direct blow feature. Retrying at next update()" - ) - else: - if anti_direct_blow: - self._has_anti_direct_blow = True - self._acOptions.update({"AntiDirectBlow": None}) - self._optionsToFetch.append("AntiDirectBlow") - _LOGGER.debug("Device has an anti direct blow feature") - else: - self._has_anti_direct_blow = False - _LOGGER.debug("Device has no anti direct blow feature") - - # Check if device has light sensor - if self._has_light_sensor is None: - _LOGGER.debug("Attempt to check whether device has a built-in light sensor") - try: - light_sensor = self.GreeGetValues(["LigSen"]) - except Exception: - _LOGGER.debug( - "Could not determine whether device has a built-in light sensor. Retrying at next update()" - ) - else: - if light_sensor: - self._has_light_sensor = True - self._acOptions.update({"LigSen": None}) - self._optionsToFetch.append("LigSen") - _LOGGER.debug("Device has a built-in light sensor") - else: - self._has_light_sensor = False - _LOGGER.debug("Device has no built-in light sensor") - - # Check if device has outside temperature sensor - if self._has_outside_temp_sensor is None: - _LOGGER.debug( - "Attempt to check whether device has an outside temperature sensor" - ) - try: - outside_temp_sensor = self.GreeGetValues(["OutEnvTem"]) - except Exception: - _LOGGER.debug( - "Could not determine whether device has an outside temperature sensor. Retrying at next update()" - ) - else: - if outside_temp_sensor: - self._has_outside_temp_sensor = True - self._acOptions.update({"OutEnvTem": None}) - self._optionsToFetch.append("OutEnvTem") - _LOGGER.debug("Device has an outside temperature sensor") - else: - self._has_outside_temp_sensor = False - _LOGGER.debug("Device has no outside temperature sensor") - - # Check if device has room humidity sensor - if self._has_room_humidity_sensor is None: - _LOGGER.debug("Attempt to check whether device has a room humidity sensor") - try: - humidity_sensor = self.GreeGetValues(["DwatSen"]) - except Exception: - _LOGGER.debug( - "Could not determine whether device has a room humidity sensor. Retrying at next update()" - ) - else: - if humidity_sensor: - self._has_room_humidity_sensor = True - self._acOptions.update({"DwatSen": None}) - self._optionsToFetch.append("DwatSen") - _LOGGER.debug("Device has a room humidity sensor") - else: - self._has_room_humidity_sensor = False - _LOGGER.debug("Device has no room humidity sensor") - - optionsToFetch = self._optionsToFetch - - try: - currentValues = self.GreeGetValues(optionsToFetch) - except Exception: - _LOGGER.info("Could not connect with device. ") - if not self._disable_available_check: - self._online_attempts += 1 - if self._online_attempts == self._max_online_attempts: - _LOGGER.info( - "Could not connect with device %s times. Set it as offline." - % self._max_online_attempts - ) - self._device_online = False - self._online_attempts = 0 - else: - if not self._disable_available_check: - if not self._device_online: - self._device_online = True - self._online_attempts = 0 - # Set latest status from device - self._acOptions = self.SetAcOptions( - self._acOptions, optionsToFetch, currentValues - ) - - # Overwrite status with our choices - if not (acOptions == {}): - self._acOptions = self.SetAcOptions(self._acOptions, acOptions) - - # Initialize the receivedJsonPayload variable (for return) - receivedJsonPayload = "" - - # If not the first (boot) run, update state towards the HVAC - if not (self._firstTimeRun): - if not (acOptions == {}): - # loop used to send changed settings from HA to HVAC - self.SendStateToAc(self._timeout) - else: - # loop used once for Gree Climate initialisation only - self._firstTimeRun = False - - # Update HA state to current HVAC state - self.UpdateHAStateToCurrentACState() - - _LOGGER.debug("Finished SyncState") - return receivedJsonPayload - - @property - def should_poll(self): - _LOGGER.debug("should_poll()") - # Return the polling state. - return True - - @property - def available(self): - if self._disable_available_check: - return True - else: - if self._device_online: - _LOGGER.info("available(): Device is online") - return True - else: - _LOGGER.info("available(): Device is offline") - return False - - def update(self): - _LOGGER.debug("update()") - if not self._encryption_key: - if self.encryption_version == 1: - key = GetDeviceKey( - self._mac_addr, self._ip_addr, self._port, self._timeout - ) - if key: - self._encryption_key = key - self.CIPHER = AES.new(self._encryption_key, AES.MODE_ECB) - self.SyncState() - elif self.encryption_version == 2: - key = GetDeviceKeyGCM( - self._mac_addr, self._ip_addr, self._port, self._timeout - ) - if key: - self._encryption_key = key - self.CIPHER = GetGCMCipher(self._encryption_key) - self.SyncState() - else: - _LOGGER.error( - "Encryption version %s is not implemented." - % self.encryption_version - ) - else: - self.SyncState() - - @property - def name(self): - _LOGGER.debug("name(): " + str(self._name)) - # Return the name of the climate device. - return self._name - - @property - def temperature_unit(self): - _LOGGER.debug("temperature_unit(): " + str(self._unit_of_measurement)) - # Return the unit of measurement. - return self._unit_of_measurement - - @property - def current_temperature(self): - _LOGGER.debug("current_temperature(): " + str(self._current_temperature)) - # Return the current temperature. - return self._current_temperature - - @property - def min_temp(self): - if self._unit_of_measurement == "°C": - MIN_TEMP = MIN_TEMP_C - else: - MIN_TEMP = MIN_TEMP_F - - _LOGGER.debug("min_temp(): " + str(MIN_TEMP)) - # Return the minimum temperature. - return MIN_TEMP - - @property - def max_temp(self): - if self._unit_of_measurement == "°C": - MAX_TEMP = MAX_TEMP_C - else: - MAX_TEMP = MAX_TEMP_F - - _LOGGER.debug("max_temp(): " + str(MAX_TEMP)) - # Return the maximum temperature. - return MAX_TEMP - - @property - def target_temperature(self): - _LOGGER.debug("target_temperature(): " + str(self._target_temperature)) - # Return the temperature we try to reach. - return self._target_temperature - - @property - def target_temperature_step(self): - _LOGGER.debug( - "target_temperature_step(): " + str(self._target_temperature_step) - ) - return self._target_temperature_step - - @property - def hvac_mode(self): - _LOGGER.debug("hvac_mode(): " + str(self._hvac_mode)) - # Return current operation mode ie. heat, cool, idle. - return self._hvac_mode - - @property - def swing_mode(self): - if self._swing_modes: - _LOGGER.debug("swing_mode(): " + str(self._swing_mode)) - # get the current swing mode - return self._swing_mode - else: - return None - - @property - def swing_modes(self): - _LOGGER.debug("swing_modes(): " + str(self._swing_modes)) - # get the list of available swing modes - return self._swing_modes - - @property - def swing_horizontal_mode(self): - if self._swing_horizontal_modes: - _LOGGER.debug( - "swing_horizontal_mode(): " + str(self._swing_horizontal_mode) - ) - # get the current preset mode - return self._swing_horizontal_mode - else: - return None - - @property - def swing_horizontal_modes(self): - _LOGGER.debug("swing_horizontal_modes(): " + str(self._swing_horizontal_modes)) - # get the list of available preset modes - return self._swing_horizontal_modes - - @property - def hvac_modes(self): - _LOGGER.debug("hvac_modes(): " + str(self._hvac_modes)) - # Return the list of available operation modes. - return self._hvac_modes - - @property - def fan_mode(self): - _LOGGER.debug("fan_mode(): " + str(self._fan_mode)) - # Return the fan mode. - return self._fan_mode - - @property - def fan_modes(self): - _LOGGER.debug("fan_list(): " + str(self._fan_modes)) - # Return the list of available fan modes. - return self._fan_modes - - @property - def supported_features(self): - sf = SUPPORT_FLAGS - if self._swing_modes: - sf = sf | ClimateEntityFeature.SWING_MODE - if self._swing_horizontal_modes: - sf = sf | ClimateEntityFeature.SWING_HORIZONTAL_MODE - _LOGGER.debug("supported_features(): " + str(sf)) - # Return the list of supported features. - return sf - - @property - def unique_id(self): - # Return unique_id - return self._unique_id - - @property - def device_info(self) -> DeviceInfo: - """Return device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self._mac_addr)}, - name=self._name, - manufacturer="Gree", - ) - - @property - def outside_temperature(self): - """Return the outside temperature if available.""" - if self._has_outside_temp_sensor: - _LOGGER.debug( - "outside_temperature(): " + str(self._current_outside_temperature) - ) - return self._current_outside_temperature - return None - - @property - def room_humidity(self): - """Return the current room humidity if available.""" - if self._has_room_humidity_sensor: - _LOGGER.debug("room_humidity(): " + str(self._current_room_humidity)) - return self._current_room_humidity - return None - - @property - def extra_state_attributes(self): - """Return additional state attributes.""" - attributes = {} - - if self.outside_temperature is not None: - attributes["outside_temperature"] = self.outside_temperature - attributes["outside_temperature_unit"] = self._unit_of_measurement - - if self.room_humidity is not None: - attributes["room_humidity"] = self.room_humidity - attributes["humidity_unit"] = "%" - - return attributes if attributes else None - - def set_temperature(self, **kwargs): - s = kwargs.get(ATTR_TEMPERATURE) - - _LOGGER.info("set_temperature(): " + str(s) + str(self._unit_of_measurement)) - # Set new target temperatures. - if s is not None: - # do nothing if temperature is none - if not (self._acOptions["Pow"] == 0): - # do nothing if HVAC is switched off - - if self._unit_of_measurement == "°C": - SetTem, TemRec = encode_temp_c(T=s) # takes care of 1/2 degrees - elif self._unit_of_measurement == "°F": - SetTem, TemRec = gree_f_to_c(desired_temp_f=s) - else: - _LOGGER.error( - "Unable to set temperature. Units not set to °C or °F" - ) - return - - self.SyncState({"SetTem": int(SetTem), "TemRec": int(TemRec)}) - _LOGGER.debug( - "method set_temperature: Set Temp to " - + str(s) - + str(self._unit_of_measurement) - + " -> SyncState with SetTem=" - + str(SetTem) - + ", SyncState with TemRec=" - + str(TemRec) - ) - - self.schedule_update_ha_state() - - def set_swing_mode(self, swing_mode): - _LOGGER.info("Set swing mode(): " + str(swing_mode)) - # set the swing mode - if not (self._acOptions["Pow"] == 0): - # do nothing if HVAC is switched off - try: - sw_up_dn = MODES_MAPPING.get("SwUpDn").get(swing_mode) - _LOGGER.info("SyncState with SwUpDn=" + str(sw_up_dn)) - self.SyncState({"SwUpDn": sw_up_dn}) - self.schedule_update_ha_state() - except ValueError: - _LOGGER.error(f"Unknown swing mode: {swing_mode}") - return - - def set_swing_horizontal_mode(self, swing_horizontal_mode): - if not (self._acOptions["Pow"] == 0): - # do nothing if HVAC is switched off - try: - swing_lf_rig = MODES_MAPPING.get("SwingLfRig").get( - swing_horizontal_mode - ) - _LOGGER.info("SyncState with SwingLfRig=" + str(swing_lf_rig)) - self.SyncState({"SwingLfRig": swing_lf_rig}) - self.schedule_update_ha_state() - except ValueError: - _LOGGER.error(f"Unknown preset mode: {swing_horizontal_mode}") - return - - def set_fan_mode(self, fan): - _LOGGER.info("set_fan_mode(): " + str(fan)) - # Set the fan mode. - if not (self._acOptions["Pow"] == 0): - try: - wd_spd = MODES_MAPPING.get("WdSpd").get(fan) - - # Check if this is turbo mode - if fan == "turbo": - _LOGGER.info("Enabling turbo mode") - self.SyncState({"Tur": 1, "Quiet": 0}) - # Check if this is quiet mode - elif fan == "quiet": - _LOGGER.info("Enabling quiet mode") - self.SyncState({"Tur": 0, "Quiet": 1}) - else: - _LOGGER.info("Setting normal fan mode to " + str(wd_spd)) - self.SyncState({"WdSpd": str(wd_spd), "Tur": 0, "Quiet": 0}) - - self.schedule_update_ha_state() - except ValueError: - _LOGGER.error(f"Unknown fan mode: {fan}") - return - - def set_hvac_mode(self, hvac_mode): - _LOGGER.info("set_hvac_mode(): " + str(hvac_mode)) - # Set new operation mode. - c = {} - if hvac_mode == HVACMode.OFF: - c.update({"Pow": 0}) - if hasattr(self, "_auto_light") and self._auto_light: - c.update({"Lig": 0}) - else: - mod = MODES_MAPPING.get("Mod").get(hvac_mode) - c.update({"Pow": 1, "Mod": mod}) - if hasattr(self, "_auto_light") and self._auto_light: - c.update({"Lig": 1}) - if hasattr(self, "_auto_xfan") and self._auto_xfan: - if (hvac_mode == HVACMode.COOL) or (hvac_mode == HVACMode.DRY): - c.update({"Blo": 1}) - self.SyncState(c) - self.schedule_update_ha_state() - - def turn_on(self): - _LOGGER.info("turn_on(): ") - # Turn on. - c = {"Pow": 1} - if hasattr(self, "_auto_light") and self._auto_light: - c.update({"Lig": 1}) - self.SyncState(c) - self.schedule_update_ha_state() - - def turn_off(self): - _LOGGER.info("turn_off(): ") - # Turn off. - c = {"Pow": 0} - if hasattr(self, "_auto_light") and self._auto_light: - c.update({"Lig": 0}) - self.SyncState(c) - self.schedule_update_ha_state() - - async def async_added_to_hass(self): - _LOGGER.info("Gree climate device added to hass()") - self.update() - - async def async_will_remove_from_hass(self) -> None: - """Clean up when entity is removed.""" - for name, entity_id, unsub in self._listeners: - _LOGGER.debug("Deregistering %s listener for %s", name, entity_id) - unsub() - self._listeners.clear() diff --git a/custom_components/gree/old_config_flow.py b/custom_components/gree/old_config_flow.py deleted file mode 100755 index 9155a1d..0000000 --- a/custom_components/gree/old_config_flow.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Config flow for Gree climate integration.""" - -from __future__ import annotations - -# Standard library imports -import logging - -# Third-party imports -import voluptuous as vol - -# Home Assistant imports -from homeassistant import config_entries -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, CONF_TIMEOUT -from homeassistant.core import callback -from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import selector - -# Local imports -from .const import ( - CONF_DISABLE_AVAILABLE_CHECK, - CONF_ENCRYPTION_KEY, - CONF_ENCRYPTION_VERSION, - CONF_FAN_MODES, - CONF_HVAC_MODES, - CONF_MAX_ONLINE_ATTEMPTS, - CONF_SWING_HORIZONTAL_MODES, - CONF_SWING_MODES, - CONF_TEMP_SENSOR_OFFSET, - CONF_UID, - DEFAULT_FAN_MODES, - DEFAULT_HVAC_MODES, - DEFAULT_PORT, - DEFAULT_SWING_HORIZONTAL_MODES, - DEFAULT_SWING_MODES, - DEFAULT_TIMEOUT, - DOMAIN, - OPTION_KEYS, -) - -_LOGGER = logging.getLogger(__name__) - - -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for Gree climate.""" - - VERSION = 1 - - def __init__(self) -> None: - self._data: dict[str, any] = {} - - async def async_step_user(self, user_input: dict | None = None) -> FlowResult: - """Handle the initial step.""" - if user_input is not None: - self._data.update(user_input) - return self.async_create_entry(title=user_input.get(CONF_NAME) or "Gree Climate", data=self._data) - - data_schema = vol.Schema( - { - vol.Required(CONF_NAME): str, - vol.Required(CONF_HOST): str, - vol.Required(CONF_MAC): str, - vol.Required(CONF_PORT, default=DEFAULT_PORT): int, - vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): int, - vol.Optional(CONF_ENCRYPTION_KEY): str, - vol.Optional(CONF_UID): int, - vol.Optional(CONF_ENCRYPTION_VERSION, default=1): int, - } - ) - return self.async_show_form(step_id="user", data_schema=data_schema) - - async def async_step_import(self, import_data: dict) -> FlowResult: - """Handle configuration via YAML import.""" - return await self.async_step_user(import_data) - - @staticmethod - @callback - def async_get_options_flow( - config_entry: config_entries.ConfigEntry, - ) -> config_entries.OptionsFlow: - return OptionsFlowHandler(config_entry) - - -class OptionsFlowHandler(config_entries.OptionsFlow): - """Handle an options flow for Gree climate.""" - - def __init__(self, config_entry: config_entries.ConfigEntry) -> None: - self.config_entry = config_entry - - async def async_step_init(self, user_input: dict | None = None) -> FlowResult: - if user_input is not None: - _LOGGER.debug("Raw user options input: %s", user_input) - normalized_input: dict[str, str | None] = {} - # Only handle known option keys - for key in OPTION_KEYS: - if key in user_input: - value = user_input[key] - normalized_input[key] = value if value not in (None, "") else None - elif key in self.config_entry.options: - normalized_input[key] = None - _LOGGER.debug("Normalized options to save: %s", normalized_input) - result = self.async_create_entry(title="", data=normalized_input) - _LOGGER.debug("Creating entry with options: %s", normalized_input) - return result - - options = {key: value for key, value in self.config_entry.options.items() if key in OPTION_KEYS} - _LOGGER.debug("Current stored options: %s", options) - schema = vol.Schema( - { - vol.Optional( - CONF_HVAC_MODES, - description={"suggested_value": options.get(CONF_HVAC_MODES, DEFAULT_HVAC_MODES)}, - default=options.get(CONF_HVAC_MODES, DEFAULT_HVAC_MODES), - ): vol.Any(None, selector.SelectSelector(selector.SelectSelectorConfig(options=DEFAULT_HVAC_MODES, multiple=True, custom_value=True, translation_key=CONF_HVAC_MODES))), - vol.Optional( - CONF_FAN_MODES, - description={"suggested_value": options.get(CONF_FAN_MODES, DEFAULT_FAN_MODES)}, - default=options.get(CONF_FAN_MODES, DEFAULT_FAN_MODES), - ): vol.Any(None, selector.SelectSelector(selector.SelectSelectorConfig(options=DEFAULT_FAN_MODES, multiple=True, custom_value=True, translation_key=CONF_FAN_MODES))), - vol.Optional( - CONF_SWING_MODES, - description={"suggested_value": options.get(CONF_SWING_MODES, DEFAULT_SWING_MODES)}, - default=options.get(CONF_SWING_MODES, DEFAULT_SWING_MODES), - ): vol.Any(None, selector.SelectSelector(selector.SelectSelectorConfig(options=DEFAULT_SWING_MODES, multiple=True, custom_value=True, translation_key=CONF_SWING_MODES))), - vol.Optional( - CONF_SWING_HORIZONTAL_MODES, - description={"suggested_value": options.get(CONF_SWING_HORIZONTAL_MODES, DEFAULT_SWING_HORIZONTAL_MODES)}, - default=options.get(CONF_SWING_HORIZONTAL_MODES, DEFAULT_SWING_HORIZONTAL_MODES), - ): vol.Any(None, selector.SelectSelector(selector.SelectSelectorConfig(options=DEFAULT_SWING_HORIZONTAL_MODES, multiple=True, custom_value=True, translation_key=CONF_SWING_HORIZONTAL_MODES))), - vol.Optional( - CONF_DISABLE_AVAILABLE_CHECK, - default=options.get(CONF_DISABLE_AVAILABLE_CHECK, False), - ): bool, - vol.Optional( - CONF_MAX_ONLINE_ATTEMPTS, - default=options.get(CONF_MAX_ONLINE_ATTEMPTS, 3), - ): int, - vol.Optional( - CONF_TEMP_SENSOR_OFFSET, - description={"suggested_value": options.get(CONF_TEMP_SENSOR_OFFSET)}, - ): vol.Any(None, bool), - } - ) - return self.async_show_form(step_id="init", data_schema=schema) diff --git a/custom_components/gree/old_gree_protocol.py b/custom_components/gree/old_gree_protocol.py deleted file mode 100755 index 13ce5a2..0000000 --- a/custom_components/gree/old_gree_protocol.py +++ /dev/null @@ -1,91 +0,0 @@ -""" -Gree protocol/network logic for Home Assistant integration. -""" - -# Standard library imports -import base64 -import logging -import socket - -# Third-party imports -try: - import simplejson -except ImportError: - import json as simplejson -from Crypto.Cipher import AES - -_LOGGER = logging.getLogger(__name__) - -GCM_IV = b"\x54\x40\x78\x44\x49\x67\x5a\x51\x6c\x5e\x63\x13" -GCM_ADD = b"qualcomm-test" -GENERIC_GREE_DEVICE_KEY = "a3K8Bx%2r8Y7#xDh" -GENERIC_GREE_DEVICE_KEY_GCM = b"{yxAHAY_Lm6pbC/<" - - -def Pad(s): - aesBlockSize = 16 - return s + (aesBlockSize - len(s) % aesBlockSize) * chr(aesBlockSize - len(s) % aesBlockSize) - - -def FetchResult(cipher, ip_addr, port, timeout, json_data, encryption_version=1): - _LOGGER.debug("Fetching(%s, %s, %s, %s)" % (ip_addr, port, timeout, json_data)) - clientSock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - clientSock.settimeout(timeout) - clientSock.sendto(bytes(json_data, "utf-8"), (ip_addr, port)) - data, addr = clientSock.recvfrom(64000) - receivedJson = simplejson.loads(data) - clientSock.close() - pack = receivedJson["pack"] - base64decodedPack = base64.b64decode(pack) - decryptedPack = cipher.decrypt(base64decodedPack) - if encryption_version == 2: - tag = receivedJson["tag"] - cipher.verify(base64.b64decode(tag)) - decodedPack = decryptedPack.decode("utf-8") - replacedPack = decodedPack.replace("\x0f", "").replace(decodedPack[decodedPack.rindex("}") + 1 :], "") - loadedJsonPack = simplejson.loads(replacedPack) - return loadedJsonPack - - -def GetDeviceKey(mac_addr, ip_addr, port, timeout): - _LOGGER.info("Retrieving HVAC encryption key") - cipher = AES.new(GENERIC_GREE_DEVICE_KEY.encode("utf8"), AES.MODE_ECB) - pack = base64.b64encode(cipher.encrypt(Pad('{"mac":"' + str(mac_addr) + '","t":"bind","uid":0}').encode("utf8"))).decode("utf-8") - jsonPayloadToSend = '{"cid": "app","i": 1,"pack": "' + pack + '","t":"pack","tcid":"' + str(mac_addr) + '","uid": 0}' - try: - key = FetchResult(cipher, ip_addr, port, timeout, jsonPayloadToSend)["key"].encode("utf8") - except Exception: - _LOGGER.info("Error getting device encryption key!") - return None - else: - _LOGGER.info("Fetched device encryption key: %s" % str(key)) - return key - - -def GetGCMCipher(key): - cipher = AES.new(key, AES.MODE_GCM, nonce=GCM_IV) - cipher.update(GCM_ADD) - return cipher - - -def EncryptGCM(key, plaintext): - cipher = GetGCMCipher(key) - encrypted_data, tag = cipher.encrypt_and_digest(plaintext.encode("utf8")) - pack = base64.b64encode(encrypted_data).decode("utf-8") - tag = base64.b64encode(tag).decode("utf-8") - return (pack, tag) - - -def GetDeviceKeyGCM(mac_addr, ip_addr, port, timeout): - _LOGGER.info("Retrieving HVAC encryption key (GCM)") - plaintext = '{"cid":"' + str(mac_addr) + '", "mac":"' + str(mac_addr) + '","t":"bind","uid":0}' - pack, tag = EncryptGCM(GENERIC_GREE_DEVICE_KEY_GCM, plaintext) - jsonPayloadToSend = '{"cid": "app","i": 1,"pack": "' + pack + '","t":"pack","tcid":"' + str(mac_addr) + '","uid": 0, "tag" : "' + tag + '"}' - try: - key = FetchResult(GetGCMCipher(GENERIC_GREE_DEVICE_KEY_GCM), ip_addr, port, timeout, jsonPayloadToSend, encryption_version=2)["key"].encode("utf8") - except Exception: - _LOGGER.info("Error getting device encryption key!") - return None - else: - _LOGGER.info("Fetched device encryption key: %s" % str(key)) - return key diff --git a/custom_components/gree/old_helpers.py b/custom_components/gree/old_helpers.py deleted file mode 100755 index 079e0e7..0000000 --- a/custom_components/gree/old_helpers.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Helper functions and classes for Gree integration.""" - -from .const import TEMSEN_OFFSET - - -class TempOffsetResolver: - """ - Detect whether this sensor reports temperatures in °C - or in (°C + 40). Continues to check, and bases decision - on historical min and max raw values, since there are extreme - cases which would result in a switch. Two running values are - stored (min & max raw). - - Note: This could be simplified by just using 40C as a max point - for the unoffset case and a min point for the offset case. But - this doesn't account for the marginal cases around 40C as well. - - Example: - - if raw < 40: - return raw - else: - return raw - 40 - """ - - def __init__( - self, - indoor_min: float = -15.0, # coldest plausible indoor °C - indoor_max: float = 40.0, # hottest plausible indoor °C - offset: float = TEMSEN_OFFSET, # device's fixed offset - margin: float = 2.0, # tolerance before "impossible": - ): - self._lo_lim = indoor_min - margin - self._hi_lim = indoor_max + margin - self._offset = offset - - self._min_raw: float | None = None - self._max_raw: float | None = None - self._has_offset: bool | None = None # undecided until True/False - - def __call__(self, raw: float) -> float: - if self._min_raw is None or raw < self._min_raw: - self._min_raw = raw - if self._max_raw is None or raw > self._max_raw: - self._max_raw = raw - self._evaluate() # evaluate every time, so it can change it's mind as needed - return raw - self._offset if self._has_offset else raw - - def _evaluate(self) -> None: - lo, hi = self._min_raw, self._max_raw - 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 - self._has_offset = penalty_off < penalty_no - - def _penalty(self, lo: float, hi: float) -> float: - pen = 0.0 - if lo < self._lo_lim: - pen += self._lo_lim - lo - if hi > self._hi_lim: - pen += hi - self._hi_lim - return pen - - -def gree_f_to_c(desired_temp_f): - # Convert to fractional C values for AC - # See: https://github.com/tomikaa87/gree-remote - SetTem = round((desired_temp_f - 32.0) * 5.0 / 9.0) - TemRec = (int)((((desired_temp_f - 32.0) * 5.0 / 9.0) - SetTem) > -0.001) - - return SetTem, TemRec - - -def gree_c_to_f(SetTem, TemRec): - # Convert SetTem back to the minimum and maximum Fahrenheit before rounding - # We consider the worst case scenario: SetTem could be the result of rounding from any value in a range - # If TemRec is 1, it indicates the value was closer to the upper range of the rounding - # If TemRec is 0, it indicates the value was closer to the lower range - - if TemRec == 1: - # SetTem is closer to its higher bound, so we consider SetTem as the lower limit - min_celsius = SetTem - max_celsius = SetTem + 0.4999 # Just below the next rounding threshold - else: - # SetTem is closer to its lower bound, so we consider SetTem-1 as the potential lower limit - min_celsius = SetTem - 0.4999 # Just above the previous rounding threshold - max_celsius = SetTem - - # Convert these Celsius values back to Fahrenheit - min_fahrenheit = (min_celsius * 9.0 / 5.0) + 32.0 - max_fahrenheit = (max_celsius * 9.0 / 5.0) + 32.0 - - int_fahrenheit = round((min_fahrenheit + max_fahrenheit) / 2.0) - - return int_fahrenheit - - -def encode_temp_c(T): - """ - Used for encoding 1/2 degree Celsius values. - 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) - """ - # 1) Compute "twice T" and round to nearest integer: - # math.floor(T * 2 + 0.5) is equivalent to rounding ties upward. - n = int(round(T * 2)) - - # 2) The low bit of n says ".5" (odd) versus ".0" (even): - TemRec = n & 1 - - # 3) Shifting right by 1 gives floor(n/2), i.e. the integer °C of that nearest half‐step: - SetTem = n >> 1 - - return SetTem, TemRec - - -def decode_temp_c(SetTem: int, TemRec: int) -> float: - """ - Given: - SetTem = the "rounded-down" integer (⌊T⌋ or for negatives, floor(T)) - TemRec = 0 or 1, where 1 means "there was a 0.5" - Returns the original temperature as a float. - """ - return SetTem + (0.5 if TemRec else 0.0) diff --git a/custom_components/gree/old_select.py b/custom_components/gree/old_select.py deleted file mode 100755 index 1481b83..0000000 --- a/custom_components/gree/old_select.py +++ /dev/null @@ -1,160 +0,0 @@ -"""Support for Gree select entities (e.g., external temperature sensor selection).""" - -from __future__ import annotations - -# Standard library imports -import logging -from collections.abc import Callable -from dataclasses import dataclass - -# Home Assistant imports -from homeassistant.components.select import ( - SelectEntity, - SelectEntityDescription, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import EntityCategory -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity - -# Local imports -from .entity import OldGreeEntity, OldGreeEntityDescription - -_LOGGER = logging.getLogger(__name__) - - -@dataclass -class GreeSelectEntityDescription(OldGreeEntityDescription, SelectEntityDescription): - """Describes Gree select entity.""" - - set_fn: Callable[[object, str], None] = None - restore_state: bool = False - options_fn: Callable[[object], list[str]] = None - - -def get_temperature_sensor_options(hass: HomeAssistant) -> list[str]: - """Get list of available temperature sensor entities.""" - options = ["None"] # Always include "None" as first option - - # Get all entities from the registry - for state in hass.states.async_all(): - # Look for temperature sensors - if state.entity_id.startswith("sensor."): - # Check for explicit device_class - if state.attributes.get("device_class") == "temperature": - options.append(state.entity_id) - # Also check for temperature units as fallback for helpers/combined sensors - elif state.attributes.get("unit_of_measurement") in ["°C", "°F", "K"]: - options.append(state.entity_id) - - return options - - -SELECTS: tuple[GreeSelectEntityDescription, ...] = ( - GreeSelectEntityDescription( - property_key="external_temperature_sensor", - icon="mdi:thermometer-lines", - options=[], # Will be populated dynamically - value_fn=lambda device: getattr(device, "_external_temperature_sensor", "None"), - set_fn=lambda device, value: setattr( - device, "_external_temperature_sensor", None if value == "None" else value - ), - entity_category=EntityCategory.CONFIG, - restore_state=True, - options_fn=lambda hass: get_temperature_sensor_options(hass), - ), -) - - -async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up Gree select entities based on a config entry.""" - async_add_entities( - GreeSelectEntity(hass, entry, description) for description in SELECTS - ) - - -class GreeSelectEntity(OldGreeEntity, SelectEntity, RestoreEntity): - """Defines a Gree select entity.""" - - entity_description: GreeSelectEntityDescription - - def __init__( - self, hass: HomeAssistant, entry, description: GreeSelectEntityDescription - ) -> None: - super().__init__(hass, entry, description) - self._hass = hass - # Initialize with no external sensor configured - self._device._external_temperature_sensor = None - # Set up options dynamically - if description.options_fn: - self._attr_options = description.options_fn(hass) - else: - self._attr_options = description.options or ["None"] - - async def async_added_to_hass(self) -> None: - """Restore state when entity is added to hass.""" - await super().async_added_to_hass() - - # Refresh options when entity is added - if self.entity_description.options_fn: - self._attr_options = self.entity_description.options_fn(self._hass) - - if self.entity_description.restore_state: - restored = await self.async_get_last_state() - if restored and restored.state not in ("unknown", "unavailable"): - # Restore the external temperature sensor entity ID - if restored.state in self._attr_options: - if self.entity_description.set_fn: - self.entity_description.set_fn(self._device, restored.state) - _LOGGER.debug( - "Restored %s state: %s", self.entity_id, restored.state - ) - else: - _LOGGER.warning( - "Restored state %s not in current options, resetting to None", - restored.state, - ) - if self.entity_description.set_fn: - self.entity_description.set_fn(self._device, "None") - - @property - def current_option(self) -> str: - """Return the current selected option.""" - if self.entity_description.value_fn: - value = self.entity_description.value_fn(self._device) - return value if value in self._attr_options else "None" - return "None" - - async def async_select_option(self, option: str) -> None: - """Select an option.""" - if option not in self._attr_options: - _LOGGER.error("Option %s not available in %s", option, self._attr_options) - return - - if self.entity_description.set_fn: - self.entity_description.set_fn(self._device, option) - self.async_write_ha_state() - _LOGGER.info( - "Selected %s: %s", self.entity_description.property_key, option - ) - - async def async_update(self) -> None: - """Update the entity.""" - # Refresh available temperature sensors periodically - if self.entity_description.options_fn: - new_options = self.entity_description.options_fn(self._hass) - if new_options != self._attr_options: - self._attr_options = new_options - _LOGGER.debug( - "Updated temperature sensor options: %s", self._attr_options - ) - - @property - def available(self) -> bool: - """Return if entity is available.""" - return True diff --git a/custom_components/gree/old_sensor.py b/custom_components/gree/old_sensor.py deleted file mode 100644 index 26a75e2..0000000 --- a/custom_components/gree/old_sensor.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Support for Gree sensors.""" - -import logging -from homeassistant.components.sensor import ( - SensorEntity, - SensorDeviceClass, - SensorStateClass, -) -from homeassistant.const import ( - PERCENTAGE, -) -from homeassistant.helpers.device_registry import DeviceInfo - -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry(hass, entry, async_add_entities): - """Set up Gree sensors from a config entry.""" - # Get the device that was created in __init__.py - entry_data = hass.data[DOMAIN][entry.entry_id] - device = entry_data["device"] - - sensors = [] - - sensors.append(GreeOutsideTemperatureSensor(device)) - _LOGGER.debug("Added outside temperature sensor") - - sensors.append(GreeRoomHumiditySensor(device)) - _LOGGER.debug("Added room humidity sensor") - - if sensors: - async_add_entities(sensors) - _LOGGER.info(f"Added {len(sensors)} Gree sensors") - - -class GreeOutsideTemperatureSensor(SensorEntity): - """Gree outside temperature sensor.""" - - def __init__(self, device): - """Initialize the sensor.""" - self._device = device - self._attr_name = f"{device.name} Outside Temperature" - self._attr_unique_id = f"{device.unique_id}_outside_temp" - self._attr_device_class = SensorDeviceClass.TEMPERATURE - self._attr_state_class = SensorStateClass.MEASUREMENT - self._attr_native_unit_of_measurement = device.temperature_unit - self._attr_suggested_display_precision = 0 - - @property - def device_info(self) -> DeviceInfo: - """Return device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self._device._mac_addr)}, - name=self._device.name, - manufacturer="Gree", - ) - - @property - def native_value(self): - """Return the state of the sensor.""" - # Only return value if sensor is detected and available - if self._device._has_outside_temp_sensor: - return self._device.outside_temperature - return None - - @property - def available(self): - """Return True if entity is available.""" - return self._device.available and self._device._has_outside_temp_sensor - - def update(self): - """Update the sensor.""" - # The climate entity handles the actual data fetching - pass - - -class GreeRoomHumiditySensor(SensorEntity): - """Gree room humidity sensor.""" - - def __init__(self, device): - """Initialize the sensor.""" - self._device = device - self._attr_name = f"{device.name} Room Humidity" - self._attr_unique_id = f"{device.unique_id}_room_humidity" - self._attr_device_class = SensorDeviceClass.HUMIDITY - self._attr_state_class = SensorStateClass.MEASUREMENT - self._attr_native_unit_of_measurement = PERCENTAGE - self._attr_suggested_display_precision = 0 - - @property - def device_info(self) -> DeviceInfo: - """Return device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self._device._mac_addr)}, - name=self._device.name, - manufacturer="Gree", - ) - - @property - def native_value(self): - """Return the state of the sensor.""" - # Only return value if sensor is detected and available - if self._device._has_room_humidity_sensor: - return self._device.room_humidity - return None - - @property - def available(self): - """Return True if entity is available.""" - return self._device.available and self._device._has_room_humidity_sensor - - def update(self): - """Update the sensor.""" - # The climate entity handles the actual data fetching - pass diff --git a/custom_components/gree/old_switch.py b/custom_components/gree/old_switch.py deleted file mode 100755 index dd4c143..0000000 --- a/custom_components/gree/old_switch.py +++ /dev/null @@ -1,207 +0,0 @@ -"""Support for Gree switches.""" - -from __future__ import annotations - -# Standard library imports -import logging -from collections.abc import Callable -from dataclasses import dataclass -from typing import Any - -# Home Assistant imports -from homeassistant.components.climate import HVACMode -from homeassistant.components.switch import ( - SwitchEntity, - SwitchEntityDescription, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import EntityCategory -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity - -# Local imports -from .entity import OldGreeEntity, OldGreeEntityDescription - -_LOGGER = logging.getLogger(__name__) - - -@dataclass -class GreeSwitchEntityDescription(OldGreeEntityDescription, SwitchEntityDescription): - """Describes Gree Switch entity.""" - - set_fn: Callable[[object, bool], None] = None - restore_state: bool = False - """Whether to restore the state of the switch on startup.""" - - -SWITCHES: tuple[GreeSwitchEntityDescription, ...] = ( - GreeSwitchEntityDescription( - property_key="xfan", - icon="mdi:fan", - value_fn=lambda device: device._acOptions.get("Blo") == 1, - set_fn=lambda device, value: device.SyncState({"Blo": 1 if value else 0}), - ), - GreeSwitchEntityDescription( - property_key="lights", - icon="mdi:lightbulb", - value_fn=lambda device: device._acOptions.get("Lig") == 1, - set_fn=lambda device, value: device.SyncState({"Lig": 1 if value else 0}), - ), - GreeSwitchEntityDescription( - property_key="health", - icon="mdi:shield-check", - value_fn=lambda device: device._acOptions.get("Health") == 1, - set_fn=lambda device, value: device.SyncState({"Health": 1 if value else 0}), - ), - GreeSwitchEntityDescription( - property_key="powersave", - icon="mdi:leaf", - value_fn=lambda device: device._acOptions.get("SvSt") == 1, - set_fn=lambda device, value: device.SyncState({"SvSt": 1 if value else 0}), - exists_fn=lambda description, device: HVACMode.COOL in device._hvac_modes, - available_fn=lambda device: device._hvac_mode == HVACMode.COOL, - ), - GreeSwitchEntityDescription( - property_key="eightdegheat", - icon="mdi:thermometer-low", - value_fn=lambda device: device._acOptions.get("StHt") == 1, - set_fn=lambda device, value: device.SyncState({"StHt": 1 if value else 0}), - exists_fn=lambda description, device: HVACMode.HEAT in device._hvac_modes, - available_fn=lambda device: device._hvac_mode == HVACMode.HEAT, - ), - GreeSwitchEntityDescription( - property_key="sleep", - icon="mdi:sleep", - value_fn=lambda device: device._acOptions.get("SwhSlp") == 1 - and device._acOptions.get("SlpMod") == 1, - set_fn=lambda device, value: device.SyncState( - {"SwhSlp": 1 if value else 0, "SlpMod": 1 if value else 0} - ), - available_fn=lambda device: device._hvac_mode in (HVACMode.COOL, HVACMode.HEAT), - ), - GreeSwitchEntityDescription( - property_key="air", - icon="mdi:air-filter", - value_fn=lambda device: device._acOptions.get("Air") == 1, - set_fn=lambda device, value: device.SyncState({"Air": 1 if value else 0}), - ), - GreeSwitchEntityDescription( - property_key="anti_direct_blow", - icon="mdi:weather-windy", - value_fn=lambda device: device._acOptions.get("AntiDirectBlow") == 1, - set_fn=lambda device, value: device.SyncState( - {"AntiDirectBlow": 1 if value else 0} - ), - available_fn=lambda device: getattr(device, "_has_anti_direct_blow", False), - ), - GreeSwitchEntityDescription( - property_key="light_sensor", - icon="mdi:lightbulb-on", - value_fn=lambda device: device._acOptions.get("LigSen") - == 0, # LigSen=0 means sensor is active - set_fn=lambda device, value: device.SyncState( - {"Lig": 1, "LigSen": 0} if value else {"LigSen": 1} - ), - available_fn=lambda device: getattr(device, "_has_light_sensor", False), - ), - # These entities are not kept in the climate device - GreeSwitchEntityDescription( - property_key="auto_xfan", - icon="mdi:fan-auto", - value_fn=lambda device: getattr(device, "_auto_xfan", False), - set_fn=lambda device, value: setattr(device, "_auto_xfan", value), - restore_state=True, - entity_category=EntityCategory.CONFIG, - ), - GreeSwitchEntityDescription( - property_key="auto_light", - icon="mdi:lightbulb-auto", - value_fn=lambda device: getattr(device, "_auto_light", False), - set_fn=lambda device, value: setattr(device, "_auto_light", value), - restore_state=True, - entity_category=EntityCategory.CONFIG, - ), - GreeSwitchEntityDescription( - property_key="beeper", - icon="mdi:volume-high", - value_fn=lambda device: getattr(device, "_beeper_enabled", True), - set_fn=lambda device, value: setattr(device, "_beeper_enabled", value), - restore_state=True, - ), -) - - -async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up Gree switch based on a config entry.""" - async_add_entities( - GreeSwitchEntity(hass, entry, description) for description in SWITCHES - ) - - -class GreeSwitchEntity(OldGreeEntity, SwitchEntity, RestoreEntity): - """Defines a Gree Switch entity.""" - - entity_description: GreeSwitchEntityDescription - - def __init__( - self, - hass, - entry, - description: GreeSwitchEntityDescription, - ) -> None: - super().__init__(hass, entry, description) - self._attr_is_on = bool(self.native_value) - self._restored = False - - async def async_added_to_hass(self): - await super().async_added_to_hass() - # Restore state if applicable - if self.entity_description.restore_state: - last_state = await self.async_get_last_state() - if last_state is not None: - value = last_state.state == "on" - setattr(self._device, f"_{self.entity_description.property_key}", value) - self._attr_is_on = value - self._restored = True - - @property - def native_value(self): - if self.entity_description.restore_state: - return getattr(self, "_attr_is_on", False) - return super().native_value - - @property - def is_on(self) -> bool: - return bool(self.native_value) - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn on the switch.""" - if not self.available: - raise HomeAssistantError("Entity unavailable") - - if self.entity_description.set_fn: - await self.hass.async_add_executor_job( - self.entity_description.set_fn, self._device, True - ) - if self.entity_description.restore_state: - self._attr_is_on = True - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn off the switch.""" - if not self.available: - raise HomeAssistantError("Entity unavailable") - - if self.entity_description.set_fn: - await self.hass.async_add_executor_job( - self.entity_description.set_fn, self._device, False - ) - if self.entity_description.restore_state: - self._attr_is_on = False - self.async_write_ha_state() From 73ebfe2f324b4f671540ebc5efd0c77232fbf07e Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Wed, 17 Sep 2025 22:42:02 +0100 Subject: [PATCH 005/113] Use encryption version as enum and cleanup --- custom_components/gree/__init__.py | 13 ++-- custom_components/gree/climate.py | 46 ++++++------ custom_components/gree/config_flow.py | 35 +++++---- custom_components/gree/const.py | 19 +---- custom_components/gree/gree_api.py | 100 ++++++++++++++++++-------- custom_components/gree/gree_const.py | 1 - custom_components/gree/gree_device.py | 34 ++++++--- custom_components/gree/select.py | 42 +++++------ custom_components/gree/sensor.py | 68 ++++++++---------- custom_components/gree/switch.py | 4 +- 10 files changed, 202 insertions(+), 160 deletions(-) delete mode 100644 custom_components/gree/gree_const.py diff --git a/custom_components/gree/__init__.py b/custom_components/gree/__init__.py index 9b86e57..196d2da 100755 --- a/custom_components/gree/__init__.py +++ b/custom_components/gree/__init__.py @@ -21,14 +21,17 @@ CONF_MAX_ONLINE_ATTEMPTS, CONF_UID, DEFAULT_ENCRYPTION_VERSION, - DEFAULT_MAX_ONLINE_ATTEMPTS, - DEFAULT_PORT, DOMAIN, ) # Home Assistant imports from .coordinator import GreeConfigEntry, GreeCoordinator -from .gree_device import GreeDevice, GreeDeviceNotBoundError +from .gree_device import ( + DEFAULT_CONNECTION_MAX_ATTEMPTS, + DEFAULT_DEVICE_PORT, + GreeDevice, + GreeDeviceNotBoundError, +) PLATFORMS = [ Platform.CLIMATE, @@ -72,14 +75,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool name=conf.get(CONF_NAME, "Gree HVAC"), ip_addr=host, mac_addr=str(conf.get(CONF_MAC, "")).replace(":", ""), - port=conf[CONF_ADVANCED].get(CONF_PORT, DEFAULT_PORT), + port=conf[CONF_ADVANCED].get(CONF_PORT, DEFAULT_DEVICE_PORT), encryption_version=conf[CONF_ADVANCED].get( CONF_ENCRYPTION_VERSION, DEFAULT_ENCRYPTION_VERSION ), encryption_key=conf[CONF_ADVANCED].get(CONF_ENCRYPTION_KEY, ""), uid=conf[CONF_ADVANCED].get(CONF_UID, 0), max_connection_attempts=conf.get( - CONF_MAX_ONLINE_ATTEMPTS, DEFAULT_MAX_ONLINE_ATTEMPTS + CONF_MAX_ONLINE_ATTEMPTS, DEFAULT_CONNECTION_MAX_ATTEMPTS ), ) diff --git a/custom_components/gree/climate.py b/custom_components/gree/climate.py index e8ee411..4c23996 100644 --- a/custom_components/gree/climate.py +++ b/custom_components/gree/climate.py @@ -40,38 +40,25 @@ GATTR_FEAT_TURBO, HVAC_MODES_GREE_TO_HA, HVAC_MODES_HA_TO_GREE, + UNITS_GREE_TO_HA, +) +from .coordinator import GreeConfigEntry, GreeCoordinator +from .entity import GreeEntity, GreeEntityDescription +from .gree_api import ( MAX_TEMP_C, MAX_TEMP_F, MIN_TEMP_C, MIN_TEMP_F, - UNITS_GREE_TO_HA, + FanSpeed, + HorizontalSwingMode, + VerticalSwingMode, ) -from .coordinator import GreeConfigEntry, GreeCoordinator -from .entity import GreeEntity, GreeEntityDescription -from .gree_api import FanSpeed, HorizontalSwingMode, VerticalSwingMode _LOGGER = logging.getLogger(__name__) GATTR_CLIMATE = "hvac" -@dataclass(frozen=True, kw_only=True) -class GreeClimateDescription(GreeEntityDescription, ClimateEntityDescription): - """Description of a Gree Climate entity.""" - - 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, entry: GreeConfigEntry, @@ -138,6 +125,23 @@ async def async_setup_entry( ) +@dataclass(frozen=True, kw_only=True) +class GreeClimateDescription(GreeEntityDescription, ClimateEntityDescription): + """Description of a Gree Climate entity.""" + + 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 + + class GreeClimate(GreeEntity, ClimateEntity, RestoreEntity): # pyright: ignore[reportIncompatibleVariableOverride] """Climate Entity.""" diff --git a/custom_components/gree/config_flow.py b/custom_components/gree/config_flow.py index 23ff19b..b569489 100644 --- a/custom_components/gree/config_flow.py +++ b/custom_components/gree/config_flow.py @@ -9,6 +9,7 @@ import voluptuous as vol from voluptuous.schema_builder import UNDEFINED +from config.custom_components.gree.gree_api import EncryptionVersion from homeassistant import config_entries from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT @@ -41,16 +42,18 @@ CONF_UID, DEFAULT_FAN_MODES, DEFAULT_HVAC_MODES, - DEFAULT_MAX_ONLINE_ATTEMPTS, - DEFAULT_PORT, DEFAULT_SUPPORTED_FEATURES, DEFAULT_SWING_HORIZONTAL_MODES, DEFAULT_SWING_MODES, - DEFAULT_UID, DOMAIN, ) from .coordinator import GreeConfigEntry -from .gree_device import GreeDevice +from .gree_device import ( + DEFAULT_CONNECTION_MAX_ATTEMPTS, + DEFAULT_DEVICE_PORT, + DEFAULT_DEVICE_UID, + GreeDevice, +) _LOGGER = logging.getLogger(__name__) @@ -75,9 +78,11 @@ def build_main_schema(data: Mapping | None) -> vol.Schema | None: { vol.Required( CONF_PORT, - default=DEFAULT_PORT + default=DEFAULT_DEVICE_PORT if data is None or data[CONF_ADVANCED] is None - else data[CONF_ADVANCED].get(CONF_PORT, DEFAULT_PORT), + else data[CONF_ADVANCED].get( + CONF_PORT, DEFAULT_DEVICE_PORT + ), ): int, vol.Required( CONF_ENCRYPTION_VERSION, @@ -95,9 +100,9 @@ def build_main_schema(data: Mapping | None) -> vol.Schema | None: ): str, vol.Required( CONF_UID, - default=DEFAULT_UID + default=DEFAULT_DEVICE_UID if data is None or data[CONF_ADVANCED] is None - else data[CONF_ADVANCED].get(CONF_UID, DEFAULT_UID), + else data[CONF_ADVANCED].get(CONF_UID, DEFAULT_DEVICE_UID), ): int, } ), @@ -178,9 +183,11 @@ def build_options_schema( ), vol.Optional( CONF_MAX_ONLINE_ATTEMPTS, - default=DEFAULT_MAX_ONLINE_ATTEMPTS + default=DEFAULT_CONNECTION_MAX_ATTEMPTS if data is None - else data.get(CONF_MAX_ONLINE_ATTEMPTS, DEFAULT_MAX_ONLINE_ATTEMPTS), + else data.get( + CONF_MAX_ONLINE_ATTEMPTS, DEFAULT_CONNECTION_MAX_ATTEMPTS + ), ): cv.positive_int, vol.Optional( CONF_DISABLE_AVAILABLE_CHECK, @@ -242,11 +249,13 @@ async def async_step_user( user_input[CONF_HOST], user_input[CONF_MAC], user_input[CONF_ADVANCED][CONF_PORT], - int(user_input[CONF_ADVANCED][CONF_ENCRYPTION_VERSION]) + user_input[CONF_ADVANCED][CONF_ENCRYPTION_KEY], + EncryptionVersion( + int(user_input[CONF_ADVANCED][CONF_ENCRYPTION_VERSION]) + ) if user_input[CONF_ADVANCED][CONF_ENCRYPTION_VERSION] != "Auto-Detect" - else 0, - user_input[CONF_ADVANCED][CONF_ENCRYPTION_KEY], + else None, user_input[CONF_ADVANCED][CONF_UID], max_connection_attempts=2, # Use fewer attempts for testing the device ) diff --git a/custom_components/gree/const.py b/custom_components/gree/const.py index d6e832a..ce70a66 100755 --- a/custom_components/gree/const.py +++ b/custom_components/gree/const.py @@ -11,12 +11,6 @@ VerticalSwingMode, ) -MIN_TEMP_C = 16 -MAX_TEMP_C = 30 - -MIN_TEMP_F = 61 -MAX_TEMP_F = 86 - DOMAIN = "gree" CONF_ADVANCED = "advanced" @@ -33,20 +27,9 @@ CONF_TEMP_SENSOR_OFFSET = "temp_sensor_offset" CONF_FEATURES = "features" -DEFAULT_PORT = 7000 -DEFAULT_TIMEOUT = 30 DEFAULT_TARGET_TEMP_STEP = 1 -DEFAULT_MAX_ONLINE_ATTEMPTS = 8 -DEFAULT_ENCRYPTION_VERSION = 0 -DEFAULT_UID = 0 - -MIN_TEMP_C = 16 -MAX_TEMP_C = 30 - -MIN_TEMP_F = 61 -MAX_TEMP_F = 86 +DEFAULT_ENCRYPTION_VERSION = None -TEMSEN_OFFSET = 40 # OPTIONAL FEATURES/MODES # use the device beeper on commands diff --git a/custom_components/gree/gree_api.py b/custom_components/gree/gree_api.py index a6bca9d..cb06574 100644 --- a/custom_components/gree/gree_api.py +++ b/custom_components/gree/gree_api.py @@ -18,6 +18,12 @@ GREE_GENERIC_DEVICE_KEY = "a3K8Bx%2r8Y7#xDh" GREE_GENERIC_DEVICE_KEY_GCM = "{yxAHAY_Lm6pbC/<" +MIN_TEMP_C = 16 +MAX_TEMP_C = 30 + +MIN_TEMP_F = 61 +MAX_TEMP_F = 86 + class GreeProp(Enum): """Enumeration of Gree device properties.""" @@ -79,6 +85,14 @@ class GreeProp(Enum): BEEPER = "Buzzer_ON_OFF" +@unique +class EncryptionVersion(IntEnum): + """Available encryption versions for the device.""" + + V1 = 1 + V2 = 2 + + @unique class TemperatureUnits(IntEnum): """Enumeration of temperature units.""" @@ -162,8 +176,8 @@ async def udp_request_async( ip_addr: str, port: int, json_data: str, - timeout: float = 2.0, - max_retries: int = 8, + max_retries: int, + timeout: float, ) -> str: """Send a payload JSON data to the device and reads the response (async).""" @@ -207,7 +221,7 @@ async def udp_request_async( async def udp_request_blocking( - ip_addr: str, port: int, json_data: str, timeout: int = 2, max_retries: int = 8 + ip_addr: str, port: int, json_data: str, max_retries: int, timeout: int ) -> str: """Send a payload JSON data to the device and reads the response (blocking).""" _LOGGER.debug("Fetching(%s, %s, %s, %s)", ip_addr, port, timeout, json_data) @@ -260,8 +274,9 @@ async def fetch_result( port: int, json_data: str, cipher, - encryption_version: int = 1, - max_connection_attempts: int = 8, + encryption_version: EncryptionVersion, + max_connection_attempts: int, + timeout: int, ): """Send a payload JSON data to the device and reads the response (async).""" @@ -271,7 +286,7 @@ async def fetch_result( try: received_json = await udp_request_async( - ip_addr, port, json_data, max_retries=max_connection_attempts + ip_addr, port, json_data, max_connection_attempts, timeout ) except Exception as err: raise ValueError(f"Error communicating with {ip_addr}", ip_addr) from err @@ -287,7 +302,7 @@ async def fetch_result( pack = base64.b64decode(encodedPack) decryptedPack = cipher.decrypt(pack) - if encryption_version == 2: + if encryption_version == EncryptionVersion.V2: tag = data["tag"] _LOGGER.debug("Verifying tag: %s", tag) cipher.verify(base64.b64decode(tag)) @@ -306,13 +321,20 @@ async def get_result_pack( port: int, json_data: str, cipher, - encryption_version: int = 1, - max_connection_attempts: int = 8, + encryption_version: EncryptionVersion, + max_connection_attempts: int, + timeout: int, ): """Get the result pack from the device (async).""" data = await fetch_result( - ip_addr, port, json_data, cipher, encryption_version, max_connection_attempts + ip_addr, + port, + json_data, + cipher, + encryption_version, + max_connection_attempts, + timeout, ) if data is not None and data["pack"] is not None: @@ -321,13 +343,13 @@ async def get_result_pack( raise ValueError("No pack received from device") -def get_cipher(key: str, encryption_version: int): +def get_cipher(key: str, encryption_version: EncryptionVersion): """Get AES cipher object based on encryption version.""" - if encryption_version == 1: + if encryption_version == EncryptionVersion.V1: return AES.new(key.encode("utf8"), AES.MODE_ECB) - if encryption_version == 2: + if encryption_version == EncryptionVersion.V2: return AES.new(key.encode("utf8"), AES.MODE_GCM, nonce=GCM_IV).update( assoc_data=GCM_ADD ) @@ -336,13 +358,13 @@ def get_cipher(key: str, encryption_version: int): return None -def gree_get_default_cipher(encryption_version: int): +def gree_get_default_cipher(encryption_version: EncryptionVersion): """Get AES cipher object based on encryption version using default keys.""" - if encryption_version == 1: + if encryption_version == EncryptionVersion.V1: return get_cipher(GREE_GENERIC_DEVICE_KEY, encryption_version) - if encryption_version == 2: + if encryption_version == EncryptionVersion.V2: return get_cipher(GREE_GENERIC_DEVICE_KEY_GCM, encryption_version) _LOGGER.error("Unsupported encryption version: %d", encryption_version) @@ -352,21 +374,21 @@ def gree_get_default_cipher(encryption_version: int): def gree_encrypt_pack( data: str, cipher, - encryption_version: int, + encryption_version: EncryptionVersion, ) -> tuple[str, str]: """Create an encrypted pack to send to the device.""" if cipher is None: raise ValueError("Cipher must not be None") - if encryption_version == 1: + if encryption_version == EncryptionVersion.V1: encrypted_data = cipher.encrypt(pad(data).encode("utf-8")) return ( base64.b64encode(encrypted_data).decode("utf-8"), "", ) - if encryption_version == 2: + if encryption_version == EncryptionVersion.V2: encrypted_data, tag = cipher.encrypt_and_digest(data.encode("utf-8")) return ( base64.b64encode(encrypted_data).decode("utf-8"), @@ -376,14 +398,16 @@ def gree_encrypt_pack( raise ValueError(f"Unsupported encryption version: {encryption_version}") -def gree_create_bind_pack(mac_addr: str, uid: int, encryption_version: int) -> str: +def gree_create_bind_pack( + mac_addr: str, uid: int, encryption_version: EncryptionVersion +) -> str: """Create a bind pack to send to the device.""" pack: str = "" - if encryption_version == 1: + if encryption_version == EncryptionVersion.V1: pack = json.dumps({"mac": mac_addr, "t": "bind", "uid": uid}) - elif encryption_version == 2: + elif encryption_version == EncryptionVersion.V2: pack = json.dumps({"cid": mac_addr, "mac": mac_addr, "t": "bind", "uid": uid}) _LOGGER.debug("Bind Pack: %s", pack) @@ -415,14 +439,14 @@ def gree_create_payload( i_command: GreeCommand, mac_addr: str, uid: int, - encryption_version: int, + encryption_version: EncryptionVersion, tag: str, ) -> str: """Create the full payload to send to the device.""" payload: str = "" - if encryption_version == 1: + if encryption_version == EncryptionVersion.V1: payload = json.dumps( { "cid": "app", @@ -433,7 +457,7 @@ def gree_create_payload( "uid": uid, } ) - elif encryption_version == 2: + elif encryption_version == EncryptionVersion.V2: payload = json.dumps( { "cid": "app", @@ -455,15 +479,20 @@ async def gree_get_device_key( mac_addr: str, port: int, uid: int, - encryption_version: int, - max_connection_attempts: int = 8, -) -> tuple[str, int]: + encryption_version: EncryptionVersion | None, + max_connection_attempts: int, + timeout: int, +) -> tuple[str, EncryptionVersion]: """Get the device key by sending a bind request to the device using a generic key (async).""" key = "" error: Exception = ValueError("Unknown error getting device encryption key") - for enc_version in [1, 2] if encryption_version == 0 else [encryption_version]: + for enc_version in ( + [EncryptionVersion.V1, EncryptionVersion.V2] + if encryption_version is None + else [encryption_version] + ): _LOGGER.info("Trying to retrieve device encryption key v%d", enc_version) pack, tag = gree_encrypt_pack( gree_create_bind_pack(mac_addr, uid, enc_version), @@ -482,6 +511,7 @@ async def gree_get_device_key( gree_get_default_cipher(enc_version), enc_version, max_connection_attempts, + timeout, ) key = result.get("key", "") except Exception as err: # noqa: BLE001 @@ -514,8 +544,10 @@ async def gree_get_status( port: int, uid: int, encryption_key: str, - encryption_version: int, + encryption_version: EncryptionVersion, props: list[GreeProp], + max_connection_attempts: int, + timeout: int, ) -> dict[GreeProp, int]: """Get the status of the device by sending a status request to the device (async).""" @@ -539,6 +571,8 @@ async def gree_get_status( jsonPayloadToSend, get_cipher(encryption_key, encryption_version), encryption_version, + max_connection_attempts, + timeout, ) except Exception as err: raise ValueError("Error getting device status") from err @@ -560,8 +594,10 @@ async def gree_set_status( port: int, uid: int, encryption_key: str, - encryption_version: int, + encryption_version: EncryptionVersion, props: dict[GreeProp, int], + max_connection_attempts: int, + timeout: int, ) -> dict[GreeProp, int]: """Set the status of the device by sending a status request to the device (async).""" @@ -585,6 +621,8 @@ async def gree_set_status( jsonPayloadToSend, get_cipher(encryption_key, encryption_version), encryption_version, + max_connection_attempts, + timeout, ) except Exception as err: raise ValueError("Error getting device status") from err diff --git a/custom_components/gree/gree_const.py b/custom_components/gree/gree_const.py deleted file mode 100644 index e9bb8e7..0000000 --- a/custom_components/gree/gree_const.py +++ /dev/null @@ -1 +0,0 @@ -"""Constants for Gree integration.""" diff --git a/custom_components/gree/gree_device.py b/custom_components/gree/gree_device.py index 886066a..523647e 100755 --- a/custom_components/gree/gree_device.py +++ b/custom_components/gree/gree_device.py @@ -4,8 +4,8 @@ from attr import dataclass -from .const import DEFAULT_UID from .gree_api import ( + EncryptionVersion, FanSpeed, GreeProp, HorizontalSwingMode, @@ -26,6 +26,11 @@ _LOGGER = logging.getLogger(__name__) +DEFAULT_DEVICE_UID = 0 +DEFAULT_DEVICE_PORT = 3000 +DEFAULT_CONNECTION_MAX_ATTEMPTS = 8 +DEFAULT_CONNECTION_TIMEOUT = 2 + class GreeDeviceNotBoundError(Exception): """Raised when the device binding fails.""" @@ -71,10 +76,11 @@ def __init__( ip_addr: str, mac_addr: str, port: int, - encryption_version: int, encryption_key: str, - uid: int = DEFAULT_UID, - max_connection_attempts: int = 8, + encryption_version: EncryptionVersion | None = None, + uid: int = DEFAULT_DEVICE_UID, + max_connection_attempts: int = DEFAULT_CONNECTION_MAX_ATTEMPTS, + timeout: int = DEFAULT_CONNECTION_TIMEOUT, ) -> None: """Initialize the Gree device.""" @@ -92,7 +98,7 @@ def __init__( self._mac_addr = self._mac_addr_sub = mac_addr.lower() if "@" in mac_addr: self._mac_addr_sub, self._mac_addr = mac_addr.lower().split("@", 1) - self._encryption_version: int = encryption_version + self._encryption_version: EncryptionVersion | None = encryption_version self._encryption_key: str = encryption_key self._uid: int = uid self._state: dict[GreeProp, int] = {} @@ -100,6 +106,7 @@ def __init__( self._is_bound: bool = False self._uniqueid: str = self._mac_addr self._max_connection_attempts: int = max_connection_attempts + self._timeout: int = timeout self._props_to_update: list[GreeProp] = list(GreeProp) self._props_to_update.remove( @@ -112,10 +119,6 @@ def __init__( self.state: GreeDeviceState = GreeDeviceState() - if encryption_version < 0 or encryption_version > 2: - _LOGGER.error("Unsupported encryption version, defaulting to 0") - self._encryption_version = 0 - async def bind_device(self) -> bool: """Setup the device (async).""" @@ -132,7 +135,8 @@ async def bind_device(self) -> bool: self._port, self._uid, self._encryption_version, - max_connection_attempts=self._max_connection_attempts, + self._max_connection_attempts, + self._timeout, ) self._is_bound = True except Exception as e: @@ -154,6 +158,8 @@ async def fetch_device_status(self) -> GreeDeviceState: if not self._is_bound: await self.bind_device() + assert self._encryption_version is not None + try: self._state.update( await gree_get_status( @@ -164,6 +170,8 @@ async def fetch_device_status(self) -> GreeDeviceState: self._encryption_key, self._encryption_version, self._props_to_update, + self._max_connection_attempts, + self._timeout, ) ) except Exception as err: @@ -180,6 +188,8 @@ async def update_device_status(self) -> GreeDeviceState: if not self._is_bound: await self.bind_device() + assert self._encryption_version is not None + # If there is no change in the properties, do nothing has_updated_states = any( self._state.get(k) != v for k, v in self._new_state.items() @@ -200,6 +210,8 @@ async def update_device_status(self) -> GreeDeviceState: self._encryption_key, self._encryption_version, self._new_state, + self._max_connection_attempts, + self._timeout, ) ) self._new_state.clear() @@ -312,7 +324,7 @@ def encryption_key(self) -> str: return self._encryption_key @property - def encryption_version(self) -> int: + def encryption_version(self) -> EncryptionVersion | None: """Return the encryption version of the device.""" return self._encryption_version diff --git a/custom_components/gree/select.py b/custom_components/gree/select.py index 5908220..04e8708 100644 --- a/custom_components/gree/select.py +++ b/custom_components/gree/select.py @@ -25,27 +25,6 @@ T = TypeVar("T") # T can be any type -@dataclass(frozen=True, kw_only=True) -class GreeSelectDescription(GreeEntityDescription, SelectEntityDescription, Generic[T]): - """Description of a Gree switch.""" - - 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 - options_func: Callable[[], list[str]] | None = None - value_func: Callable[[T], str | None] - set_func: Callable[[T, str], None] - updates_device: bool = True - - async def async_setup_entry( hass: HomeAssistant, entry: GreeConfigEntry, @@ -78,6 +57,27 @@ async def async_setup_entry( ) +@dataclass(frozen=True, kw_only=True) +class GreeSelectDescription(GreeEntityDescription, SelectEntityDescription, Generic[T]): + """Description of a Gree switch.""" + + 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 + options_func: Callable[[], list[str]] | None = None + value_func: Callable[[T], str | None] + set_func: Callable[[T, str], None] + updates_device: bool = True + + class GreeSelectEntity(GreeEntity, SelectEntity, RestoreEntity): # pyright: ignore[reportIncompatibleVariableOverride] """A Gree select entity.""" diff --git a/custom_components/gree/sensor.py b/custom_components/gree/sensor.py index db2f1bb..2b10a13 100644 --- a/custom_components/gree/sensor.py +++ b/custom_components/gree/sensor.py @@ -10,7 +10,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -27,6 +26,35 @@ GATTR_HUMIDITY = "humidity" +async def async_setup_entry( + hass: HomeAssistant, + entry: GreeConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensors from a config entry.""" + + coordinator = entry.runtime_data + + sensors = [] + + if coordinator.device.has_indoor_temperature_sensor: + sensors.append(GATTR_INDOOR_TEMPERATURE) + if coordinator.device.has_outdoor_temperature_sensor: + sensors.append(GATTR_OUTDOOR_TEMPERATURE) + if coordinator.device.has_humidity_sensor: + sensors.append(GATTR_HUMIDITY) + + _LOGGER.debug("Adding Sensor Entities: %s", sensors) + + entities = [ + GreeSensor(description, coordinator, restore_state=True) + for description in SENSOR_TYPES + if description.key in sensors + ] + + async_add_entities(entities) + + @dataclass(frozen=True, kw_only=True) class GreeSensorDescription(GreeEntityDescription, SensorEntityDescription): """Description of a Gree temperature sensor.""" @@ -34,7 +62,7 @@ class GreeSensorDescription(GreeEntityDescription, SensorEntityDescription): value_func: Callable[[GreeDevice], float | None] -SENSOR_TYPES: tuple[GreeSensorDescription, ...] = ( +SENSOR_TYPES: list[GreeSensorDescription] = [ GreeSensorDescription( key=GATTR_INDOOR_TEMPERATURE, translation_key=GATTR_INDOOR_TEMPERATURE, @@ -65,41 +93,7 @@ class GreeSensorDescription(GreeEntityDescription, SensorEntityDescription): value_func=lambda device: device.humidity, available_func=lambda device: device.has_humidity_sensor, ), -) - - -async def async_setup_entry( - hass: HomeAssistant, - entry: GreeConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up sensors from a config entry.""" - - coordinator = entry.runtime_data - - sensors = [] - - if coordinator.device.has_indoor_temperature_sensor: - sensors.append(GATTR_INDOOR_TEMPERATURE) - if coordinator.device.has_outdoor_temperature_sensor: - sensors.append(GATTR_OUTDOOR_TEMPERATURE) - if coordinator.device.has_humidity_sensor: - sensors.append(GATTR_HUMIDITY) - - _LOGGER.debug("Adding Sensor Entities: %s", sensors) - - entities = [ - GreeSensor(description, coordinator, restore_state=True) - for description in SENSOR_TYPES - if description.key in sensors - ] - - async_add_entities(entities) - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): - """Unload a config entry.""" - return True +] class GreeSensor(GreeEntity, SensorEntity, RestoreEntity): # pyright: ignore[reportIncompatibleVariableOverride] diff --git a/custom_components/gree/switch.py b/custom_components/gree/switch.py index 248471e..e559f55 100644 --- a/custom_components/gree/switch.py +++ b/custom_components/gree/switch.py @@ -48,7 +48,7 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): updates_device: bool = True -SWITCH_TYPES: tuple[GreeSwitchDescription, ...] = ( +SWITCH_TYPES: list[GreeSwitchDescription] = [ GreeSwitchDescription( key=GATTR_FEAT_FRESH_AIR, translation_key=GATTR_FEAT_FRESH_AIR, @@ -126,7 +126,7 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): entity_category=EntityCategory.CONFIG, updates_device=False, # Local entity ), -) +] async def async_setup_entry( From e0d9886aec9549043dfefaf60cf649590c1a4e36 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Thu, 18 Sep 2025 21:37:15 +0100 Subject: [PATCH 006/113] Multiple config_flow enhancements: - Move consts around - Proper error report on config_flow - Proper translations on config_flow - Configuration of timeout - Fetch device info during binding --- custom_components/gree/__init__.py | 21 +++- custom_components/gree/config_flow.py | 51 +++++---- custom_components/gree/const.py | 1 - custom_components/gree/entity.py | 1 + custom_components/gree/gree_api.py | 66 +++++++++-- custom_components/gree/gree_device.py | 38 +++++-- custom_components/gree/translations/en.json | 119 +++++++------------- 7 files changed, 170 insertions(+), 127 deletions(-) diff --git a/custom_components/gree/__init__.py b/custom_components/gree/__init__.py index 196d2da..b603583 100755 --- a/custom_components/gree/__init__.py +++ b/custom_components/gree/__init__.py @@ -8,7 +8,14 @@ # Third-party imports from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, Platform +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_NAME, + CONF_PORT, + CONF_TIMEOUT, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.typing import ConfigType @@ -26,12 +33,13 @@ # Home Assistant imports from .coordinator import GreeConfigEntry, GreeCoordinator -from .gree_device import ( +from .gree_api import ( DEFAULT_CONNECTION_MAX_ATTEMPTS, + DEFAULT_CONNECTION_TIMEOUT, DEFAULT_DEVICE_PORT, - GreeDevice, - GreeDeviceNotBoundError, + DEFAULT_DEVICE_UID, ) +from .gree_device import GreeDevice, GreeDeviceNotBoundError PLATFORMS = [ Platform.CLIMATE, @@ -74,16 +82,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool new_device = GreeDevice( name=conf.get(CONF_NAME, "Gree HVAC"), ip_addr=host, - mac_addr=str(conf.get(CONF_MAC, "")).replace(":", ""), + mac_addr=str(conf.get(CONF_MAC, "")), port=conf[CONF_ADVANCED].get(CONF_PORT, DEFAULT_DEVICE_PORT), encryption_version=conf[CONF_ADVANCED].get( CONF_ENCRYPTION_VERSION, DEFAULT_ENCRYPTION_VERSION ), encryption_key=conf[CONF_ADVANCED].get(CONF_ENCRYPTION_KEY, ""), - uid=conf[CONF_ADVANCED].get(CONF_UID, 0), + uid=conf[CONF_ADVANCED].get(CONF_UID, DEFAULT_DEVICE_UID), max_connection_attempts=conf.get( CONF_MAX_ONLINE_ATTEMPTS, DEFAULT_CONNECTION_MAX_ATTEMPTS ), + timeout=conf.get(CONF_TIMEOUT, DEFAULT_CONNECTION_TIMEOUT), ) try: diff --git a/custom_components/gree/config_flow.py b/custom_components/gree/config_flow.py index b569489..5c343b2 100644 --- a/custom_components/gree/config_flow.py +++ b/custom_components/gree/config_flow.py @@ -12,7 +12,7 @@ from config.custom_components.gree.gree_api import EncryptionVersion from homeassistant import config_entries from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, CONF_TIMEOUT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import section from homeassistant.exceptions import HomeAssistantError @@ -38,7 +38,6 @@ CONF_MAX_ONLINE_ATTEMPTS, CONF_SWING_HORIZONTAL_MODES, CONF_SWING_MODES, - CONF_TEMP_SENSOR_OFFSET, CONF_UID, DEFAULT_FAN_MODES, DEFAULT_HVAC_MODES, @@ -48,12 +47,13 @@ DOMAIN, ) from .coordinator import GreeConfigEntry -from .gree_device import ( +from .gree_api import ( DEFAULT_CONNECTION_MAX_ATTEMPTS, + DEFAULT_CONNECTION_TIMEOUT, DEFAULT_DEVICE_PORT, DEFAULT_DEVICE_UID, - GreeDevice, ) +from .gree_device import GreeDevice, GreeDeviceNotBoundError _LOGGER = logging.getLogger(__name__) @@ -103,7 +103,7 @@ def build_main_schema(data: Mapping | None) -> vol.Schema | None: default=DEFAULT_DEVICE_UID if data is None or data[CONF_ADVANCED] is None else data[CONF_ADVANCED].get(CONF_UID, DEFAULT_DEVICE_UID), - ): int, + ): cv.positive_int, } ), {"collapsed": True}, @@ -181,24 +181,6 @@ def build_options_schema( translation_key=CONF_FEATURES, ) ), - vol.Optional( - CONF_MAX_ONLINE_ATTEMPTS, - default=DEFAULT_CONNECTION_MAX_ATTEMPTS - if data is None - else data.get( - CONF_MAX_ONLINE_ATTEMPTS, DEFAULT_CONNECTION_MAX_ATTEMPTS - ), - ): cv.positive_int, - vol.Optional( - CONF_DISABLE_AVAILABLE_CHECK, - default=False - if data is None - else data.get(CONF_DISABLE_AVAILABLE_CHECK, False), - ): cv.boolean, - vol.Optional( - CONF_TEMP_SENSOR_OFFSET, - default=False if data is None else data.get(CONF_TEMP_SENSOR_OFFSET, 0), - ): cv.boolean, vol.Optional( ATTR_EXTERNAL_TEMPERATURE_SENSOR, default=UNDEFINED @@ -223,6 +205,26 @@ def build_options_schema( device_class=SensorDeviceClass.HUMIDITY, ) ), + vol.Required( + CONF_DISABLE_AVAILABLE_CHECK, + default=False + if data is None + else data.get(CONF_DISABLE_AVAILABLE_CHECK, False), + ): cv.boolean, + vol.Required( + CONF_MAX_ONLINE_ATTEMPTS, + default=DEFAULT_CONNECTION_MAX_ATTEMPTS + if data is None + else data.get( + CONF_MAX_ONLINE_ATTEMPTS, DEFAULT_CONNECTION_MAX_ATTEMPTS + ), + ): cv.positive_int, + vol.Required( + CONF_TIMEOUT, + default=DEFAULT_CONNECTION_TIMEOUT + if data is None + else data.get(CONF_TIMEOUT, DEFAULT_CONNECTION_TIMEOUT), + ): cv.positive_int, } ) @@ -258,10 +260,13 @@ async def async_step_user( else None, user_input[CONF_ADVANCED][CONF_UID], max_connection_attempts=2, # Use fewer attempts for testing the device + timeout=2, # Use smaller timeout for testing the device ) await self._device.fetch_device_status() except CannotConnect: errors["base"] = "cannot_connect" + except GreeDeviceNotBoundError: + errors["base"] = "cannot_connect" except Exception as err: # noqa: BLE001 errors["base"] = "unknown: " + repr(err) else: diff --git a/custom_components/gree/const.py b/custom_components/gree/const.py index ce70a66..c8e5eed 100755 --- a/custom_components/gree/const.py +++ b/custom_components/gree/const.py @@ -24,7 +24,6 @@ CONF_FAN_MODES = "fan_modes" CONF_SWING_MODES = "swing_modes" CONF_SWING_HORIZONTAL_MODES = "swing_horizontal_modes" -CONF_TEMP_SENSOR_OFFSET = "temp_sensor_offset" CONF_FEATURES = "features" DEFAULT_TARGET_TEMP_STEP = 1 diff --git a/custom_components/gree/entity.py b/custom_components/gree/entity.py index 221e0c9..1fcbe62 100755 --- a/custom_components/gree/entity.py +++ b/custom_components/gree/entity.py @@ -31,6 +31,7 @@ def __init__(self, coordinator: GreeCoordinator, restore_state: bool) -> None: identifiers={(DOMAIN, self._device.unique_id)}, name=self._device.name, manufacturer="Gree", + sw_version=self._device.firmware_version ) self.restore_state = restore_state diff --git a/custom_components/gree/gree_api.py b/custom_components/gree/gree_api.py index cb06574..235631b 100644 --- a/custom_components/gree/gree_api.py +++ b/custom_components/gree/gree_api.py @@ -5,6 +5,7 @@ from enum import Enum, IntEnum, unique import json import logging +import re import socket import asyncio_dgram @@ -24,6 +25,11 @@ MIN_TEMP_F = 61 MAX_TEMP_F = 86 +DEFAULT_DEVICE_UID = 0 +DEFAULT_DEVICE_PORT = 7000 +DEFAULT_CONNECTION_MAX_ATTEMPTS = 5 +DEFAULT_CONNECTION_TIMEOUT = 10 + class GreeProp(Enum): """Enumeration of Gree device properties.""" @@ -177,9 +183,12 @@ async def udp_request_async( port: int, json_data: str, max_retries: int, - timeout: float, + timeout: int, ) -> str: """Send a payload JSON data to the device and reads the response (async).""" + # _LOGGER.info( + # "%s:%d max_r=%d t=%d json:\n%s", ip_addr, port, max_retries, timeout, json_data + # ) for attempt in range(max_retries): stream: asyncio_dgram.DatagramClient | None = None @@ -289,7 +298,7 @@ async def fetch_result( ip_addr, port, json_data, max_connection_attempts, timeout ) except Exception as err: - raise ValueError(f"Error communicating with {ip_addr}", ip_addr) from err + raise ValueError(f"Error communicating with {ip_addr}: {err}") from err # try: # received_json = await udp_request_blocking(ip_addr, port, json_data) @@ -371,7 +380,7 @@ def gree_get_default_cipher(encryption_version: EncryptionVersion): return None -def gree_encrypt_pack( +def gree_encrypted_pack( data: str, cipher, encryption_version: EncryptionVersion, @@ -494,7 +503,7 @@ async def gree_get_device_key( else [encryption_version] ): _LOGGER.info("Trying to retrieve device encryption key v%d", enc_version) - pack, tag = gree_encrypt_pack( + pack, tag = gree_encrypted_pack( gree_create_bind_pack(mac_addr, uid, enc_version), gree_get_default_cipher(enc_version), enc_version, @@ -517,9 +526,9 @@ async def gree_get_device_key( except Exception as err: # noqa: BLE001 error = err _LOGGER.error( - "Error getting device encryption key with version %d: %s", + "Error getting device encryption key with version %d:\n%s", enc_version, - repr(err), + err, ) # raise ValueError("Error getting device encryption key") from err continue @@ -555,7 +564,7 @@ async def gree_get_status( status_values: dict[GreeProp, int] = {} - pack, tag = gree_encrypt_pack( + pack, tag = gree_encrypted_pack( gree_create_status_pack(mac_addr, [prop.value for prop in props]), get_cipher(encryption_key, encryption_version), encryption_version, @@ -604,7 +613,7 @@ async def gree_set_status( _LOGGER.debug("Trying to set device status") set_pack = gree_create_set_pack(props) - pack, tag = gree_encrypt_pack( + pack, tag = gree_encrypted_pack( set_pack, get_cipher(encryption_key, encryption_version), encryption_version, @@ -658,3 +667,44 @@ async def gree_set_status( _LOGGER.warning("Expected updated props %s but got %s", props, updated_props) return updated_props + + +async def gree_get_device_info( + ip_addr: str, + max_connection_attempts: int, + timeout: int, +) -> dict[str, str | None]: + """Tries to retrive the device info.""" + try: + data: dict = await get_result_pack( + ip_addr, + DEFAULT_DEVICE_PORT, + json.dumps({"t": "scan"}), + gree_get_default_cipher(EncryptionVersion.V1), + EncryptionVersion.V1, + max_connection_attempts, + timeout, + ) + except Exception as err: + _LOGGER.exception("Error retrieving basic device info") + raise ValueError("Error retrieving basic device info") from err + else: + _LOGGER.debug(data) + info: dict[str, str | None] = {} + info["firmware_version"], info["firmware_code"] = extract_version(data) + return info + + +def extract_version(info: dict) -> tuple[str | None, str | None]: + """Finds the firmware info.""" + hid = info.get("hid", "") + ver_match = re.search(r"V([\d.]+)\.bin", hid) + if ver_match: + ver = ver_match.group(1) # version from hid + else: + ver = info.get("ver") + ver = ver.lstrip("V") if ver else None # clean ver or None + + id_match = re.match(r"(\d+)", hid) # leading digits + device_id = id_match.group(1) if id_match else None + return ver, device_id diff --git a/custom_components/gree/gree_device.py b/custom_components/gree/gree_device.py index 523647e..9c68be8 100755 --- a/custom_components/gree/gree_device.py +++ b/custom_components/gree/gree_device.py @@ -5,6 +5,9 @@ from attr import dataclass from .gree_api import ( + DEFAULT_CONNECTION_MAX_ATTEMPTS, + DEFAULT_CONNECTION_TIMEOUT, + DEFAULT_DEVICE_UID, EncryptionVersion, FanSpeed, GreeProp, @@ -12,6 +15,7 @@ OperationMode, TemperatureUnits, VerticalSwingMode, + gree_get_device_info, gree_get_device_key, gree_get_status, gree_set_status, @@ -26,13 +30,8 @@ _LOGGER = logging.getLogger(__name__) -DEFAULT_DEVICE_UID = 0 -DEFAULT_DEVICE_PORT = 3000 -DEFAULT_CONNECTION_MAX_ATTEMPTS = 8 -DEFAULT_CONNECTION_TIMEOUT = 2 - -class GreeDeviceNotBoundError(Exception): +class GreeDeviceNotBoundError(BaseException): """Raised when the device binding fails.""" @@ -95,12 +94,14 @@ def __init__( self._name: str = name self._ip_addr: str = ip_addr self._port: int = port - self._mac_addr = self._mac_addr_sub = mac_addr.lower() + self._mac_addr = self._mac_addr_sub = mac_addr.replace(":", "").lower() if "@" in mac_addr: self._mac_addr_sub, self._mac_addr = mac_addr.lower().split("@", 1) self._encryption_version: EncryptionVersion | None = encryption_version self._encryption_key: str = encryption_key self._uid: int = uid + self._firmware_version: str | None = None + self._firmware_code: str | None = None self._state: dict[GreeProp, int] = {} self._new_state: dict[GreeProp, int] = {} self._is_bound: bool = False @@ -121,8 +122,18 @@ def __init__( async def bind_device(self) -> bool: """Setup the device (async).""" - if not self._is_bound: + try: + info = await gree_get_device_info( + self._ip_addr, + self._max_connection_attempts, + self._timeout, + ) + self._firmware_version = info.get("firmware_version") + self._firmware_code = info.get("firmware_code") + except Exception: + _LOGGER.exception("Could not retrieve basic device info") + if not self._encryption_key.strip(): _LOGGER.info("No encryption key provided") try: @@ -333,6 +344,17 @@ def unique_id(self) -> str: """Return the unique ID of the device (MAC).""" return self._uniqueid + @property + def firmware_version(self) -> str | None: + """Returns the firmware version.""" + if self._firmware_version and self._firmware_code: + return f"{self._firmware_version} ({self._firmware_code})" + if self._firmware_version: + return self._firmware_version + if self._firmware_code: + return self._firmware_code + return None + @property def available(self) -> bool: """Return True if entity is available.""" diff --git a/custom_components/gree/translations/en.json b/custom_components/gree/translations/en.json index 1c1156c..cca937a 100755 --- a/custom_components/gree/translations/en.json +++ b/custom_components/gree/translations/en.json @@ -1,112 +1,73 @@ { "config": { "error": { - "cannot_connect": "Unable to connect to the device. Please check the network connection and try again." - }, - "abort": { - "already_configured": "A device with this MAC address is already configured." + "cannot_connect": "Unable to connect to the device. Please check the device configuration and network connection and try again." }, "title": "Gree Climate", "description": "Configure your Gree air conditioner", "step": { "user": { - "title": "Gree Climate Setup", - "description": "Choose how to add your Gree air conditioner", - "data": { - "discovery": "Setup Method" - } - }, - "discovery": { - "title": "Discovered Devices", - "description": "Found {devices_found} Gree device(s). Select one to add or choose manual setup.", - "data": { - "device": "Device" - } - }, - "detect_encryption": { - "title": "Configure Device", - "description": "Device connection successful. Enter a name for this device.", - "data": { - "name": "Device Name" - } - }, - "manual": { - "title": "Manual Setup", - "description": "Enter the details for your Gree air conditioner", + "title": "Device configuration", "data": { "name": "Name", "host": "IP Address", - "port": "Port", - "mac": "MAC Address", - "timeout": "Timeout", - "encryption_key": "Encryption Key", - "uid": "UID", - "encryption_version": "Encryption Version" + "mac": "MAC Address" + }, + "sections": { + "advanced": { + "name": "Advanced Settings", + "description": "Configure advanced setting of the device", + "data": { + "port": "Port", + "encryption_key": "Encryption Key", + "encryption_version": "Encryption Version", + "uid": "UID" + } + } } }, "device_options": { - "title": "Configure the device features", - "description": "The Gree API doesn't have a reliable method of getting a the supported features of a device. Please use the options bellow to the best of your knowledge about your device.", + "title": "Device features", + "description": "The Gree API doesn't have a reliable method of getting the supported features of a device. Please use the options bellow to the best of your knowledge about your device.", "data": { "hvac_modes": "HVAC Modes", "fan_modes": "Fan Speeds", "swing_modes": "Vertical Swing Modes", "swing_horizontal_modes": "Horizontal Swing Modes", "features": "Device Features and Modes", - "disable_available_check": "Disable Available Check", - "max_online_attempts": "Max Online Attempts", - "temp_sensor_offset": "Temperature Sensor Offset", "external_temperature_sensor": "External Temperature Sensor", - "external_humidity_sensor": "External Humidity Sensor" + "external_humidity_sensor": "External Humidity Sensor", + "disable_available_check": "Disable Available Check", + "max_online_attempts": "Max Connection Attempts", + "timeout": "Connection Timeout" + }, + "data_description": { + "external_temperature_sensor": "If set will replace the built-in HVAC temperature sensor", + "external_humidity_sensor": "If set will replace the built-in HVAC humidity sensor", + "max_online_attempts": "The number of attempts to communicate with the device before it is marked as unavailable", + "timeout": "The timeout for each of the connection attempts" } }, "reconfigure": { - "title": "Configure the device features", - "description": "The Gree API doesn't have a reliable method of getting a the supported features of a device. Please use the options bellow to the best of your knowledge about your device.", + "title": "Device features", + "description": "The Gree API doesn't have a reliable method of getting the supported features of a device. Please use the options bellow to the best of your knowledge about your device.", "data": { "hvac_modes": "HVAC Modes", "fan_modes": "Fan Speeds", "swing_modes": "Vertical Swing Modes", "swing_horizontal_modes": "Horizontal Swing Modes", "features": "Device Features and Modes", - "disable_available_check": "Disable Available Check", - "max_online_attempts": "Max Online Attempts", - "temp_sensor_offset": "Temperature Sensor Offset", "external_temperature_sensor": "External Temperature Sensor", - "external_humidity_sensor": "External Humidity Sensor" - } - } - }, - "data": { - "name": "Name", - "host": "IP Address", - "port": "Port", - "mac": "MAC Address", - "timeout": "Timeout", - "hvac_modes": "HVAC Modes", - "fan_modes": "Fan Modes", - "swing_modes": "Vertical Swing Modes", - "swing_horizontal_modes": "Horizontal Swing Modes", - "encryption_key": "Encryption Key", - "uid": "UID", - "encryption_version": "Encryption Version", - "disable_available_check": "Disable Available Check", - "max_online_attempts": "Max Online Attempts", - "temp_sensor_offset": "Temperature Sensor Offset" - } - }, - "options": { - "step": { - "init": { - "title": "Gree Climate Options", - "data": { - "hvac_modes": "HVAC Modes", - "fan_modes": "Fan Modes", - "swing_modes": "Vertical Swing Modes", - "swing_horizontal_modes": "Horizontal Swing Modes", + "external_humidity_sensor": "External Humidity Sensor", "disable_available_check": "Disable Available Check", - "max_online_attempts": "Max Online Attempts", - "temp_sensor_offset": "Temperature Sensor Offset" + "max_online_attempts": "Max Connection Attempts", + "timeout": "Connection Timeout" + }, + "data_description": { + "external_temperature_sensor": "If set will replace the built-in HVAC temperature sensor", + "external_humidity_sensor": "If set will replace the built-in HVAC humidity sensor", + "max_online_attempts": "The number of attempts to communicate with the device before it is marked as unavailable", + "timeout": "The timeout for each of the connection attempts" } } } @@ -252,10 +213,6 @@ } }, "select": { - "external_temperature_sensor": { - "name": "External Temperature Sensor", - "description": "Select a temperature sensor entity to use instead of the built-in AC sensor. Choose 'None' to use the built-in sensor." - }, "temperature_units": { "name": "Temperature Units", "description": "Select the temperature units used by the device." From 74809e93e491f912aa5da6564aba7cab6fbc17e6 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Thu, 18 Sep 2025 23:12:06 +0100 Subject: [PATCH 007/113] Listen to external sensor states if using them --- custom_components/gree/climate.py | 188 +++++++++++++++++++----------- 1 file changed, 122 insertions(+), 66 deletions(-) diff --git a/custom_components/gree/climate.py b/custom_components/gree/climate.py index 4c23996..cd6b6dd 100644 --- a/custom_components/gree/climate.py +++ b/custom_components/gree/climate.py @@ -17,9 +17,16 @@ STATE_UNKNOWN, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import ( + Event, + EventStateChangedData, + HomeAssistant, + State, + callback, +) from homeassistant.exceptions import HomeAssistantError 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 @@ -195,7 +202,6 @@ def __init__( self._attr_swing_horizontal_modes = swing_horizontal_modes self.update_attributes() - _LOGGER.debug("Initialized climate %s", self._attr_unique_id) def _handle_coordinator_update(self) -> None: @@ -203,6 +209,108 @@ def _handle_coordinator_update(self) -> None: _LOGGER.debug("Updating Climate Entity for %s", self._device.unique_id) self.update_attributes() + async def async_added_to_hass(self): + """When this entity is added to hass.""" + await super().async_added_to_hass() + + self.update_attributes() + + # When using an external temperature sensor, subscribe to its state changes for updating the current temperature + if self._external_temperature_sensor: + self._update_current_temperature_from_external( + self.hass.states.get(self._external_temperature_sensor) + ) + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._external_temperature_sensor], + self._external_temperature_sensor_listener, + ) + ) + + # When using an external himidity sensor, subscribe to its state changes for updating the current humidity + if self._external_humidity_sensor: + self._update_current_humidity_from_external( + self.hass.states.get(self._external_humidity_sensor) + ) + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._external_humidity_sensor], + self._external_humidity_sensor_listener, + ) + ) + + @callback + def _external_temperature_sensor_listener( + self, event: Event[EventStateChangedData] + ) -> None: + """Update current temperature based on external sensor updates.""" + new_state = event.data.get("new_state") + self._update_current_temperature_from_external(new_state) + + def _update_current_temperature_from_external(self, new_state: State | None): + """Update current temperature based on external sensor data.""" + if new_state and new_state.state not in ( + STATE_UNKNOWN, + STATE_UNAVAILABLE, + ): + try: + unit: str = new_state.attributes.get( + ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature.CELSIUS + ) + value = float(new_state.state) + + except (ValueError, TypeError) as ex: + _LOGGER.error( + "Unable to update from external temp sensor %s: %s", + self._external_temperature_sensor, + ex, + ) + else: + _LOGGER.debug( + "Using external temperature sensor: %s, value: %s, unit: %s", + self._external_temperature_sensor, + value, + unit, + ) + # Update internal state based on the other entity + self._attr_current_temperature = self.hass.config.units.temperature( + value, unit + ) + self.async_write_ha_state() + + @callback + def _external_humidity_sensor_listener( + self, event: Event[EventStateChangedData] + ) -> None: + """Update current humidity based on external sensor updates.""" + new_state = event.data.get("new_state") + self._update_current_humidity_from_external(new_state) + + def _update_current_humidity_from_external(self, new_state: State | None) -> None: + """Update current humidity based on external sensor updates.""" + if new_state and new_state.state not in ( + STATE_UNKNOWN, + STATE_UNAVAILABLE, + ): + try: + value = float(new_state.state) + + except (ValueError, TypeError) as ex: + _LOGGER.error( + "Unable to update from humidity temp sensor %s: %s", + self._external_humidity_sensor, + ex, + ) + else: + _LOGGER.debug( + "Using external humidity sensor: %s, value: %s", + self._external_humidity_sensor, + value, + ) + self._attr_current_humidity = value + def update_attributes(self): """Updates the entity attributes with the device values.""" self._attr_available = self._device.available @@ -227,8 +335,12 @@ def update_attributes(self): self._attr_temperature_unit = self.get_temp_units() self._attr_target_temperature = self.get_current_target_temp() - self._attr_current_temperature = self.get_current_temp() - self._attr_current_humidity = self.get_current_humidity() + + if self._external_temperature_sensor is None: + self._attr_current_temperature = self.get_current_temp() + + if self._external_humidity_sensor is None: + self._attr_current_humidity = self.get_current_humidity() if self._attr_supported_features & ClimateEntityFeature.TARGET_TEMPERATURE: self._attr_max_temp = ( @@ -488,48 +600,16 @@ def get_temp_units(self) -> UnitOfTemperature: def get_current_temp(self) -> float | None: """Returns the current temperature of the room. Accounting for units.""" - # Use external temperature sensor if available - if self._external_temperature_sensor and self.hass: - external_state = self.hass.states.get(self._external_temperature_sensor) - - if external_state and external_state.state not in ( - STATE_UNKNOWN, - STATE_UNAVAILABLE, - ): - try: - unit: str = external_state.attributes.get( - ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature.CELSIUS - ) - value = float(external_state.state) - - except (ValueError, TypeError) as ex: - _LOGGER.error( - "Unable to update from external temp sensor %s: %s", - self._external_temperature_sensor, - ex, - ) - else: - _LOGGER.debug( - "Using external temperature sensor: %s, value: %s, unit: %s", - self._external_temperature_sensor, - value, - unit, - ) - return self.hass.config.units.temperature(value, unit) - # Gree API always return current temperature in ºC # so if we are dealing with ºF we convert to that first if ( - self._device.has_indoor_temperature_sensor + self.hass + and self._device.has_indoor_temperature_sensor and self._device.indoors_temperature_c is not None ): - if self._attr_temperature_unit == UnitOfTemperature.FAHRENHEIT: - return TemperatureConverter.convert( - self._device.indoors_temperature_c, - UnitOfTemperature.CELSIUS, - UnitOfTemperature.FAHRENHEIT, - ) - return float(self._device.indoors_temperature_c) + return self.hass.config.units.temperature( + float(self._device.indoors_temperature_c), UnitOfTemperature.CELSIUS + ) # FIXME: When changing Units in HA Settings, the temp does not update @@ -538,31 +618,6 @@ def get_current_temp(self) -> float | None: def get_current_humidity(self) -> float | None: """Returns the current humidity of the room.""" - # Use external humidity sensor if available - if self._external_humidity_sensor and self.hass: - external_state = self.hass.states.get(self._external_humidity_sensor) - - if external_state and external_state.state not in ( - STATE_UNKNOWN, - STATE_UNAVAILABLE, - ): - try: - value = float(external_state.state) - - except (ValueError, TypeError) as ex: - _LOGGER.error( - "Unable to update from humidity temp sensor %s: %s", - self._external_humidity_sensor, - ex, - ) - else: - _LOGGER.debug( - "Using external humidity sensor: %s, value: %s", - self._external_humidity_sensor, - value, - ) - return value - # Gree API always return current humidity in % if self._device.has_humidity_sensor and self._device.humidity is not None: return float(self._device.humidity) @@ -592,6 +647,7 @@ async def async_set_temperature(self, **kwargs): ) try: + # TODO: Confirm that HA sends the values in this entity's temperature_unit which matches the device unit self._device.set_target_temperature(temperature) await self._device.update_device_status() From f088882806af48c3062c1e09753eb5ba99c37eb9 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Thu, 18 Sep 2025 23:12:59 +0100 Subject: [PATCH 008/113] Try to fix external entities not being unselectable --- custom_components/gree/config_flow.py | 50 ++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/custom_components/gree/config_flow.py b/custom_components/gree/config_flow.py index 5c343b2..db983cc 100644 --- a/custom_components/gree/config_flow.py +++ b/custom_components/gree/config_flow.py @@ -58,7 +58,7 @@ _LOGGER = logging.getLogger(__name__) -def build_main_schema(data: Mapping | None) -> vol.Schema | None: +def build_main_schema(data: Mapping | None) -> vol.Schema: """Builds the main option schema.""" return vol.Schema( { @@ -112,9 +112,7 @@ def build_main_schema(data: Mapping | None) -> vol.Schema | None: ) -def build_options_schema( - hass: HomeAssistant, data: Mapping | None -) -> vol.Schema | None: +def build_options_schema(hass: HomeAssistant, data: Mapping | None) -> vol.Schema: """Builds the device option schema.""" return vol.Schema( @@ -182,10 +180,7 @@ def build_options_schema( ) ), vol.Optional( - ATTR_EXTERNAL_TEMPERATURE_SENSOR, - default=UNDEFINED - if data is None - else data.get(ATTR_EXTERNAL_TEMPERATURE_SENSOR, UNDEFINED), + ATTR_EXTERNAL_TEMPERATURE_SENSOR, default=UNDEFINED ): EntitySelector( config=EntitySelectorConfig( multiple=False, @@ -229,6 +224,20 @@ def build_options_schema( ) +DEVICE_OPTIONS_KEYS = { + CONF_TIMEOUT, + CONF_MAX_ONLINE_ATTEMPTS, + CONF_DISABLE_AVAILABLE_CHECK, + ATTR_EXTERNAL_HUMIDITY_SENSOR, + ATTR_EXTERNAL_TEMPERATURE_SENSOR, + CONF_FEATURES, + CONF_SWING_HORIZONTAL_MODES, + CONF_SWING_MODES, + CONF_FAN_MODES, + CONF_HVAC_MODES, +} # keys in the device_options schema + + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow from user.""" @@ -322,10 +331,13 @@ async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None) await self.async_set_unique_id(entry.unique_id) if user_input is not None: + _LOGGER.warning(user_input) + # FIXME: Cannot remove external entities after setting + data = self._merge_device_options(entry.data, user_input) self._abort_if_unique_id_mismatch() return self.async_update_reload_and_abort( entry, - data_updates=user_input, + data=data, ) return self.async_show_form( @@ -335,6 +347,26 @@ async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None) ), ) + def _merge_device_options(self, data, device_options_data: Mapping) -> dict: + """Removes optional keys if unset and updates the others.""" + old_data = dict(data) + + # Update or drop managed keys based on user_input + + for key in DEVICE_OPTIONS_KEYS: + if key in device_options_data: + old_data[key] = device_options_data[key] + else: + old_data.pop(key, None) + _LOGGER.warning("Removing key %s", key) + + # If there are any unmanaged keys in user_input, merge them too + for key, value in device_options_data.items(): + if key not in DEVICE_OPTIONS_KEYS: + old_data[key] = value + + return old_data + class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" From 9a78e8f887dd68f47164a967ed0ad383250a56b4 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Fri, 19 Sep 2025 23:18:55 +0100 Subject: [PATCH 009/113] Reimplement Auto Light and X-Fan --- custom_components/gree/climate.py | 126 ++++++++++++++------------ custom_components/gree/const.py | 2 + custom_components/gree/coordinator.py | 20 ++++ custom_components/gree/entity.py | 10 +- custom_components/gree/select.py | 22 ++--- custom_components/gree/sensor.py | 8 +- custom_components/gree/switch.py | 54 +++++++++-- 7 files changed, 157 insertions(+), 85 deletions(-) diff --git a/custom_components/gree/climate.py b/custom_components/gree/climate.py index cd6b6dd..b357aec 100644 --- a/custom_components/gree/climate.py +++ b/custom_components/gree/climate.py @@ -29,7 +29,6 @@ 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 .const import ( ATTR_EXTERNAL_HUMIDITY_SENSOR, @@ -167,7 +166,7 @@ def __init__( """Initialize the Gree Climate entity.""" super().__init__(coordinator, restore_state) self.entity_description = description - self._attr_unique_id = f"{self._device.name}_{description.key}" + self._attr_unique_id = f"{self.device.name}_{description.key}" self._attr_name = None # Main entity self._external_temperature_sensor = external_temperature_sensor_id @@ -206,7 +205,7 @@ def __init__( def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - _LOGGER.debug("Updating Climate Entity for %s", self._device.unique_id) + _LOGGER.debug("Updating Climate Entity for %s", self.device.unique_id) self.update_attributes() async def async_added_to_hass(self): @@ -313,7 +312,7 @@ def _update_current_humidity_from_external(self, new_state: State | None) -> Non def update_attributes(self): """Updates the entity attributes with the device values.""" - self._attr_available = self._device.available + self._attr_available = self.device.available if ( self._attr_supported_features @@ -360,67 +359,78 @@ def update_attributes(self): async def async_turn_on(self): """Turn on.""" - _LOGGER.debug("turn_on(%s)", self._device.unique_id) + _LOGGER.debug("turn_on(%s)", self.device.unique_id) if not self.available: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="entity_unavailable" ) + try: - self._device.set_power_mode(True) - await self._device.update_device_status() + self.device.set_power_mode(True) - # notify coordinator listeners of state change so that dependent entities are updated immediately - self.coordinator.async_update_listeners() + # If Auto Light is enabled, turn the display lights on + if self.coordinator.feature_auto_light: + self.device.set_feature_light(True) - # TODO: Turn Light on if auto light is on + await self.device.update_device_status() - await self.coordinator.async_request_refresh() + self.async_write_ha_state() + + # notify coordinator listeners of state change so that dependent entities are updated immediately + self.coordinator.async_update_listeners() except Exception as err: _LOGGER.exception("Error in '%s'", "async_turn_on") raise HomeAssistantError( translation_domain=DOMAIN, translation_key="generic" ) from err - self.async_write_ha_state() + finally: + await self.coordinator.async_request_refresh() async def async_turn_off(self): """Turn off.""" - _LOGGER.debug("turn_off(%s)", self._device.unique_id) + _LOGGER.debug("turn_off(%s)", self.device.unique_id) if not self.available: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="entity_unavailable" ) + try: - self._device.set_power_mode(False) - await self._device.update_device_status() + self.device.set_power_mode(False) + + # If Auto Light is enabled, turn the display lights off + if self.coordinator.feature_auto_light: + self.device.set_feature_light(False) + + await self.device.update_device_status() + + self.async_write_ha_state() # notify coordinator listeners of state change so that dependent entities are updated immediately self.coordinator.async_update_listeners() - # TODO: Turn Light off if auto light is on - - await self.coordinator.async_request_refresh() except Exception as err: _LOGGER.exception("Error in '%s'", "async_turn_off") raise HomeAssistantError( translation_domain=DOMAIN, translation_key="generic" ) from err - self.async_write_ha_state() + finally: + await self.coordinator.async_request_refresh() def get_hvac_mode(self) -> HVACMode: """Converts Gree Operation Modes to HA.""" return ( HVACMode.OFF - if not self._device.power_mode - else HVAC_MODES_GREE_TO_HA[self._device.operation_mode] + if not self.device.power_mode + else HVAC_MODES_GREE_TO_HA[self.device.operation_mode] ) async def async_set_hvac_mode(self, hvac_mode: HVACMode): """Set the HVAC Mode.""" - _LOGGER.debug("set_hvac_mode(%s, %s)", self._device.unique_id, hvac_mode) + _LOGGER.debug("set_hvac_mode(%s, %s)", self.device.unique_id, hvac_mode) if not self.available: raise HomeAssistantError( @@ -429,18 +439,27 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode): try: if hvac_mode == HVACMode.OFF: + if self.coordinator.feature_auto_xfan: + self.device.set_feature_xfan(False) + await self.async_turn_off() # This will be called in the turn on # await self._device.update_device_status() else: - self._device.set_operation_mode(HVAC_MODES_HA_TO_GREE[hvac_mode]) + self.device.set_operation_mode(HVAC_MODES_HA_TO_GREE[hvac_mode]) + + # The Auto X-FAN enables that feature if the device is set to a hvac mode taht supports X-FAN + if self.coordinator.feature_auto_xfan: + if hvac_mode in (HVACMode.COOL, HVACMode.DRY): + self.device.set_feature_xfan(True) + else: + self.device.set_feature_xfan(False) + await self.async_turn_on() # This will be called in the turn on # await self._device.update_device_status() - # TODO: Control X-FAN based on auto X-FAN - # notify coordinator listeners of state change so that dependent entities are updated immediately self.coordinator.async_update_listeners() @@ -455,20 +474,17 @@ 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 ( - GATTR_FEAT_QUIET_MODE in self._attr_hvac_modes - and self._device.feature_quiet - ): + if GATTR_FEAT_QUIET_MODE in self._attr_hvac_modes and self.device.feature_quiet: return GATTR_FEAT_QUIET_MODE - if GATTR_FEAT_TURBO in self._attr_hvac_modes and self._device.feature_turbo: + if GATTR_FEAT_TURBO in self._attr_hvac_modes and self.device.feature_turbo: return GATTR_FEAT_TURBO - return self._device.fan_speed.name + return self.device.fan_speed.name async def async_set_fan_mode(self, fan_mode: str): """Set new target fan mode.""" - _LOGGER.debug("set_fan_mode(%s, %s)", self._device.unique_id, fan_mode) + _LOGGER.debug("set_fan_mode(%s, %s)", self.device.unique_id, fan_mode) if not self.available: raise HomeAssistantError( @@ -497,13 +513,13 @@ async def async_set_fan_mode(self, fan_mode: str): ) try: - self._device.set_feature_quiet(fan_mode == GATTR_FEAT_QUIET_MODE) - self._device.set_feature_turbo(fan_mode == GATTR_FEAT_TURBO) + self.device.set_feature_quiet(fan_mode == GATTR_FEAT_QUIET_MODE) + self.device.set_feature_turbo(fan_mode == GATTR_FEAT_TURBO) if fan_mode not in (GATTR_FEAT_QUIET_MODE, GATTR_FEAT_TURBO): - self._device.set_fan_speed(FanSpeed[fan_mode]) + self.device.set_fan_speed(FanSpeed[fan_mode]) - await self._device.update_device_status() + await self.device.update_device_status() # notify coordinator listeners of state change so that dependent entities are updated immediately self.coordinator.async_update_listeners() @@ -519,13 +535,11 @@ async def async_set_fan_mode(self, fan_mode: str): def get_swing_mode(self) -> str: """Converts Gree Swing Modes to HA.""" - return self._device.vertical_swing_mode.name + return self.device.vertical_swing_mode.name async def async_set_swing_mode(self, swing_mode): """Set new target swing operation.""" - _LOGGER.debug( - "async_set_swing_mode(%s, %s)", self._device.unique_id, swing_mode - ) + _LOGGER.debug("async_set_swing_mode(%s, %s)", self.device.unique_id, swing_mode) if not self.available: raise HomeAssistantError( @@ -538,8 +552,8 @@ async def async_set_swing_mode(self, swing_mode): ) try: - self._device.set_vertical_swing_mode(VerticalSwingMode[swing_mode]) - await self._device.update_device_status() + self.device.set_vertical_swing_mode(VerticalSwingMode[swing_mode]) + await self.device.update_device_status() # notify coordinator listeners of state change so that dependent entities are updated immediately self.coordinator.async_update_listeners() @@ -555,13 +569,13 @@ async def async_set_swing_mode(self, swing_mode): def get_swing_horizontal_mode(self) -> str: """Converts Gree Swing Horizontal Modes to HA.""" - return self._device.horizontal_swing_mode.name + return self.device.horizontal_swing_mode.name async def async_set_swing_horizontal_mode(self, swing_horizontal_mode): """Set new target horizontal swing operation.""" _LOGGER.debug( "async_set_swing_horizontal_mode(%s, %s)", - self._device.unique_id, + self.device.unique_id, swing_horizontal_mode, ) @@ -576,10 +590,10 @@ async def async_set_swing_horizontal_mode(self, swing_horizontal_mode): ) try: - self._device.set_horizontal_swing_mode( + self.device.set_horizontal_swing_mode( HorizontalSwingMode[swing_horizontal_mode] ) - await self._device.update_device_status() + await self.device.update_device_status() # notify coordinator listeners of state change so that dependent entities are updated immediately self.coordinator.async_update_listeners() @@ -595,7 +609,7 @@ async def async_set_swing_horizontal_mode(self, swing_horizontal_mode): def get_temp_units(self) -> UnitOfTemperature: """Returns the device units of temperature.""" - return UNITS_GREE_TO_HA[self._device.target_temperature_unit] + return UNITS_GREE_TO_HA[self.device.target_temperature_unit] def get_current_temp(self) -> float | None: """Returns the current temperature of the room. Accounting for units.""" @@ -604,11 +618,11 @@ def get_current_temp(self) -> float | None: # so if we are dealing with ºF we convert to that first if ( self.hass - and self._device.has_indoor_temperature_sensor - and self._device.indoors_temperature_c is not None + and self.device.has_indoor_temperature_sensor + and self.device.indoors_temperature_c is not None ): return self.hass.config.units.temperature( - float(self._device.indoors_temperature_c), UnitOfTemperature.CELSIUS + float(self.device.indoors_temperature_c), UnitOfTemperature.CELSIUS ) # FIXME: When changing Units in HA Settings, the temp does not update @@ -619,21 +633,21 @@ def get_current_humidity(self) -> float | None: """Returns the current humidity of the room.""" # Gree API always return current humidity in % - if self._device.has_humidity_sensor and self._device.humidity is not None: - return float(self._device.humidity) + if self.device.has_humidity_sensor and self.device.humidity is not None: + return float(self.device.humidity) return None def get_current_target_temp(self) -> float | None: """Returns the current target temperature set on the device.""" # Device already return in the temperature_units - return self._device.target_temperature + return self.device.target_temperature async def async_set_temperature(self, **kwargs): """Set new target temperature.""" temperature: float | None = kwargs.get(ATTR_TEMPERATURE) _LOGGER.debug( - "async_set_temperature(%s, %s)", self._device.unique_id, temperature + "async_set_temperature(%s, %s)", self.device.unique_id, temperature ) _LOGGER.debug(kwargs) @@ -648,8 +662,8 @@ async def async_set_temperature(self, **kwargs): try: # TODO: Confirm that HA sends the values in this entity's temperature_unit which matches the device unit - self._device.set_target_temperature(temperature) - await self._device.update_device_status() + self.device.set_target_temperature(temperature) + await self.device.update_device_status() # notify coordinator listeners of state change so that dependent entities are updated immediately self.coordinator.async_update_listeners() diff --git a/custom_components/gree/const.py b/custom_components/gree/const.py index c8e5eed..40ee44b 100755 --- a/custom_components/gree/const.py +++ b/custom_components/gree/const.py @@ -60,6 +60,8 @@ ATTR_EXTERNAL_TEMPERATURE_SENSOR = "external_temperature_sensor" ATTR_EXTERNAL_HUMIDITY_SENSOR = "external_humidity_sensor" +ATTR_AUTO_XFAN = "auto_xfan" +ATTR_AUTO_LIGHT = "auto_light" # HVAC modes - these come from Home Assistant and are standard DEFAULT_HVAC_MODES = [ diff --git a/custom_components/gree/coordinator.py b/custom_components/gree/coordinator.py index c736d18..18121b4 100644 --- a/custom_components/gree/coordinator.py +++ b/custom_components/gree/coordinator.py @@ -39,6 +39,8 @@ def __init__( always_update=True, ) self.device: GreeDevice = device + self._feature_auto_xfan: bool = False + self._feature_auto_light: bool = False async def _async_setup(self): """Set up the coordinator. @@ -64,3 +66,21 @@ async def _async_update_data(self): raise ConfigEntryAuthFailed("Failed to initiate Gree device") from err except ValueError as err: raise UpdateFailed("Error getting state from device") from err + + @property + def feature_auto_light(self) -> bool: + """Returns the state of the Auto Display Light Feature.""" + return self._feature_auto_light + + def set_feature_auto_light(self, value: bool) -> None: + """Sets the state of the Auto Display Light Feature.""" + self._feature_auto_light = value + + @property + def feature_auto_xfan(self) -> bool: + """Returns the state of the Auto X-Fan Feature.""" + return self._feature_auto_xfan + + def set_feature_auto_xfan(self, value: bool) -> None: + """Sets the state of the Auto X-Fan Feature.""" + self._feature_auto_xfan = value diff --git a/custom_components/gree/entity.py b/custom_components/gree/entity.py index 1fcbe62..2bee868 100755 --- a/custom_components/gree/entity.py +++ b/custom_components/gree/entity.py @@ -25,13 +25,13 @@ class GreeEntity(CoordinatorEntity[GreeCoordinator]): def __init__(self, coordinator: GreeCoordinator, restore_state: bool) -> None: """Initialize Gree entity.""" super().__init__(coordinator) - self._device = coordinator.device + self.device = coordinator.device self._attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, self._device.unique_id)}, - identifiers={(DOMAIN, self._device.unique_id)}, - name=self._device.name, + connections={(CONNECTION_NETWORK_MAC, self.device.unique_id)}, + identifiers={(DOMAIN, self.device.unique_id)}, + name=self.device.name, manufacturer="Gree", - sw_version=self._device.firmware_version + sw_version=self.device.firmware_version, ) self.restore_state = restore_state diff --git a/custom_components/gree/select.py b/custom_components/gree/select.py index 04e8708..97407c2 100644 --- a/custom_components/gree/select.py +++ b/custom_components/gree/select.py @@ -93,7 +93,7 @@ def __init__( super().__init__(coordinator, restore_state) self.entity_description = description # pyright: ignore[reportIncompatibleVariableOverride] - self._attr_unique_id = f"{self._device.name}_{description.key}" + self._attr_unique_id = f"{self.device.name}_{description.key}" # Set up options dynamically if description.options_func: @@ -101,35 +101,35 @@ def __init__( else: self._attr_options = description.options or ["None"] - self._attr_current_option = self.entity_description.value_func(self._device) + self._attr_current_option = self.entity_description.value_func(self.device) _LOGGER.debug("Initialized select %s", self._attr_unique_id) _LOGGER.debug("Options: %s", 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) + _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.""" - return self.entity_description.value_func(self._device) + return self.entity_description.value_func(self.device) async def async_select_option(self, option: str) -> None: """Change the selected option.""" _LOGGER.debug( "async_select_option(%s, %s, %s -> %s)", - self._device.unique_id, + self.device.unique_id, self.entity_description.key, self.current_option, option, ) try: - self.entity_description.set_func(self._device, option) + self.entity_description.set_func(self.device, option) if self.entity_description.updates_device: - await self._device.update_device_status() + await self.device.update_device_status() # notify coordinator listeners of state change so that dependent entities are updated immediately self.coordinator.async_update_listeners() @@ -138,7 +138,7 @@ async def async_select_option(self, option: str) -> None: except Exception as err: _LOGGER.debug( "Error in async_select_option(%s, %s, %s -> %s)", - self._device.unique_id, + self.device.unique_id, self.entity_description.key, self.current_option, option, @@ -162,10 +162,10 @@ async def async_added_to_hass(self): ) if last_state.state not in ("unknown", "unavailable"): try: - self.entity_description.set_func(self._device, last_state.state) + self.entity_description.set_func(self.device, last_state.state) if self.entity_description.updates_device: - await self._device.update_device_status() + await self.device.update_device_status() self._attr_current_option = last_state.state except Exception as err: # noqa: BLE001 diff --git a/custom_components/gree/sensor.py b/custom_components/gree/sensor.py index 2b10a13..424134d 100644 --- a/custom_components/gree/sensor.py +++ b/custom_components/gree/sensor.py @@ -111,20 +111,20 @@ def __init__( super().__init__(coordinator, restore_state) self.entity_description = description # pyright: ignore[reportIncompatibleVariableOverride] - self._attr_unique_id = f"{self._device.name}_{description.key}" + self._attr_unique_id = f"{self.device.name}_{description.key}" _LOGGER.debug("Initialized sensor %s", self._attr_unique_id) @property def available(self): # pyright: ignore[reportIncompatibleVariableOverride] """Return True if entity is available.""" - return self._device.available and self.entity_description.available_func( - self._device + return self.device.available and self.entity_description.available_func( + self.device ) @property def native_value(self): # pyright: ignore[reportIncompatibleVariableOverride] """Return the state of the sensor.""" - return self.entity_description.value_func(self._device) + return self.entity_description.value_func(self.device) async def async_added_to_hass(self): """Handle entity which will be added.""" diff --git a/custom_components/gree/switch.py b/custom_components/gree/switch.py index e559f55..d45ba54 100644 --- a/custom_components/gree/switch.py +++ b/custom_components/gree/switch.py @@ -17,6 +17,8 @@ from homeassistant.helpers.restore_state import RestoreEntity from .const import ( + ATTR_AUTO_LIGHT, + ATTR_AUTO_XFAN, CONF_FEATURES, DEFAULT_SUPPORTED_FEATURES, GATTR_ANTI_DIRECT_BLOW, @@ -153,6 +155,40 @@ async def async_setup_entry( if description.key in supported_features ] + if GATTR_FEAT_LIGHT in supported_features: + entities.append( + GreeSwitch( + GreeSwitchDescription( + key=ATTR_AUTO_LIGHT, + translation_key=ATTR_AUTO_LIGHT, + available_func=lambda device: device.available, + value_func=lambda _: coordinator.feature_auto_light, + set_func=lambda _, value: coordinator.set_feature_auto_light(value), + updates_device=False, + entity_category=EntityCategory.CONFIG, + ), + coordinator, + restore_state=True, + ) + ) + + if GATTR_FEAT_XFAN in supported_features: + entities.append( + GreeSwitch( + GreeSwitchDescription( + key=ATTR_AUTO_XFAN, + translation_key=ATTR_AUTO_XFAN, + available_func=lambda device: device.available, + value_func=lambda _: coordinator.feature_auto_xfan, + set_func=lambda _, value: coordinator.set_feature_auto_xfan(value), + updates_device=False, + entity_category=EntityCategory.CONFIG, + ), + coordinator, + restore_state=True, + ) + ) + async_add_entities(entities) @@ -171,18 +207,18 @@ def __init__( super().__init__(coordinator, restore_state) self.entity_description = description # pyright: ignore[reportIncompatibleVariableOverride] - self._attr_unique_id = f"{self._device.name}_{description.key}" + self._attr_unique_id = f"{self.device.name}_{description.key}" _LOGGER.debug("Initialized sensor %s", self._attr_unique_id) @property def available(self): # pyright: ignore[reportIncompatibleVariableOverride] """Return True if entity is available.""" - return self.entity_description.available_func(self._device) + return self.entity_description.available_func(self.device) @property def is_on(self) -> bool | None: # pyright: ignore[reportIncompatibleVariableOverride] """Return true if the switch is on.""" - return self.entity_description.value_func(self._device) + return self.entity_description.value_func(self.device) async def async_added_to_hass(self): """Handle entity which will be added.""" @@ -197,10 +233,10 @@ async def async_added_to_hass(self): if last_state.state in ("on", "off"): value: bool = last_state.state == "on" try: - self.entity_description.set_func(self._device, value) + self.entity_description.set_func(self.device, value) if self.entity_description.updates_device: - await self._device.update_device_status() + await self.device.update_device_status() self._attr_is_on = value except Exception as err: # noqa: BLE001 @@ -216,10 +252,10 @@ async def async_turn_on(self, **kwargs: Any) -> None: raise HomeAssistantError("Entity unavailable") try: - self.entity_description.set_func(self._device, True) + self.entity_description.set_func(self.device, True) if self.entity_description.updates_device: - await self._device.update_device_status() + await self.device.update_device_status() # notify coordinator listeners of state change so that dependent entities are updated immediately self.coordinator.async_update_listeners() @@ -239,10 +275,10 @@ async def async_turn_off(self, **kwargs: Any) -> None: raise HomeAssistantError("Entity unavailable") try: - self.entity_description.set_func(self._device, False) + self.entity_description.set_func(self.device, False) if self.entity_description.updates_device: - await self._device.update_device_status() + await self.device.update_device_status() # notify coordinator listeners of state change so that dependent entities are updated immediately self.coordinator.async_update_listeners() From 29cbd3152e2acbc6274575223ae64080a1ea5299 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Fri, 19 Sep 2025 23:21:25 +0100 Subject: [PATCH 010/113] Remove timeout from the coordinator --- custom_components/gree/coordinator.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/custom_components/gree/coordinator.py b/custom_components/gree/coordinator.py index 18121b4..56af5c9 100644 --- a/custom_components/gree/coordinator.py +++ b/custom_components/gree/coordinator.py @@ -60,8 +60,7 @@ async def _async_update_data(self): so entities can quickly look up their data. """ try: - async with timeout(10): - await self.device.fetch_device_status() + await self.device.fetch_device_status() except GreeDeviceNotBoundError as err: raise ConfigEntryAuthFailed("Failed to initiate Gree device") from err except ValueError as err: From 97201dee828b5d3fbce4e41cb200a14bdb08cd14 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Fri, 19 Sep 2025 23:39:27 +0100 Subject: [PATCH 011/113] Feature: Restore entity states configurable by user --- custom_components/gree/config_flow.py | 5 +++++ custom_components/gree/const.py | 1 + custom_components/gree/coordinator.py | 1 - custom_components/gree/icons.json | 6 ++++++ custom_components/gree/switch.py | 11 ++++++++++- custom_components/gree/translations/en.json | 12 ++++++++---- 6 files changed, 30 insertions(+), 6 deletions(-) diff --git a/custom_components/gree/config_flow.py b/custom_components/gree/config_flow.py index db983cc..79bbbfa 100644 --- a/custom_components/gree/config_flow.py +++ b/custom_components/gree/config_flow.py @@ -36,6 +36,7 @@ CONF_FEATURES, CONF_HVAC_MODES, CONF_MAX_ONLINE_ATTEMPTS, + CONF_RESTORE_STATES, CONF_SWING_HORIZONTAL_MODES, CONF_SWING_MODES, CONF_UID, @@ -200,6 +201,10 @@ def build_options_schema(hass: HomeAssistant, data: Mapping | None) -> vol.Schem device_class=SensorDeviceClass.HUMIDITY, ) ), + vol.Required( + CONF_RESTORE_STATES, + default=True if data is None else data.get(CONF_RESTORE_STATES, True), + ): cv.boolean, vol.Required( CONF_DISABLE_AVAILABLE_CHECK, default=False diff --git a/custom_components/gree/const.py b/custom_components/gree/const.py index 40ee44b..c81f48a 100755 --- a/custom_components/gree/const.py +++ b/custom_components/gree/const.py @@ -19,6 +19,7 @@ CONF_ENCRYPTION_VERSION = "encryption_version" CONF_DISABLE_AVAILABLE_CHECK = "disable_available_check" CONF_MAX_ONLINE_ATTEMPTS = "max_online_attempts" +CONF_RESTORE_STATES = "restore_states" CONF_HVAC_MODES = "hvac_modes" CONF_FAN_MODES = "fan_modes" diff --git a/custom_components/gree/coordinator.py b/custom_components/gree/coordinator.py index 56af5c9..04c05ec 100644 --- a/custom_components/gree/coordinator.py +++ b/custom_components/gree/coordinator.py @@ -1,6 +1,5 @@ """Data update coordinator for Gree integration.""" -from asyncio import timeout import logging from homeassistant.config_entries import ConfigEntry diff --git a/custom_components/gree/icons.json b/custom_components/gree/icons.json index ea12824..dc0a3f0 100755 --- a/custom_components/gree/icons.json +++ b/custom_components/gree/icons.json @@ -81,6 +81,12 @@ "feat_light_sensor": { "default": "mdi:brightness-auto" }, + "auto_xfan": { + "default": "mdi:fan-auto" + }, + "auto_light": { + "default": "mdi:lightbulb-auto" + }, "beeper": { "default": "mdi:volume-high" } diff --git a/custom_components/gree/switch.py b/custom_components/gree/switch.py index d45ba54..162cbd1 100644 --- a/custom_components/gree/switch.py +++ b/custom_components/gree/switch.py @@ -20,6 +20,7 @@ ATTR_AUTO_LIGHT, ATTR_AUTO_XFAN, CONF_FEATURES, + CONF_RESTORE_STATES, DEFAULT_SUPPORTED_FEATURES, GATTR_ANTI_DIRECT_BLOW, GATTR_BEEPER, @@ -150,7 +151,15 @@ async def async_setup_entry( _LOGGER.debug("Adding Switch Entities: %s", supported_features) entities = [ - GreeSwitch(description, coordinator, restore_state=True) + GreeSwitch( + description, + coordinator, + restore_state=( + entry.data.get(CONF_RESTORE_STATES, True) + if description.key != GATTR_BEEPER # Always restore beeper + else True + ), + ) for description in SWITCH_TYPES if description.key in supported_features ] diff --git a/custom_components/gree/translations/en.json b/custom_components/gree/translations/en.json index cca937a..f8d8ee7 100755 --- a/custom_components/gree/translations/en.json +++ b/custom_components/gree/translations/en.json @@ -39,13 +39,15 @@ "external_humidity_sensor": "External Humidity Sensor", "disable_available_check": "Disable Available Check", "max_online_attempts": "Max Connection Attempts", - "timeout": "Connection Timeout" + "timeout": "Connection Timeout", + "restore_states": "Restore Entities" }, "data_description": { "external_temperature_sensor": "If set will replace the built-in HVAC temperature sensor", "external_humidity_sensor": "If set will replace the built-in HVAC humidity sensor", "max_online_attempts": "The number of attempts to communicate with the device before it is marked as unavailable", - "timeout": "The timeout for each of the connection attempts" + "timeout": "The timeout for each of the connection attempts", + "restore_states": "If set to true, when the integration is started the device will be overriden by previous states of the integration instead of using the current device state." } }, "reconfigure": { @@ -61,13 +63,15 @@ "external_humidity_sensor": "External Humidity Sensor", "disable_available_check": "Disable Available Check", "max_online_attempts": "Max Connection Attempts", - "timeout": "Connection Timeout" + "timeout": "Connection Timeout", + "restore_states": "Restore Entities" }, "data_description": { "external_temperature_sensor": "If set will replace the built-in HVAC temperature sensor", "external_humidity_sensor": "If set will replace the built-in HVAC humidity sensor", "max_online_attempts": "The number of attempts to communicate with the device before it is marked as unavailable", - "timeout": "The timeout for each of the connection attempts" + "timeout": "The timeout for each of the connection attempts", + "restore_states": "If set to true, when the integration is started the device will be overriden by previous states of the integration instead of using the current device state." } } } From 03b95978b7b6342e88425f45f28842e56c50f421 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Fri, 19 Sep 2025 23:45:45 +0100 Subject: [PATCH 012/113] Change set_fan debug --- custom_components/gree/climate.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/custom_components/gree/climate.py b/custom_components/gree/climate.py index b357aec..f434144 100644 --- a/custom_components/gree/climate.py +++ b/custom_components/gree/climate.py @@ -484,7 +484,12 @@ def get_fan_mode(self) -> str: async def async_set_fan_mode(self, fan_mode: str): """Set new target fan mode.""" - _LOGGER.debug("set_fan_mode(%s, %s)", self.device.unique_id, fan_mode) + _LOGGER.debug( + "set_fan_mode(%s, %s -> %s)", + self.device.unique_id, + self.get_fan_mode(), + fan_mode, + ) if not self.available: raise HomeAssistantError( From 98ce9236bfb21481b9aa589cd493d9929bf7d726 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Sat, 20 Sep 2025 19:46:42 +0100 Subject: [PATCH 013/113] Fix auto x-fan logic --- custom_components/gree/climate.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/custom_components/gree/climate.py b/custom_components/gree/climate.py index f434144..04a283e 100644 --- a/custom_components/gree/climate.py +++ b/custom_components/gree/climate.py @@ -439,21 +439,17 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode): try: if hvac_mode == HVACMode.OFF: - if self.coordinator.feature_auto_xfan: - self.device.set_feature_xfan(False) - await self.async_turn_off() # This will be called in the turn on # await self._device.update_device_status() else: self.device.set_operation_mode(HVAC_MODES_HA_TO_GREE[hvac_mode]) - # The Auto X-FAN enables that feature if the device is set to a hvac mode taht supports X-FAN + # The Auto X-FAN enables that feature if the device is set to a hvac mode that supports X-FAN if self.coordinator.feature_auto_xfan: - if hvac_mode in (HVACMode.COOL, HVACMode.DRY): - self.device.set_feature_xfan(True) - else: - self.device.set_feature_xfan(False) + self.device.set_feature_xfan( + hvac_mode in (HVACMode.COOL, HVACMode.DRY) + ) await self.async_turn_on() From 5425b80099034271703af5adb84bc4f7aef02631 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Sat, 20 Sep 2025 20:03:56 +0100 Subject: [PATCH 014/113] Improve Temperature Units select and restore state --- custom_components/gree/select.py | 13 +++++++++---- custom_components/gree/translations/en.json | 6 ------ 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/custom_components/gree/select.py b/custom_components/gree/select.py index 97407c2..2f17ac1 100644 --- a/custom_components/gree/select.py +++ b/custom_components/gree/select.py @@ -15,7 +15,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import UNDEFINED -from .const import GATTR_TEMP_UNITS +from .const import CONF_RESTORE_STATES, GATTR_TEMP_UNITS from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription from .gree_api import TemperatureUnits @@ -40,11 +40,11 @@ async def async_setup_entry( key=GATTR_TEMP_UNITS, translation_key=GATTR_TEMP_UNITS, entity_category=EntityCategory.CONFIG, - options=[member.name for member in TemperatureUnits], + options=[f"º{member.name}" for member in TemperatureUnits], available_func=lambda device: device.available, value_func=lambda device: device.target_temperature_unit.name, set_func=lambda device, value: device.set_target_temperature_unit( - TemperatureUnits[value] + TemperatureUnits[value.replace("º", "")] ), updates_device=True, ) @@ -53,7 +53,12 @@ async def async_setup_entry( _LOGGER.debug("Adding Select Entities: %s", [desc.key for desc in descriptions]) async_add_entities( - [GreeSelectEntity(description, coordinator) for description in descriptions] + [ + GreeSelectEntity( + description, coordinator, entry.data.get(CONF_RESTORE_STATES, True) + ) + for description in descriptions + ] ) diff --git a/custom_components/gree/translations/en.json b/custom_components/gree/translations/en.json index f8d8ee7..9a88a4d 100755 --- a/custom_components/gree/translations/en.json +++ b/custom_components/gree/translations/en.json @@ -299,11 +299,5 @@ "generic": { "message": "There was a problem performing the requested change, please consult the integration log." } - }, - "state": { - "temperature_units": { - "C": "Celsius", - "F": "Fahrenheit" - } } } From 8686f440028782ec676e818b7e0a4ec73d12fa39 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Mon, 22 Sep 2025 19:40:40 +0100 Subject: [PATCH 015/113] Realign with main branch for easier migrations --- custom_components/gree/const.py | 22 ++++++++++----------- custom_components/gree/icons.json | 18 ++++++++--------- custom_components/gree/translations/en.json | 18 ++++++++--------- 3 files changed, 29 insertions(+), 29 deletions(-) diff --git a/custom_components/gree/const.py b/custom_components/gree/const.py index c81f48a..1ccaec1 100755 --- a/custom_components/gree/const.py +++ b/custom_components/gree/const.py @@ -35,27 +35,27 @@ # use the device beeper on commands GATTR_BEEPER = "beeper" # controls the state of the fresh air valve (not available on all units) -GATTR_FEAT_FRESH_AIR = "feat_fresh_air" +GATTR_FEAT_FRESH_AIR = "air" # "Blow" or "X-Fan", this function keeps the fan running for a while after shutting down. Only usable in Dry and Cool mode -GATTR_FEAT_XFAN = "feat_xfan" +GATTR_FEAT_XFAN = "xfan" # sleep mode, which gradually changes the temperature in Cool, Heat and Dry mode -GATTR_FEAT_SLEEP_MODE = "feat_sleep" +GATTR_FEAT_SLEEP_MODE = "sleep" # 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 -GATTR_FEAT_SMART_HEAT_8C = "feat_smart_heat" +GATTR_FEAT_SMART_HEAT_8C = "eightdegheat" # turns all indicators and the display on the unit on or off -GATTR_FEAT_LIGHT = "feat_lights" +GATTR_FEAT_LIGHT = "lights" # controls Health ("Cold plasma") mode -GATTR_FEAT_HEALTH = "feat_health" +GATTR_FEAT_HEALTH = "health" # prevents the wind from blowing directly on people -GATTR_ANTI_DIRECT_BLOW = "feat_anti_direct_blow" +GATTR_ANTI_DIRECT_BLOW = "anti_direct_blow" # energy saving mode -GATTR_FEAT_ENERGY_SAVING = "feat_energy_saving" +GATTR_FEAT_ENERGY_SAVING = "powersave" # use light sensor for unit display -GATTR_FEAT_SENSOR_LIGHT = "feat_light_sensor" +GATTR_FEAT_SENSOR_LIGHT = "light_sensor" # Quiet mode which slows down the fan to its most quiet speed. Not available in Dry and Fan mode. -GATTR_FEAT_QUIET_MODE = "feat_quiet" +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 = "feat_turbo" +GATTR_FEAT_TURBO = "turbo" GATTR_TEMP_UNITS = "temperature_units" diff --git a/custom_components/gree/icons.json b/custom_components/gree/icons.json index dc0a3f0..7ccd180 100755 --- a/custom_components/gree/icons.json +++ b/custom_components/gree/icons.json @@ -54,31 +54,31 @@ } }, "switch": { - "feat_fresh_air": { + "air": { "default": "mdi:air-filter" }, - "feat_xfan": { + "xfan": { "default": "mdi:fan" }, - "feat_sleep": { + "sleep": { "default": "mdi:sleep" }, - "feat_smart_heat": { + "eightdegheat": { "default": "mdi:thermometer-low" }, - "feat_health": { + "health": { "default": "mdi:shield-check" }, - "feat_anti_direct_blow": { + "anti_direct_blow": { "default": "mdi:weather-windy" }, - "feat_energy_saving": { + "powersave": { "default": "mdi:leaf" }, - "feat_lights": { + "lights": { "default": "mdi:lightbulb" }, - "feat_light_sensor": { + "light_sensor": { "default": "mdi:brightness-auto" }, "auto_xfan": { diff --git a/custom_components/gree/translations/en.json b/custom_components/gree/translations/en.json index 9a88a4d..98df534 100755 --- a/custom_components/gree/translations/en.json +++ b/custom_components/gree/translations/en.json @@ -241,39 +241,39 @@ "name": "Auto X-Fan", "description": "Automatically controls the X-Fan mode based on HVAC operations. When enabled, X-Fan will automatically turn on in cooling and dry modes." }, - "feat_lights": { + "lights": { "name": "Display Light", "description": "Controls the display lights on the air conditioner unit." }, - "feat_xfan": { + "xfan": { "name": "X-Fan", "description": "Enables or disables the X-Fan mode for extra drying when turning off." }, - "feat_health": { + "health": { "name": "Health", "description": "Enables or disables the Health mode for air ionization and purification." }, - "feat_energy_saving": { + "powersave": { "name": "Power Save", "description": "Enables or disables the power saving mode for energy efficiency. Only available in cooling mode." }, - "feat_smart_heat": { + "eightdegheat": { "name": "Smart Heat 8ºC", "description": "Enables or disables the 8°C heating mode for frost protection. Only available in heating mode." }, - "feat_sleep": { + "sleep": { "name": "Sleep", "description": "Enables or disables the sleep mode for comfortable overnight operation. Only available in cooling or heating mode." }, - "feat_fresh_air": { + "air": { "name": "Fresh Air", "description": "Enables or disables the fresh air circulation mode." }, - "feat_anti_direct_blow": { + "anti_direct_blow": { "name": "Anti Direct Blow", "description": "Prevents direct air flow from blowing on people by adjusting the air deflector position." }, - "feat_light_sensor": { + "light_sensor": { "name": "Display Auto Brightness", "description": "Enables or disables light sensor for automatic brightness. Requires lights to be enabled." }, From 4f0876b028dc63b7be57dd882c9841462ab1e379 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Mon, 22 Sep 2025 19:56:08 +0100 Subject: [PATCH 016/113] Add API support for device discovery --- custom_components/gree/gree_api.py | 129 +++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/custom_components/gree/gree_api.py b/custom_components/gree/gree_api.py index 235631b..1439224 100644 --- a/custom_components/gree/gree_api.py +++ b/custom_components/gree/gree_api.py @@ -7,6 +7,8 @@ import logging import re import socket +import time +from typing import Any import asyncio_dgram from Crypto.Cipher import AES @@ -178,6 +180,66 @@ def pad(s: str): return s + requiredPaddingSize * chr(requiredPaddingSize) +def udp_broadcast_request( + addresses: list[str], port: int, json_data: str, timeout: int +): + """Sends a UDP message to the bradcast address and returns the responses.""" + # Create UDP socket manually so we can enable broadcast + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + sock.settimeout(timeout) + sock.bind(("", 0)) + + responses: dict = {} + + # Default broadcast addresses to try + default_broadcast_addresses = [ + "255.255.255.255", # Limited broadcast + "192.168.255.255", # /16 broadcast for 192.168.x.x networks + "10.255.255.255", # /8 broadcast for 10.x.x.x networks + "172.31.255.255", # /12 broadcast for 172.16-31.x.x networks + ] + addresses.extend(default_broadcast_addresses) + + # Remove duplicates + broadcast_addresses = list(dict.fromkeys(addresses)) + + try: + for broadcast_addr in broadcast_addresses: + try: + _LOGGER.debug("Sending broadcast to %s", broadcast_addr) + sock.sendto(json_data.encode("utf-8"), (broadcast_addr, port)) + except Exception: + _LOGGER.exception("Failed to send to %s", broadcast_addr) + + # Send broadcast + _LOGGER.debug("Sent broadcast packets, waiting for replies... ") + + start_time: float = time.time() + while time.time() - start_time < timeout: + try: + response, addr = sock.recvfrom(1024) + + try: + # Try to parse as JSON and decrypt if possible + response = json.loads(response.decode(errors="ignore")) + except Exception: + _LOGGER.exception("Could not parse response from %s", addr) + else: + responses[addr] = response + except TimeoutError: + break + except Exception: + _LOGGER.exception("Error sending broadcast packet") + finally: + sock.close() + + _LOGGER.debug( + "Got %d responses in %d seconds: %s", len(responses), timeout, responses + ) + return responses + + async def udp_request_async( ip_addr: str, port: int, @@ -325,6 +387,32 @@ async def fetch_result( return data +def get_gree_response_data( + received_json: str, + cipher, + encryption_version: EncryptionVersion, +): + """Decodes a response from a gree device.""" + data = json.loads(received_json) + + encodedPack = data.get("pack") + if encodedPack: + pack = base64.b64decode(encodedPack) + decryptedPack = cipher.decrypt(pack) + pack = decryptedPack.decode("utf-8") + replacedPack = pack.replace("\x0f", "").replace( + pack[pack.rindex("}") + 1 :], "" + ) + data["pack"] = json.loads(replacedPack) + + if encryption_version == EncryptionVersion.V2: + tag = data["tag"] + _LOGGER.debug("Verifying tag: %s", tag) + cipher.verify(base64.b64decode(tag)) + + return data + + async def get_result_pack( ip_addr: str, port: int, @@ -708,3 +796,44 @@ def extract_version(info: dict) -> tuple[str | None, str | None]: id_match = re.match(r"(\d+)", hid) # leading digits device_id = id_match.group(1) if id_match else None return ver, device_id + + +def discover_gree_devices( + broadcast_addresses: list[str], timeout: int +) -> list[dict[str, Any]]: + """Discovers gree devices in the network.""" + + discovered_devices: list[dict[str, Any]] = [] + + responses = udp_broadcast_request( + broadcast_addresses, DEFAULT_DEVICE_PORT, json.dumps({"t": "scan"}), timeout + ) + + for address, response in responses.items(): + data = get_gree_response_data( + response, + gree_get_default_cipher(EncryptionVersion.V1), + EncryptionVersion.V1, + ) + + if data is not None: + pack = data.get("pack") + if pack is not None: + if pack.get("t") == "dev": + mac_addr = pack.get("mac", "") + if not mac_addr: + _LOGGER.debug("No MAC address in response from %s", address) + continue + + # Just collect basic device info for now - encryption detection happens later + discovered_device: dict[str, Any] = { + "name": pack.get("name", "") or f"Gree {mac_addr[-4:]}", + "host": address, + "port": DEFAULT_DEVICE_PORT, + "mac": mac_addr, + } + + discovered_devices.append(discovered_device) + _LOGGER.debug("Discovered device: %s", discovered_device) + + return discovered_devices From ce79013e80d66074f516421611b8a7779926d88a Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Mon, 22 Sep 2025 20:02:27 +0100 Subject: [PATCH 017/113] Gree API: Reuse pack decryption code --- custom_components/gree/gree_api.py | 25 ++++++------------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/custom_components/gree/gree_api.py b/custom_components/gree/gree_api.py index 1439224..2738609 100644 --- a/custom_components/gree/gree_api.py +++ b/custom_components/gree/gree_api.py @@ -367,22 +367,9 @@ async def fetch_result( # except Exception as err: # raise ValueError(f"Error communicating with {ip_addr}", ip_addr) from err - data = json.loads(received_json) - - encodedPack = data["pack"] - pack = base64.b64decode(encodedPack) - decryptedPack = cipher.decrypt(pack) - - if encryption_version == EncryptionVersion.V2: - tag = data["tag"] - _LOGGER.debug("Verifying tag: %s", tag) - cipher.verify(base64.b64decode(tag)) - - pack = decryptedPack.decode("utf-8") - replacedPack = pack.replace("\x0f", "").replace(pack[pack.rindex("}") + 1 :], "") - data["pack"] = json.loads(replacedPack) + data = get_gree_response_data(received_json, cipher, encryption_version) - _LOGGER.debug("Got data from %s", ip_addr) + _LOGGER.debug("Got data from %s: %s", ip_addr, data) return data @@ -468,7 +455,7 @@ def gree_get_default_cipher(encryption_version: EncryptionVersion): return None -def gree_encrypted_pack( +def gree_create_encrypted_pack( data: str, cipher, encryption_version: EncryptionVersion, @@ -591,7 +578,7 @@ async def gree_get_device_key( else [encryption_version] ): _LOGGER.info("Trying to retrieve device encryption key v%d", enc_version) - pack, tag = gree_encrypted_pack( + pack, tag = gree_create_encrypted_pack( gree_create_bind_pack(mac_addr, uid, enc_version), gree_get_default_cipher(enc_version), enc_version, @@ -652,7 +639,7 @@ async def gree_get_status( status_values: dict[GreeProp, int] = {} - pack, tag = gree_encrypted_pack( + pack, tag = gree_create_encrypted_pack( gree_create_status_pack(mac_addr, [prop.value for prop in props]), get_cipher(encryption_key, encryption_version), encryption_version, @@ -701,7 +688,7 @@ async def gree_set_status( _LOGGER.debug("Trying to set device status") set_pack = gree_create_set_pack(props) - pack, tag = gree_encrypted_pack( + pack, tag = gree_create_encrypted_pack( set_pack, get_cipher(encryption_key, encryption_version), encryption_version, From 3adef52fa4c9669b44ff1553742f5d7f149c3866 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Mon, 22 Sep 2025 21:13:11 +0100 Subject: [PATCH 018/113] Fix translations --- custom_components/gree/translations/en.json | 26 ++++++++++----------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/custom_components/gree/translations/en.json b/custom_components/gree/translations/en.json index 98df534..37ea50a 100755 --- a/custom_components/gree/translations/en.json +++ b/custom_components/gree/translations/en.json @@ -101,8 +101,8 @@ "Medium": "Medium", "MediumHigh": "Medium-High", "High": "High", - "feat_turbo": "Turbo", - "feat_quiet": "Quiet" + "turbo": "Turbo", + "quiet": "Quiet" } }, "swing_modes": { @@ -135,15 +135,15 @@ "features": { "options": { "beeper": "Beeper", - "feat_fresh_air": "Fresh Air", - "feat_xfan": "X-Fan", - "feat_sleep": "Sleep", - "feat_smart_heat": "8ºC Smart Heat", - "feat_lights": "Display Light", - "feat_health": "Health", - "feat_anti_direct_blow": "Anti Direct Blow", - "feat_energy_saving": "Energy Saving", - "feat_light_sensor": "Display Auto Brightness" + "air": "Fresh Air", + "xfan": "X-Fan", + "sleep": "Sleep", + "smart_heat": "8ºC Smart Heat", + "lights": "Display Light", + "health": "Health", + "anti_direct_blow": "Anti Direct Blow", + "energy_saving": "Energy Saving", + "light_sensor": "Display Auto Brightness" } } }, @@ -174,8 +174,8 @@ "Medium": "Medium", "MediumHigh": "Medium-High", "High": "High", - "feat_turbo": "Turbo", - "feat_quiet": "Quiet" + "turbo": "Turbo", + "quiet": "Quiet" } }, "swing_mode": { From f779529a85279804492e48918e6fccbae8c9652a Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Mon, 22 Sep 2025 21:42:22 +0100 Subject: [PATCH 019/113] Fix imports --- custom_components/gree/__init__.py | 1 - custom_components/gree/config_flow.py | 2 +- custom_components/gree/entity.py | 7 ++----- custom_components/gree/select.py | 2 +- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/custom_components/gree/__init__.py b/custom_components/gree/__init__.py index b603583..62046f0 100755 --- a/custom_components/gree/__init__.py +++ b/custom_components/gree/__init__.py @@ -6,7 +6,6 @@ import asyncio import logging -# Third-party imports from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, diff --git a/custom_components/gree/config_flow.py b/custom_components/gree/config_flow.py index 79bbbfa..626423f 100644 --- a/custom_components/gree/config_flow.py +++ b/custom_components/gree/config_flow.py @@ -9,7 +9,6 @@ import voluptuous as vol from voluptuous.schema_builder import UNDEFINED -from config.custom_components.gree.gree_api import EncryptionVersion from homeassistant import config_entries from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, CONF_TIMEOUT @@ -53,6 +52,7 @@ DEFAULT_CONNECTION_TIMEOUT, DEFAULT_DEVICE_PORT, DEFAULT_DEVICE_UID, + EncryptionVersion, ) from .gree_device import GreeDevice, GreeDeviceNotBoundError diff --git a/custom_components/gree/entity.py b/custom_components/gree/entity.py index 2bee868..1078c63 100755 --- a/custom_components/gree/entity.py +++ b/custom_components/gree/entity.py @@ -2,19 +2,16 @@ from __future__ import annotations -# Standard library imports from collections.abc import Callable from dataclasses import dataclass -# Home Assistant imports -from config.custom_components.gree.coordinator import GreeCoordinator -from config.custom_components.gree.gree_device import GreeDevice from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.entity import DeviceInfo, EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -# Local imports from .const import DOMAIN +from .coordinator import GreeCoordinator +from .gree_device import GreeDevice class GreeEntity(CoordinatorEntity[GreeCoordinator]): diff --git a/custom_components/gree/select.py b/custom_components/gree/select.py index 2f17ac1..73c895c 100644 --- a/custom_components/gree/select.py +++ b/custom_components/gree/select.py @@ -6,7 +6,6 @@ from attr import dataclass -from config.custom_components.gree.gree_device import GreeDevice from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant @@ -19,6 +18,7 @@ from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription from .gree_api import TemperatureUnits +from .gree_device import GreeDevice _LOGGER = logging.getLogger(__name__) From 3a9f0b2870d7dbf8fda9cd37de9fdf77cf354a00 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Mon, 22 Sep 2025 23:26:47 +0100 Subject: [PATCH 020/113] Bring back add device by discovery --- custom_components/gree/config_flow.py | 130 ++++++++++++++++++-- custom_components/gree/gree_api.py | 33 +++-- custom_components/gree/translations/en.json | 24 +++- 3 files changed, 163 insertions(+), 24 deletions(-) diff --git a/custom_components/gree/config_flow.py b/custom_components/gree/config_flow.py index 626423f..73337f4 100644 --- a/custom_components/gree/config_flow.py +++ b/custom_components/gree/config_flow.py @@ -10,6 +10,10 @@ from voluptuous.schema_builder import UNDEFINED from homeassistant import config_entries +from homeassistant.components.network import ( + IPv4Address, + async_get_ipv4_broadcast_addresses, +) from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, CONF_TIMEOUT from homeassistant.core import HomeAssistant @@ -53,6 +57,8 @@ DEFAULT_DEVICE_PORT, DEFAULT_DEVICE_UID, EncryptionVersion, + GreeDiscoveredDevice, + discover_gree_devices, ) from .gree_device import GreeDevice, GreeDeviceNotBoundError @@ -64,15 +70,16 @@ def build_main_schema(data: Mapping | None) -> vol.Schema: return vol.Schema( { vol.Required( - CONF_NAME, default="AC" if data is None else data.get(CONF_NAME, "") + CONF_NAME, + default="Gree AC" if data is None else data.get(CONF_NAME, ""), ): str, vol.Required( CONF_HOST, - default="192.168.1.103" if data is None else data.get(CONF_HOST, ""), + default="" if data is None else data.get(CONF_HOST, ""), ): str, vol.Required( CONF_MAC, - default="C0:39:37:B1:22:80" if data is None else data.get(CONF_MAC, ""), + default="" if data is None else data.get(CONF_MAC, ""), ): str, vol.Required(CONF_ADVANCED): section( vol.Schema( @@ -80,7 +87,7 @@ def build_main_schema(data: Mapping | None) -> vol.Schema: vol.Required( CONF_PORT, default=DEFAULT_DEVICE_PORT - if data is None or data[CONF_ADVANCED] is None + if data is None or data.get(CONF_ADVANCED) is None else data[CONF_ADVANCED].get( CONF_PORT, DEFAULT_DEVICE_PORT ), @@ -88,7 +95,7 @@ def build_main_schema(data: Mapping | None) -> vol.Schema: vol.Required( CONF_ENCRYPTION_VERSION, default="Auto-Detect" - if data is None or data[CONF_ADVANCED] is None + if data is None or data.get(CONF_ADVANCED) is None else data[CONF_ADVANCED].get( CONF_ENCRYPTION_VERSION, "Auto-Detect" ), @@ -96,13 +103,13 @@ def build_main_schema(data: Mapping | None) -> vol.Schema: vol.Optional( CONF_ENCRYPTION_KEY, default="" - if data is None or data[CONF_ADVANCED] is None + if data is None or data.get(CONF_ADVANCED) is None else data[CONF_ADVANCED].get(CONF_ENCRYPTION_KEY, ""), ): str, vol.Required( CONF_UID, default=DEFAULT_DEVICE_UID - if data is None or data[CONF_ADVANCED] is None + if data is None or data.get(CONF_ADVANCED) is None else data[CONF_ADVANCED].get(CONF_UID, DEFAULT_DEVICE_UID), ): cv.positive_int, } @@ -247,6 +254,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow from user.""" VERSION = 2 + _discovered_devices: list[GreeDiscoveredDevice] | None = None + _selected_device: GreeDiscoveredDevice | None = None + _discovery_performed: bool = False def __init__(self) -> None: """Initialize the config flow.""" @@ -256,7 +266,77 @@ def __init__(self) -> None: async def async_step_user( self, user_input: dict | None = None ) -> config_entries.ConfigFlowResult: - """Handle the initial step.""" + """Handle the initial step - show discovery or manual entry.""" + if user_input is not None: + if user_input.get("discovery") == "discover": + return await self.async_step_manual_discovery() + return await self.async_step_manual_add() + + # Show discovery vs manual choice + data_schema = vol.Schema( + { + vol.Required("discovery", default="discover"): SelectSelector( + SelectSelectorConfig( + options=["discover", "manual"], + translation_key="discovery_method", + ) + ) + } + ) + return self.async_show_form(step_id="user", data_schema=data_schema) + + async def async_step_manual_discovery( + self, user_input: dict | None = None + ) -> config_entries.ConfigFlowResult: + """Handle device discovery.""" + if user_input is not None: + # User selected a discovered device + selected_device = user_input["device"] + + assert self._discovered_devices + + for device in self._discovered_devices: + device_id = f"{device.mac}_{device.host}" + if device_id == selected_device: + # Check if already configured + await self.async_set_unique_id(format_mac(device.mac)) + self._abort_if_unique_id_configured() + + # Store selected device for next step + self._selected_device = device + return await self.async_step_manual_add() + + # If no matching device found, something went wrong - go to manual + return await self.async_step_manual_add() + + # Discover devices + self._discovery_performed = True + self._discovered_devices = await self._discover_devices(self.hass) + + if not self._discovered_devices: + # No devices found, go to manual entry + return await self.async_step_manual_add() + + # Create device selection options + device_options = {} + for device in self._discovered_devices: + device_id = f"{device.mac}_{device.host}" + device_options[device_id] = f"IP: {device.host}, MAC: {device.mac}" + + data_schema = vol.Schema({vol.Required("device"): vol.In(device_options)}) + + return self.async_show_form( + step_id="manual_discovery", + data_schema=data_schema, + description_placeholders={ + "devices_found": str(len(self._discovered_devices)) + }, + ) + + async def async_step_manual_add( + self, user_input: dict | None = None + ) -> config_entries.ConfigFlowResult: + """Handle the manual add of a device.""" errors = {} if user_input is not None: try: @@ -295,9 +375,18 @@ async def async_step_user( self._abort_if_unique_id_configured() return await self.async_step_device_options() + elif self._selected_device is not None: + user_input = {} + user_input[CONF_NAME] = self._selected_device.name + user_input[CONF_HOST] = self._selected_device.host + user_input[CONF_MAC] = self._selected_device.mac + elif self._discovery_performed and self._selected_device is None: + errors["base"] = "no_devices_found" return self.async_show_form( - step_id="user", data_schema=build_main_schema(user_input), errors=errors + step_id="manual_add", + data_schema=build_main_schema(user_input), + errors=errors, ) async def async_step_device_options( @@ -372,6 +461,29 @@ def _merge_device_options(self, data, device_options_data: Mapping) -> dict: return old_data + async def _discover_devices( + self, hass: HomeAssistant + ) -> list[GreeDiscoveredDevice]: + """Debug for discovering devices.""" + # Get broadcast addresses from Home Assistant's network helper + broadcast_addresses: list[str] = [] + try: + ha_broadcast_addresses: set[ + IPv4Address + ] = await async_get_ipv4_broadcast_addresses(hass) + ha_broadcast_strings: list[str] = [ + str(addr) for addr in ha_broadcast_addresses + ] + broadcast_addresses.extend(ha_broadcast_strings) + _LOGGER.debug("Found broadcast addresses from HA: %s", ha_broadcast_strings) + + except Exception: + _LOGGER.exception("Could not get HA broadcast addresses") + + return await hass.async_add_executor_job( + discover_gree_devices, broadcast_addresses, 5 + ) + class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/custom_components/gree/gree_api.py b/custom_components/gree/gree_api.py index 2738609..22b85c9 100644 --- a/custom_components/gree/gree_api.py +++ b/custom_components/gree/gree_api.py @@ -8,9 +8,9 @@ import re import socket import time -from typing import Any import asyncio_dgram +from attr import dataclass from Crypto.Cipher import AES _LOGGER = logging.getLogger(__name__) @@ -170,6 +170,16 @@ class GreeCommand(IntEnum): BIND = 1 +@dataclass +class GreeDiscoveredDevice: + """Device discovered data.""" + + name: str + host: str + mac: str + port: int + + propkey_to_enum = {prop.value: prop for prop in GreeProp} @@ -221,12 +231,11 @@ def udp_broadcast_request( response, addr = sock.recvfrom(1024) try: - # Try to parse as JSON and decrypt if possible - response = json.loads(response.decode(errors="ignore")) + response = response.decode(errors="ignore") except Exception: _LOGGER.exception("Could not parse response from %s", addr) else: - responses[addr] = response + responses[addr[0]] = response except TimeoutError: break except Exception: @@ -787,10 +796,10 @@ def extract_version(info: dict) -> tuple[str | None, str | None]: def discover_gree_devices( broadcast_addresses: list[str], timeout: int -) -> list[dict[str, Any]]: +) -> list[GreeDiscoveredDevice]: """Discovers gree devices in the network.""" - discovered_devices: list[dict[str, Any]] = [] + discovered_devices: list[GreeDiscoveredDevice] = [] responses = udp_broadcast_request( broadcast_addresses, DEFAULT_DEVICE_PORT, json.dumps({"t": "scan"}), timeout @@ -813,12 +822,12 @@ def discover_gree_devices( continue # Just collect basic device info for now - encryption detection happens later - discovered_device: dict[str, Any] = { - "name": pack.get("name", "") or f"Gree {mac_addr[-4:]}", - "host": address, - "port": DEFAULT_DEVICE_PORT, - "mac": mac_addr, - } + discovered_device = GreeDiscoveredDevice( + name=pack.get("name", "") or f"Gree {mac_addr[-4:]}", + host=address, + mac=mac_addr, + port=DEFAULT_DEVICE_PORT, + ) discovered_devices.append(discovered_device) _LOGGER.debug("Discovered device: %s", discovered_device) diff --git a/custom_components/gree/translations/en.json b/custom_components/gree/translations/en.json index 37ea50a..e92a7db 100755 --- a/custom_components/gree/translations/en.json +++ b/custom_components/gree/translations/en.json @@ -1,12 +1,30 @@ { "config": { - "error": { - "cannot_connect": "Unable to connect to the device. Please check the device configuration and network connection and try again." - }, "title": "Gree Climate", "description": "Configure your Gree air conditioner", + "error": { + "cannot_connect": "Unable to connect to the device. Please check the device configuration and network connection and try again.", + "no_devices_found": "Couldn't find any Gree device in the network. Please add your device manually." + }, + "abort": { + "already_configured": "A device with this MAC address is already configured." + }, "step": { "user": { + "title": "Gree Climate Setup", + "description": "Choose how to add your Gree air conditioner", + "data": { + "discovery": "Setup Method" + } + }, + "manual_discovery": { + "title": "Discovered Devices", + "description": "Found {devices_found} Gree device(s). Select one to add or choose manual setup.", + "data": { + "device": "Device" + } + }, + "manual_add": { "title": "Device configuration", "data": { "name": "Name", From 034c93ddc13f353f2bb355bc0db5bdd1def32032 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Wed, 24 Sep 2025 21:15:54 +0100 Subject: [PATCH 021/113] Fix Temperature Unit selector --- custom_components/gree/select.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/gree/select.py b/custom_components/gree/select.py index 73c895c..443f99c 100644 --- a/custom_components/gree/select.py +++ b/custom_components/gree/select.py @@ -42,7 +42,7 @@ async def async_setup_entry( entity_category=EntityCategory.CONFIG, options=[f"º{member.name}" for member in TemperatureUnits], available_func=lambda device: device.available, - value_func=lambda device: device.target_temperature_unit.name, + value_func=lambda device: f"º{device.target_temperature_unit.name}", set_func=lambda device, value: device.set_target_temperature_unit( TemperatureUnits[value.replace("º", "")] ), From 6ff856d2687edbc85a48fd5c28699a7ff99c2184 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Wed, 24 Sep 2025 21:35:15 +0100 Subject: [PATCH 022/113] Fix current temperature units in Climate Entity --- custom_components/gree/climate.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/custom_components/gree/climate.py b/custom_components/gree/climate.py index 04a283e..d8b7ea8 100644 --- a/custom_components/gree/climate.py +++ b/custom_components/gree/climate.py @@ -29,6 +29,7 @@ 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 .const import ( ATTR_EXTERNAL_HUMIDITY_SENSOR, @@ -200,19 +201,19 @@ def __init__( self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE self._attr_swing_horizontal_modes = swing_horizontal_modes - self.update_attributes() + self._update_attributes() _LOGGER.debug("Initialized climate %s", self._attr_unique_id) def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" _LOGGER.debug("Updating Climate Entity for %s", self.device.unique_id) - self.update_attributes() + self._update_attributes() async def async_added_to_hass(self): """When this entity is added to hass.""" await super().async_added_to_hass() - self.update_attributes() + self._update_attributes() # When using an external temperature sensor, subscribe to its state changes for updating the current temperature if self._external_temperature_sensor: @@ -310,7 +311,7 @@ def _update_current_humidity_from_external(self, new_state: State | None) -> Non ) self._attr_current_humidity = value - def update_attributes(self): + def _update_attributes(self): """Updates the entity attributes with the device values.""" self._attr_available = self.device.available @@ -616,14 +617,16 @@ def get_current_temp(self) -> float | None: """Returns the current temperature of the room. Accounting for units.""" # Gree API always return current temperature in ºC - # so if we are dealing with ºF we convert to that first + # so here we need to convert to the unit of the entity (same as device) if ( self.hass and self.device.has_indoor_temperature_sensor and self.device.indoors_temperature_c is not None ): - return self.hass.config.units.temperature( - float(self.device.indoors_temperature_c), UnitOfTemperature.CELSIUS + return TemperatureConverter.convert( + float(self.device.indoors_temperature_c), + UnitOfTemperature.CELSIUS, + self._attr_temperature_unit, ) # FIXME: When changing Units in HA Settings, the temp does not update From 4622516a66da60f437bdf17c44e2e849ddf291dd Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Wed, 24 Sep 2025 21:49:15 +0100 Subject: [PATCH 023/113] Request a update when system units are changed --- custom_components/gree/climate.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/custom_components/gree/climate.py b/custom_components/gree/climate.py index d8b7ea8..1a1ca2d 100644 --- a/custom_components/gree/climate.py +++ b/custom_components/gree/climate.py @@ -13,6 +13,7 @@ from homeassistant.const import ( ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, + EVENT_CORE_CONFIG_UPDATE, STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfTemperature, @@ -241,6 +242,13 @@ async def async_added_to_hass(self): ) ) + # Refresh entity when HA unit system changes + self.async_on_remove( + self.hass.bus.async_listen( + EVENT_CORE_CONFIG_UPDATE, self._handle_unit_change + ) + ) + @callback def _external_temperature_sensor_listener( self, event: Event[EventStateChangedData] @@ -311,6 +319,11 @@ def _update_current_humidity_from_external(self, new_state: State | None) -> Non ) self._attr_current_humidity = value + async def _handle_unit_change(self, event): + """Handle HA unit system change (°C <-> °F).""" + # Force refresh from coordinator + await self.coordinator.async_request_refresh() + def _update_attributes(self): """Updates the entity attributes with the device values.""" self._attr_available = self.device.available @@ -629,8 +642,6 @@ def get_current_temp(self) -> float | None: self._attr_temperature_unit, ) - # FIXME: When changing Units in HA Settings, the temp does not update - return None def get_current_humidity(self) -> float | None: From fd5a17ea6f5ec92a6d26c184339faa3624d21bd2 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Wed, 24 Sep 2025 23:41:25 +0100 Subject: [PATCH 024/113] Implement RestoreEntity in GreeClimate --- custom_components/gree/climate.py | 159 ++++++++++++++++++++++++++++-- 1 file changed, 153 insertions(+), 6 deletions(-) diff --git a/custom_components/gree/climate.py b/custom_components/gree/climate.py index 1a1ca2d..ddafbc2 100644 --- a/custom_components/gree/climate.py +++ b/custom_components/gree/climate.py @@ -5,6 +5,9 @@ from attr import dataclass from homeassistant.components.climate import ( + ATTR_FAN_MODE, + ATTR_SWING_HORIZONTAL_MODE, + ATTR_SWING_MODE, ClimateEntity, ClimateEntityDescription, ClimateEntityFeature, @@ -37,6 +40,7 @@ ATTR_EXTERNAL_TEMPERATURE_SENSOR, CONF_FAN_MODES, CONF_HVAC_MODES, + CONF_RESTORE_STATES, CONF_SWING_HORIZONTAL_MODES, CONF_SWING_MODES, DEFAULT_FAN_MODES, @@ -122,6 +126,7 @@ async def async_setup_entry( fan_modes, swing_modes, swing_horizontal_modes, + restore_state=(entry.data.get(CONF_RESTORE_STATES, True)), external_temperature_sensor_id=entry.data.get( ATTR_EXTERNAL_TEMPERATURE_SENSOR ), @@ -203,12 +208,11 @@ def __init__( self._attr_swing_horizontal_modes = swing_horizontal_modes self._update_attributes() - _LOGGER.debug("Initialized climate %s", self._attr_unique_id) - - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - _LOGGER.debug("Updating Climate Entity for %s", self.device.unique_id) - self._update_attributes() + _LOGGER.debug( + "Initialized climate '%s' with features: %s", + self._attr_unique_id, + repr(self._attr_supported_features), + ) async def async_added_to_hass(self): """When this entity is added to hass.""" @@ -216,6 +220,10 @@ async def async_added_to_hass(self): self._update_attributes() + # Restore last HA state to device if applicable + if self.restore_state: + await self._restore_entity_state() + # When using an external temperature sensor, subscribe to its state changes for updating the current temperature if self._external_temperature_sensor: self._update_current_temperature_from_external( @@ -249,6 +257,145 @@ async def async_added_to_hass(self): ) ) + async def _restore_entity_state(self): + last_state = await self.async_get_last_state() + if last_state is not None: + _LOGGER.debug( + "Restoring state for %s:\n%s", + self.entity_id, + last_state, + ) + + # hvac mode + if last_state.state not in [None, STATE_UNKNOWN, STATE_UNAVAILABLE]: + last_hvac_mode: HVACMode | None = HVACMode(last_state.state) + if ( + last_hvac_mode is not None + and last_hvac_mode != self.hvac_mode + and last_hvac_mode in self.hvac_modes + ): + try: + await self.async_set_hvac_mode(last_hvac_mode) + except Exception: + _LOGGER.exception( + "Failed to restore the hvac_mode: %s", last_hvac_mode + ) + else: + _LOGGER.debug( + "No need to restore the hvac_mode: %s", + last_hvac_mode, + ) + + # fan mode + last_fan_mode: str | None = last_state.attributes.get(ATTR_FAN_MODE) + if ( + last_fan_mode not in [None, STATE_UNKNOWN, STATE_UNAVAILABLE] + and self.fan_modes is not None + and last_fan_mode != self.fan_mode + and last_fan_mode in self.fan_modes + ): + try: + await self.async_set_fan_mode(last_fan_mode) + except Exception: + _LOGGER.exception( + "Failed to restore the fan_mode: %s", last_fan_mode + ) + else: + _LOGGER.debug( + "No need to restore the fan_mode: %s", + last_fan_mode, + ) + + # swings + last_swing_mode: str | None = last_state.attributes.get(ATTR_SWING_MODE) + if ( + last_swing_mode not in [None, STATE_UNKNOWN, STATE_UNAVAILABLE] + and self.swing_modes is not None + and last_swing_mode != self.swing_mode + and last_swing_mode in self.swing_modes + ): + try: + await self.async_set_swing_mode(last_swing_mode) + except Exception: + _LOGGER.exception( + "Failed to restore the swing_mode: %s", last_swing_mode + ) + else: + _LOGGER.debug( + "No need to restore the swing_mode: %s", + last_swing_mode, + ) + + last_swing_horizontal_mode: str | None = last_state.attributes.get( + ATTR_SWING_HORIZONTAL_MODE + ) + if ( + last_swing_horizontal_mode + not in [None, STATE_UNKNOWN, STATE_UNAVAILABLE] + and self.swing_horizontal_modes is not None + and last_swing_horizontal_mode != self.swing_horizontal_mode + and last_swing_horizontal_mode in self.swing_horizontal_modes + ): + try: + await self.async_set_swing_horizontal_mode( + last_swing_horizontal_mode + ) + except Exception: + _LOGGER.exception( + "Failed to restore the swing_horizontal_mode: %s", + last_swing_horizontal_mode, + ) + else: + _LOGGER.debug( + "No need to restore the swing_horizontal_mode: %s", + last_swing_horizontal_mode, + ) + + # target temp + last_target_temperature: float | None = last_state.attributes.get( + ATTR_TEMPERATURE + ) + if last_target_temperature is not None and last_target_temperature not in [ + STATE_UNKNOWN, + STATE_UNAVAILABLE, + ]: + # since the ºC and ºF ranges don't overlap we can guess the last state units + last_unit: UnitOfTemperature = ( + UnitOfTemperature.CELSIUS + if last_target_temperature <= MAX_TEMP_C + else UnitOfTemperature.FAHRENHEIT + ) + last_target_temperature = TemperatureConverter.convert( + last_target_temperature, + last_unit, + self.temperature_unit, + ) + if ( + self.supported_features & ClimateEntityFeature.TARGET_TEMPERATURE + and last_target_temperature != self.target_temperature + ): + try: + await self.async_set_temperature( + **{ATTR_TEMPERATURE: last_target_temperature} + ) + except Exception: + _LOGGER.exception( + "Failed to restore the target_temperature: %s%s", + last_target_temperature, + last_unit, + ) + else: + _LOGGER.debug( + "No need to restore the target_temperature: %s%s", + last_target_temperature, + self.temperature_unit, + ) + + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + _LOGGER.debug("Updating Climate Entity for %s", self.device.unique_id) + self._update_attributes() + @callback def _external_temperature_sensor_listener( self, event: Event[EventStateChangedData] From e013a196db9a94a6df2371e52288d0d8004557d1 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Wed, 24 Sep 2025 23:49:48 +0100 Subject: [PATCH 025/113] Use _attr_ instead of the cached property --- custom_components/gree/climate.py | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/custom_components/gree/climate.py b/custom_components/gree/climate.py index ddafbc2..5151560 100644 --- a/custom_components/gree/climate.py +++ b/custom_components/gree/climate.py @@ -270,9 +270,9 @@ async def _restore_entity_state(self): if last_state.state not in [None, STATE_UNKNOWN, STATE_UNAVAILABLE]: last_hvac_mode: HVACMode | None = HVACMode(last_state.state) if ( - last_hvac_mode is not None - and last_hvac_mode != self.hvac_mode - and last_hvac_mode in self.hvac_modes + last_hvac_mode + and last_hvac_mode != self._attr_hvac_mode + and last_hvac_mode in self._attr_hvac_modes ): try: await self.async_set_hvac_mode(last_hvac_mode) @@ -290,9 +290,9 @@ async def _restore_entity_state(self): last_fan_mode: str | None = last_state.attributes.get(ATTR_FAN_MODE) if ( last_fan_mode not in [None, STATE_UNKNOWN, STATE_UNAVAILABLE] - and self.fan_modes is not None - and last_fan_mode != self.fan_mode - and last_fan_mode in self.fan_modes + and self._attr_fan_modes + and last_fan_mode != self._attr_fan_mode + and last_fan_mode in self._attr_fan_modes ): try: await self.async_set_fan_mode(last_fan_mode) @@ -310,9 +310,9 @@ async def _restore_entity_state(self): last_swing_mode: str | None = last_state.attributes.get(ATTR_SWING_MODE) if ( last_swing_mode not in [None, STATE_UNKNOWN, STATE_UNAVAILABLE] - and self.swing_modes is not None - and last_swing_mode != self.swing_mode - and last_swing_mode in self.swing_modes + and self._attr_swing_modes + and last_swing_mode != self._attr_swing_mode + and last_swing_mode in self._attr_swing_modes ): try: await self.async_set_swing_mode(last_swing_mode) @@ -332,9 +332,9 @@ async def _restore_entity_state(self): if ( last_swing_horizontal_mode not in [None, STATE_UNKNOWN, STATE_UNAVAILABLE] - and self.swing_horizontal_modes is not None - and last_swing_horizontal_mode != self.swing_horizontal_mode - and last_swing_horizontal_mode in self.swing_horizontal_modes + and self._attr_swing_horizontal_modes + and last_swing_horizontal_mode != self._attr_swing_horizontal_mode + and last_swing_horizontal_mode in self._attr_swing_horizontal_modes ): try: await self.async_set_swing_horizontal_mode( @@ -368,11 +368,12 @@ async def _restore_entity_state(self): last_target_temperature = TemperatureConverter.convert( last_target_temperature, last_unit, - self.temperature_unit, + self._attr_temperature_unit, ) if ( - self.supported_features & ClimateEntityFeature.TARGET_TEMPERATURE - and last_target_temperature != self.target_temperature + self._attr_supported_features + & ClimateEntityFeature.TARGET_TEMPERATURE + and last_target_temperature != self._attr_target_temperature ): try: await self.async_set_temperature( From 58856aa6304d0075c43cdef240053aee603987b8 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Thu, 25 Sep 2025 21:48:04 +0100 Subject: [PATCH 026/113] Implement availability check based on coordinator data update success and device comunication state --- custom_components/gree/climate.py | 11 +++++++-- custom_components/gree/entity.py | 25 ++++++++++++++++++-- custom_components/gree/gree_device.py | 10 ++++++-- custom_components/gree/select.py | 20 ++++++++++++---- custom_components/gree/sensor.py | 34 +++++++++++++++++---------- custom_components/gree/switch.py | 32 ++++++++++++++++--------- 6 files changed, 98 insertions(+), 34 deletions(-) diff --git a/custom_components/gree/climate.py b/custom_components/gree/climate.py index 5151560..928dfb0 100644 --- a/custom_components/gree/climate.py +++ b/custom_components/gree/climate.py @@ -38,6 +38,7 @@ from .const import ( ATTR_EXTERNAL_HUMIDITY_SENSOR, ATTR_EXTERNAL_TEMPERATURE_SENSOR, + CONF_DISABLE_AVAILABLE_CHECK, CONF_FAN_MODES, CONF_HVAC_MODES, CONF_RESTORE_STATES, @@ -127,6 +128,9 @@ async def async_setup_entry( swing_modes, swing_horizontal_modes, restore_state=(entry.data.get(CONF_RESTORE_STATES, True)), + check_availability=( + entry.data.get(CONF_DISABLE_AVAILABLE_CHECK, False) is False + ), external_temperature_sensor_id=entry.data.get( ATTR_EXTERNAL_TEMPERATURE_SENSOR ), @@ -167,11 +171,13 @@ def __init__( swing_modes: list[str], swing_horizontal_modes: list[str], restore_state: bool = True, + check_availability: bool = True, external_temperature_sensor_id: str | None = None, external_humidity_sensor_id: str | None = None, ) -> None: """Initialize the Gree Climate entity.""" - super().__init__(coordinator, restore_state) + super().__init__(description, coordinator, restore_state, check_availability) + self.entity_description = description self._attr_unique_id = f"{self.device.name}_{description.key}" self._attr_name = None # Main entity @@ -209,8 +215,9 @@ def __init__( self._update_attributes() _LOGGER.debug( - "Initialized climate '%s' with features: %s", + "Initialized climate: %s (check_availability=%s) Features:\n%s", self._attr_unique_id, + self.check_availability, repr(self._attr_supported_features), ) diff --git a/custom_components/gree/entity.py b/custom_components/gree/entity.py index 1078c63..a708da6 100755 --- a/custom_components/gree/entity.py +++ b/custom_components/gree/entity.py @@ -18,11 +18,23 @@ class GreeEntity(CoordinatorEntity[GreeCoordinator]): """Base Gree entity.""" _attr_has_entity_name = True + entity_description: GreeEntityDescription - def __init__(self, coordinator: GreeCoordinator, restore_state: bool) -> None: + def __init__( + self, + description: GreeEntityDescription, + coordinator: GreeCoordinator, + restore_state: bool, + check_availability: bool, + ) -> None: """Initialize Gree entity.""" super().__init__(coordinator) + + self.entity_description = description # pyright: ignore[reportIncompatibleVariableOverride] self.device = coordinator.device + self.restore_state = restore_state + self.check_availability = check_availability + self._attr_device_info = DeviceInfo( connections={(CONNECTION_NETWORK_MAC, self.device.unique_id)}, identifiers={(DOMAIN, self.device.unique_id)}, @@ -30,7 +42,16 @@ def __init__(self, coordinator: GreeCoordinator, restore_state: bool) -> None: manufacturer="Gree", sw_version=self.device.firmware_version, ) - self.restore_state = restore_state + + @property + def available(self): # pyright: ignore[reportIncompatibleVariableOverride] + """Return True if entity is available.""" + if self.check_availability: + return ( + self.coordinator.last_update_success + and self.entity_description.available_func(self.device) + ) + return True @dataclass(frozen=True, kw_only=True) diff --git a/custom_components/gree/gree_device.py b/custom_components/gree/gree_device.py index 9c68be8..8c5f561 100755 --- a/custom_components/gree/gree_device.py +++ b/custom_components/gree/gree_device.py @@ -105,6 +105,7 @@ def __init__( self._state: dict[GreeProp, int] = {} self._new_state: dict[GreeProp, int] = {} self._is_bound: bool = False + self._is_available: bool = False self._uniqueid: str = self._mac_addr self._max_connection_attempts: int = max_connection_attempts self._timeout: int = timeout @@ -151,6 +152,7 @@ async def bind_device(self) -> bool: ) self._is_bound = True except Exception as e: + self._is_available = True raise GreeDeviceNotBoundError("Device not bound") from e else: _LOGGER.info( @@ -185,7 +187,9 @@ async def fetch_device_status(self) -> GreeDeviceState: self._timeout, ) ) + self._is_available = True except Exception as err: + self._is_available = False raise ValueError("Error getting device status") from err self._update_state() @@ -226,7 +230,9 @@ async def update_device_status(self) -> GreeDeviceState: ) ) self._new_state.clear() + self._is_available = True except Exception as err: + self._is_available = False raise ValueError("Error setting device status") from err self._update_state() @@ -357,8 +363,8 @@ def firmware_version(self) -> str | None: @property def available(self) -> bool: - """Return True if entity is available.""" - return bool(self._is_bound) + """Return True if the device is bouund and last connection was successful.""" + return self._is_bound and self._is_available @property def beeper(self) -> bool: diff --git a/custom_components/gree/select.py b/custom_components/gree/select.py index 443f99c..0de1a24 100644 --- a/custom_components/gree/select.py +++ b/custom_components/gree/select.py @@ -14,7 +14,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import UNDEFINED -from .const import CONF_RESTORE_STATES, GATTR_TEMP_UNITS +from .const import CONF_DISABLE_AVAILABLE_CHECK, CONF_RESTORE_STATES, GATTR_TEMP_UNITS from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription from .gree_api import TemperatureUnits @@ -55,7 +55,12 @@ async def async_setup_entry( async_add_entities( [ GreeSelectEntity( - description, coordinator, entry.data.get(CONF_RESTORE_STATES, True) + description, + coordinator, + entry.data.get(CONF_RESTORE_STATES, True), + check_availability=( + entry.data.get(CONF_DISABLE_AVAILABLE_CHECK, False) is False + ), ) for description in descriptions ] @@ -93,9 +98,10 @@ def __init__( description: GreeSelectDescription, coordinator: GreeCoordinator, restore_state: bool = True, + check_availability: bool = True, ) -> None: """Initialize select.""" - super().__init__(coordinator, restore_state) + super().__init__(description, coordinator, restore_state, check_availability) self.entity_description = description # pyright: ignore[reportIncompatibleVariableOverride] self._attr_unique_id = f"{self.device.name}_{description.key}" @@ -107,8 +113,12 @@ def __init__( self._attr_options = description.options or ["None"] self._attr_current_option = self.entity_description.value_func(self.device) - _LOGGER.debug("Initialized select %s", self._attr_unique_id) - _LOGGER.debug("Options: %s", self._attr_options) + _LOGGER.debug( + "Initialized select: %s (check_availability=%s) Options:\n%s", + self._attr_unique_id, + self.check_availability, + self._attr_options, + ) def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" diff --git a/custom_components/gree/sensor.py b/custom_components/gree/sensor.py index 424134d..18398c9 100644 --- a/custom_components/gree/sensor.py +++ b/custom_components/gree/sensor.py @@ -15,6 +15,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity +from .const import CONF_DISABLE_AVAILABLE_CHECK from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription from .gree_device import GreeDevice @@ -47,7 +48,14 @@ async def async_setup_entry( _LOGGER.debug("Adding Sensor Entities: %s", sensors) entities = [ - GreeSensor(description, coordinator, restore_state=True) + GreeSensor( + description, + coordinator, + restore_state=True, + check_availability=( + entry.data.get(CONF_DISABLE_AVAILABLE_CHECK, False) is False + ), + ) for description in SENSOR_TYPES if description.key in sensors ] @@ -71,7 +79,9 @@ class GreeSensorDescription(GreeEntityDescription, SensorEntityDescription): native_unit_of_measurement=UnitOfTemperature.CELSIUS, suggested_display_precision=0, value_func=lambda device: device.indoors_temperature_c, - available_func=lambda device: device.has_indoor_temperature_sensor, + available_func=lambda device: ( + device.available and device.has_indoor_temperature_sensor + ), ), GreeSensorDescription( key=GATTR_OUTDOOR_TEMPERATURE, @@ -81,7 +91,9 @@ class GreeSensorDescription(GreeEntityDescription, SensorEntityDescription): native_unit_of_measurement=UnitOfTemperature.CELSIUS, suggested_display_precision=0, value_func=lambda device: device.outdoors_temperature_c, - available_func=lambda device: device.has_outdoor_temperature_sensor, + available_func=lambda device: ( + device.available and device.has_outdoor_temperature_sensor + ), ), GreeSensorDescription( key=GATTR_HUMIDITY, @@ -91,7 +103,7 @@ class GreeSensorDescription(GreeEntityDescription, SensorEntityDescription): native_unit_of_measurement=PERCENTAGE, suggested_display_precision=0, value_func=lambda device: device.humidity, - available_func=lambda device: device.has_humidity_sensor, + available_func=lambda device: (device.available and device.has_humidity_sensor), ), ] @@ -106,19 +118,17 @@ def __init__( description: GreeSensorDescription, coordinator: GreeCoordinator, restore_state: bool = True, + check_availability: bool = True, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, restore_state) + super().__init__(description, coordinator, restore_state, check_availability) self.entity_description = description # pyright: ignore[reportIncompatibleVariableOverride] self._attr_unique_id = f"{self.device.name}_{description.key}" - _LOGGER.debug("Initialized sensor %s", self._attr_unique_id) - - @property - def available(self): # pyright: ignore[reportIncompatibleVariableOverride] - """Return True if entity is available.""" - return self.device.available and self.entity_description.available_func( - self.device + _LOGGER.debug( + "Initialized sensor: %s (check_availability=%s)", + self._attr_unique_id, + self.check_availability, ) @property diff --git a/custom_components/gree/switch.py b/custom_components/gree/switch.py index 162cbd1..69bb1e1 100644 --- a/custom_components/gree/switch.py +++ b/custom_components/gree/switch.py @@ -19,6 +19,7 @@ from .const import ( ATTR_AUTO_LIGHT, ATTR_AUTO_XFAN, + CONF_DISABLE_AVAILABLE_CHECK, CONF_FEATURES, CONF_RESTORE_STATES, DEFAULT_SUPPORTED_FEATURES, @@ -70,9 +71,11 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): GreeSwitchDescription( key=GATTR_FEAT_SLEEP_MODE, translation_key=GATTR_FEAT_SLEEP_MODE, - available_func=lambda device: device.available - and device.operation_mode - in [OperationMode.Cool, OperationMode.Dry, OperationMode.Heat], + available_func=( + lambda device: device.available + and device.operation_mode + in [OperationMode.Cool, OperationMode.Dry, OperationMode.Heat] + ), value_func=lambda device: device.feature_sleep, set_func=lambda device, value: device.set_feature_sleep(value), ), @@ -115,7 +118,7 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): GreeSwitchDescription( key=GATTR_FEAT_SENSOR_LIGHT, translation_key=GATTR_FEAT_SENSOR_LIGHT, - available_func=lambda device: device.available and device.feature_light, + available_func=lambda device: (device.available and device.feature_light), value_func=lambda device: device.feature_light_sensor, set_func=lambda device, value: device.set_feature_light_sensor(value), entity_category=EntityCategory.CONFIG, @@ -159,6 +162,11 @@ async def async_setup_entry( if description.key != GATTR_BEEPER # Always restore beeper else True ), + check_availability=( + entry.data.get(CONF_DISABLE_AVAILABLE_CHECK, False) is False + if description.key != GATTR_BEEPER # Beeper is always available + else False + ), ) for description in SWITCH_TYPES if description.key in supported_features @@ -178,6 +186,7 @@ async def async_setup_entry( ), coordinator, restore_state=True, + check_availability=False, # Auto Light is always available ) ) @@ -195,6 +204,7 @@ async def async_setup_entry( ), coordinator, restore_state=True, + check_availability=False, # Auto X-Fan is always available ) ) @@ -211,18 +221,18 @@ def __init__( description: GreeSwitchDescription, coordinator: GreeCoordinator, restore_state: bool = True, + check_availability: bool = True, ) -> None: """Initialize switch.""" - super().__init__(coordinator, restore_state) + super().__init__(description, coordinator, restore_state, check_availability) self.entity_description = description # pyright: ignore[reportIncompatibleVariableOverride] self._attr_unique_id = f"{self.device.name}_{description.key}" - _LOGGER.debug("Initialized sensor %s", self._attr_unique_id) - - @property - def available(self): # pyright: ignore[reportIncompatibleVariableOverride] - """Return True if entity is available.""" - return self.entity_description.available_func(self.device) + _LOGGER.debug( + "Initialized switch: %s (check_availability=%s)", + self._attr_unique_id, + self.check_availability, + ) @property def is_on(self) -> bool | None: # pyright: ignore[reportIncompatibleVariableOverride] From 61815268f7e931eb155d8fe0c445cbefc026fe7e Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Fri, 26 Sep 2025 21:41:11 +0100 Subject: [PATCH 027/113] Improve README --- README.md | 113 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 67 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 77bca02..28b18bc 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,59 @@ -[![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge)](https://github.com/hacs/integration) +[![HACS](https://img.shields.io/badge/HACS-Default-orange.svg)](https://hacs.xyz) +[![Home Assistant](https://img.shields.io/badge/Compatible-Home_Assistant_2025.9.4-blue.svg)](https://www.home-assistant.io) # HomeAssistant-GreeClimateComponent -Custom Gree climate component written in Python3 for Home Assistant. Controls ACs supporting the Gree protocol. -For a comprehensive list of tested devices, see [Supported Devices](supported-devices.md). - -Tested on Home Assistant 2025.6.3 - -**If you are experiencing issues please be sure to provide details about your device, Home Assistant version and what exactly went wrong.** +Custom Gree inetgration for Home Assistant written in Python3. Controls ACs supporting the Gree protocol. This integration connects directly to your HVAC devices via their IP address on the local network, unlike the official mobile app, which establish a direct connection only during initial setup and subsequently operate through Gree’s servers. + The integration attempts to obtain the encryption key by the initial setup protocol, which has been reverse-engineered. +> [!WARNING] +> If your HVAC device was previously set up for remote access using a mobile app, the integration may fail to retrieve the encryption key automatically. Find out more on methods of obtaining your device key bellow. + + +For a comprehensive list of tested devices, see [Supported Devices](supported-devices.md). + +**If you are experiencing issues please read the [Debugging](#debugging) section** + + Official mobile applications: - [Gree+ Android App](https://play.google.com/store/apps/details?id=com.gree.greeplus) - [Gree+ iOS App](https://apps.apple.com/app/gree/id1167857672) - [EWPE Smart Android App](https://play.google.com/store/apps/details?id=com.gree.ewpesmart) - [EWPE Smart iOS App](https://apps.apple.com/app/ewpe-smart/id1189467454) -If your HVAC device was previously set up for remote access using a mobile app, the integration may fail to retrieve the encryption key automatically. +To configure HVAC wifi (without the mobile app): https://github.com/arthurkrupa/gree-hvac-mqtt-bridge#configuring-hvac-wifi -To extract encryption keys from an account on Gree’s cloud server: https://github.com/luc10/gree-api-client -To configure HVAC wifi (without the mobile app): https://github.com/arthurkrupa/gree-hvac-mqtt-bridge#configuring-hvac-wifi +## Installation + +### HACS (recommended) + +This integration is added to HACS default repository list. Search for 'Gree' in the HACS dashboard to find and install it. + +### Manual -## HACS -This component is added to HACS default repository list. +Copy the `custom_components` folder to your own hassio `/config` folder. + + +## Configuration + +### UI Configuration - Config Flow (recommended) -## Config Flow - UI Configuration (recommended) The integration can be added from the Home Assistant UI. -1. Navigate to **Settings** > **Devices & Services** and click **Add Integration**. -2. Search for **Gree Climate** and fill in the desired `name`, `host`, `port` and `MAC address`. -3. After setup you can open the integration options to configure additional parameters. -4. Saving any changes in the options dialog automatically reloads the - integration, so new settings take effect immediately without - restarting Home Assistant. -## Manual Installation +1. Navigate to **Settings** > **Devices & Services** and click **Add Integration**. +2. Search for **Gree Climate** +3. Choose automatic discovery or manual setup and fill in the desired `name`, `host` and `MAC address`. +4. After a successfull connection with the device, you will be asked to configure the device options. +Your can also **Reconfigure** a device by changing its options. Saving any changes in the options dialog automatically reloads the integration, so new settings take effect immediately without restarting Home Assistant. -1. *(Skip if using HACS)* Copy the `custom_components` folder to your own hassio `/config` folder. +### Manual - YAML Configuration -2. **YAML Configuration:** See [`manual-configuration.yaml`](manual-configuration.yaml) for a complete configuration example with all available options and detailed comments. +See [`manual-configuration.yaml`](manual-configuration.yaml) for a complete configuration example with all available options and detailed comments. Basic example: ```yaml @@ -52,41 +64,50 @@ The integration can be added from the Home Assistant UI. encryption_version: 2 ``` -3. In your configuration.yaml add the following: +### Obtaining the Encryption Key - ```yaml - climate: !include your_configuration.yaml - ``` +The integration has the capability of automatically retrieve the encryption version and key of a device using the gree protocol which has been reverse-engineered. -4. *(Optional)* Add info logging to this component (to see if/how it works) +However, if your HVAC device was previously set up for remote access using a mobile app, the integration may fail to retrieve the encryption key automatically. - ```yaml - logger: - default: error - logs: - custom_components.gree: debug - custom_components.gree.climate: debug - ``` +#### Method 1: From Gree's cloud server -5. *(Optional)* Provide encryption key if you have it or feel like extracting it. +To extract encryption keys from an account on Gree’s cloud server, follow the instructions in https://github.com/luc10/gree-api-client - One way is to pull the sqlite db from android device like described here: +#### Method 2: From the Android app - https://stackoverflow.com/questions/9997976/android-pulling-sqlite-database-android-device +One way is to pull the sqlite db from android device like described here: - ```bash - adb backup -f ~/backup.ab -noapk com.gree.ewpesmart - dd if=data.ab bs=1 skip=24 | python -c "import zlib,sys;sys.stdout.write(zlib.decompress(sys.stdin.read()))" | tar -xvf - - sqlite3 data.ab 'select privateKey from db_device_20170503;' # but table name can differ a little bit. - ``` +https://stackoverflow.com/questions/9997976/android-pulling-sqlite-database-android-device + +```bash +adb backup -f ~/backup.ab -noapk com.gree.ewpesmart +dd if=data.ab bs=1 skip=24 | python -c "import zlib,sys;sys.stdout.write(zlib.decompress(sys.stdin.read()))" | tar -xvf - +sqlite3 data.ab 'select privateKey from db_device_20170503;' # but table name can differ a little bit. +``` + +> [!TIP] +> If you are getting an UTF-8 error (like: "UnicodeDecodeError: 'utf-8' codec can't decode byte 0xda in position 1: invalid continuation byte"), see https://github.com/RobHofmann/HomeAssistant-GreeClimateComponent/issues/318. + +Optionally, you can also sniff the `uid` parameter. This is not needed for all devices. + +### Icon configuration + +You can set custom icons for the climate enity by modifying the icon translation file `icons.json`. Refer to this documentation: https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/icon-translations/ - Write it down in `climate.yaml`: `encryption_key: `. +## Debugging - > If you are getting an UTF-8 error (like: "UnicodeDecodeError: 'utf-8' codec can't decode byte 0xda in position 1: invalid continuation byte"), see https://github.com/RobHofmann/HomeAssistant-GreeClimateComponent/issues/318. +If you are having problems with your device, whenever you write a bug report, be sure to provide details about your device, Home Assistant version and what exactly went wrong. -6. *(Optional)* Provide the `uid` parameter (can be sniffed). This is not needed for all devices. +It also helps tremendously if you include debug logs directly in your issue (otherwise we will just ask for them and it will take longer). So please enable debug logs in the integration UI or like this: -7. *(Optional)* You can set custom icons by modifying the icon translation file `icons.json`. Refer to this documentation: https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/icon-translations/ +```yaml +logger: + default: error + logs: + custom_components.gree: debug + custom_components.gree.climate: debug +``` ## Additional Sensors From 2f53486e0c0b2680bf777a9494e0e7c92f4abf92 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Fri, 26 Sep 2025 21:48:28 +0100 Subject: [PATCH 028/113] Fix LICENSE links --- LICENSE | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/LICENSE b/LICENSE index 94a9ed0..f288702 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,7 @@ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 - Copyright (C) 2007 Free Software Foundation, Inc. + Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. @@ -645,7 +645,7 @@ the "copyright" line and a pointer to where the full notice is found. GNU General Public License for more details. You should have received a copy of the GNU General Public License - along with this program. If not, see . + along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. @@ -664,11 +664,11 @@ might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see -. +. The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read -. +. From 679283ce4a41a717af5963dd759f08252b1b0af1 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Fri, 26 Sep 2025 22:41:12 +0100 Subject: [PATCH 029/113] Add contributing iinstructions to setup the devcontainer --- CONTRIBUTING | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 CONTRIBUTING diff --git a/CONTRIBUTING b/CONTRIBUTING new file mode 100644 index 0000000..7667d20 --- /dev/null +++ b/CONTRIBUTING @@ -0,0 +1,41 @@ +# Contributing + +This integration follows the development guidelines for Home Assistant integrations, while keeping the repository compatible with HACS. + +## Development Environment + +Home Assistant provides [several guidelines](https://developers.home-assistant.io/docs/development_environment) regarding the setup of the development environment. Because we are not contributing to the official integrations, there is no need to fork the official [Home Assistant Core](https://github.com/home-assistant/core) repository. However, it is useful to use it as it provides a preconfigured VSCode development environment with the necessary tools. + +Here's a general guide to get it working with this integration repository: + +1. Create a folder for the development (for example `development/`) +2. Clone [home-assistant/core ](https://github.com/home-assistant/core) inside of it (`development/core`) +3. Follow the [guidelines](https://developers.home-assistant.io/docs/development_environment) on getting the devcontainer working +4. Fork [this](https://github.com/RobHofmann/HomeAssistant-GreeClimateComponent) repository and clone your fork inside of the same folder (`development/YourForkName`) +5. Create a branch for your changes in the cloned repo `git checkout -b my-branch-name` +6. Create a mount point for this integration in the devcontainer + 1. Open `development/core/devcontainer/devcontainer.json` + 2. Add the mounting: + ```json + "mounts": [ + "source=${localWorkspaceFolder}/../YourForkName/custom_components/gree,target=/workspaces/core/config/custom_components/gree,type=bind" + ], + ``` +7. Open `development/core` with VSCode +8. Use the command **"Dev Containers: Reopen in Container"** +9. Once inside the container make sure the folder `config/custom_components/gree` exists +10. You should be now be able to edit the integration files from inside the devcontainer +11. Make your changes +12. Push to your fork, rebase with the latest upstream version and submit a pull request + +## Testing + +Use the **Run Home Assistant Core** Task to start Home Assistant. + +You should also be able to set and hit breakpoints in your code. + +If you change your code, you have to restart Home Assistant (rerun the Task) + +## Styling + +Please adhere to the recomended coding style: https://developers.home-assistant.io/docs/development_guidelines \ No newline at end of file From 1200efc13ca63f58c58cd857fe48b2db7651ae34 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Fri, 26 Sep 2025 22:44:59 +0100 Subject: [PATCH 030/113] Fix CONTRIBUTING mistake --- CONTRIBUTING => CONTRIBUTING.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename CONTRIBUTING => CONTRIBUTING.md (100%) diff --git a/CONTRIBUTING b/CONTRIBUTING.md similarity index 100% rename from CONTRIBUTING rename to CONTRIBUTING.md From a2ef9d1bc264d8c8ef4a77898d8d3c61f0f3a0ab Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Fri, 26 Sep 2025 22:56:26 +0100 Subject: [PATCH 031/113] Fix sensor translation --- custom_components/gree/const.py | 4 ++++ custom_components/gree/sensor.py | 11 ++++++----- custom_components/gree/translations/en.json | 14 ++------------ 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/custom_components/gree/const.py b/custom_components/gree/const.py index 1ccaec1..f68ddaf 100755 --- a/custom_components/gree/const.py +++ b/custom_components/gree/const.py @@ -58,12 +58,16 @@ GATTR_FEAT_TURBO = "turbo" GATTR_TEMP_UNITS = "temperature_units" +GATTR_INDOOR_TEMPERATURE = "indoor_temperature" +GATTR_OUTDOOR_TEMPERATURE = "outdoor_temperature" +GATTR_HUMIDITY = "rooom_humidity" ATTR_EXTERNAL_TEMPERATURE_SENSOR = "external_temperature_sensor" ATTR_EXTERNAL_HUMIDITY_SENSOR = "external_humidity_sensor" ATTR_AUTO_XFAN = "auto_xfan" ATTR_AUTO_LIGHT = "auto_light" + # HVAC modes - these come from Home Assistant and are standard DEFAULT_HVAC_MODES = [ HVACMode.AUTO, diff --git a/custom_components/gree/sensor.py b/custom_components/gree/sensor.py index 18398c9..12422a1 100644 --- a/custom_components/gree/sensor.py +++ b/custom_components/gree/sensor.py @@ -15,17 +15,18 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .const import CONF_DISABLE_AVAILABLE_CHECK +from .const import ( + CONF_DISABLE_AVAILABLE_CHECK, + GATTR_HUMIDITY, + GATTR_INDOOR_TEMPERATURE, + GATTR_OUTDOOR_TEMPERATURE, +) from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription from .gree_device import GreeDevice _LOGGER = logging.getLogger(__name__) -GATTR_INDOOR_TEMPERATURE = "indoor_temperature" -GATTR_OUTDOOR_TEMPERATURE = "outdoor_temperature" -GATTR_HUMIDITY = "humidity" - async def async_setup_entry( hass: HomeAssistant, diff --git a/custom_components/gree/translations/en.json b/custom_components/gree/translations/en.json index e92a7db..42fb35b 100755 --- a/custom_components/gree/translations/en.json +++ b/custom_components/gree/translations/en.json @@ -175,9 +175,9 @@ "name": "Outdoor Temperature", "description": "The temperature reported by the HVAC outdoors unit" }, - "humidity": { + "room_humidity": { "name": "Indoor Humidity", - "description": "The humidity reported by the HVAC indoors unit" + "description": "Shows the room humidity level measured by the air conditioner's internal sensor." } }, "climate": { @@ -240,16 +240,6 @@ "description": "Select the temperature units used by the device." } }, - "sensor": { - "outside_temperature": { - "name": "Outside Temperature", - "description": "Shows the outside temperature measured by the air conditioner's external sensor." - }, - "room_humidity": { - "name": "Room Humidity", - "description": "Shows the room humidity level measured by the air conditioner's internal sensor." - } - }, "switch": { "auto_light": { "name": "Auto Display Light", From b3e3008516b229a93eeb5a22790e813ae2c4707f Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Sat, 27 Sep 2025 19:32:02 +0100 Subject: [PATCH 032/113] Remove limitation of hvac on to change parameters --- custom_components/gree/climate.py | 15 --------------- custom_components/gree/translations/en.json | 3 --- 2 files changed, 18 deletions(-) diff --git a/custom_components/gree/climate.py b/custom_components/gree/climate.py index 928dfb0..e06b69c 100644 --- a/custom_components/gree/climate.py +++ b/custom_components/gree/climate.py @@ -661,11 +661,6 @@ async def async_set_fan_mode(self, fan_mode: str): translation_domain=DOMAIN, translation_key="entity_unavailable" ) - if self._attr_hvac_mode == HVACMode.OFF: - raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="change_while_device_off" - ) - if fan_mode == GATTR_FEAT_TURBO and self._attr_hvac_mode in ( HVACMode.DRY, HVACMode.FAN_ONLY, @@ -716,11 +711,6 @@ async def async_set_swing_mode(self, swing_mode): translation_domain=DOMAIN, translation_key="entity_unavailable" ) - if self._attr_hvac_mode == HVACMode.OFF: - raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="change_while_device_off" - ) - try: self.device.set_vertical_swing_mode(VerticalSwingMode[swing_mode]) await self.device.update_device_status() @@ -754,11 +744,6 @@ async def async_set_swing_horizontal_mode(self, swing_horizontal_mode): translation_domain=DOMAIN, translation_key="entity_unavailable" ) - if self._attr_hvac_mode == HVACMode.OFF: - raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="change_while_device_off" - ) - try: self.device.set_horizontal_swing_mode( HorizontalSwingMode[swing_horizontal_mode] diff --git a/custom_components/gree/translations/en.json b/custom_components/gree/translations/en.json index 42fb35b..253d41a 100755 --- a/custom_components/gree/translations/en.json +++ b/custom_components/gree/translations/en.json @@ -292,9 +292,6 @@ } }, "exceptions": { - "change_while_device_off": { - "message": "You cannot change this while the device is off." - }, "turbo_availability": { "message": "Trubo mode is not available in Dry and Fan-only modes." }, From 9bb9338c75a1765c5f47687b326b46a93492556b Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Sat, 27 Sep 2025 20:00:00 +0100 Subject: [PATCH 033/113] Support hvac_mode in the set_target_temperature action This will only send one command to the device --- custom_components/gree/climate.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/custom_components/gree/climate.py b/custom_components/gree/climate.py index e06b69c..ec89fc6 100644 --- a/custom_components/gree/climate.py +++ b/custom_components/gree/climate.py @@ -6,6 +6,7 @@ from homeassistant.components.climate import ( ATTR_FAN_MODE, + ATTR_HVAC_MODE, ATTR_SWING_HORIZONTAL_MODE, ATTR_SWING_MODE, ClimateEntity, @@ -800,11 +801,10 @@ def get_current_target_temp(self) -> float | None: async def async_set_temperature(self, **kwargs): """Set new target temperature.""" + _LOGGER.debug("async_set_temperature(%s, %s)", self.device.unique_id, kwargs) + temperature: float | None = kwargs.get(ATTR_TEMPERATURE) - _LOGGER.debug( - "async_set_temperature(%s, %s)", self.device.unique_id, temperature - ) - _LOGGER.debug(kwargs) + hvac_mode: HVACMode | None = kwargs.get(ATTR_HVAC_MODE) if temperature is None: _LOGGER.error("No temperature received to set as target") @@ -818,12 +818,17 @@ async def async_set_temperature(self, **kwargs): try: # TODO: Confirm that HA sends the values in this entity's temperature_unit which matches the device unit self.device.set_target_temperature(temperature) - await self.device.update_device_status() - # notify coordinator listeners of state change so that dependent entities are updated immediately - self.coordinator.async_update_listeners() + if hvac_mode and hvac_mode in self._attr_hvac_modes: + # This will call the set_hvac_mode which internally will send to device + await self.async_set_hvac_mode(hvac_mode) + else: + await self.device.update_device_status() - await self.coordinator.async_request_refresh() + # notify coordinator listeners of state change so that dependent entities are updated immediately + self.coordinator.async_update_listeners() + + await self.coordinator.async_request_refresh() except Exception as err: _LOGGER.exception("Error in '%s'", "async_set_temperature") raise HomeAssistantError( From 27620170cd4fd03e9132c7b2a89754d83694af83 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Thu, 2 Oct 2025 22:54:55 +0100 Subject: [PATCH 034/113] Fix the use of a sub device (for VRF) --- custom_components/gree/gree_api.py | 30 ++++++++++++++++++++------- custom_components/gree/gree_device.py | 4 +++- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/custom_components/gree/gree_api.py b/custom_components/gree/gree_api.py index 22b85c9..5d9dd5b 100644 --- a/custom_components/gree/gree_api.py +++ b/custom_components/gree/gree_api.py @@ -178,6 +178,9 @@ class GreeDiscoveredDevice: host: str mac: str port: int + brand: str + model: str + uid: int propkey_to_enum = {prop.value: prop for prop in GreeProp} @@ -516,11 +519,16 @@ def gree_create_status_pack(mac_addr: str, props: list[str]) -> str: return pack -def gree_create_set_pack(props: dict[GreeProp, int]) -> str: +def gree_create_set_pack(mac_addr: str, props: dict[GreeProp, int]) -> str: """Create a set pack to send to the device.""" pack: str = json.dumps( - {"opt": [prop.value for prop in props], "p": list(props.values()), "t": "cmd"} + { + "opt": [prop.value for prop in props], + "p": list(props.values()), + "t": "cmd", + "sub": mac_addr, + } ) _LOGGER.debug("Status Pack: %s", pack) @@ -529,6 +537,7 @@ def gree_create_set_pack(props: dict[GreeProp, int]) -> str: def gree_create_payload( pack: str, + payload_type: str, i_command: GreeCommand, mac_addr: str, uid: int, @@ -545,7 +554,7 @@ def gree_create_payload( "cid": "app", "i": i_command.value, "pack": pack, - "t": "pack", + "t": payload_type, "tcid": mac_addr, "uid": uid, } @@ -593,7 +602,7 @@ async def gree_get_device_key( enc_version, ) jsonPayloadToSend = gree_create_payload( - pack, GreeCommand.BIND, mac_addr, uid, enc_version, tag + pack, "pack", GreeCommand.BIND, mac_addr, uid, enc_version, tag ) try: @@ -634,6 +643,7 @@ async def gree_get_device_key( async def gree_get_status( ip_addr: str, mac_addr: str, + mac_addr_sub: str, port: int, uid: int, encryption_key: str, @@ -649,12 +659,12 @@ async def gree_get_status( status_values: dict[GreeProp, int] = {} pack, tag = gree_create_encrypted_pack( - gree_create_status_pack(mac_addr, [prop.value for prop in props]), + gree_create_status_pack(mac_addr_sub, [prop.value for prop in props]), get_cipher(encryption_key, encryption_version), encryption_version, ) jsonPayloadToSend = gree_create_payload( - pack, GreeCommand.STATUS, mac_addr, uid, encryption_version, tag + pack, "pack", GreeCommand.STATUS, mac_addr, uid, encryption_version, tag ) try: @@ -684,6 +694,7 @@ async def gree_get_status( async def gree_set_status( ip_addr: str, mac_addr: str, + mac_addr_sub: str, port: int, uid: int, encryption_key: str, @@ -696,7 +707,7 @@ async def gree_set_status( _LOGGER.debug("Trying to set device status") - set_pack = gree_create_set_pack(props) + set_pack = gree_create_set_pack(mac_addr_sub, props) pack, tag = gree_create_encrypted_pack( set_pack, get_cipher(encryption_key, encryption_version), @@ -704,7 +715,7 @@ async def gree_set_status( ) jsonPayloadToSend = gree_create_payload( - pack, GreeCommand.STATUS, mac_addr, uid, encryption_version, tag + pack, "pack", GreeCommand.STATUS, mac_addr, uid, encryption_version, tag ) try: @@ -827,6 +838,9 @@ def discover_gree_devices( host=address, mac=mac_addr, port=DEFAULT_DEVICE_PORT, + brand=pack.get("brand", "gree"), + model=pack.get("brand", "gree"), + uid=data.get("uid", 0), ) discovered_devices.append(discovered_device) diff --git a/custom_components/gree/gree_device.py b/custom_components/gree/gree_device.py index 8c5f561..7d717a1 100755 --- a/custom_components/gree/gree_device.py +++ b/custom_components/gree/gree_device.py @@ -106,7 +106,7 @@ def __init__( self._new_state: dict[GreeProp, int] = {} self._is_bound: bool = False self._is_available: bool = False - self._uniqueid: str = self._mac_addr + self._uniqueid: str = self._mac_addr_sub self._max_connection_attempts: int = max_connection_attempts self._timeout: int = timeout @@ -178,6 +178,7 @@ async def fetch_device_status(self) -> GreeDeviceState: await gree_get_status( self._ip_addr, self._mac_addr, + self._mac_addr_sub, self._port, self._uid, self._encryption_key, @@ -220,6 +221,7 @@ async def update_device_status(self) -> GreeDeviceState: await gree_set_status( self._ip_addr, self._mac_addr, + self._mac_addr_sub, self._port, self._uid, self._encryption_key, From 593ea5b2a29a1b0048b8b308b23d159b2c16b5e9 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Thu, 2 Oct 2025 23:14:02 +0100 Subject: [PATCH 035/113] Added support for discovery of VRF devices (@meirlo) --- custom_components/gree/config_flow.py | 7 +- custom_components/gree/gree_api.py | 103 +++++++++++++++++++++++++- 2 files changed, 103 insertions(+), 7 deletions(-) diff --git a/custom_components/gree/config_flow.py b/custom_components/gree/config_flow.py index 73337f4..213f5ac 100644 --- a/custom_components/gree/config_flow.py +++ b/custom_components/gree/config_flow.py @@ -380,6 +380,9 @@ async def async_step_manual_add( user_input[CONF_NAME] = self._selected_device.name user_input[CONF_HOST] = self._selected_device.host user_input[CONF_MAC] = self._selected_device.mac + user_input[CONF_ADVANCED] = {} + user_input[CONF_ADVANCED][CONF_PORT] = self._selected_device.port + user_input[CONF_ADVANCED][CONF_UID] = self._selected_device.uid elif self._discovery_performed and self._selected_device is None: errors["base"] = "no_devices_found" @@ -480,9 +483,7 @@ async def _discover_devices( except Exception: _LOGGER.exception("Could not get HA broadcast addresses") - return await hass.async_add_executor_job( - discover_gree_devices, broadcast_addresses, 5 - ) + return await discover_gree_devices(broadcast_addresses, 5) class CannotConnect(HomeAssistantError): diff --git a/custom_components/gree/gree_api.py b/custom_components/gree/gree_api.py index 5d9dd5b..0c1b490 100644 --- a/custom_components/gree/gree_api.py +++ b/custom_components/gree/gree_api.py @@ -510,6 +510,15 @@ def gree_create_bind_pack( return pack +def gree_create_sub_bind_pack(mac_addr: str) -> str: + """Create a bind pack to send to the device.""" + + pack: str = json.dumps({"mac": mac_addr, "i": 1}) + + _LOGGER.debug("Sub Bind Pack: %s", pack) + return pack + + def gree_create_status_pack(mac_addr: str, props: list[str]) -> str: """Create a status pack to send to the device.""" @@ -805,7 +814,7 @@ def extract_version(info: dict) -> tuple[str | None, str | None]: return ver, device_id -def discover_gree_devices( +async def discover_gree_devices( broadcast_addresses: list[str], timeout: int ) -> list[GreeDiscoveredDevice]: """Discovers gree devices in the network.""" @@ -822,7 +831,6 @@ def discover_gree_devices( gree_get_default_cipher(EncryptionVersion.V1), EncryptionVersion.V1, ) - if data is not None: pack = data.get("pack") if pack is not None: @@ -843,7 +851,94 @@ def discover_gree_devices( uid=data.get("uid", 0), ) - discovered_devices.append(discovered_device) - _LOGGER.debug("Discovered device: %s", discovered_device) + # If VRF, the mac is of the main device and we have to query it for the sub devices + # Sub-devices will be created with a mac of sub@main + # check if the device has sub-devices + sub_count = pack.get("subCnt", 0) + + if sub_count == 0: # Is normal HVAC + discovered_devices.append(discovered_device) + _LOGGER.debug("Discovered device: %s", discovered_device) + else: # Is VRF with multiple sub devices + _LOGGER.debug( + "Trying to fetching sub-devices for '%s' (subCount=%d)", + mac_addr, + sub_count, + ) + try: + discovered_sub_devices = await get_sub_devices_list( + discovered_device.mac, + discovered_device.host, + discovered_device.uid, + max_connection_attempts=2, + timeout=timeout, + ) + + for sub_device in discovered_sub_devices: + sub_mac = sub_device.get("mac", "") + if sub_mac: + discovered_sub_device = GreeDiscoveredDevice( + name=f"{discovered_device.name}@{sub_mac[:4]}" + or f"Gree {mac_addr[-4:]}", + host=discovered_device.host, + mac=f"{sub_mac}@{discovered_device.mac}", + port=discovered_device.port, + brand=discovered_device.brand, + model=sub_device.get("mid", discovered_device), + uid=discovered_device.uid, + ) + discovered_devices.append(discovered_sub_device) + _LOGGER.debug( + "Discovered sub-device: %s", discovered_device + ) + except Exception: + _LOGGER.exception("Failed to fetch sub-devices") return discovered_devices + + +async def get_sub_devices_list( + mac_addr: str, ip_addr: str, uid: int, max_connection_attempts: int, timeout: int +) -> list: + """Fetch the list of sub-devices for a Gree device.""" + try: + key, version = await gree_get_device_key( + ip_addr, + mac_addr, + DEFAULT_DEVICE_PORT, + uid, + None, + max_connection_attempts, + timeout, + ) + + pack, tag = gree_create_encrypted_pack( + gree_create_sub_bind_pack(mac_addr), + get_cipher(key, version), + version, + ) + + jsonPayloadToSend = gree_create_payload( + pack, + "subList", + GreeCommand.BIND, + mac_addr, + uid, + version, + tag, + ) + + result = await get_result_pack( + ip_addr, + DEFAULT_DEVICE_PORT, + jsonPayloadToSend, + get_cipher(key, version), + version, + max_connection_attempts, + timeout, + ) + + return result.get("list", []) + + except Exception as err: + raise ValueError(f"Error fetching sub-device list for '{mac_addr}'") from err From d6e0a48e9f9a7e02039859de0ad53fbe04fa8d0b Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Fri, 3 Oct 2025 18:36:45 +0100 Subject: [PATCH 036/113] Add further debug to pack decoding --- custom_components/gree/gree_api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/custom_components/gree/gree_api.py b/custom_components/gree/gree_api.py index 0c1b490..848a68a 100644 --- a/custom_components/gree/gree_api.py +++ b/custom_components/gree/gree_api.py @@ -398,6 +398,7 @@ def get_gree_response_data( if encodedPack: pack = base64.b64decode(encodedPack) decryptedPack = cipher.decrypt(pack) + _LOGGER.debug("Decoding pack: %s", decryptedPack) pack = decryptedPack.decode("utf-8") replacedPack = pack.replace("\x0f", "").replace( pack[pack.rindex("}") + 1 :], "" From eac1fc813604c6d06753990701ec904464fe41ba Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Fri, 3 Oct 2025 18:43:16 +0100 Subject: [PATCH 037/113] Do not leak device key to debug output --- custom_components/gree/gree_device.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/gree/gree_device.py b/custom_components/gree/gree_device.py index 7d717a1..7ad7f76 100755 --- a/custom_components/gree/gree_device.py +++ b/custom_components/gree/gree_device.py @@ -89,7 +89,7 @@ def __init__( ip_addr, port, ) - _LOGGER.debug("Version: %s, Key: %s", encryption_version, encryption_key) + _LOGGER.debug("Version: %s, Key: %s", encryption_version, encryption_key[:5]) self._name: str = name self._ip_addr: str = ip_addr From c6540e0a6b3ec4d5c9c549aee7c39af67903332b Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Tue, 7 Oct 2025 19:32:41 +0100 Subject: [PATCH 038/113] Use default cipher for ffetching subdevice list --- custom_components/gree/gree_api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/gree/gree_api.py b/custom_components/gree/gree_api.py index 848a68a..a768801 100644 --- a/custom_components/gree/gree_api.py +++ b/custom_components/gree/gree_api.py @@ -903,7 +903,7 @@ async def get_sub_devices_list( ) -> list: """Fetch the list of sub-devices for a Gree device.""" try: - key, version = await gree_get_device_key( + _, version = await gree_get_device_key( ip_addr, mac_addr, DEFAULT_DEVICE_PORT, @@ -915,7 +915,7 @@ async def get_sub_devices_list( pack, tag = gree_create_encrypted_pack( gree_create_sub_bind_pack(mac_addr), - get_cipher(key, version), + gree_get_default_cipher(version), version, ) @@ -933,7 +933,7 @@ async def get_sub_devices_list( ip_addr, DEFAULT_DEVICE_PORT, jsonPayloadToSend, - get_cipher(key, version), + gree_get_default_cipher(version), version, max_connection_attempts, timeout, From 6869d0fa53533e01544317c985b536cbf0bd449c Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Tue, 7 Oct 2025 19:36:36 +0100 Subject: [PATCH 039/113] Fix payload with incorrect t parameter --- custom_components/gree/gree_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/gree/gree_api.py b/custom_components/gree/gree_api.py index a768801..87597bf 100644 --- a/custom_components/gree/gree_api.py +++ b/custom_components/gree/gree_api.py @@ -575,7 +575,7 @@ def gree_create_payload( "cid": "app", "i": i_command.value, "pack": pack, - "t": "pack", + "t": payload_type, "tcid": mac_addr, "uid": uid, "tag": tag, From 660f18eabe2bb3a05e3f1632d524029c2e2f1a68 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Tue, 7 Oct 2025 19:43:25 +0100 Subject: [PATCH 040/113] Simplify payload creation to avoid duplicate code --- custom_components/gree/gree_api.py | 37 ++++++++++-------------------- 1 file changed, 12 insertions(+), 25 deletions(-) diff --git a/custom_components/gree/gree_api.py b/custom_components/gree/gree_api.py index 87597bf..41c9958 100644 --- a/custom_components/gree/gree_api.py +++ b/custom_components/gree/gree_api.py @@ -8,6 +8,7 @@ import re import socket import time +from typing import Any import asyncio_dgram from attr import dataclass @@ -556,34 +557,20 @@ def gree_create_payload( ) -> str: """Create the full payload to send to the device.""" - payload: str = "" + base_payload: dict[str, Any] = { + "cid": "app", + "i": i_command.value, + "pack": pack, + "t": payload_type, + "tcid": mac_addr, + "uid": uid, + } - if encryption_version == EncryptionVersion.V1: - payload = json.dumps( - { - "cid": "app", - "i": i_command.value, - "pack": pack, - "t": payload_type, - "tcid": mac_addr, - "uid": uid, - } - ) - elif encryption_version == EncryptionVersion.V2: - payload = json.dumps( - { - "cid": "app", - "i": i_command.value, - "pack": pack, - "t": payload_type, - "tcid": mac_addr, - "uid": uid, - "tag": tag, - } - ) + if encryption_version == EncryptionVersion.V2 and tag is not None: + base_payload["tag"] = tag # _LOGGER.debug("Payload: %s", payload) - return payload + return json.dumps(base_payload) async def gree_get_device_key( From bdeb7d205585523aaa4e44e567498f840beeeca5 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Tue, 7 Oct 2025 22:04:50 +0100 Subject: [PATCH 041/113] Fix entity selectors for external sensors Unfortunately we can't use the EntitySelector as it doesn't allow for unsetting a previously set value --- custom_components/gree/__init__.py | 4 +- custom_components/gree/climate.py | 21 +++-- custom_components/gree/config_flow.py | 123 ++++++++++++++++++-------- 3 files changed, 102 insertions(+), 46 deletions(-) diff --git a/custom_components/gree/__init__.py b/custom_components/gree/__init__.py index 62046f0..680b062 100755 --- a/custom_components/gree/__init__.py +++ b/custom_components/gree/__init__.py @@ -88,10 +88,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool ), encryption_key=conf[CONF_ADVANCED].get(CONF_ENCRYPTION_KEY, ""), uid=conf[CONF_ADVANCED].get(CONF_UID, DEFAULT_DEVICE_UID), - max_connection_attempts=conf.get( + max_connection_attempts=conf[CONF_ADVANCED].get( CONF_MAX_ONLINE_ATTEMPTS, DEFAULT_CONNECTION_MAX_ATTEMPTS ), - timeout=conf.get(CONF_TIMEOUT, DEFAULT_CONNECTION_TIMEOUT), + timeout=conf[CONF_ADVANCED].get(CONF_TIMEOUT, DEFAULT_CONNECTION_TIMEOUT), ) try: diff --git a/custom_components/gree/climate.py b/custom_components/gree/climate.py index ec89fc6..cb529b7 100644 --- a/custom_components/gree/climate.py +++ b/custom_components/gree/climate.py @@ -39,6 +39,7 @@ from .const import ( ATTR_EXTERNAL_HUMIDITY_SENSOR, ATTR_EXTERNAL_TEMPERATURE_SENSOR, + CONF_ADVANCED, CONF_DISABLE_AVAILABLE_CHECK, CONF_FAN_MODES, CONF_HVAC_MODES, @@ -130,7 +131,8 @@ async def async_setup_entry( swing_horizontal_modes, restore_state=(entry.data.get(CONF_RESTORE_STATES, True)), check_availability=( - entry.data.get(CONF_DISABLE_AVAILABLE_CHECK, False) is False + entry.data[CONF_ADVANCED].get(CONF_DISABLE_AVAILABLE_CHECK, False) + is False ), external_temperature_sensor_id=entry.data.get( ATTR_EXTERNAL_TEMPERATURE_SENSOR @@ -233,7 +235,10 @@ async def async_added_to_hass(self): await self._restore_entity_state() # When using an external temperature sensor, subscribe to its state changes for updating the current temperature - if self._external_temperature_sensor: + if ( + self._external_temperature_sensor + and self._external_temperature_sensor != "None" + ): self._update_current_temperature_from_external( self.hass.states.get(self._external_temperature_sensor) ) @@ -246,7 +251,7 @@ async def async_added_to_hass(self): ) # When using an external himidity sensor, subscribe to its state changes for updating the current humidity - if self._external_humidity_sensor: + if self._external_humidity_sensor and self._external_humidity_sensor != "None": self._update_current_humidity_from_external( self.hass.states.get(self._external_humidity_sensor) ) @@ -505,10 +510,16 @@ def _update_attributes(self): self._attr_temperature_unit = self.get_temp_units() self._attr_target_temperature = self.get_current_target_temp() - if self._external_temperature_sensor is None: + if ( + self._external_temperature_sensor is None + or self._external_temperature_sensor == "None" + ): self._attr_current_temperature = self.get_current_temp() - if self._external_humidity_sensor is None: + if ( + self._external_humidity_sensor is None + or self._external_humidity_sensor == "None" + ): self._attr_current_humidity = self.get_current_humidity() if self._attr_supported_features & ClimateEntityFeature.TARGET_TEMPERATURE: diff --git a/custom_components/gree/config_flow.py b/custom_components/gree/config_flow.py index 213f5ac..8f35e65 100644 --- a/custom_components/gree/config_flow.py +++ b/custom_components/gree/config_flow.py @@ -14,7 +14,6 @@ IPv4Address, async_get_ipv4_broadcast_addresses, ) -from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, CONF_TIMEOUT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import section @@ -22,10 +21,9 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.selector import ( - EntitySelector, - EntitySelectorConfig, SelectSelector, SelectSelectorConfig, + SelectSelectorMode, ) from .const import ( @@ -71,7 +69,7 @@ def build_main_schema(data: Mapping | None) -> vol.Schema: { vol.Required( CONF_NAME, - default="Gree AC" if data is None else data.get(CONF_NAME, ""), + default="Gree AC" if data is None else data.get(CONF_NAME, "Gree AC"), ): str, vol.Required( CONF_HOST, @@ -112,6 +110,27 @@ def build_main_schema(data: Mapping | None) -> vol.Schema: if data is None or data.get(CONF_ADVANCED) is None else data[CONF_ADVANCED].get(CONF_UID, DEFAULT_DEVICE_UID), ): cv.positive_int, + vol.Required( + CONF_DISABLE_AVAILABLE_CHECK, + default=False + if data is None + else data.get(CONF_DISABLE_AVAILABLE_CHECK, False), + ): cv.boolean, + vol.Required( + CONF_MAX_ONLINE_ATTEMPTS, + default=DEFAULT_CONNECTION_MAX_ATTEMPTS + if data is None + else data.get( + CONF_MAX_ONLINE_ATTEMPTS, + DEFAULT_CONNECTION_MAX_ATTEMPTS, + ), + ): cv.positive_int, + vol.Required( + CONF_TIMEOUT, + default=DEFAULT_CONNECTION_TIMEOUT + if data is None + else data.get(CONF_TIMEOUT, DEFAULT_CONNECTION_TIMEOUT), + ): cv.positive_int, } ), {"collapsed": True}, @@ -187,55 +206,78 @@ def build_options_schema(hass: HomeAssistant, data: Mapping | None) -> vol.Schem translation_key=CONF_FEATURES, ) ), - vol.Optional( - ATTR_EXTERNAL_TEMPERATURE_SENSOR, default=UNDEFINED - ): EntitySelector( - config=EntitySelectorConfig( + # Ideally we would use an Optional EntitySelector for external sensors. + # Currently we can't because unsetting the value in the UI makes HA + # populate the user_input with the previous set value, making the user + # unable to unset the external sensors. + vol.Required( + ATTR_EXTERNAL_TEMPERATURE_SENSOR, + default="None" + if data is None + else data.get(ATTR_EXTERNAL_TEMPERATURE_SENSOR, "None"), + ): SelectSelector( + config=SelectSelectorConfig( + options=get_temperature_sensor_options(hass), multiple=False, - domain="sensor", - device_class=SensorDeviceClass.TEMPERATURE, + mode=SelectSelectorMode.DROPDOWN, + translation_key=ATTR_EXTERNAL_TEMPERATURE_SENSOR, ) ), - vol.Optional( + vol.Required( ATTR_EXTERNAL_HUMIDITY_SENSOR, default=UNDEFINED if data is None else data.get(ATTR_EXTERNAL_HUMIDITY_SENSOR, UNDEFINED), - ): EntitySelector( - config=EntitySelectorConfig( + ): SelectSelector( + config=SelectSelectorConfig( + options=get_humidity_sensor_options(hass), multiple=False, - domain="sensor", - device_class=SensorDeviceClass.HUMIDITY, + mode=SelectSelectorMode.DROPDOWN, + translation_key=ATTR_EXTERNAL_HUMIDITY_SENSOR, ) ), vol.Required( CONF_RESTORE_STATES, default=True if data is None else data.get(CONF_RESTORE_STATES, True), ): cv.boolean, - vol.Required( - CONF_DISABLE_AVAILABLE_CHECK, - default=False - if data is None - else data.get(CONF_DISABLE_AVAILABLE_CHECK, False), - ): cv.boolean, - vol.Required( - CONF_MAX_ONLINE_ATTEMPTS, - default=DEFAULT_CONNECTION_MAX_ATTEMPTS - if data is None - else data.get( - CONF_MAX_ONLINE_ATTEMPTS, DEFAULT_CONNECTION_MAX_ATTEMPTS - ), - ): cv.positive_int, - vol.Required( - CONF_TIMEOUT, - default=DEFAULT_CONNECTION_TIMEOUT - if data is None - else data.get(CONF_TIMEOUT, DEFAULT_CONNECTION_TIMEOUT), - ): cv.positive_int, } ) +def get_temperature_sensor_options(hass: HomeAssistant) -> list[str]: + """Get list of available temperature sensor entities.""" + options: list[str] = [ + "None" + ] # Include None as option since otherwise the user can't unset the external sensor + + # Get all entities from the registry + for state in hass.states.async_all(): + # Look for temperature sensors + if state.entity_id.startswith("sensor."): + # Check for explicit device_class + if state.attributes.get("device_class") == "temperature": + options.append(state.entity_id) + + return options + + +def get_humidity_sensor_options(hass: HomeAssistant) -> list[str]: + """Get list of available temperature sensor entities.""" + options: list[str] = [ + "None" + ] # Include None as option since otherwise the user can't unset the external sensor + + # Get all entities from the registry + for state in hass.states.async_all(): + # Look for temperature sensors + if state.entity_id.startswith("sensor."): + # Check for explicit device_class + if state.attributes.get("device_class") == "humidity": + options.append(state.entity_id) + + return options + + DEVICE_OPTIONS_KEYS = { CONF_TIMEOUT, CONF_MAX_ONLINE_ATTEMPTS, @@ -429,7 +471,6 @@ async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None) if user_input is not None: _LOGGER.warning(user_input) - # FIXME: Cannot remove external entities after setting data = self._merge_device_options(entry.data, user_input) self._abort_if_unique_id_mismatch() return self.async_update_reload_and_abort( @@ -458,9 +499,13 @@ def _merge_device_options(self, data, device_options_data: Mapping) -> dict: _LOGGER.warning("Removing key %s", key) # If there are any unmanaged keys in user_input, merge them too - for key, value in device_options_data.items(): - if key not in DEVICE_OPTIONS_KEYS: - old_data[key] = value + old_data.update( + { + key: value + for key, value in device_options_data.items() + if key not in DEVICE_OPTIONS_KEYS + } + ) return old_data From c29d5c6838e07c25018402f1294986c7d0abe16a Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Tue, 7 Oct 2025 22:29:05 +0100 Subject: [PATCH 042/113] Changed domain and bump version --- CONTRIBUTING.md | 4 ++-- README.md | 3 +-- custom_components/{gree => gree_custom}/__init__.py | 0 custom_components/{gree => gree_custom}/climate.py | 0 custom_components/{gree => gree_custom}/config_flow.py | 0 custom_components/{gree => gree_custom}/const.py | 2 +- custom_components/{gree => gree_custom}/coordinator.py | 0 custom_components/{gree => gree_custom}/entity.py | 0 custom_components/{gree => gree_custom}/gree_api.py | 0 custom_components/{gree => gree_custom}/gree_device.py | 0 custom_components/{gree => gree_custom}/gree_helpers.py | 0 custom_components/{gree => gree_custom}/icons.json | 0 custom_components/{gree => gree_custom}/manifest.json | 4 ++-- custom_components/{gree => gree_custom}/select.py | 0 custom_components/{gree => gree_custom}/sensor.py | 0 custom_components/{gree => gree_custom}/switch.py | 0 custom_components/{gree => gree_custom}/translations/de.json | 0 custom_components/{gree => gree_custom}/translations/en.json | 0 custom_components/{gree => gree_custom}/translations/he.json | 0 custom_components/{gree => gree_custom}/translations/hu.json | 0 custom_components/{gree => gree_custom}/translations/it.json | 0 custom_components/{gree => gree_custom}/translations/pl.json | 0 .../{gree => gree_custom}/translations/pt-BR.json | 0 custom_components/{gree => gree_custom}/translations/ro.json | 0 custom_components/{gree => gree_custom}/translations/ru.json | 0 .../{gree => gree_custom}/translations/zh-Hans.json | 0 26 files changed, 6 insertions(+), 7 deletions(-) rename custom_components/{gree => gree_custom}/__init__.py (100%) rename custom_components/{gree => gree_custom}/climate.py (100%) rename custom_components/{gree => gree_custom}/config_flow.py (100%) rename custom_components/{gree => gree_custom}/const.py (99%) rename custom_components/{gree => gree_custom}/coordinator.py (100%) rename custom_components/{gree => gree_custom}/entity.py (100%) rename custom_components/{gree => gree_custom}/gree_api.py (100%) rename custom_components/{gree => gree_custom}/gree_device.py (100%) rename custom_components/{gree => gree_custom}/gree_helpers.py (100%) rename custom_components/{gree => gree_custom}/icons.json (100%) rename custom_components/{gree => gree_custom}/manifest.json (85%) rename custom_components/{gree => gree_custom}/select.py (100%) rename custom_components/{gree => gree_custom}/sensor.py (100%) rename custom_components/{gree => gree_custom}/switch.py (100%) rename custom_components/{gree => gree_custom}/translations/de.json (100%) rename custom_components/{gree => gree_custom}/translations/en.json (100%) rename custom_components/{gree => gree_custom}/translations/he.json (100%) rename custom_components/{gree => gree_custom}/translations/hu.json (100%) rename custom_components/{gree => gree_custom}/translations/it.json (100%) rename custom_components/{gree => gree_custom}/translations/pl.json (100%) rename custom_components/{gree => gree_custom}/translations/pt-BR.json (100%) rename custom_components/{gree => gree_custom}/translations/ro.json (100%) rename custom_components/{gree => gree_custom}/translations/ru.json (100%) rename custom_components/{gree => gree_custom}/translations/zh-Hans.json (100%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7667d20..e27e6ad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,12 +18,12 @@ Here's a general guide to get it working with this integration repository: 2. Add the mounting: ```json "mounts": [ - "source=${localWorkspaceFolder}/../YourForkName/custom_components/gree,target=/workspaces/core/config/custom_components/gree,type=bind" + "source=${localWorkspaceFolder}/../YourForkName/custom_components/gree_custom,target=/workspaces/core/config/custom_components/gree_custom,type=bind" ], ``` 7. Open `development/core` with VSCode 8. Use the command **"Dev Containers: Reopen in Container"** -9. Once inside the container make sure the folder `config/custom_components/gree` exists +9. Once inside the container make sure the folder `config/custom_components/gree_custom` exists 10. You should be now be able to edit the integration files from inside the devcontainer 11. Make your changes 12. Push to your fork, rebase with the latest upstream version and submit a pull request diff --git a/README.md b/README.md index 28b18bc..612e878 100644 --- a/README.md +++ b/README.md @@ -105,8 +105,7 @@ It also helps tremendously if you include debug logs directly in your issue (oth logger: default: error logs: - custom_components.gree: debug - custom_components.gree.climate: debug + custom_components.gree_custom: debug ``` ## Additional Sensors diff --git a/custom_components/gree/__init__.py b/custom_components/gree_custom/__init__.py similarity index 100% rename from custom_components/gree/__init__.py rename to custom_components/gree_custom/__init__.py diff --git a/custom_components/gree/climate.py b/custom_components/gree_custom/climate.py similarity index 100% rename from custom_components/gree/climate.py rename to custom_components/gree_custom/climate.py diff --git a/custom_components/gree/config_flow.py b/custom_components/gree_custom/config_flow.py similarity index 100% rename from custom_components/gree/config_flow.py rename to custom_components/gree_custom/config_flow.py diff --git a/custom_components/gree/const.py b/custom_components/gree_custom/const.py similarity index 99% rename from custom_components/gree/const.py rename to custom_components/gree_custom/const.py index f68ddaf..b743a8f 100755 --- a/custom_components/gree/const.py +++ b/custom_components/gree_custom/const.py @@ -11,7 +11,7 @@ VerticalSwingMode, ) -DOMAIN = "gree" +DOMAIN = "gree_custom" CONF_ADVANCED = "advanced" CONF_UID = "uid" diff --git a/custom_components/gree/coordinator.py b/custom_components/gree_custom/coordinator.py similarity index 100% rename from custom_components/gree/coordinator.py rename to custom_components/gree_custom/coordinator.py diff --git a/custom_components/gree/entity.py b/custom_components/gree_custom/entity.py similarity index 100% rename from custom_components/gree/entity.py rename to custom_components/gree_custom/entity.py diff --git a/custom_components/gree/gree_api.py b/custom_components/gree_custom/gree_api.py similarity index 100% rename from custom_components/gree/gree_api.py rename to custom_components/gree_custom/gree_api.py diff --git a/custom_components/gree/gree_device.py b/custom_components/gree_custom/gree_device.py similarity index 100% rename from custom_components/gree/gree_device.py rename to custom_components/gree_custom/gree_device.py diff --git a/custom_components/gree/gree_helpers.py b/custom_components/gree_custom/gree_helpers.py similarity index 100% rename from custom_components/gree/gree_helpers.py rename to custom_components/gree_custom/gree_helpers.py diff --git a/custom_components/gree/icons.json b/custom_components/gree_custom/icons.json similarity index 100% rename from custom_components/gree/icons.json rename to custom_components/gree_custom/icons.json diff --git a/custom_components/gree/manifest.json b/custom_components/gree_custom/manifest.json similarity index 85% rename from custom_components/gree/manifest.json rename to custom_components/gree_custom/manifest.json index 6db2b26..7f7bb72 100755 --- a/custom_components/gree/manifest.json +++ b/custom_components/gree_custom/manifest.json @@ -1,7 +1,7 @@ { - "domain": "gree", + "domain": "gree_custom", "name": "Gree A/C", - "version": "3.3.0", + "version": "4.0.0", "documentation": "https://github.com/RobHofmann/HomeAssistant-GreeClimateComponent", "dependencies": [], "codeowners": ["@robhofmann"], diff --git a/custom_components/gree/select.py b/custom_components/gree_custom/select.py similarity index 100% rename from custom_components/gree/select.py rename to custom_components/gree_custom/select.py diff --git a/custom_components/gree/sensor.py b/custom_components/gree_custom/sensor.py similarity index 100% rename from custom_components/gree/sensor.py rename to custom_components/gree_custom/sensor.py diff --git a/custom_components/gree/switch.py b/custom_components/gree_custom/switch.py similarity index 100% rename from custom_components/gree/switch.py rename to custom_components/gree_custom/switch.py diff --git a/custom_components/gree/translations/de.json b/custom_components/gree_custom/translations/de.json similarity index 100% rename from custom_components/gree/translations/de.json rename to custom_components/gree_custom/translations/de.json diff --git a/custom_components/gree/translations/en.json b/custom_components/gree_custom/translations/en.json similarity index 100% rename from custom_components/gree/translations/en.json rename to custom_components/gree_custom/translations/en.json diff --git a/custom_components/gree/translations/he.json b/custom_components/gree_custom/translations/he.json similarity index 100% rename from custom_components/gree/translations/he.json rename to custom_components/gree_custom/translations/he.json diff --git a/custom_components/gree/translations/hu.json b/custom_components/gree_custom/translations/hu.json similarity index 100% rename from custom_components/gree/translations/hu.json rename to custom_components/gree_custom/translations/hu.json diff --git a/custom_components/gree/translations/it.json b/custom_components/gree_custom/translations/it.json similarity index 100% rename from custom_components/gree/translations/it.json rename to custom_components/gree_custom/translations/it.json diff --git a/custom_components/gree/translations/pl.json b/custom_components/gree_custom/translations/pl.json similarity index 100% rename from custom_components/gree/translations/pl.json rename to custom_components/gree_custom/translations/pl.json diff --git a/custom_components/gree/translations/pt-BR.json b/custom_components/gree_custom/translations/pt-BR.json similarity index 100% rename from custom_components/gree/translations/pt-BR.json rename to custom_components/gree_custom/translations/pt-BR.json diff --git a/custom_components/gree/translations/ro.json b/custom_components/gree_custom/translations/ro.json similarity index 100% rename from custom_components/gree/translations/ro.json rename to custom_components/gree_custom/translations/ro.json diff --git a/custom_components/gree/translations/ru.json b/custom_components/gree_custom/translations/ru.json similarity index 100% rename from custom_components/gree/translations/ru.json rename to custom_components/gree_custom/translations/ru.json diff --git a/custom_components/gree/translations/zh-Hans.json b/custom_components/gree_custom/translations/zh-Hans.json similarity index 100% rename from custom_components/gree/translations/zh-Hans.json rename to custom_components/gree_custom/translations/zh-Hans.json From 964f389487012734fe4b0874727bf78d2aaf2f90 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Wed, 8 Oct 2025 19:42:44 +0100 Subject: [PATCH 043/113] Readd Temperature Step and move connection settings to the advanced schema --- custom_components/gree_custom/climate.py | 9 ++++-- custom_components/gree_custom/config_flow.py | 19 ++++++++++++ custom_components/gree_custom/const.py | 1 + custom_components/gree_custom/gree_device.py | 8 ++++- custom_components/gree_custom/gree_helpers.py | 9 +++--- .../gree_custom/translations/en.json | 31 ++++++++++--------- 6 files changed, 55 insertions(+), 22 deletions(-) diff --git a/custom_components/gree_custom/climate.py b/custom_components/gree_custom/climate.py index cb529b7..e948cff 100644 --- a/custom_components/gree_custom/climate.py +++ b/custom_components/gree_custom/climate.py @@ -46,10 +46,12 @@ CONF_RESTORE_STATES, CONF_SWING_HORIZONTAL_MODES, CONF_SWING_MODES, + CONF_TEMPERATURE_STEP, DEFAULT_FAN_MODES, DEFAULT_HVAC_MODES, DEFAULT_SWING_HORIZONTAL_MODES, DEFAULT_SWING_MODES, + DEFAULT_TARGET_TEMP_STEP, DOMAIN, GATTR_FEAT_QUIET_MODE, GATTR_FEAT_TURBO, @@ -129,6 +131,9 @@ async def async_setup_entry( fan_modes, swing_modes, swing_horizontal_modes, + temperature_step=entry.data.get( + CONF_TEMPERATURE_STEP, DEFAULT_TARGET_TEMP_STEP + ), restore_state=(entry.data.get(CONF_RESTORE_STATES, True)), check_availability=( entry.data[CONF_ADVANCED].get(CONF_DISABLE_AVAILABLE_CHECK, False) @@ -173,6 +178,7 @@ def __init__( fan_modes: list[str], swing_modes: list[str], swing_horizontal_modes: list[str], + temperature_step: int, restore_state: bool = True, check_availability: bool = True, external_temperature_sensor_id: str | None = None, @@ -188,8 +194,7 @@ def __init__( self._external_temperature_sensor = external_temperature_sensor_id self._external_humidity_sensor = external_humidity_sensor_id - self._attr_precision = 1 - self._attr_target_temperature_step = 1 + self._attr_target_temperature_step = temperature_step self._attr_hvac_modes = hvac_modes diff --git a/custom_components/gree_custom/config_flow.py b/custom_components/gree_custom/config_flow.py index 8f35e65..478a601 100644 --- a/custom_components/gree_custom/config_flow.py +++ b/custom_components/gree_custom/config_flow.py @@ -21,6 +21,9 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, SelectSelector, SelectSelectorConfig, SelectSelectorMode, @@ -40,12 +43,14 @@ CONF_RESTORE_STATES, CONF_SWING_HORIZONTAL_MODES, CONF_SWING_MODES, + CONF_TEMPERATURE_STEP, CONF_UID, DEFAULT_FAN_MODES, DEFAULT_HVAC_MODES, DEFAULT_SUPPORTED_FEATURES, DEFAULT_SWING_HORIZONTAL_MODES, DEFAULT_SWING_MODES, + DEFAULT_TARGET_TEMP_STEP, DOMAIN, ) from .coordinator import GreeConfigEntry @@ -206,6 +211,20 @@ def build_options_schema(hass: HomeAssistant, data: Mapping | None) -> vol.Schem translation_key=CONF_FEATURES, ) ), + vol.Required( + CONF_TEMPERATURE_STEP, + default=DEFAULT_TARGET_TEMP_STEP + if data is None + else data.get(CONF_TEMPERATURE_STEP, DEFAULT_TARGET_TEMP_STEP), + ): NumberSelector( + NumberSelectorConfig( + min=0.5, + max=5, + step=0.5, + mode=NumberSelectorMode.BOX, + unit_of_measurement="ºC", + ) + ), # Ideally we would use an Optional EntitySelector for external sensors. # Currently we can't because unsetting the value in the UI makes HA # populate the user_input with the previous set value, making the user diff --git a/custom_components/gree_custom/const.py b/custom_components/gree_custom/const.py index b743a8f..481910c 100755 --- a/custom_components/gree_custom/const.py +++ b/custom_components/gree_custom/const.py @@ -26,6 +26,7 @@ CONF_SWING_MODES = "swing_modes" CONF_SWING_HORIZONTAL_MODES = "swing_horizontal_modes" CONF_FEATURES = "features" +CONF_TEMPERATURE_STEP = "target_temp_step" DEFAULT_TARGET_TEMP_STEP = 1 DEFAULT_ENCRYPTION_VERSION = None diff --git a/custom_components/gree_custom/gree_device.py b/custom_components/gree_custom/gree_device.py index 7ad7f76..ad6622e 100755 --- a/custom_components/gree_custom/gree_device.py +++ b/custom_components/gree_custom/gree_device.py @@ -524,7 +524,13 @@ def set_target_temperature(self, value: float) -> None: """Sets the target temperature in target_temperature_unit.""" if self.target_temperature_unit == TemperatureUnits.F: - raw_c, tem_rec = gree_get_target_temp_props_from_f(value) + if not value.is_integer(): + _LOGGER.warning( + "The Gree API does not support floating Fahrenheit values, the applied value will be: %.2f -> %d", + value, + round(value), + ) + raw_c, tem_rec = gree_get_target_temp_props_from_f(round(value)) else: raw_c, tem_rec = gree_get_target_temp_props_from_c(value) diff --git a/custom_components/gree_custom/gree_helpers.py b/custom_components/gree_custom/gree_helpers.py index 0453d47..8854014 100644 --- a/custom_components/gree_custom/gree_helpers.py +++ b/custom_components/gree_custom/gree_helpers.py @@ -66,12 +66,13 @@ def _penalty(self, lo: float, hi: float) -> float: return pen -def gree_get_target_temp_props_from_f(desired_temp_f: float) -> tuple[int, int]: - """Get SetTem and TemRec for a given Fahrenheit temperature.""" +def gree_get_target_temp_props_from_f(desired_temp_f: int) -> tuple[int, int]: + """Get SetTem and TemRec for a given Fahrenheit temperature. Only integer values supported.""" # See: https://github.com/tomikaa87/gree-remote - SetTem = round((desired_temp_f - 32.0) * 5.0 / 9.0) - TemRec = (int)((((desired_temp_f - 32.0) * 5.0 / 9.0) - SetTem) > -0.001) + celsius = (desired_temp_f - 32.0) * 5.0 / 9.0 + SetTem = round(celsius) + TemRec = int((celsius - SetTem) > -0.001) return SetTem, TemRec diff --git a/custom_components/gree_custom/translations/en.json b/custom_components/gree_custom/translations/en.json index 253d41a..783e652 100755 --- a/custom_components/gree_custom/translations/en.json +++ b/custom_components/gree_custom/translations/en.json @@ -39,7 +39,14 @@ "port": "Port", "encryption_key": "Encryption Key", "encryption_version": "Encryption Version", - "uid": "UID" + "uid": "UID", + "disable_available_check": "Disable Available Check", + "max_online_attempts": "Max Connection Attempts", + "timeout": "Connection Timeout" + }, + "data_description": { + "max_online_attempts": "The number of attempts to communicate with the device before it is marked as unavailable", + "timeout": "The timeout for each of the connection attempts" } } } @@ -55,17 +62,14 @@ "features": "Device Features and Modes", "external_temperature_sensor": "External Temperature Sensor", "external_humidity_sensor": "External Humidity Sensor", - "disable_available_check": "Disable Available Check", - "max_online_attempts": "Max Connection Attempts", - "timeout": "Connection Timeout", - "restore_states": "Restore Entities" + "restore_states": "Restore Entities", + "target_temp_step": "Temperature Step" }, "data_description": { "external_temperature_sensor": "If set will replace the built-in HVAC temperature sensor", "external_humidity_sensor": "If set will replace the built-in HVAC humidity sensor", - "max_online_attempts": "The number of attempts to communicate with the device before it is marked as unavailable", - "timeout": "The timeout for each of the connection attempts", - "restore_states": "If set to true, when the integration is started the device will be overriden by previous states of the integration instead of using the current device state." + "restore_states": "If set to true, when the integration is started the device will be overriden by previous states of the integration instead of using the current device state.", + "target_temp_step": "Sets the increment step for adjusting the target temperature. Fahrenheit degrees are clamped to the nearest integer." } }, "reconfigure": { @@ -79,17 +83,14 @@ "features": "Device Features and Modes", "external_temperature_sensor": "External Temperature Sensor", "external_humidity_sensor": "External Humidity Sensor", - "disable_available_check": "Disable Available Check", - "max_online_attempts": "Max Connection Attempts", - "timeout": "Connection Timeout", - "restore_states": "Restore Entities" + "restore_states": "Restore Entities", + "target_temp_step": "Temperature Step" }, "data_description": { "external_temperature_sensor": "If set will replace the built-in HVAC temperature sensor", "external_humidity_sensor": "If set will replace the built-in HVAC humidity sensor", - "max_online_attempts": "The number of attempts to communicate with the device before it is marked as unavailable", - "timeout": "The timeout for each of the connection attempts", - "restore_states": "If set to true, when the integration is started the device will be overriden by previous states of the integration instead of using the current device state." + "restore_states": "If set to true, when the integration is started the device will be overriden by previous states of the integration instead of using the current device state.", + "target_temp_step": "Sets the increment step for adjusting the target temperature. Fahrenheit degrees are clamped to the nearest integer." } } } From 8391d5fa500d9efaa7643895d8f3025374c0199f Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Wed, 8 Oct 2025 22:13:29 +0100 Subject: [PATCH 044/113] More debug logs for VRF --- custom_components/gree_custom/gree_api.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/custom_components/gree_custom/gree_api.py b/custom_components/gree_custom/gree_api.py index 41c9958..13e8fd7 100644 --- a/custom_components/gree_custom/gree_api.py +++ b/custom_components/gree_custom/gree_api.py @@ -569,7 +569,7 @@ def gree_create_payload( if encryption_version == EncryptionVersion.V2 and tag is not None: base_payload["tag"] = tag - # _LOGGER.debug("Payload: %s", payload) + _LOGGER.debug("Payload: %s", base_payload) return json.dumps(base_payload) @@ -866,8 +866,7 @@ async def discover_gree_devices( sub_mac = sub_device.get("mac", "") if sub_mac: discovered_sub_device = GreeDiscoveredDevice( - name=f"{discovered_device.name}@{sub_mac[:4]}" - or f"Gree {mac_addr[-4:]}", + name=f"{discovered_device.name or f'Gree {mac_addr[-4:]}'}@{sub_mac[:4]}", host=discovered_device.host, mac=f"{sub_mac}@{discovered_device.mac}", port=discovered_device.port, @@ -877,7 +876,8 @@ async def discover_gree_devices( ) discovered_devices.append(discovered_sub_device) _LOGGER.debug( - "Discovered sub-device: %s", discovered_device + "Discovered sub-device: %s", + discovered_sub_device, ) except Exception: _LOGGER.exception("Failed to fetch sub-devices") From abc6f100f67c3d6734bcc74ea541c54303b8b70c Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Wed, 8 Oct 2025 22:25:05 +0100 Subject: [PATCH 045/113] Log exceptions during device add --- custom_components/gree_custom/config_flow.py | 9 ++++++--- custom_components/gree_custom/translations/en.json | 3 ++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/custom_components/gree_custom/config_flow.py b/custom_components/gree_custom/config_flow.py index 478a601..3caf5b9 100644 --- a/custom_components/gree_custom/config_flow.py +++ b/custom_components/gree_custom/config_flow.py @@ -417,13 +417,16 @@ async def async_step_manual_add( max_connection_attempts=2, # Use fewer attempts for testing the device timeout=2, # Use smaller timeout for testing the device ) - await self._device.fetch_device_status() + await self._device.bind_device() except CannotConnect: errors["base"] = "cannot_connect" + _LOGGER.exception("Cannot connect") except GreeDeviceNotBoundError: errors["base"] = "cannot_connect" - except Exception as err: # noqa: BLE001 - errors["base"] = "unknown: " + repr(err) + _LOGGER.exception("Error while binding") + except Exception: + errors["base"] = "unknown" + _LOGGER.exception("Unknown error while binding") else: self._step_main_data = user_input self._step_main_data["advanced"].update( diff --git a/custom_components/gree_custom/translations/en.json b/custom_components/gree_custom/translations/en.json index 783e652..00fe6cc 100755 --- a/custom_components/gree_custom/translations/en.json +++ b/custom_components/gree_custom/translations/en.json @@ -3,7 +3,8 @@ "title": "Gree Climate", "description": "Configure your Gree air conditioner", "error": { - "cannot_connect": "Unable to connect to the device. Please check the device configuration and network connection and try again.", + "unknown": "Something went wrong, please try again. If the issue persists, please check the logs.", + "cannot_connect": "Unable to connect to the device. Please check the device configuration and network connection and try again. If the issue persists, please check the logs.", "no_devices_found": "Couldn't find any Gree device in the network. Please add your device manually." }, "abort": { From e934a9220305df13d37b46026248c488547aa27a Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Thu, 9 Oct 2025 19:10:07 +0100 Subject: [PATCH 046/113] Fix get status when the device returns an empty string --- custom_components/gree_custom/coordinator.py | 2 ++ custom_components/gree_custom/gree_api.py | 6 +++--- custom_components/gree_custom/gree_device.py | 5 +++++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/custom_components/gree_custom/coordinator.py b/custom_components/gree_custom/coordinator.py index 04c05ec..389cb10 100644 --- a/custom_components/gree_custom/coordinator.py +++ b/custom_components/gree_custom/coordinator.py @@ -61,8 +61,10 @@ async def _async_update_data(self): try: await self.device.fetch_device_status() except GreeDeviceNotBoundError as err: + _LOGGER.exception("Failed to initiate Gree device") raise ConfigEntryAuthFailed("Failed to initiate Gree device") from err except ValueError as err: + _LOGGER.exception("Error getting state from device") raise UpdateFailed("Error getting state from device") from err @property diff --git a/custom_components/gree_custom/gree_api.py b/custom_components/gree_custom/gree_api.py index 13e8fd7..2de3229 100644 --- a/custom_components/gree_custom/gree_api.py +++ b/custom_components/gree_custom/gree_api.py @@ -653,7 +653,7 @@ async def gree_get_status( _LOGGER.debug("Trying to get device status") - status_values: dict[GreeProp, int] = {} + status_values: dict[GreeProp, int | None] = {} pack, tag = gree_create_encrypted_pack( gree_create_status_pack(mac_addr_sub, [prop.value for prop in props]), @@ -681,11 +681,11 @@ async def gree_get_status( raise ValueError("Error getting device status, no data received") cols = [propkey_to_enum[c] for c in result["cols"] if c in propkey_to_enum] - values = list(map(int, result["dat"])) + values = [int(x) if x != "" else None for x in result["dat"]] status_values = dict(zip(cols, values, strict=True)) _LOGGER.debug("Device status values: %s", status_values) - return status_values + return {k: v for k, v in status_values.items() if v is not None} async def gree_set_status( diff --git a/custom_components/gree_custom/gree_device.py b/custom_components/gree_custom/gree_device.py index ad6622e..099f458 100755 --- a/custom_components/gree_custom/gree_device.py +++ b/custom_components/gree_custom/gree_device.py @@ -300,6 +300,11 @@ def _remove_unsupported_props(self): def _get_prop_raw(self, prop: GreeProp, default: int | None = None) -> int | None: """Get the raw value of a property.""" + if prop not in self._state: + _LOGGER.warning( + "Property '%s' not found in state of device '%s'", prop, self.name + ) + return default return self._state.get(prop, default) def LogDeviceInfo(self): From dfab2d5a984f88314c13b79917b8f19eabc4bf8f Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Sat, 11 Oct 2025 15:48:35 +0100 Subject: [PATCH 047/113] Use stable entity ids Fixes changing the device name breaking previous entities --- custom_components/gree_custom/climate.py | 1 - custom_components/gree_custom/entity.py | 1 + custom_components/gree_custom/select.py | 1 - custom_components/gree_custom/sensor.py | 1 - custom_components/gree_custom/switch.py | 1 - 5 files changed, 1 insertion(+), 4 deletions(-) diff --git a/custom_components/gree_custom/climate.py b/custom_components/gree_custom/climate.py index e948cff..0a0e528 100644 --- a/custom_components/gree_custom/climate.py +++ b/custom_components/gree_custom/climate.py @@ -188,7 +188,6 @@ def __init__( super().__init__(description, coordinator, restore_state, check_availability) self.entity_description = description - self._attr_unique_id = f"{self.device.name}_{description.key}" self._attr_name = None # Main entity self._external_temperature_sensor = external_temperature_sensor_id diff --git a/custom_components/gree_custom/entity.py b/custom_components/gree_custom/entity.py index a708da6..0a1956d 100755 --- a/custom_components/gree_custom/entity.py +++ b/custom_components/gree_custom/entity.py @@ -35,6 +35,7 @@ def __init__( self.restore_state = restore_state self.check_availability = check_availability + self._attr_unique_id = description.key self._attr_device_info = DeviceInfo( connections={(CONNECTION_NETWORK_MAC, self.device.unique_id)}, identifiers={(DOMAIN, self.device.unique_id)}, diff --git a/custom_components/gree_custom/select.py b/custom_components/gree_custom/select.py index 0de1a24..57a89f0 100644 --- a/custom_components/gree_custom/select.py +++ b/custom_components/gree_custom/select.py @@ -104,7 +104,6 @@ def __init__( super().__init__(description, coordinator, restore_state, check_availability) self.entity_description = description # pyright: ignore[reportIncompatibleVariableOverride] - self._attr_unique_id = f"{self.device.name}_{description.key}" # Set up options dynamically if description.options_func: diff --git a/custom_components/gree_custom/sensor.py b/custom_components/gree_custom/sensor.py index 12422a1..dacab8e 100644 --- a/custom_components/gree_custom/sensor.py +++ b/custom_components/gree_custom/sensor.py @@ -125,7 +125,6 @@ def __init__( super().__init__(description, coordinator, restore_state, check_availability) self.entity_description = description # pyright: ignore[reportIncompatibleVariableOverride] - self._attr_unique_id = f"{self.device.name}_{description.key}" _LOGGER.debug( "Initialized sensor: %s (check_availability=%s)", self._attr_unique_id, diff --git a/custom_components/gree_custom/switch.py b/custom_components/gree_custom/switch.py index 69bb1e1..9b140ef 100644 --- a/custom_components/gree_custom/switch.py +++ b/custom_components/gree_custom/switch.py @@ -227,7 +227,6 @@ def __init__( super().__init__(description, coordinator, restore_state, check_availability) self.entity_description = description # pyright: ignore[reportIncompatibleVariableOverride] - self._attr_unique_id = f"{self.device.name}_{description.key}" _LOGGER.debug( "Initialized switch: %s (check_availability=%s)", self._attr_unique_id, From 2b94488283d3cc1778cbe5cc8a15df746e8a62a0 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Sat, 11 Oct 2025 15:49:42 +0100 Subject: [PATCH 048/113] Allow to reconfigure main options and add more error checking This is useful for when a device changes IP --- custom_components/gree_custom/config_flow.py | 128 ++++++++++++++---- custom_components/gree_custom/gree_api.py | 3 +- custom_components/gree_custom/gree_device.py | 73 ++++++---- .../gree_custom/translations/en.json | 43 +++--- 4 files changed, 176 insertions(+), 71 deletions(-) diff --git a/custom_components/gree_custom/config_flow.py b/custom_components/gree_custom/config_flow.py index 3caf5b9..a3ec4eb 100644 --- a/custom_components/gree_custom/config_flow.py +++ b/custom_components/gree_custom/config_flow.py @@ -17,7 +17,6 @@ from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, CONF_TIMEOUT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import section -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.selector import ( @@ -63,13 +62,21 @@ GreeDiscoveredDevice, discover_gree_devices, ) -from .gree_device import GreeDevice, GreeDeviceNotBoundError +from .gree_device import ( + CannotConnect, + GreeDevice, + GreeDeviceNotBoundError, + GreeDeviceNotBoundErrorKey, +) _LOGGER = logging.getLogger(__name__) def build_main_schema(data: Mapping | None) -> vol.Schema: """Builds the main option schema.""" + if data: + _LOGGER.debug("Building main schema with previous values: %s", data) + return vol.Schema( { vol.Required( @@ -102,7 +109,7 @@ def build_main_schema(data: Mapping | None) -> vol.Schema: else data[CONF_ADVANCED].get( CONF_ENCRYPTION_VERSION, "Auto-Detect" ), - ): vol.In(["Auto-Detect", "1", "2"]), + ): vol.In(["Auto-Detect", 1, 2]), vol.Optional( CONF_ENCRYPTION_KEY, default="" @@ -146,6 +153,8 @@ def build_main_schema(data: Mapping | None) -> vol.Schema: def build_options_schema(hass: HomeAssistant, data: Mapping | None) -> vol.Schema: """Builds the device option schema.""" + if data: + _LOGGER.debug("Building device options schema with previous values: %s", data) return vol.Schema( { @@ -323,6 +332,13 @@ def __init__(self) -> None: """Initialize the config flow.""" self._step_main_data: dict | None = None self._device: GreeDevice | None = None + self._reconfiguringEntry: GreeConfigEntry | None = None + + async def async_step_import( + self, user_input: dict + ) -> config_entries.ConfigFlowResult: + """Handle import from configuration.yaml.""" + return await self.async_step_user(user_input) async def async_step_user( self, user_input: dict | None = None @@ -424,6 +440,9 @@ async def async_step_manual_add( except GreeDeviceNotBoundError: errors["base"] = "cannot_connect" _LOGGER.exception("Error while binding") + except GreeDeviceNotBoundErrorKey: + errors["base"] = "cannot_connect_key" + _LOGGER.exception("Error while binding with wrong key") except Exception: errors["base"] = "unknown" _LOGGER.exception("Unknown error while binding") @@ -457,7 +476,8 @@ async def async_step_manual_add( ) async def async_step_device_options( - self, user_input: dict | None = None + self, + user_input: dict | None = None, ) -> config_entries.ConfigFlowResult: """Second step: configure features/modes.""" if ( @@ -466,45 +486,101 @@ async def async_step_device_options( and self._device is not None ): data = {**self._step_main_data, **user_input} - await self.async_set_unique_id(self._device.unique_id) - self._abort_if_unique_id_configured() - _LOGGER.debug("New entry with config: %s", data) - return self.async_create_entry( - title=self._step_main_data[CONF_NAME], data=data + + # If we are adding a new device + if not self._reconfiguringEntry: + await self.async_set_unique_id(self._device.unique_id) + self._abort_if_unique_id_configured() + _LOGGER.debug("New entry with config: %s", data) + return self.async_create_entry( + title=self._step_main_data[CONF_NAME], data=data + ) + + # If we are reconfiguring a device + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + self._reconfiguringEntry, + title=self._step_main_data[CONF_NAME], + data=data, ) return self.async_show_form( step_id="device_options", - data_schema=build_options_schema(self.hass, user_input), + data_schema=build_options_schema( + self.hass, + user_input + if not self._reconfiguringEntry + else self._reconfiguringEntry.data, + ), ) - async def async_step_import( - self, user_input: dict - ) -> config_entries.ConfigFlowResult: - """Handle import from configuration.yaml.""" - return await self.async_step_user(user_input) - async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None): """Handle reconfiguration of an existing entry.""" entry: GreeConfigEntry = self._get_reconfigure_entry() _LOGGER.debug("Reconfiguring: %s", entry) await self.async_set_unique_id(entry.unique_id) + self._reconfiguringEntry = entry + + errors = {} if user_input is not None: - _LOGGER.warning(user_input) - data = self._merge_device_options(entry.data, user_input) self._abort_if_unique_id_mismatch() - return self.async_update_reload_and_abort( - entry, - data=data, - ) + _LOGGER.debug("User input: %s", user_input) + try: + self._device = GreeDevice( + user_input[CONF_NAME], + user_input[CONF_HOST], + user_input[CONF_MAC], + user_input[CONF_ADVANCED][CONF_PORT], + user_input[CONF_ADVANCED][CONF_ENCRYPTION_KEY], + EncryptionVersion( + int(user_input[CONF_ADVANCED][CONF_ENCRYPTION_VERSION]) + ) + if user_input[CONF_ADVANCED][CONF_ENCRYPTION_VERSION] + != "Auto-Detect" + else None, + user_input[CONF_ADVANCED][CONF_UID], + max_connection_attempts=2, # Use fewer attempts for testing the device + timeout=2, # Use smaller timeout for testing the device + ) + await self._device.bind_device() + except CannotConnect: + errors["base"] = "cannot_connect" + _LOGGER.exception("Cannot connect") + except GreeDeviceNotBoundError: + errors["base"] = "cannot_connect" + _LOGGER.exception("Error while binding") + except GreeDeviceNotBoundErrorKey: + errors["base"] = "cannot_connect_key" + _LOGGER.exception("Error while binding with wrong key") + except Exception: + errors["base"] = "unknown" + _LOGGER.exception("Unknown error while binding") + else: + self._step_main_data = user_input + self._step_main_data["advanced"].update( + { + CONF_ENCRYPTION_VERSION: self._device.encryption_version, + CONF_ENCRYPTION_KEY: self._device.encryption_key, + } + ) + return await self.async_step_device_options() + + # if user_input is not None: + # data = self._merge_device_options(entry.data, user_input) + # self._abort_if_unique_id_mismatch() + # return self.async_update_reload_and_abort( + # entry, + # data=data, + # ) return self.async_show_form( step_id="reconfigure", - data_schema=build_options_schema( - self.hass, entry.data if entry.data is not None else user_input + data_schema=build_main_schema( + entry.data if entry.data is not None else user_input ), + errors=errors, ) def _merge_device_options(self, data, device_options_data: Mapping) -> dict: @@ -551,7 +627,3 @@ async def _discover_devices( _LOGGER.exception("Could not get HA broadcast addresses") return await discover_gree_devices(broadcast_addresses, 5) - - -class CannotConnect(HomeAssistantError): - """Error to indicate we cannot connect.""" diff --git a/custom_components/gree_custom/gree_api.py b/custom_components/gree_custom/gree_api.py index 2de3229..cac6be1 100644 --- a/custom_components/gree_custom/gree_api.py +++ b/custom_components/gree_custom/gree_api.py @@ -781,9 +781,10 @@ async def gree_get_device_info( _LOGGER.exception("Error retrieving basic device info") raise ValueError("Error retrieving basic device info") from err else: - _LOGGER.debug(data) + _LOGGER.debug("Got device info: %s", data) info: dict[str, str | None] = {} info["firmware_version"], info["firmware_code"] = extract_version(data) + info["mac"] = data.get("mac", "") return info diff --git a/custom_components/gree_custom/gree_device.py b/custom_components/gree_custom/gree_device.py index 099f458..9adf821 100755 --- a/custom_components/gree_custom/gree_device.py +++ b/custom_components/gree_custom/gree_device.py @@ -35,6 +35,14 @@ class GreeDeviceNotBoundError(BaseException): """Raised when the device binding fails.""" +class GreeDeviceNotBoundErrorKey(BaseException): + """Raised when the device binding fails because of wrong key.""" + + +class CannotConnect(BaseException): + """Error to indicate we cannot connect.""" + + @dataclass class GreeDeviceState: """Data structure for Gree device state.""" @@ -125,41 +133,58 @@ async def bind_device(self) -> bool: """Setup the device (async).""" if not self._is_bound: try: + # Used also as basic communication test info = await gree_get_device_info( self._ip_addr, self._max_connection_attempts, self._timeout, ) + except Exception as e: + _LOGGER.exception("Could not retrieve basic device info") + raise CannotConnect( + f"Not able to connect to the device {self._ip_addr}" + ) from e + else: + if info.get("mac", "") != self._mac_addr: + raise CannotConnect( + f"Not able to connect to the device {self._ip_addr}. MAC mismatch {info.get('mac', '')} not {self._mac_addr}." + ) self._firmware_version = info.get("firmware_version") self._firmware_code = info.get("firmware_code") - except Exception: - _LOGGER.exception("Could not retrieve basic device info") - if not self._encryption_key.strip(): - _LOGGER.info("No encryption key provided") - try: - ( - self._encryption_key, - self._encryption_version, - ) = await gree_get_device_key( - self._ip_addr, - self._mac_addr, - self._port, - self._uid, - self._encryption_version, - self._max_connection_attempts, - self._timeout, - ) - self._is_bound = True - except Exception as e: - self._is_available = True - raise GreeDeviceNotBoundError("Device not bound") from e - else: - _LOGGER.info( - "Using the provided encryption key with version %d", + try: + ( + encryption_key, + encryption_version, + ) = await gree_get_device_key( + self._ip_addr, + self._mac_addr, + self._port, + self._uid, self._encryption_version, + self._max_connection_attempts, + self._timeout, ) self._is_bound = True + except Exception as e: + self._is_available = True + raise GreeDeviceNotBoundError("Unbale to obtain device key") from e + else: + if not self._encryption_key.strip() or not self._encryption_version: + _LOGGER.info("No encryption key provided") + self._encryption_key = encryption_key + self._encryption_version = encryption_version + else: + if encryption_key != self._encryption_key: + raise GreeDeviceNotBoundErrorKey( + "Wrong encryption key provided" + ) + _LOGGER.info( + "Using the provided encryption key with version %d", + self._encryption_version, + ) + + self._is_bound = True return self._is_bound diff --git a/custom_components/gree_custom/translations/en.json b/custom_components/gree_custom/translations/en.json index 00fe6cc..beafa91 100755 --- a/custom_components/gree_custom/translations/en.json +++ b/custom_components/gree_custom/translations/en.json @@ -5,6 +5,7 @@ "error": { "unknown": "Something went wrong, please try again. If the issue persists, please check the logs.", "cannot_connect": "Unable to connect to the device. Please check the device configuration and network connection and try again. If the issue persists, please check the logs.", + "cannot_connect_key": "Unable to connect to the device. The provided encryption key does not match the device key.", "no_devices_found": "Couldn't find any Gree device in the network. Please add your device manually." }, "abort": { @@ -74,24 +75,30 @@ } }, "reconfigure": { - "title": "Device features", - "description": "The Gree API doesn't have a reliable method of getting the supported features of a device. Please use the options bellow to the best of your knowledge about your device.", + "title": "Device configuration", "data": { - "hvac_modes": "HVAC Modes", - "fan_modes": "Fan Speeds", - "swing_modes": "Vertical Swing Modes", - "swing_horizontal_modes": "Horizontal Swing Modes", - "features": "Device Features and Modes", - "external_temperature_sensor": "External Temperature Sensor", - "external_humidity_sensor": "External Humidity Sensor", - "restore_states": "Restore Entities", - "target_temp_step": "Temperature Step" + "name": "Name", + "host": "IP Address", + "mac": "MAC Address" }, - "data_description": { - "external_temperature_sensor": "If set will replace the built-in HVAC temperature sensor", - "external_humidity_sensor": "If set will replace the built-in HVAC humidity sensor", - "restore_states": "If set to true, when the integration is started the device will be overriden by previous states of the integration instead of using the current device state.", - "target_temp_step": "Sets the increment step for adjusting the target temperature. Fahrenheit degrees are clamped to the nearest integer." + "sections": { + "advanced": { + "name": "Advanced Settings", + "description": "Configure advanced setting of the device", + "data": { + "port": "Port", + "encryption_key": "Encryption Key", + "encryption_version": "Encryption Version", + "uid": "UID", + "disable_available_check": "Disable Available Check", + "max_online_attempts": "Max Connection Attempts", + "timeout": "Connection Timeout" + }, + "data_description": { + "max_online_attempts": "The number of attempts to communicate with the device before it is marked as unavailable", + "timeout": "The timeout for each of the connection attempts" + } + } } } } @@ -158,11 +165,11 @@ "air": "Fresh Air", "xfan": "X-Fan", "sleep": "Sleep", - "smart_heat": "8ºC Smart Heat", + "eightdegheat": "8ºC Smart Heat", "lights": "Display Light", "health": "Health", "anti_direct_blow": "Anti Direct Blow", - "energy_saving": "Energy Saving", + "powersave": "Energy Saving", "light_sensor": "Display Auto Brightness" } } From fdc041ddb591e029ec564832729f9e02e214a0f9 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Sat, 11 Oct 2025 16:09:02 +0100 Subject: [PATCH 049/113] Make device key a password field --- custom_components/gree_custom/config_flow.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/custom_components/gree_custom/config_flow.py b/custom_components/gree_custom/config_flow.py index a3ec4eb..5f8a5f7 100644 --- a/custom_components/gree_custom/config_flow.py +++ b/custom_components/gree_custom/config_flow.py @@ -26,6 +26,9 @@ SelectSelector, SelectSelectorConfig, SelectSelectorMode, + TextSelector, + TextSelectorConfig, + TextSelectorType, ) from .const import ( @@ -115,7 +118,9 @@ def build_main_schema(data: Mapping | None) -> vol.Schema: default="" if data is None or data.get(CONF_ADVANCED) is None else data[CONF_ADVANCED].get(CONF_ENCRYPTION_KEY, ""), - ): str, + ): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), vol.Required( CONF_UID, default=DEFAULT_DEVICE_UID From 8a4ae3c40096950312c6c979a33295026178c7f2 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Sat, 11 Oct 2025 23:49:49 +0100 Subject: [PATCH 050/113] Support importing from configuration YAML --- custom_components/gree_custom/__init__.py | 4 +- custom_components/gree_custom/climate.py | 13 +- custom_components/gree_custom/config_flow.py | 133 ++++++++++++------- custom_components/gree_custom/gree_api.py | 2 +- custom_components/gree_custom/gree_device.py | 10 +- 5 files changed, 103 insertions(+), 59 deletions(-) diff --git a/custom_components/gree_custom/__init__.py b/custom_components/gree_custom/__init__.py index 680b062..1eee6dd 100755 --- a/custom_components/gree_custom/__init__.py +++ b/custom_components/gree_custom/__init__.py @@ -120,8 +120,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" _LOGGER.debug("Options updated for entry %s: %s", entry.entry_id, entry.options) _LOGGER.debug("Reloading config entry %s after options update", entry.entry_id) - await hass.config_entries.async_reload(entry.entry_id) + hass.config_entries.async_schedule_reload(entry.entry_id) diff --git a/custom_components/gree_custom/climate.py b/custom_components/gree_custom/climate.py index 0a0e528..a623aa6 100644 --- a/custom_components/gree_custom/climate.py +++ b/custom_components/gree_custom/climate.py @@ -117,7 +117,6 @@ async def async_setup_entry( "Climate Entity will not be created because no Climate options and features are available for the device" ) return - async_add_entities( [ GreeClimate( @@ -211,14 +210,20 @@ def __init__( if fan_modes: self._attr_supported_features |= ClimateEntityFeature.FAN_MODE self._attr_fan_modes = fan_modes + else: + self._attr_fan_modes = None if swing_modes: self._attr_supported_features |= ClimateEntityFeature.SWING_MODE self._attr_swing_modes = swing_modes + else: + self._attr_swing_modes = None if swing_horizontal_modes: self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE self._attr_swing_horizontal_modes = swing_horizontal_modes + else: + self._attr_swing_horizontal_modes = None self._update_attributes() _LOGGER.debug( @@ -349,9 +354,9 @@ async def _restore_entity_state(self): if ( last_swing_horizontal_mode not in [None, STATE_UNKNOWN, STATE_UNAVAILABLE] - and self._attr_swing_horizontal_modes - and last_swing_horizontal_mode != self._attr_swing_horizontal_mode - and last_swing_horizontal_mode in self._attr_swing_horizontal_modes + and self.swing_horizontal_modes + and last_swing_horizontal_mode != self.swing_horizontal_mode + and last_swing_horizontal_mode in self.swing_horizontal_modes ): try: await self.async_set_swing_horizontal_mode( diff --git a/custom_components/gree_custom/config_flow.py b/custom_components/gree_custom/config_flow.py index 5f8a5f7..b37c4f1 100644 --- a/custom_components/gree_custom/config_flow.py +++ b/custom_components/gree_custom/config_flow.py @@ -7,7 +7,6 @@ from typing import Any import voluptuous as vol -from voluptuous.schema_builder import UNDEFINED from homeassistant import config_entries from homeassistant.components.network import ( @@ -258,9 +257,9 @@ def build_options_schema(hass: HomeAssistant, data: Mapping | None) -> vol.Schem ), vol.Required( ATTR_EXTERNAL_HUMIDITY_SENSOR, - default=UNDEFINED + default="None" if data is None - else data.get(ATTR_EXTERNAL_HUMIDITY_SENSOR, UNDEFINED), + else data.get(ATTR_EXTERNAL_HUMIDITY_SENSOR, "None"), ): SelectSelector( config=SelectSelectorConfig( options=get_humidity_sensor_options(hass), @@ -311,6 +310,42 @@ def get_humidity_sensor_options(hass: HomeAssistant) -> list[str]: return options +def apply_schema_defaults(schema: vol.Schema, data: dict) -> dict: + """Fill in defaults for missing required keys (including nested).""" + data = dict(data or {}) + result = {} + + for key_obj, validator in schema.schema.items(): + key = key_obj.schema # actual string name + value = data.get(key, vol.UNDEFINED) + + # Extract default if missing + if value is vol.UNDEFINED: + default = getattr(key_obj, "default", vol.UNDEFINED) + if default is not vol.UNDEFINED: + value = default() if callable(default) else default + + # Handle nested schema recursively + if isinstance(validator, vol.Schema) and isinstance(value, dict): + value = apply_schema_defaults(validator, value) + + # Run individual field validator (type checks etc.) + if value is not vol.UNDEFINED: + value = validator(value) if callable(validator) else value + + result[key] = value + + return result + + +def format_mac_id(mac_addr: str) -> str: + """Returns a formated mac address for use as unique id.""" + if "@" in mac_addr: + _mac_addr_sub, _ = mac_addr.lower().split("@", 1) + return format_mac(_mac_addr_sub) + return format_mac(mac_addr) + + DEVICE_OPTIONS_KEYS = { CONF_TIMEOUT, CONF_MAX_ONLINE_ATTEMPTS, @@ -337,13 +372,47 @@ def __init__(self) -> None: """Initialize the config flow.""" self._step_main_data: dict | None = None self._device: GreeDevice | None = None - self._reconfiguringEntry: GreeConfigEntry | None = None + self._reconfiguring_entry: GreeConfigEntry | None = None async def async_step_import( - self, user_input: dict + self, import_config: dict ) -> config_entries.ConfigFlowResult: """Handle import from configuration.yaml.""" - return await self.async_step_user(user_input) + _LOGGER.debug("Importing config entry: %s", import_config) + + # Combine the schemas + schema1 = build_main_schema(import_config) + schema2 = build_options_schema(self.hass, import_config) + COMBINED = vol.Schema( + { + **schema1.schema, + **schema2.schema, + } + ) + + # Fill the imported data with the schema defaults + data = apply_schema_defaults(COMBINED, import_config) + + unique_id = format_mac_id(import_config[CONF_MAC]) + entry = next( + ( + e + for e in self.hass.config_entries.async_entries(DOMAIN) + if e.unique_id == unique_id + ), + None, + ) + + await self.async_set_unique_id(unique_id) + + if entry: + return self.async_update_reload_and_abort( + entry, + title=import_config[CONF_NAME], + data=data, + ) + + return self.async_create_entry(title=data[CONF_NAME], data=data) async def async_step_user( self, user_input: dict | None = None @@ -381,7 +450,7 @@ async def async_step_manual_discovery( device_id = f"{device.mac}_{device.host}" if device_id == selected_device: # Check if already configured - await self.async_set_unique_id(format_mac(device.mac)) + await self.async_set_unique_id(format_mac_id(device.mac)) self._abort_if_unique_id_configured() # Store selected device for next step @@ -421,6 +490,9 @@ async def async_step_manual_add( """Handle the manual add of a device.""" errors = {} if user_input is not None: + await self.async_set_unique_id(format_mac_id(user_input[CONF_MAC])) + self._abort_if_unique_id_configured() + try: self._device = GreeDevice( user_input[CONF_NAME], @@ -459,9 +531,6 @@ async def async_step_manual_add( CONF_ENCRYPTION_KEY: self._device.encryption_key, } ) - await self.async_set_unique_id(format_mac(self._device.unique_id)) - self._abort_if_unique_id_configured() - return await self.async_step_device_options() elif self._selected_device is not None: user_input = {} @@ -493,8 +562,8 @@ async def async_step_device_options( data = {**self._step_main_data, **user_input} # If we are adding a new device - if not self._reconfiguringEntry: - await self.async_set_unique_id(self._device.unique_id) + if not self._reconfiguring_entry: + await self.async_set_unique_id(format_mac_id(data[CONF_MAC])) self._abort_if_unique_id_configured() _LOGGER.debug("New entry with config: %s", data) return self.async_create_entry( @@ -504,7 +573,7 @@ async def async_step_device_options( # If we are reconfiguring a device self._abort_if_unique_id_mismatch() return self.async_update_reload_and_abort( - self._reconfiguringEntry, + self._reconfiguring_entry, title=self._step_main_data[CONF_NAME], data=data, ) @@ -514,8 +583,8 @@ async def async_step_device_options( data_schema=build_options_schema( self.hass, user_input - if not self._reconfiguringEntry - else self._reconfiguringEntry.data, + if not self._reconfiguring_entry + else self._reconfiguring_entry.data, ), ) @@ -525,7 +594,7 @@ async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None) _LOGGER.debug("Reconfiguring: %s", entry) await self.async_set_unique_id(entry.unique_id) - self._reconfiguringEntry = entry + self._reconfiguring_entry = entry errors = {} @@ -572,14 +641,6 @@ async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None) ) return await self.async_step_device_options() - # if user_input is not None: - # data = self._merge_device_options(entry.data, user_input) - # self._abort_if_unique_id_mismatch() - # return self.async_update_reload_and_abort( - # entry, - # data=data, - # ) - return self.async_show_form( step_id="reconfigure", data_schema=build_main_schema( @@ -588,30 +649,6 @@ async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None) errors=errors, ) - def _merge_device_options(self, data, device_options_data: Mapping) -> dict: - """Removes optional keys if unset and updates the others.""" - old_data = dict(data) - - # Update or drop managed keys based on user_input - - for key in DEVICE_OPTIONS_KEYS: - if key in device_options_data: - old_data[key] = device_options_data[key] - else: - old_data.pop(key, None) - _LOGGER.warning("Removing key %s", key) - - # If there are any unmanaged keys in user_input, merge them too - old_data.update( - { - key: value - for key, value in device_options_data.items() - if key not in DEVICE_OPTIONS_KEYS - } - ) - - return old_data - async def _discover_devices( self, hass: HomeAssistant ) -> list[GreeDiscoveredDevice]: diff --git a/custom_components/gree_custom/gree_api.py b/custom_components/gree_custom/gree_api.py index cac6be1..f23e4ee 100644 --- a/custom_components/gree_custom/gree_api.py +++ b/custom_components/gree_custom/gree_api.py @@ -630,7 +630,7 @@ async def gree_get_device_key( _LOGGER.info( "Fetched device encryption key with version %d with success", enc_version ) - _LOGGER.debug("Fetched encryption key: %s", key) + _LOGGER.debug("Fetched encryption key: %s[omitted]", key[:5]) return key, enc_version diff --git a/custom_components/gree_custom/gree_device.py b/custom_components/gree_custom/gree_device.py index 9adf821..6a50776 100755 --- a/custom_components/gree_custom/gree_device.py +++ b/custom_components/gree_custom/gree_device.py @@ -97,7 +97,9 @@ def __init__( ip_addr, port, ) - _LOGGER.debug("Version: %s, Key: %s", encryption_version, encryption_key[:5]) + _LOGGER.debug( + "Version: %s, Key: %s[omitted]", encryption_version, encryption_key[:5] + ) self._name: str = name self._ip_addr: str = ip_addr @@ -167,11 +169,10 @@ async def bind_device(self) -> bool: ) self._is_bound = True except Exception as e: - self._is_available = True - raise GreeDeviceNotBoundError("Unbale to obtain device key") from e + raise GreeDeviceNotBoundError("Unable to obtain device key") from e else: if not self._encryption_key.strip() or not self._encryption_version: - _LOGGER.info("No encryption key provided") + _LOGGER.info("Using the obtained encryption key and version") self._encryption_key = encryption_key self._encryption_version = encryption_version else: @@ -184,6 +185,7 @@ async def bind_device(self) -> bool: self._encryption_version, ) + self._is_available = True self._is_bound = True return self._is_bound From ef00776c1ca3f5fbdbba7ef4405c4f6ff9a2b2e4 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Sun, 12 Oct 2025 19:42:59 +0100 Subject: [PATCH 051/113] Also add main VRF to the discovered devices --- custom_components/gree_custom/gree_api.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/custom_components/gree_custom/gree_api.py b/custom_components/gree_custom/gree_api.py index f23e4ee..87edac4 100644 --- a/custom_components/gree_custom/gree_api.py +++ b/custom_components/gree_custom/gree_api.py @@ -840,15 +840,16 @@ async def discover_gree_devices( uid=data.get("uid", 0), ) + discovered_devices.append(discovered_device) + _LOGGER.debug("Discovered device: %s", discovered_device) + # If VRF, the mac is of the main device and we have to query it for the sub devices # Sub-devices will be created with a mac of sub@main # check if the device has sub-devices sub_count = pack.get("subCnt", 0) - if sub_count == 0: # Is normal HVAC - discovered_devices.append(discovered_device) - _LOGGER.debug("Discovered device: %s", discovered_device) - else: # Is VRF with multiple sub devices + if sub_count > 0: + # Is VRF with multiple sub devices _LOGGER.debug( "Trying to fetching sub-devices for '%s' (subCount=%d)", mac_addr, From 20d3a6f9f9a3e71129dbd8905fe14a2ceb6039db Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Wed, 15 Oct 2025 18:56:59 +0100 Subject: [PATCH 052/113] General cleanup of device code in preparation for VRF adjustments --- custom_components/gree_custom/climate.py | 8 +- custom_components/gree_custom/gree_api.py | 12 +- custom_components/gree_custom/gree_device.py | 288 ++++++++----------- custom_components/gree_custom/select.py | 5 +- custom_components/gree_custom/sensor.py | 100 +++---- custom_components/gree_custom/switch.py | 56 +++- 6 files changed, 231 insertions(+), 238 deletions(-) diff --git a/custom_components/gree_custom/climate.py b/custom_components/gree_custom/climate.py index a623aa6..1f0b0a2 100644 --- a/custom_components/gree_custom/climate.py +++ b/custom_components/gree_custom/climate.py @@ -67,6 +67,7 @@ MIN_TEMP_C, MIN_TEMP_F, FanSpeed, + GreeProp, HorizontalSwingMode, VerticalSwingMode, ) @@ -794,7 +795,7 @@ def get_current_temp(self) -> float | None: # so here we need to convert to the unit of the entity (same as device) if ( self.hass - and self.device.has_indoor_temperature_sensor + and self.device.supports_property(GreeProp.SENSOR_TEMPERATURE) and self.device.indoors_temperature_c is not None ): return TemperatureConverter.convert( @@ -809,7 +810,10 @@ def get_current_humidity(self) -> float | None: """Returns the current humidity of the room.""" # Gree API always return current humidity in % - if self.device.has_humidity_sensor and self.device.humidity is not None: + if ( + self.device.supports_property(GreeProp.SENSOR_HUMIDITY) + and self.device.humidity is not None + ): return float(self.device.humidity) return None diff --git a/custom_components/gree_custom/gree_api.py b/custom_components/gree_custom/gree_api.py index 87edac4..8e56787 100644 --- a/custom_components/gree_custom/gree_api.py +++ b/custom_components/gree_custom/gree_api.py @@ -648,12 +648,12 @@ async def gree_get_status( props: list[GreeProp], max_connection_attempts: int, timeout: int, -) -> dict[GreeProp, int]: - """Get the status of the device by sending a status request to the device (async).""" +) -> tuple[dict[GreeProp, int], list[GreeProp]]: + """Get the status of the device by sending a status request to the device (async). Also returns the props not present.""" _LOGGER.debug("Trying to get device status") - status_values: dict[GreeProp, int | None] = {} + status_values_raw: dict[GreeProp, int | None] = {} pack, tag = gree_create_encrypted_pack( gree_create_status_pack(mac_addr_sub, [prop.value for prop in props]), @@ -682,10 +682,12 @@ async def gree_get_status( cols = [propkey_to_enum[c] for c in result["cols"] if c in propkey_to_enum] values = [int(x) if x != "" else None for x in result["dat"]] - status_values = dict(zip(cols, values, strict=True)) + status_values_raw = dict(zip(cols, values, strict=True)) + status_values = {k: v for k, v in status_values_raw.items() if v is not None} _LOGGER.debug("Device status values: %s", status_values) - return {k: v for k, v in status_values.items() if v is not None} + + return status_values, [p for p in props if p not in status_values] async def gree_set_status( diff --git a/custom_components/gree_custom/gree_device.py b/custom_components/gree_custom/gree_device.py index 6a50776..ed16363 100755 --- a/custom_components/gree_custom/gree_device.py +++ b/custom_components/gree_custom/gree_device.py @@ -2,8 +2,6 @@ import logging -from attr import dataclass - from .gree_api import ( DEFAULT_CONNECTION_MAX_ATTEMPTS, DEFAULT_CONNECTION_TIMEOUT, @@ -43,37 +41,6 @@ class CannotConnect(BaseException): """Error to indicate we cannot connect.""" -@dataclass -class GreeDeviceState: - """Data structure for Gree device state.""" - - power: bool = False - operation_mode: OperationMode = OperationMode.Auto - fan_speed: FanSpeed = FanSpeed.Auto - target_temperature: float = -1 - target_temperature_unit: TemperatureUnits = TemperatureUnits.C - horizontal_swing_mode: HorizontalSwingMode = HorizontalSwingMode.Default - vertical_swing_mode: VerticalSwingMode = VerticalSwingMode.Default - feature_fresh_air: bool = False - feature_x_fan: bool = False - feature_health: bool = False - feature_sleep: bool = False - feature_light: bool = False - feature_light_sensor: bool = False - feature_quiet: bool = False - feature_turbo: bool = False - feature_smart_heat: bool = False - feature_energy_saving: bool = False - feature_anti_direct_blow: bool = False - has_indoor_temperature_sensor: bool = False - indoors_temperature_c: int | None = None - has_outdoor_temperature_sensor: bool = False - outdoors_temperature_c: int | None = None - has_humidity_sensor: bool = False - humidity: int | None = None - has_light_sensor: bool = False - - class GreeDevice: """Representation of a Gree device.""" @@ -112,8 +79,8 @@ def __init__( self._uid: int = uid self._firmware_version: str | None = None self._firmware_code: str | None = None - self._state: dict[GreeProp, int] = {} - self._new_state: dict[GreeProp, int] = {} + self._raw_state: dict[GreeProp, int] = {} + self._new_raw_state: dict[GreeProp, int] = {} self._is_bound: bool = False self._is_available: bool = False self._uniqueid: str = self._mac_addr_sub @@ -129,8 +96,6 @@ def __init__( self._temp_processor_outdoors: TempOffsetResolver | None = None self._beeper = False - self.state: GreeDeviceState = GreeDeviceState() - async def bind_device(self) -> bool: """Setup the device (async).""" if not self._is_bound: @@ -190,7 +155,7 @@ async def bind_device(self) -> bool: return self._is_bound - async def fetch_device_status(self) -> GreeDeviceState: + async def fetch_device_status(self): """Get the device status (async).""" _LOGGER.debug("Trying to get device status") @@ -201,32 +166,43 @@ async def fetch_device_status(self) -> GreeDeviceState: assert self._encryption_version is not None try: - self._state.update( - await gree_get_status( + state, props_not_present = await gree_get_status( + self._ip_addr, + self._mac_addr, + self._mac_addr_sub, + self._port, + self._uid, + self._encryption_key, + self._encryption_version, + self._props_to_update, + self._max_connection_attempts, + self._timeout, + ) + self._raw_state.update(state) + + if self._mac_addr != self._mac_addr_sub: + sub_state, _ = await gree_get_status( self._ip_addr, self._mac_addr, - self._mac_addr_sub, + self._mac_addr, self._port, self._uid, self._encryption_key, self._encryption_version, - self._props_to_update, + props_not_present, self._max_connection_attempts, self._timeout, ) - ) + # self._raw_state.update(sub_state) + self._is_available = True except Exception as err: self._is_available = False raise ValueError("Error getting device status") from err - self._update_state() - self._remove_unsupported_props() - return self.state - - async def update_device_status(self) -> GreeDeviceState: + async def update_device_status(self): """Send the new local device state to the device and updates local state if successfull.""" if not self._is_bound: await self.bind_device() @@ -235,16 +211,16 @@ async def update_device_status(self) -> GreeDeviceState: # If there is no change in the properties, do nothing has_updated_states = any( - self._state.get(k) != v for k, v in self._new_state.items() + self._raw_state.get(k) != v for k, v in self._new_raw_state.items() ) if not has_updated_states: _LOGGER.debug("No changes in the properties, skipping update to device") - return self.state + return - self._new_state[GreeProp.BEEPER] = 0 if self._beeper else 1 + self._new_raw_state[GreeProp.BEEPER] = 0 if self._beeper else 1 try: - self._state.update( + self._raw_state.update( await gree_set_status( self._ip_addr, self._mac_addr, @@ -253,96 +229,90 @@ async def update_device_status(self) -> GreeDeviceState: self._uid, self._encryption_key, self._encryption_version, - self._new_state, + self._new_raw_state, self._max_connection_attempts, self._timeout, ) ) - self._new_state.clear() + self._new_raw_state.clear() self._is_available = True except Exception as err: self._is_available = False raise ValueError("Error setting device status") from err - self._update_state() - return self.state - - def set_device_status(self, props: dict[GreeProp, int]) -> None: + 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_state.update(props) - - def _update_state(self) -> None: - """Update the state from the internal state.""" - - self.state.power = self.power_mode - self.state.operation_mode = self.operation_mode - self.state.fan_speed = self.fan_speed - self.state.target_temperature = self.target_temperature - self.state.target_temperature_unit = self.target_temperature_unit - self.state.horizontal_swing_mode = self.horizontal_swing_mode - self.state.vertical_swing_mode = self.vertical_swing_mode - self.state.feature_fresh_air = self.feature_fresh_air - self.state.feature_x_fan = self.feature_x_fan - self.state.feature_health = self.feature_health - self.state.feature_sleep = self.feature_sleep - self.state.feature_light = self.feature_light - self.state.feature_light_sensor = self.feature_light_sensor - self.state.feature_quiet = self.feature_quiet - self.state.feature_turbo = self.feature_turbo - self.state.feature_smart_heat = self.feature_smart_heat - self.state.feature_energy_saving = self.feature_energy_saving - self.state.feature_anti_direct_blow = self.feature_anti_direct_blow - self.state.has_indoor_temperature_sensor = self.has_indoor_temperature_sensor - self.state.indoors_temperature_c = self.indoors_temperature_c - self.state.has_outdoor_temperature_sensor = self.has_outdoor_temperature_sensor - self.state.outdoors_temperature_c = self.outdoors_temperature_c - self.state.has_humidity_sensor = self.has_humidity_sensor - self.state.humidity = self.humidity + self._new_raw_state.update(props) + + def _bool_from_raw_state(self, prop: GreeProp) -> bool: + return self._get_prop_raw(prop, 0) != 0 def _remove_unsupported_props(self): """Remove unsupported properties from the list to update.""" + + # Remove all unsupported properties + # A unsupported propery is one that the device returns + # 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): + if not self.supports_property(p): + self._props_to_update.remove(p) + self._raw_state.pop(p, None) + _LOGGER.debug("No longer updating property: %s", p) + + # Sensors should also be invalidated if their values are not expected (=0) if ( GreeProp.SENSOR_TEMPERATURE in self._props_to_update - and not self.has_indoor_temperature_sensor + and self._get_prop_raw(GreeProp.SENSOR_TEMPERATURE, 0) == 0 ): self._props_to_update.remove(GreeProp.SENSOR_TEMPERATURE) - self._state.pop(GreeProp.SENSOR_TEMPERATURE, None) - _LOGGER.debug("No longer updating temperature sensor property") + self._raw_state.pop(GreeProp.SENSOR_TEMPERATURE, None) + _LOGGER.debug( + "No longer updating property due to bad value: %s", + GreeProp.SENSOR_TEMPERATURE, + ) if ( GreeProp.SENSOR_OUTSIDE_TEMPERATURE in self._props_to_update - and not self.has_outdoor_temperature_sensor + and self._get_prop_raw(GreeProp.SENSOR_OUTSIDE_TEMPERATURE, 0) == 0 ): self._props_to_update.remove(GreeProp.SENSOR_OUTSIDE_TEMPERATURE) - self._state.pop(GreeProp.SENSOR_OUTSIDE_TEMPERATURE, None) - _LOGGER.debug("No longer updating outside temperature sensor property") + self._raw_state.pop(GreeProp.SENSOR_OUTSIDE_TEMPERATURE, None) + _LOGGER.debug( + "No longer updating property due to bad value: %s", + GreeProp.SENSOR_OUTSIDE_TEMPERATURE, + ) if ( GreeProp.SENSOR_HUMIDITY in self._props_to_update - and not self.has_humidity_sensor + and self._get_prop_raw(GreeProp.SENSOR_HUMIDITY, 0) == 0 ): self._props_to_update.remove(GreeProp.SENSOR_HUMIDITY) - self._state.pop(GreeProp.SENSOR_HUMIDITY, None) - _LOGGER.debug("No longer updating humidity sensor property") + self._raw_state.pop(GreeProp.SENSOR_HUMIDITY, None) + _LOGGER.debug( + "No longer updating property due to bad value: %s", + GreeProp.SENSOR_HUMIDITY, + ) def _get_prop_raw(self, prop: GreeProp, default: int | None = None) -> int | None: - """Get the raw value of a property.""" - if prop not in self._state: + """Get the raw value of a property. If does not exist, returns default.""" + if prop not in self._raw_state: _LOGGER.warning( "Property '%s' not found in state of device '%s'", prop, self.name ) return default - return self._state.get(prop, default) + return self._raw_state.get(prop, default) - def LogDeviceInfo(self): + def log_device_info(self): """Log basic device information.""" capabilities = [] - if self.has_indoor_temperature_sensor: + if self.supports_property(GreeProp.SENSOR_TEMPERATURE): capabilities.append("Temperature Sensor") - if self.has_outdoor_temperature_sensor: + if self.supports_property(GreeProp.SENSOR_OUTSIDE_TEMPERATURE): capabilities.append("Outside Temperature Sensor") - if self.has_humidity_sensor: + if self.supports_property(GreeProp.SENSOR_HUMIDITY): capabilities.append("Humidity Sensor") _LOGGER.info( @@ -351,11 +321,15 @@ def LogDeviceInfo(self): _LOGGER.info( "Indoor Temperature: %s ºC", - self.indoors_temperature_c if self.has_indoor_temperature_sensor else None, + self.indoors_temperature_c + if self.supports_property(GreeProp.SENSOR_TEMPERATURE) + else None, ) _LOGGER.info( "Outddor Temperature: %s ºC", - self.indoors_temperature_c if self.has_indoor_temperature_sensor else None, + self.outdoors_temperature_c + if self.supports_property(GreeProp.SENSOR_OUTSIDE_TEMPERATURE) + else None, ) _LOGGER.info( "Target Temperature: %s º%s", @@ -364,6 +338,12 @@ def LogDeviceInfo(self): ) _LOGGER.info("Mode: %s", self.operation_mode.name) + 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 + @property def name(self) -> str: """Returns the friendly name of the device.""" @@ -409,34 +389,10 @@ def set_beeper(self, value: bool) -> None: """Set the device beeper state.""" self._beeper = value - @property - def has_indoor_temperature_sensor(self) -> bool: - """Return True if the device has a temperature sensor.""" - return ( - GreeProp.SENSOR_TEMPERATURE in self._state - and self._get_prop_raw(GreeProp.SENSOR_TEMPERATURE, 0) != 0 - ) - - @property - def has_outdoor_temperature_sensor(self) -> bool: - """Return True if the device has an outdoor temperature sensor.""" - return ( - GreeProp.SENSOR_OUTSIDE_TEMPERATURE in self._state - and self._get_prop_raw(GreeProp.SENSOR_OUTSIDE_TEMPERATURE, 0) != 0 - ) - - @property - def has_humidity_sensor(self) -> bool: - """Return True if the device has an humidity sensor.""" - return ( - GreeProp.SENSOR_HUMIDITY in self._state - and self._get_prop_raw(GreeProp.SENSOR_HUMIDITY, 0) != 0 - ) - @property def indoors_temperature_c(self) -> int | None: """Return the current temperature if available.""" - if self.has_indoor_temperature_sensor: + if self.supports_property(GreeProp.SENSOR_TEMPERATURE): if self._temp_processor_indoors is None: self._temp_processor_indoors = TempOffsetResolver() @@ -452,7 +408,7 @@ def indoors_temperature_c(self) -> int | None: @property def outdoors_temperature_c(self) -> int | None: """Return the current outside temperature if available.""" - if self.has_outdoor_temperature_sensor: + if self.supports_property(GreeProp.SENSOR_OUTSIDE_TEMPERATURE): if self._temp_processor_outdoors is None: self._temp_processor_outdoors = TempOffsetResolver() @@ -468,18 +424,16 @@ def outdoors_temperature_c(self) -> int | None: @property def humidity(self) -> int | None: """Return the current humidity if available.""" - if self.has_humidity_sensor: - return self._get_prop_raw(GreeProp.SENSOR_HUMIDITY, None) - return None + return self._get_prop_raw(GreeProp.SENSOR_HUMIDITY, None) @property def power_mode(self) -> bool: """Return the current power mode.""" - return self._get_prop_raw(GreeProp.POWER, 0) == 1 + return self._bool_from_raw_state(GreeProp.POWER) def set_power_mode(self, value: bool): """Sets the device power mode.""" - self.set_device_status({GreeProp.POWER: 1 if value else 0}) + self._set_device_status({GreeProp.POWER: 1 if value else 0}) @property def operation_mode(self) -> OperationMode: @@ -490,7 +444,7 @@ def operation_mode(self) -> OperationMode: def set_operation_mode(self, mode: OperationMode): """Sets the device operation mode.""" - self.set_device_status({GreeProp.OP_MODE: mode}) + self._set_device_status({GreeProp.OP_MODE: mode}) @property def fan_speed(self) -> FanSpeed: @@ -499,7 +453,7 @@ def fan_speed(self) -> FanSpeed: def set_fan_speed(self, speed: FanSpeed): """Sets the device fan speed mode.""" - self.set_device_status({GreeProp.FAN_SPEED: speed}) + self._set_device_status({GreeProp.FAN_SPEED: speed}) @property def vertical_swing_mode(self) -> VerticalSwingMode: @@ -510,7 +464,7 @@ def vertical_swing_mode(self) -> VerticalSwingMode: def set_vertical_swing_mode(self, swing_mode: VerticalSwingMode): """Sets the device vertical swing mode.""" - self.set_device_status({GreeProp.SWING_VERTICAL: swing_mode}) + self._set_device_status({GreeProp.SWING_VERTICAL: swing_mode}) @property def horizontal_swing_mode(self) -> HorizontalSwingMode: @@ -523,7 +477,7 @@ def horizontal_swing_mode(self) -> HorizontalSwingMode: def set_horizontal_swing_mode(self, swing_mode: HorizontalSwingMode): """Sets the device horizontal swing mode.""" - self.set_device_status({GreeProp.SWING_HORIZONTAL: swing_mode}) + self._set_device_status({GreeProp.SWING_HORIZONTAL: swing_mode}) @property def target_temperature_unit(self) -> TemperatureUnits: @@ -536,7 +490,7 @@ def target_temperature_unit(self) -> TemperatureUnits: def set_target_temperature_unit(self, units: TemperatureUnits): """Sets the units of the target temperature.""" - self.set_device_status({GreeProp.TARGET_TEMPERATURE_UNIT: units}) + self._set_device_status({GreeProp.TARGET_TEMPERATURE_UNIT: units}) @property def target_temperature(self) -> float: @@ -566,7 +520,7 @@ def set_target_temperature(self, value: float) -> None: else: raw_c, tem_rec = gree_get_target_temp_props_from_c(value) - self.set_device_status( + self._set_device_status( { GreeProp.TARGET_TEMPERATURE: raw_c, GreeProp.TARGET_TEMPERATURE_BIT: tem_rec, @@ -576,50 +530,50 @@ def set_target_temperature(self, value: float) -> None: @property def feature_light_sensor(self) -> bool: """Return the light sensor state.""" - return self._get_prop_raw(GreeProp.FEAT_SENSOR_LIGHT, 0) != 0 + return self._bool_from_raw_state(GreeProp.FEAT_SENSOR_LIGHT) def set_feature_light_sensor(self, value: bool) -> None: """Set the light sensor state.""" - self.set_device_status({GreeProp.FEAT_SENSOR_LIGHT: 1 if value else 0}) + self._set_device_status({GreeProp.FEAT_SENSOR_LIGHT: 1 if value else 0}) @property def feature_fresh_air(self) -> bool: """Return the fresh air mode state.""" - return self._get_prop_raw(GreeProp.FEAT_FRESH_AIR, 0) == 1 + return self._bool_from_raw_state(GreeProp.FEAT_FRESH_AIR) def set_feature_fresh_air(self, value: bool) -> None: """Set the fresh air mode state.""" - self.set_device_status({GreeProp.FEAT_FRESH_AIR: 1 if value else 0}) + self._set_device_status({GreeProp.FEAT_FRESH_AIR: 1 if value else 0}) @property def feature_x_fan(self) -> bool: """Return the x-fan mode state.""" - return self._get_prop_raw(GreeProp.FEAT_XFAN, 0) == 1 + return self._bool_from_raw_state(GreeProp.FEAT_XFAN) def set_feature_xfan(self, value: bool) -> None: """Set the x-fan mode state.""" - self.set_device_status({GreeProp.FEAT_XFAN: 1 if value else 0}) + self._set_device_status({GreeProp.FEAT_XFAN: 1 if value else 0}) @property def feature_health(self) -> bool: """Return the health mode state.""" - return self._get_prop_raw(GreeProp.FEAT_HEALTH, 0) == 1 + return self._bool_from_raw_state(GreeProp.FEAT_HEALTH) def set_feature_health(self, value: bool) -> None: """Set the health mode state.""" - self.set_device_status({GreeProp.FEAT_HEALTH: 1 if value else 0}) + self._set_device_status({GreeProp.FEAT_HEALTH: 1 if value else 0}) @property def feature_sleep(self) -> bool: """Return the sleep mode state.""" - return ( - self._get_prop_raw(GreeProp.FEAT_SLEEP_MODE_SWING, 0) == 1 - or self._get_prop_raw(GreeProp.FEAT_SLEEP_MODE, 0) == 1 - ) + 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 def set_feature_sleep(self, value: bool) -> None: """Set the sleep mode state.""" - self.set_device_status( + self._set_device_status( { GreeProp.FEAT_SLEEP_MODE: 1 if value else 0, GreeProp.FEAT_SLEEP_MODE_SWING: 1 if value else 0, @@ -629,53 +583,53 @@ def set_feature_sleep(self, value: bool) -> None: @property def feature_light(self) -> bool: """Return the light state.""" - return self._get_prop_raw(GreeProp.FEAT_LIGHT, 0) == 1 + return self._bool_from_raw_state(GreeProp.FEAT_LIGHT) def set_feature_light(self, value: bool) -> None: """Set the light state.""" - self.set_device_status({GreeProp.FEAT_LIGHT: 1 if value else 0}) + self._set_device_status({GreeProp.FEAT_LIGHT: 1 if value else 0}) @property def feature_quiet(self) -> bool: """Return the quiet mode state.""" - return self._get_prop_raw(GreeProp.FEAT_QUIET_MODE, 0) == 1 + return self._bool_from_raw_state(GreeProp.FEAT_QUIET_MODE) def set_feature_quiet(self, value: bool) -> None: """Set the quiet mode state.""" - self.set_device_status({GreeProp.FEAT_QUIET_MODE: 1 if value else 0}) + self._set_device_status({GreeProp.FEAT_QUIET_MODE: 1 if value else 0}) @property def feature_turbo(self) -> bool: """Return the turbo mode state.""" - return self._get_prop_raw(GreeProp.FEAT_TURBO_MODE, 0) == 1 + return self._bool_from_raw_state(GreeProp.FEAT_TURBO_MODE) def set_feature_turbo(self, value: bool) -> None: """Set the turbo mode state.""" - self.set_device_status({GreeProp.FEAT_TURBO_MODE: 1 if value else 0}) + self._set_device_status({GreeProp.FEAT_TURBO_MODE: 1 if value else 0}) @property def feature_smart_heat(self) -> bool: """Return the smart heat (8ºC / anti-freeze) mode state.""" - return self._get_prop_raw(GreeProp.FEAT_SMART_HEAT_8C, 0) == 1 + 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.""" - self.set_device_status({GreeProp.FEAT_SMART_HEAT_8C: 1 if value else 0}) + self._set_device_status({GreeProp.FEAT_SMART_HEAT_8C: 1 if value else 0}) @property def feature_energy_saving(self) -> bool: """Return the energy saving mode state.""" - return self._get_prop_raw(GreeProp.FEAT_ENERGY_SAVING, 0) == 1 + 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.""" - self.set_device_status({GreeProp.FEAT_ENERGY_SAVING: 1 if value else 0}) + self._set_device_status({GreeProp.FEAT_ENERGY_SAVING: 1 if value else 0}) @property def feature_anti_direct_blow(self) -> bool: """Return the anti direct blow mode state.""" - return self._get_prop_raw(GreeProp.FEAT_ANTI_DIRECT_BLOW, None) == 1 + return self._bool_from_raw_state(GreeProp.FEAT_ANTI_DIRECT_BLOW) 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}) + self._set_device_status({GreeProp.FEAT_ANTI_DIRECT_BLOW: 1 if value else 0}) diff --git a/custom_components/gree_custom/select.py b/custom_components/gree_custom/select.py index 57a89f0..74d51cc 100644 --- a/custom_components/gree_custom/select.py +++ b/custom_components/gree_custom/select.py @@ -17,7 +17,7 @@ from .const import CONF_DISABLE_AVAILABLE_CHECK, CONF_RESTORE_STATES, GATTR_TEMP_UNITS from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription -from .gree_api import TemperatureUnits +from .gree_api import GreeProp, TemperatureUnits from .gree_device import GreeDevice _LOGGER = logging.getLogger(__name__) @@ -41,7 +41,8 @@ async def async_setup_entry( translation_key=GATTR_TEMP_UNITS, entity_category=EntityCategory.CONFIG, options=[f"º{member.name}" for member in TemperatureUnits], - available_func=lambda device: device.available, + available_func=lambda device: (device.available + and device.supports_property(GreeProp.TARGET_TEMPERATURE_UNIT)), value_func=lambda device: f"º{device.target_temperature_unit.name}", set_func=lambda device, value: device.set_target_temperature_unit( TemperatureUnits[value.replace("º", "")] diff --git a/custom_components/gree_custom/sensor.py b/custom_components/gree_custom/sensor.py index dacab8e..0e9d5f3 100644 --- a/custom_components/gree_custom/sensor.py +++ b/custom_components/gree_custom/sensor.py @@ -4,6 +4,7 @@ from dataclasses import dataclass import logging +from config.custom_components.gree_custom.gree_api import GreeProp from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -37,14 +38,56 @@ async def async_setup_entry( coordinator = entry.runtime_data - sensors = [] - - if coordinator.device.has_indoor_temperature_sensor: - sensors.append(GATTR_INDOOR_TEMPERATURE) - if coordinator.device.has_outdoor_temperature_sensor: - sensors.append(GATTR_OUTDOOR_TEMPERATURE) - if coordinator.device.has_humidity_sensor: - sensors.append(GATTR_HUMIDITY) + sensors: list[GreeSensorDescription] = [] + + if coordinator.device.supports_property(GreeProp.SENSOR_TEMPERATURE): + sensors.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, + available_func=lambda device: ( + device.available + and device.supports_property(GreeProp.SENSOR_TEMPERATURE) + ), + ) + ) + if coordinator.device.supports_property(GreeProp.SENSOR_OUTSIDE_TEMPERATURE): + sensors.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, + available_func=lambda device: ( + device.available + and device.supports_property(GreeProp.SENSOR_OUTSIDE_TEMPERATURE) + ), + ) + ) + if coordinator.device.supports_property(GreeProp.SENSOR_HUMIDITY): + sensors.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, + available_func=lambda device: ( + device.available + and device.supports_property(GreeProp.SENSOR_HUMIDITY) + ), + ) + ) _LOGGER.debug("Adding Sensor Entities: %s", sensors) @@ -57,8 +100,7 @@ async def async_setup_entry( entry.data.get(CONF_DISABLE_AVAILABLE_CHECK, False) is False ), ) - for description in SENSOR_TYPES - if description.key in sensors + for description in sensors ] async_add_entities(entities) @@ -71,44 +113,6 @@ class GreeSensorDescription(GreeEntityDescription, SensorEntityDescription): 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, - available_func=lambda device: ( - device.available and device.has_indoor_temperature_sensor - ), - ), - 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, - available_func=lambda device: ( - device.available and device.has_outdoor_temperature_sensor - ), - ), - 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, - available_func=lambda device: (device.available and device.has_humidity_sensor), - ), -] - - 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 9b140ef..11b6498 100644 --- a/custom_components/gree_custom/switch.py +++ b/custom_components/gree_custom/switch.py @@ -36,7 +36,7 @@ ) from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription -from .gree_api import OperationMode +from .gree_api import GreeProp, OperationMode from .gree_device import GreeDevice _LOGGER = logging.getLogger(__name__) @@ -56,15 +56,20 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): GreeSwitchDescription( key=GATTR_FEAT_FRESH_AIR, translation_key=GATTR_FEAT_FRESH_AIR, - available_func=lambda device: device.available, + available_func=lambda device: ( + device.available and device.supports_property(GreeProp.FEAT_FRESH_AIR) + ), value_func=lambda device: device.feature_fresh_air, set_func=lambda device, value: device.set_feature_fresh_air(value), ), GreeSwitchDescription( key=GATTR_FEAT_XFAN, translation_key=GATTR_FEAT_XFAN, - available_func=lambda device: device.available - and device.operation_mode in [OperationMode.Cool, OperationMode.Dry], + available_func=lambda device: ( + device.available + and device.supports_property(GreeProp.FEAT_XFAN) + and device.operation_mode in [OperationMode.Cool, OperationMode.Dry] + ), value_func=lambda device: device.feature_x_fan, set_func=lambda device, value: device.set_feature_xfan(value), ), @@ -73,6 +78,7 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): translation_key=GATTR_FEAT_SLEEP_MODE, available_func=( lambda device: device.available + and device.supports_property(GreeProp.FEAT_SLEEP_MODE) and device.operation_mode in [OperationMode.Cool, OperationMode.Dry, OperationMode.Heat] ), @@ -82,35 +88,46 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): GreeSwitchDescription( key=GATTR_FEAT_SMART_HEAT_8C, translation_key=GATTR_FEAT_SMART_HEAT_8C, - available_func=lambda device: device.available, + available_func=lambda device: ( + device.available and device.supports_property(GreeProp.FEAT_SMART_HEAT_8C) + ), value_func=lambda device: device.feature_smart_heat, set_func=lambda device, value: device.set_feature_smart_heat(value), ), GreeSwitchDescription( key=GATTR_FEAT_HEALTH, translation_key=GATTR_FEAT_HEALTH, - available_func=lambda device: device.available, + available_func=lambda device: ( + device.available and device.supports_property(GreeProp.FEAT_HEALTH) + ), value_func=lambda device: device.feature_health, set_func=lambda device, value: device.set_feature_health(value), ), GreeSwitchDescription( key=GATTR_ANTI_DIRECT_BLOW, translation_key=GATTR_ANTI_DIRECT_BLOW, - available_func=lambda device: device.available, + available_func=lambda device: ( + device.available + and device.supports_property(GreeProp.FEAT_ANTI_DIRECT_BLOW) + ), value_func=lambda device: device.feature_anti_direct_blow, set_func=lambda device, value: device.set_feature_anti_direct_blow(value), ), GreeSwitchDescription( key=GATTR_FEAT_ENERGY_SAVING, translation_key=GATTR_FEAT_ENERGY_SAVING, - available_func=lambda device: device.available, + available_func=lambda device: ( + device.available and device.supports_property(GreeProp.FEAT_ENERGY_SAVING) + ), value_func=lambda device: device.feature_energy_saving, set_func=lambda device, value: device.set_feature_energy_saving(value), ), GreeSwitchDescription( key=GATTR_FEAT_LIGHT, translation_key=GATTR_FEAT_LIGHT, - available_func=lambda device: device.available, + available_func=lambda device: ( + device.available and device.supports_property(GreeProp.FEAT_LIGHT) + ), value_func=lambda device: device.feature_light, set_func=lambda device, value: device.set_feature_light(value), entity_category=EntityCategory.CONFIG, @@ -118,7 +135,12 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): GreeSwitchDescription( key=GATTR_FEAT_SENSOR_LIGHT, translation_key=GATTR_FEAT_SENSOR_LIGHT, - available_func=lambda device: (device.available and device.feature_light), + available_func=lambda device: ( + device.available + and device.supports_property(GreeProp.FEAT_SENSOR_LIGHT) + and device.supports_property(GreeProp.FEAT_LIGHT) + and device.feature_light + ), value_func=lambda device: device.feature_light_sensor, set_func=lambda device, value: device.set_feature_light_sensor(value), entity_category=EntityCategory.CONFIG, @@ -178,7 +200,10 @@ async def async_setup_entry( GreeSwitchDescription( key=ATTR_AUTO_LIGHT, translation_key=ATTR_AUTO_LIGHT, - available_func=lambda device: device.available, + available_func=( + lambda device: device.available + and device.supports_property(GreeProp.FEAT_LIGHT) + ), value_func=lambda _: coordinator.feature_auto_light, set_func=lambda _, value: coordinator.set_feature_auto_light(value), updates_device=False, @@ -186,7 +211,7 @@ async def async_setup_entry( ), coordinator, restore_state=True, - check_availability=False, # Auto Light is always available + check_availability=True, ) ) @@ -196,7 +221,10 @@ async def async_setup_entry( GreeSwitchDescription( key=ATTR_AUTO_XFAN, translation_key=ATTR_AUTO_XFAN, - available_func=lambda device: device.available, + available_func=lambda device: ( + device.available + and device.supports_property(GreeProp.FEAT_XFAN) + ), value_func=lambda _: coordinator.feature_auto_xfan, set_func=lambda _, value: coordinator.set_feature_auto_xfan(value), updates_device=False, @@ -204,7 +232,7 @@ async def async_setup_entry( ), coordinator, restore_state=True, - check_availability=False, # Auto X-Fan is always available + check_availability=True, ) ) From 67ceed0b8a234b32f91af9db17bbe424763f23f9 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Tue, 4 Nov 2025 23:01:28 +0000 Subject: [PATCH 053/113] Rework config entry and schema in preparation for multiple devices (VRF) in a config entry --- custom_components/gree_custom/__init__.py | 87 ++- custom_components/gree_custom/climate.py | 84 ++- custom_components/gree_custom/config_flow.py | 629 +++++++++++++----- custom_components/gree_custom/const.py | 20 +- custom_components/gree_custom/coordinator.py | 2 +- custom_components/gree_custom/entity.py | 22 +- custom_components/gree_custom/gree_api.py | 154 +++-- custom_components/gree_custom/gree_device.py | 97 ++- custom_components/gree_custom/select.py | 77 ++- custom_components/gree_custom/sensor.py | 138 ++-- custom_components/gree_custom/switch.py | 235 ++++--- .../gree_custom/translations/en.json | 2 +- manual-configuration.yaml | 180 +++-- 13 files changed, 1128 insertions(+), 599 deletions(-) diff --git a/custom_components/gree_custom/__init__.py b/custom_components/gree_custom/__init__.py index 1eee6dd..030ce74 100755 --- a/custom_components/gree_custom/__init__.py +++ b/custom_components/gree_custom/__init__.py @@ -7,21 +7,17 @@ import logging from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_HOST, - CONF_MAC, - CONF_NAME, - CONF_PORT, - CONF_TIMEOUT, - Platform, -) +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.typing import ConfigType # Local imports from .const import ( CONF_ADVANCED, + CONF_DEV_NAME, + CONF_DEVICES, CONF_ENCRYPTION_KEY, CONF_ENCRYPTION_VERSION, CONF_MAX_ONLINE_ATTEMPTS, @@ -34,7 +30,6 @@ from .coordinator import GreeConfigEntry, GreeCoordinator from .gree_api import ( DEFAULT_CONNECTION_MAX_ATTEMPTS, - DEFAULT_CONNECTION_TIMEOUT, DEFAULT_DEVICE_PORT, DEFAULT_DEVICE_UID, ) @@ -69,47 +64,51 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool: """Set up Gree from a config entry.""" - _LOGGER.debug("Setting up entry: %s\n%s", entry, entry.data) + _LOGGER.info( + "Setting up entry '%s' for: %s at %s", + entry.entry_id, + entry.data[CONF_MAC], + entry.data[CONF_HOST], + ) + _LOGGER.debug("Entry '%s' data: %s\n%s", entry.entry_id, entry, entry.data) conf = entry.data if conf is None or conf[CONF_ADVANCED] is None: _LOGGER.error("Bad config entry, this should not happen") return False - host: str = conf[CONF_HOST] - - new_device = GreeDevice( - name=conf.get(CONF_NAME, "Gree HVAC"), - ip_addr=host, - mac_addr=str(conf.get(CONF_MAC, "")), - port=conf[CONF_ADVANCED].get(CONF_PORT, DEFAULT_DEVICE_PORT), - encryption_version=conf[CONF_ADVANCED].get( - CONF_ENCRYPTION_VERSION, DEFAULT_ENCRYPTION_VERSION - ), - encryption_key=conf[CONF_ADVANCED].get(CONF_ENCRYPTION_KEY, ""), - uid=conf[CONF_ADVANCED].get(CONF_UID, DEFAULT_DEVICE_UID), - max_connection_attempts=conf[CONF_ADVANCED].get( - CONF_MAX_ONLINE_ATTEMPTS, DEFAULT_CONNECTION_MAX_ATTEMPTS - ), - timeout=conf[CONF_ADVANCED].get(CONF_TIMEOUT, DEFAULT_CONNECTION_TIMEOUT), - ) - - try: - async with asyncio.timeout(30): - await new_device.bind_device() - _LOGGER.debug("Bound to device %s", host) - except TimeoutError as err: - _LOGGER.debug("Conection to %s timed out", host) - raise ConfigEntryNotReady from err - except GreeDeviceNotBoundError as err: - _LOGGER.debug("Failed to bind to device %s", host) - raise ConfigEntryNotReady from err - - coordinator = GreeCoordinator(hass, entry, new_device) - - await coordinator.async_config_entry_first_refresh() - - entry.runtime_data = coordinator + coordinators: dict[str, GreeCoordinator] = {} + for d in conf.get(CONF_DEVICES, []): + mac = str(d.get(CONF_MAC, "")) + device = GreeDevice( + d.get(CONF_DEV_NAME, "Gree HVAC"), + conf.get(CONF_HOST, ""), + mac, + conf[CONF_ADVANCED].get(CONF_PORT, DEFAULT_DEVICE_PORT), + conf[CONF_ADVANCED].get(CONF_ENCRYPTION_KEY, ""), + conf[CONF_ADVANCED].get( + CONF_ENCRYPTION_VERSION, DEFAULT_ENCRYPTION_VERSION + ), + conf[CONF_ADVANCED].get(CONF_UID, DEFAULT_DEVICE_UID), + max_connection_attempts=conf[CONF_ADVANCED].get( + CONF_MAX_ONLINE_ATTEMPTS, DEFAULT_CONNECTION_MAX_ATTEMPTS + ), + ) + try: + async with asyncio.timeout(30): + await device.bind_device() + # TODO: Add scan interval to config + coordinators[mac] = GreeCoordinator(hass, entry, device) + await coordinators[mac].async_config_entry_first_refresh() + _LOGGER.debug("Bound to device %s", mac) + except TimeoutError as err: + _LOGGER.debug("Conection to %s timed out", mac) + raise ConfigEntryNotReady from err + except GreeDeviceNotBoundError as err: + _LOGGER.debug("Failed to bind to device %s", mac) + raise ConfigEntryNotReady from err + + entry.runtime_data = coordinators await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/custom_components/gree_custom/climate.py b/custom_components/gree_custom/climate.py index 1f0b0a2..2390475 100644 --- a/custom_components/gree_custom/climate.py +++ b/custom_components/gree_custom/climate.py @@ -17,6 +17,7 @@ from homeassistant.const import ( ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, + CONF_MAC, EVENT_CORE_CONFIG_UPDATE, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -40,6 +41,7 @@ ATTR_EXTERNAL_HUMIDITY_SENSOR, ATTR_EXTERNAL_TEMPERATURE_SENSOR, CONF_ADVANCED, + CONF_DEVICES, CONF_DISABLE_AVAILABLE_CHECK, CONF_FAN_MODES, CONF_HVAC_MODES, @@ -84,42 +86,53 @@ async def async_setup_entry( ) -> None: """Set up sensors from a config entry.""" - coordinator = entry.runtime_data + entities: list[GreeClimate] = [] - hvac_modes: list[HVACMode] = [ - HVACMode[mode.upper()] - for mode in ( - entry.data[CONF_HVAC_MODES] - if entry.data[CONF_HVAC_MODES] is not None - else DEFAULT_HVAC_MODES + for d in entry.data.get(CONF_DEVICES, []): + coordinator: GreeCoordinator = entry.runtime_data[d.get(CONF_MAC, "")] + if not coordinator: + _LOGGER.error( + "Cannot create Gree Climate. No coordinator found for device '%s'", + d.get(CONF_MAC, ""), + ) + + hvac_modes: list[HVACMode] = [ + HVACMode[mode.upper()] + for mode in ( + d[CONF_HVAC_MODES] + if d[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 + ) + + swing_modes: list[str] = ( + d[CONF_SWING_MODES] + if d[CONF_SWING_MODES] is not None + else DEFAULT_SWING_MODES + ) + + swing_horizontal_modes: list[str] = ( + d[CONF_SWING_HORIZONTAL_MODES] + if d[CONF_SWING_HORIZONTAL_MODES] is not None + else DEFAULT_SWING_HORIZONTAL_MODES ) - ] - - fan_modes: list[str] = ( - entry.data[CONF_FAN_MODES] - if entry.data[CONF_FAN_MODES] is not None - else DEFAULT_FAN_MODES - ) - - swing_modes: list[str] = ( - entry.data[CONF_SWING_MODES] - if entry.data[CONF_SWING_MODES] is not None - else DEFAULT_SWING_MODES - ) - - swing_horizontal_modes: list[str] = ( - entry.data[CONF_SWING_HORIZONTAL_MODES] - if entry.data[CONF_SWING_HORIZONTAL_MODES] is not None - else DEFAULT_SWING_HORIZONTAL_MODES - ) - - if not hvac_modes: - _LOGGER.info( - "Climate Entity will not be created because no Climate options and features are available for the device" + + if not hvac_modes: + _LOGGER.info( + "Climate Entity will not be created because no Climate options and features are available for the device" + ) + return + + _LOGGER.debug( + "Adding Climate Entity for device '%s'", + coordinator.device.mac_address_sub, ) - return - async_add_entities( - [ + + entities.append( GreeClimate( GreeClimateDescription( key=GATTR_CLIMATE, @@ -146,8 +159,9 @@ async def async_setup_entry( ATTR_EXTERNAL_HUMIDITY_SENSOR ), ) - ] - ) + ) + + async_add_entities(entities) @dataclass(frozen=True, kw_only=True) diff --git a/custom_components/gree_custom/config_flow.py b/custom_components/gree_custom/config_flow.py index b37c4f1..05a3472 100644 --- a/custom_components/gree_custom/config_flow.py +++ b/custom_components/gree_custom/config_flow.py @@ -13,7 +13,7 @@ IPv4Address, async_get_ipv4_broadcast_addresses, ) -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, CONF_TIMEOUT +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT, CONF_TIMEOUT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import section from homeassistant.helpers import config_validation as cv @@ -34,6 +34,8 @@ ATTR_EXTERNAL_HUMIDITY_SENSOR, ATTR_EXTERNAL_TEMPERATURE_SENSOR, CONF_ADVANCED, + CONF_DEV_NAME, + CONF_DEVICES, CONF_DISABLE_AVAILABLE_CHECK, CONF_ENCRYPTION_KEY, CONF_ENCRYPTION_VERSION, @@ -48,11 +50,22 @@ CONF_UID, DEFAULT_FAN_MODES, DEFAULT_HVAC_MODES, - DEFAULT_SUPPORTED_FEATURES, DEFAULT_SWING_HORIZONTAL_MODES, DEFAULT_SWING_MODES, DEFAULT_TARGET_TEMP_STEP, DOMAIN, + GATTR_ANTI_DIRECT_BLOW, + GATTR_BEEPER, + GATTR_FEAT_ENERGY_SAVING, + GATTR_FEAT_FRESH_AIR, + GATTR_FEAT_HEALTH, + GATTR_FEAT_LIGHT, + GATTR_FEAT_QUIET_MODE, + GATTR_FEAT_SENSOR_LIGHT, + GATTR_FEAT_SLEEP_MODE, + GATTR_FEAT_SMART_HEAT_8C, + GATTR_FEAT_TURBO, + GATTR_FEAT_XFAN, ) from .coordinator import GreeConfigEntry from .gree_api import ( @@ -62,6 +75,7 @@ DEFAULT_DEVICE_UID, EncryptionVersion, GreeDiscoveredDevice, + GreeProp, discover_gree_devices, ) from .gree_device import ( @@ -81,10 +95,6 @@ def build_main_schema(data: Mapping | None) -> vol.Schema: return vol.Schema( { - vol.Required( - CONF_NAME, - default="Gree AC" if data is None else data.get(CONF_NAME, "Gree AC"), - ): str, vol.Required( CONF_HOST, default="" if data is None else data.get(CONF_HOST, ""), @@ -155,89 +165,170 @@ def build_main_schema(data: Mapping | None) -> vol.Schema: ) -def build_options_schema(hass: HomeAssistant, data: Mapping | None) -> vol.Schema: +def build_options_schema( + hass: HomeAssistant, device: GreeDevice, data: Mapping | None +) -> vol.Schema: """Builds the device option schema.""" if data: _LOGGER.debug("Building device options schema with previous values: %s", data) - return vol.Schema( + schema: dict = {} + schema.update( { - vol.Optional( - CONF_HVAC_MODES, - default=DEFAULT_HVAC_MODES - if data is None - else data.get(CONF_HVAC_MODES, DEFAULT_HVAC_MODES), - ): SelectSelector( - config=SelectSelectorConfig( - options=DEFAULT_HVAC_MODES, - multiple=True, - translation_key=CONF_HVAC_MODES, - ) - ), - vol.Optional( - CONF_FAN_MODES, - default=DEFAULT_FAN_MODES - if data is None - else data.get(CONF_FAN_MODES, DEFAULT_FAN_MODES), - ): SelectSelector( - config=SelectSelectorConfig( - options=DEFAULT_FAN_MODES, - multiple=True, - translation_key=CONF_FAN_MODES, - ) - ), - vol.Optional( - CONF_SWING_MODES, - default=DEFAULT_SWING_MODES - if data is None - else data.get(CONF_SWING_MODES, DEFAULT_SWING_MODES), - ): SelectSelector( - config=SelectSelectorConfig( - options=DEFAULT_SWING_MODES, - multiple=True, - translation_key=CONF_SWING_MODES, - ) - ), - vol.Optional( - CONF_SWING_HORIZONTAL_MODES, - default=DEFAULT_SWING_HORIZONTAL_MODES + vol.Required( + CONF_DEV_NAME, + default=f"Gree AC {device.unique_id}" if data is None - else data.get( - CONF_SWING_HORIZONTAL_MODES, DEFAULT_SWING_HORIZONTAL_MODES + else data.get(CONF_DEV_NAME, f"Gree AC {device.unique_id}"), + ): str + } + ) + + if device.supports_property(GreeProp.OP_MODE): + schema.update( + { + vol.Optional( + CONF_HVAC_MODES, + default=DEFAULT_HVAC_MODES + if data is None + else data.get(CONF_HVAC_MODES, DEFAULT_HVAC_MODES), + ): SelectSelector( + config=SelectSelectorConfig( + options=DEFAULT_HVAC_MODES, + multiple=True, + translation_key=CONF_HVAC_MODES, + ) ), - ): SelectSelector( - config=SelectSelectorConfig( - options=DEFAULT_SWING_HORIZONTAL_MODES, - multiple=True, - translation_key=CONF_SWING_HORIZONTAL_MODES, - ) - ), + } + ) + + valid_fan_modes = [] + if device.supports_property(GreeProp.FAN_SPEED): + valid_fan_modes = DEFAULT_FAN_MODES + if device.supports_property(GreeProp.FEAT_TURBO_MODE): + valid_fan_modes.append(GATTR_FEAT_TURBO) + if device.supports_property(GreeProp.FEAT_QUIET_MODE): + valid_fan_modes.append(GATTR_FEAT_QUIET_MODE) + + if valid_fan_modes: + schema.update( + { + vol.Optional( + CONF_FAN_MODES, + default=valid_fan_modes + if data is None + else data.get(CONF_FAN_MODES, valid_fan_modes), + ): SelectSelector( + config=SelectSelectorConfig( + options=valid_fan_modes, + multiple=True, + translation_key=CONF_FAN_MODES, + ) + ), + } + ) + + if device.supports_property(GreeProp.SWING_VERTICAL): + schema.update( + { + vol.Optional( + CONF_SWING_MODES, + default=DEFAULT_SWING_MODES + if data is None + else data.get(CONF_SWING_MODES, DEFAULT_SWING_MODES), + ): SelectSelector( + config=SelectSelectorConfig( + options=DEFAULT_SWING_MODES, + multiple=True, + translation_key=CONF_SWING_MODES, + ) + ), + } + ) + + if device.supports_property(GreeProp.SWING_HORIZONTAL): + schema.update( + { + vol.Optional( + CONF_SWING_HORIZONTAL_MODES, + default=DEFAULT_SWING_HORIZONTAL_MODES + if data is None + else data.get( + CONF_SWING_HORIZONTAL_MODES, DEFAULT_SWING_HORIZONTAL_MODES + ), + ): SelectSelector( + config=SelectSelectorConfig( + options=DEFAULT_SWING_HORIZONTAL_MODES, + multiple=True, + translation_key=CONF_SWING_HORIZONTAL_MODES, + ) + ), + } + ) + + valid_features = [GATTR_BEEPER] + if device.supports_property(GreeProp.FEAT_FRESH_AIR): + valid_features.append(GATTR_FEAT_FRESH_AIR) + if device.supports_property(GreeProp.FEAT_XFAN): + valid_features.append(GATTR_FEAT_XFAN) + if device.supports_property(GreeProp.FEAT_SLEEP_MODE) or device.supports_property( + GreeProp.FEAT_SLEEP_MODE_SWING + ): + valid_features.append(GATTR_FEAT_SLEEP_MODE) + if device.supports_property(GreeProp.FEAT_SMART_HEAT_8C): + 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_HEALTH): + valid_features.append(GATTR_FEAT_HEALTH) + if device.supports_property(GreeProp.FEAT_ANTI_DIRECT_BLOW): + valid_features.append(GATTR_ANTI_DIRECT_BLOW) + if device.supports_property(GreeProp.FEAT_ENERGY_SAVING): + valid_features.append(GATTR_FEAT_ENERGY_SAVING) + + schema.update( + { vol.Optional( CONF_FEATURES, - default=DEFAULT_SUPPORTED_FEATURES + default=valid_features if data is None - else data.get(CONF_FEATURES, DEFAULT_SUPPORTED_FEATURES), + else data.get(CONF_FEATURES, valid_features), ): SelectSelector( config=SelectSelectorConfig( - options=DEFAULT_SUPPORTED_FEATURES, + options=valid_features, multiple=True, translation_key=CONF_FEATURES, ) - ), - vol.Required( - CONF_TEMPERATURE_STEP, - default=DEFAULT_TARGET_TEMP_STEP - if data is None - else data.get(CONF_TEMPERATURE_STEP, DEFAULT_TARGET_TEMP_STEP), - ): NumberSelector( - NumberSelectorConfig( - min=0.5, - max=5, - step=0.5, - mode=NumberSelectorMode.BOX, - unit_of_measurement="ºC", + ) + } + ) + + if device.supports_property(GreeProp.TARGET_TEMPERATURE): + schema.update( + { + vol.Required( + CONF_TEMPERATURE_STEP, + default=DEFAULT_TARGET_TEMP_STEP + if data is None + else data.get(CONF_TEMPERATURE_STEP, DEFAULT_TARGET_TEMP_STEP), + ): NumberSelector( + NumberSelectorConfig( + min=0.5, + max=5, + step=0.5, + mode=NumberSelectorMode.BOX, + unit_of_measurement="ºC", + ) ) - ), + } + ) + + schema.update( + { # Ideally we would use an Optional EntitySelector for external sensors. # Currently we can't because unsetting the value in the UI makes HA # populate the user_input with the previous set value, making the user @@ -274,6 +365,7 @@ def build_options_schema(hass: HomeAssistant, data: Mapping | None) -> vol.Schem ): cv.boolean, } ) + return vol.Schema(schema) def get_temperature_sensor_options(hass: HomeAssistant) -> list[str]: @@ -365,14 +457,19 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 2 _discovered_devices: list[GreeDiscoveredDevice] | None = None - _selected_device: GreeDiscoveredDevice | None = None + _discovery_selected_device: GreeDiscoveredDevice | None = None _discovery_performed: bool = False def __init__(self) -> None: """Initialize the config flow.""" self._step_main_data: dict | None = None - self._device: GreeDevice | None = None + self._main_mac: str = "" + self._discovered_subdevices: list[GreeDiscoveredDevice] | None = None + self._device_configs: dict = {} + self._selected_subdevices_macs: list = [] self._reconfiguring_entry: GreeConfigEntry | None = None + self._devices: dict[str, GreeDevice] = {} + self._is_reconfigure: bool = False async def async_step_import( self, import_config: dict @@ -380,20 +477,75 @@ async def async_step_import( """Handle import from configuration.yaml.""" _LOGGER.debug("Importing config entry: %s", import_config) + mac = import_config.get(CONF_MAC, "") + + if not mac: + _LOGGER.error("No MAC for imported device: %s", import_config) + raise ValueError(f"No MAC for imported device: {import_config}") + # Combine the schemas schema1 = build_main_schema(import_config) - schema2 = build_options_schema(self.hass, import_config) - COMBINED = vol.Schema( - { - **schema1.schema, - **schema2.schema, - } + data = apply_schema_defaults(schema1, import_config) + + device: GreeDevice = GreeDevice( + f"Temporary Device for {data[CONF_MAC]}", + data[CONF_HOST], + data[CONF_MAC], + data[CONF_ADVANCED][CONF_PORT], + data[CONF_ADVANCED][CONF_ENCRYPTION_KEY], + EncryptionVersion(int(data[CONF_ADVANCED][CONF_ENCRYPTION_VERSION])) + if data[CONF_ADVANCED][CONF_ENCRYPTION_VERSION] != "Auto-Detect" + else None, + data[CONF_ADVANCED][CONF_UID], + max_connection_attempts=2, # Use fewer attempts for testing the device + timeout=2, # Use smaller timeout for testing the device + ) + await device.fetch_device_status() + + data[CONF_MAC] = device.mac_address + data[CONF_ADVANCED][CONF_ENCRYPTION_VERSION] = ( + int(device.encryption_version) if device.encryption_version else 0 ) + data[CONF_ADVANCED][CONF_ENCRYPTION_KEY] = device.encryption_key + + device_configs: list[dict] = import_config.get(CONF_DEVICES, []) + + # add the main device to the configs if not present + if not self._get_device_conf( + import_config, device.mac_address_sub + ) and not self._get_device_conf(import_config, import_config[CONF_MAC]): + device_configs.append({CONF_MAC: device.mac_address_sub}) + + data[CONF_DEVICES] = [] + for dev_config in device_configs: + mac = dev_config.get(CONF_MAC, "") + + if not mac: + _LOGGER.error("No MAC for imported device: %s", dev_config) + continue + + dev: GreeDevice = GreeDevice( + f"Temporary Device for {mac}", + data[CONF_HOST], + mac, + data[CONF_ADVANCED][CONF_PORT], + data[CONF_ADVANCED][CONF_ENCRYPTION_KEY], + EncryptionVersion(int(data[CONF_ADVANCED][CONF_ENCRYPTION_VERSION])), + data[CONF_ADVANCED][CONF_UID], + max_connection_attempts=2, # Use fewer attempts for testing the device + timeout=2, # Use smaller timeout for testing the device + ) - # Fill the imported data with the schema defaults - data = apply_schema_defaults(COMBINED, import_config) + await dev.fetch_device_status() + schema_dev = build_options_schema(self.hass, dev, dev_config) + data[CONF_DEVICES].append( + { + **apply_schema_defaults(schema_dev, import_config), + CONF_MAC: dev.mac_address_sub, + } + ) - unique_id = format_mac_id(import_config[CONF_MAC]) + unique_id = format_mac_id(device.mac_address) entry = next( ( e @@ -408,11 +560,13 @@ async def async_step_import( if entry: return self.async_update_reload_and_abort( entry, - title=import_config[CONF_NAME], + title=f"Gree System at {data[CONF_HOST]}", data=data, ) - return self.async_create_entry(title=data[CONF_NAME], data=data) + return self.async_create_entry( + title=f"Gree System at {data[CONF_HOST]}", data=data + ) async def async_step_user( self, user_input: dict | None = None @@ -440,6 +594,7 @@ async def async_step_manual_discovery( self, user_input: dict | None = None ) -> config_entries.ConfigFlowResult: """Handle device discovery.""" + if user_input is not None: # User selected a discovered device selected_device = user_input["device"] @@ -447,14 +602,14 @@ async def async_step_manual_discovery( assert self._discovered_devices for device in self._discovered_devices: - device_id = f"{device.mac}_{device.host}" + device_id = device.mac if device_id == selected_device: # Check if already configured await self.async_set_unique_id(format_mac_id(device.mac)) self._abort_if_unique_id_configured() # Store selected device for next step - self._selected_device = device + self._discovery_selected_device = device return await self.async_step_manual_add() # If no matching device found, something went wrong - go to manual @@ -471,8 +626,13 @@ async def async_step_manual_discovery( # Create device selection options device_options = {} for device in self._discovered_devices: - device_id = f"{device.mac}_{device.host}" - device_options[device_id] = f"IP: {device.host}, MAC: {device.mac}" + device_id = device.mac + if device.subdevices > 0: + device_options[device_id] = ( + f"IP: {device.host}, MAC: {device.mac}, Subdevices: {device.subdevices}" + ) + else: + device_options[device_id] = f"IP: {device.host}, MAC: {device.mac}" data_schema = vol.Schema({vol.Required("device"): vol.In(device_options)}) @@ -485,17 +645,15 @@ async def async_step_manual_discovery( ) async def async_step_manual_add( - self, user_input: dict | None = None + self, user_input: dict | None = None, reconfigure_input: dict | None = None ) -> config_entries.ConfigFlowResult: """Handle the manual add of a device.""" errors = {} - if user_input is not None: - await self.async_set_unique_id(format_mac_id(user_input[CONF_MAC])) - self._abort_if_unique_id_configured() + if user_input is not None: try: - self._device = GreeDevice( - user_input[CONF_NAME], + _main_device = GreeDevice( + f"Gree Device {user_input[CONF_MAC]}", user_input[CONF_HOST], user_input[CONF_MAC], user_input[CONF_ADVANCED][CONF_PORT], @@ -510,7 +668,37 @@ async def async_step_manual_add( max_connection_attempts=2, # Use fewer attempts for testing the device timeout=2, # Use smaller timeout for testing the device ) - await self._device.bind_device() + self._main_mac = _main_device.mac_address + await self.async_set_unique_id(format_mac_id(self._main_mac)) + + if self._is_reconfigure: + self._abort_if_unique_id_mismatch() + else: + self._abort_if_unique_id_configured() + + self._devices[_main_device.mac_address_sub] = _main_device + + # self._discovered_subdevices = await get_sub_devices( + # _main_device.mac_address, user_input[CONF_HOST], 0, 2, 2 + # ) + self._discovered_subdevices = await self._devices[ + _main_device.mac_address_sub + ].fetch_sub_devices() + + # for d in self._discovered_subdevices: + # self._devices[d.mac] = GreeDevice( + # d.name, + # user_input[CONF_HOST], + # f"{d.mac}@{_main_device.mac_address}", + # user_input[CONF_ADVANCED][CONF_PORT], + # _main_device.encryption_key, + # _main_device.encryption_version, + # user_input[CONF_ADVANCED][CONF_UID], + # max_connection_attempts=2, # Use fewer attempts for testing the device + # timeout=2, # Use smaller timeout for testing the device + # ) + + await self._devices[_main_device.mac_address_sub].fetch_device_status() except CannotConnect: errors["base"] = "cannot_connect" _LOGGER.exception("Cannot connect") @@ -524,24 +712,33 @@ async def async_step_manual_add( errors["base"] = "unknown" _LOGGER.exception("Unknown error while binding") else: - self._step_main_data = user_input - self._step_main_data["advanced"].update( + if self._step_main_data: + self._step_main_data.update(user_input) + else: + self._step_main_data = user_input + self._step_main_data[CONF_MAC] = _main_device.mac_address + self._step_main_data[CONF_ADVANCED].update( { - CONF_ENCRYPTION_VERSION: self._device.encryption_version, - CONF_ENCRYPTION_KEY: self._device.encryption_key, + CONF_ENCRYPTION_VERSION: _main_device.encryption_version, + CONF_ENCRYPTION_KEY: _main_device.encryption_key, } ) + return await self.async_step_device_options() - elif self._selected_device is not None: + + elif self._discovery_selected_device is not None: user_input = {} - user_input[CONF_NAME] = self._selected_device.name - user_input[CONF_HOST] = self._selected_device.host - user_input[CONF_MAC] = self._selected_device.mac + # user_input[CONF_NAME] = self._selected_device.name + user_input[CONF_HOST] = self._discovery_selected_device.host + user_input[CONF_MAC] = self._discovery_selected_device.mac user_input[CONF_ADVANCED] = {} - user_input[CONF_ADVANCED][CONF_PORT] = self._selected_device.port - user_input[CONF_ADVANCED][CONF_UID] = self._selected_device.uid - elif self._discovery_performed and self._selected_device is None: + user_input[CONF_ADVANCED][CONF_PORT] = self._discovery_selected_device.port + user_input[CONF_ADVANCED][CONF_UID] = self._discovery_selected_device.uid + elif self._discovery_performed and self._discovery_selected_device is None: errors["base"] = "no_devices_found" + elif reconfigure_input is not None: + user_input = reconfigure_input + self._step_main_data = reconfigure_input return self.async_show_form( step_id="manual_add", @@ -552,40 +749,102 @@ async def async_step_manual_add( async def async_step_device_options( self, user_input: dict | None = None, + index: int | None = None, ) -> config_entries.ConfigFlowResult: """Second step: configure features/modes.""" if ( user_input is not None and self._step_main_data is not None - and self._device is not None + and self._devices[self._main_mac] is not None ): - data = {**self._step_main_data, **user_input} - - # If we are adding a new device - if not self._reconfiguring_entry: - await self.async_set_unique_id(format_mac_id(data[CONF_MAC])) + await self.async_set_unique_id(format_mac_id(self._main_mac)) + if self._is_reconfigure: + self._abort_if_unique_id_mismatch() + else: self._abort_if_unique_id_configured() - _LOGGER.debug("New entry with config: %s", data) - return self.async_create_entry( - title=self._step_main_data[CONF_NAME], data=data - ) - # If we are reconfiguring a device - self._abort_if_unique_id_mismatch() - return self.async_update_reload_and_abort( - self._reconfiguring_entry, - title=self._step_main_data[CONF_NAME], - data=data, + # Configuring the main device + # If it has no subdevices, finalyze entry + # Otherwise repeat form while iterating the subdevices + if index is None: + self._device_configs[self._main_mac] = { + # Ignore the subdevice selection item + k: v + for k, v in user_input.items() + if k != CONF_DEVICES + } + + self._selected_subdevices_macs = user_input.get(CONF_DEVICES, []) + # Remove the device configs for the ones not selected so they are removed from the entry + self._device_configs = { + k: v + for k, v in self._device_configs.items() + if k in self._selected_subdevices_macs or k == self._main_mac + } + + if self._selected_subdevices_macs: + return await self.async_step_device_options(None, 0) + + if self._is_reconfigure: + return self._update_entry() + return self._create_final_entry() + + # If configuring a subdevice iterate the chosen subdevices + # If the last subdevice, finalyze the entry + self._device_configs[self._selected_subdevices_macs[index]] = user_input + if index + 1 < len(self._selected_subdevices_macs): + return await self.async_step_device_options(None, index + 1) + + if self._is_reconfigure: + return self._update_entry() + return self._create_final_entry() + + if self._step_main_data is None: + raise ValueError("No data from main options") + + if self._devices[self._main_mac] is None: + raise ValueError("No device created in main options step") + + device: GreeDevice = self._devices[self._main_mac] + + if index is not None and self._discovered_subdevices: + device = self._devices[self._selected_subdevices_macs[index]] + + await device.fetch_device_status() + + conf_input = user_input + if self._is_reconfigure: + conf_input = self._get_device_conf( + self._step_main_data, device.mac_address_sub + ) + + schema = build_options_schema(self.hass, device, conf_input) + + # If we are configuring the main device, + # add list of subdevices to include if any + if index is None and self._discovered_subdevices: + subdev_options = {d.mac: d.name for d in self._discovered_subdevices} + selected_options = subdev_options.keys() + + # If reconfiguring, only preselect the devices already configured + if self._is_reconfigure: + configured_device_macs = [ + device["mac"] for device in self._step_main_data["devices"] + ] + selected_options = [ + mac for mac in subdev_options if mac in configured_device_macs + ] + schema.extend( + { + vol.Required( + CONF_DEVICES, default=selected_options + ): cv.multi_select(subdev_options) + } ) return self.async_show_form( step_id="device_options", - data_schema=build_options_schema( - self.hass, - user_input - if not self._reconfiguring_entry - else self._reconfiguring_entry.data, - ), + data_schema=schema, ) async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None): @@ -595,60 +854,20 @@ async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None) _LOGGER.debug("Reconfiguring: %s", entry) await self.async_set_unique_id(entry.unique_id) self._reconfiguring_entry = entry + self._is_reconfigure = True - errors = {} - - if user_input is not None: - self._abort_if_unique_id_mismatch() - _LOGGER.debug("User input: %s", user_input) - try: - self._device = GreeDevice( - user_input[CONF_NAME], - user_input[CONF_HOST], - user_input[CONF_MAC], - user_input[CONF_ADVANCED][CONF_PORT], - user_input[CONF_ADVANCED][CONF_ENCRYPTION_KEY], - EncryptionVersion( - int(user_input[CONF_ADVANCED][CONF_ENCRYPTION_VERSION]) - ) - if user_input[CONF_ADVANCED][CONF_ENCRYPTION_VERSION] - != "Auto-Detect" - else None, - user_input[CONF_ADVANCED][CONF_UID], - max_connection_attempts=2, # Use fewer attempts for testing the device - timeout=2, # Use smaller timeout for testing the device - ) - await self._device.bind_device() - except CannotConnect: - errors["base"] = "cannot_connect" - _LOGGER.exception("Cannot connect") - except GreeDeviceNotBoundError: - errors["base"] = "cannot_connect" - _LOGGER.exception("Error while binding") - except GreeDeviceNotBoundErrorKey: - errors["base"] = "cannot_connect_key" - _LOGGER.exception("Error while binding with wrong key") - except Exception: - errors["base"] = "unknown" - _LOGGER.exception("Unknown error while binding") - else: - self._step_main_data = user_input - self._step_main_data["advanced"].update( - { - CONF_ENCRYPTION_VERSION: self._device.encryption_version, - CONF_ENCRYPTION_KEY: self._device.encryption_key, - } - ) - return await self.async_step_device_options() - - return self.async_show_form( - step_id="reconfigure", - data_schema=build_main_schema( - entry.data if entry.data is not None else user_input - ), - errors=errors, + return await self.async_step_manual_add( + None, dict(entry.data) if entry.data is not None else None ) + # return self.async_show_form( + # step_id="reconfigure", + # data_schema=build_main_schema( + # entry.data if entry.data is not None else user_input + # ), + # errors=errors, + # ) + async def _discover_devices( self, hass: HomeAssistant ) -> list[GreeDiscoveredDevice]: @@ -669,3 +888,51 @@ async def _discover_devices( _LOGGER.exception("Could not get HA broadcast addresses") return await discover_gree_devices(broadcast_addresses, 5) + + def _create_final_entry(self): + """Build final entry data.""" + data: dict = {} + + if self._step_main_data: + data = self._step_main_data.copy() + + # build devices list: main + subdevices + devices = [] + for mac, conf in self._device_configs.items(): + devices.append({**conf, CONF_MAC: mac}) + + data[CONF_DEVICES] = devices + + _LOGGER.debug("New entry with config: %s", data) + return self.async_create_entry( + title=f"Gree System at {data[CONF_HOST]}", data=data + ) + + def _update_entry(self): + """Build final entry data.""" + data: dict = {} + + if self._reconfiguring_entry is None: + raise ValueError("Error updating entry which is not set") + + if self._step_main_data: + data = self._step_main_data.copy() + + # build devices list: main + subdevices + devices = [] + for mac, conf in self._device_configs.items(): + devices.append({**conf, CONF_MAC: mac}) + + data[CONF_DEVICES] = devices + + _LOGGER.debug("Updating entry with config: %s", data) + + return self.async_update_reload_and_abort( + self._reconfiguring_entry, + title=f"Gree System at {data[CONF_HOST]}", + data=data, + ) + + def _get_device_conf(self, config: dict, mac: str) -> dict | None: + configured_devices = config.get(CONF_DEVICES, []) + return next((d for d in configured_devices if d.get(CONF_MAC) == mac), None) diff --git a/custom_components/gree_custom/const.py b/custom_components/gree_custom/const.py index 481910c..3a7e984 100755 --- a/custom_components/gree_custom/const.py +++ b/custom_components/gree_custom/const.py @@ -5,6 +5,7 @@ from .gree_api import ( FanSpeed, + GreeProp, HorizontalSwingMode, OperationMode, TemperatureUnits, @@ -20,7 +21,8 @@ CONF_DISABLE_AVAILABLE_CHECK = "disable_available_check" CONF_MAX_ONLINE_ATTEMPTS = "max_online_attempts" CONF_RESTORE_STATES = "restore_states" - +CONF_DEVICES = "devices" +CONF_DEV_NAME = "device_name" CONF_HVAC_MODES = "hvac_modes" CONF_FAN_MODES = "fan_modes" CONF_SWING_MODES = "swing_modes" @@ -68,6 +70,18 @@ ATTR_AUTO_XFAN = "auto_xfan" ATTR_AUTO_LIGHT = "auto_light" +# Map each feature constant to its corresponding GreeProp +CONF_TO_PROP_FEATURE_MAP = { + GATTR_BEEPER: GreeProp.BEEPER, + GATTR_FEAT_FRESH_AIR: GreeProp.FEAT_FRESH_AIR, + GATTR_FEAT_XFAN: GreeProp.FEAT_XFAN, + GATTR_FEAT_SLEEP_MODE: GreeProp.FEAT_SLEEP_MODE, + GATTR_FEAT_SMART_HEAT_8C: GreeProp.FEAT_SMART_HEAT_8C, + 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, +} # HVAC modes - these come from Home Assistant and are standard DEFAULT_HVAC_MODES = [ @@ -101,8 +115,8 @@ FanSpeed.Medium.name, FanSpeed.MediumHigh.name, FanSpeed.High.name, - GATTR_FEAT_TURBO, # Special mode on Gree device - GATTR_FEAT_QUIET_MODE, # Special mode on Gree device + # GATTR_FEAT_TURBO, # Special mode on Gree device + # GATTR_FEAT_QUIET_MODE, # Special mode on Gree device ] DEFAULT_SWING_MODES = [ diff --git a/custom_components/gree_custom/coordinator.py b/custom_components/gree_custom/coordinator.py index 389cb10..bd70ceb 100644 --- a/custom_components/gree_custom/coordinator.py +++ b/custom_components/gree_custom/coordinator.py @@ -15,7 +15,7 @@ _LOGGER = logging.getLogger(__name__) -type GreeConfigEntry = ConfigEntry[GreeCoordinator] +type GreeConfigEntry = ConfigEntry[dict[str, GreeCoordinator]] class GreeCoordinator(DataUpdateCoordinator[None]): diff --git a/custom_components/gree_custom/entity.py b/custom_components/gree_custom/entity.py index 0a1956d..7c95bf7 100755 --- a/custom_components/gree_custom/entity.py +++ b/custom_components/gree_custom/entity.py @@ -35,9 +35,25 @@ def __init__( self.restore_state = restore_state self.check_availability = check_availability - self._attr_unique_id = description.key - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, self.device.unique_id)}, + @property + def unique_id(self) -> str | None: + """Returns a unique id for the entity.""" + return self.entity_description.key + + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + if self.device.mac_address_sub != self.device.mac_address: + return DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, self.device.mac_address_sub)}, + identifiers={(DOMAIN, self.device.unique_id)}, + name=self.device.name, + manufacturer="Gree", + sw_version=self.device.firmware_version, + via_device=(DOMAIN, self.device.mac_address), + ) + return DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, self.device.mac_address_sub)}, identifiers={(DOMAIN, self.device.unique_id)}, name=self.device.name, manufacturer="Gree", diff --git a/custom_components/gree_custom/gree_api.py b/custom_components/gree_custom/gree_api.py index 8e56787..892e4f6 100644 --- a/custom_components/gree_custom/gree_api.py +++ b/custom_components/gree_custom/gree_api.py @@ -182,6 +182,7 @@ class GreeDiscoveredDevice: brand: str model: str uid: int + subdevices: int propkey_to_enum = {prop.value: prop for prop in GreeProp} @@ -787,6 +788,7 @@ async def gree_get_device_info( info: dict[str, str | None] = {} info["firmware_version"], info["firmware_code"] = extract_version(data) info["mac"] = data.get("mac", "") + info["subdevices_count"] = data.get("subCnt", 0) return info @@ -840,56 +842,101 @@ async def discover_gree_devices( brand=pack.get("brand", "gree"), model=pack.get("brand", "gree"), uid=data.get("uid", 0), + subdevices=pack.get("subCnt", 0), ) discovered_devices.append(discovered_device) _LOGGER.debug("Discovered device: %s", discovered_device) - # If VRF, the mac is of the main device and we have to query it for the sub devices - # Sub-devices will be created with a mac of sub@main - # check if the device has sub-devices - sub_count = pack.get("subCnt", 0) - - if sub_count > 0: - # Is VRF with multiple sub devices - _LOGGER.debug( - "Trying to fetching sub-devices for '%s' (subCount=%d)", - mac_addr, - sub_count, - ) - try: - discovered_sub_devices = await get_sub_devices_list( - discovered_device.mac, - discovered_device.host, - discovered_device.uid, - max_connection_attempts=2, - timeout=timeout, - ) - - for sub_device in discovered_sub_devices: - sub_mac = sub_device.get("mac", "") - if sub_mac: - discovered_sub_device = GreeDiscoveredDevice( - name=f"{discovered_device.name or f'Gree {mac_addr[-4:]}'}@{sub_mac[:4]}", - host=discovered_device.host, - mac=f"{sub_mac}@{discovered_device.mac}", - port=discovered_device.port, - brand=discovered_device.brand, - model=sub_device.get("mid", discovered_device), - uid=discovered_device.uid, - ) - discovered_devices.append(discovered_sub_device) - _LOGGER.debug( - "Discovered sub-device: %s", - discovered_sub_device, - ) - except Exception: - _LOGGER.exception("Failed to fetch sub-devices") + # # If VRF, the mac is of the main device and we have to query it for the sub devices + # # Sub-devices will be created with a mac of sub@main + # # check if the device has sub-devices + # sub_count = pack.get("subCnt", 0) + + # if sub_count > 0: + # # Is VRF with multiple sub devices + # _LOGGER.debug( + # "Trying to fetching sub-devices for '%s' (subCount=%d)", + # mac_addr, + # sub_count, + # ) + # try: + # discovered_sub_devices = await get_sub_devices_list( + # discovered_device.mac, + # discovered_device.host, + # discovered_device.uid, + # max_connection_attempts=2, + # timeout=timeout, + # ) + + # for sub_device in discovered_sub_devices: + # sub_mac = sub_device.get("mac", "") + # if sub_mac: + # discovered_sub_device = GreeDiscoveredDevice( + # name=f"{discovered_device.name or f'Gree {mac_addr[-4:]}'}@{sub_mac[:4]}", + # host=discovered_device.host, + # mac=f"{sub_mac}@{discovered_device.mac}", + # port=discovered_device.port, + # brand=discovered_device.brand, + # model=sub_device.get("mid", discovered_device), + # uid=discovered_device.uid, + # ) + # discovered_devices.append(discovered_sub_device) + # _LOGGER.debug( + # "Discovered sub-device: %s", + # discovered_sub_device, + # ) + # except Exception: + # _LOGGER.exception("Failed to fetch sub-devices") return discovered_devices -async def get_sub_devices_list( +async def gree_get_sub_devices_list( + ip_addr: str, + mac_addr: str, + port: int, + uid: int, + encryption_key: str, + encryption_version: EncryptionVersion, + max_connection_attempts: int, + timeout: int, +) -> list: + """Fetch the list of sub-devices for a Gree device.""" + try: + pack, tag = gree_create_encrypted_pack( + gree_create_sub_bind_pack(mac_addr), + gree_get_default_cipher(encryption_version), + encryption_version, + ) + + jsonPayloadToSend = gree_create_payload( + pack, + "subList", + GreeCommand.BIND, + mac_addr, + uid, + encryption_version, + tag, + ) + + result = await get_result_pack( + ip_addr, + port, + jsonPayloadToSend, + gree_get_default_cipher(encryption_version), + encryption_version, + max_connection_attempts, + timeout, + ) + + return result.get("list", []) + + except Exception as err: + raise ValueError(f"Error fetching sub-device list for '{mac_addr}'") from err + + +async def get_sub_devices( mac_addr: str, ip_addr: str, uid: int, max_connection_attempts: int, timeout: int ) -> list: """Fetch the list of sub-devices for a Gree device.""" @@ -930,7 +977,28 @@ async def get_sub_devices_list( timeout, ) - return result.get("list", []) - except Exception as err: raise ValueError(f"Error fetching sub-device list for '{mac_addr}'") from err + else: + discovered_devices: list[GreeDiscoveredDevice] = [] + + for sub_device in result.get("list", []): + sub_mac = sub_device.get("mac", "") + if sub_mac: + discovered_sub_device = GreeDiscoveredDevice( + name=f"{sub_device.get('name', '') or f'Gree {sub_mac[:4]}@{mac_addr[-4:]}'}", + host=ip_addr, + mac=sub_mac, + port=DEFAULT_DEVICE_PORT, + brand=sub_device.get("brand", "Gree"), + model=sub_device.get("mid", "HVAC"), + uid=0, + subdevices=0, + ) + discovered_devices.append(discovered_sub_device) + _LOGGER.debug( + "Discovered sub-device: %s", + discovered_sub_device, + ) + + return discovered_devices diff --git a/custom_components/gree_custom/gree_device.py b/custom_components/gree_custom/gree_device.py index ed16363..03856fb 100755 --- a/custom_components/gree_custom/gree_device.py +++ b/custom_components/gree_custom/gree_device.py @@ -8,6 +8,7 @@ DEFAULT_DEVICE_UID, EncryptionVersion, FanSpeed, + GreeDiscoveredDevice, GreeProp, HorizontalSwingMode, OperationMode, @@ -16,6 +17,7 @@ gree_get_device_info, gree_get_device_key, gree_get_status, + gree_get_sub_devices_list, gree_set_status, ) from .gree_helpers import ( @@ -71,14 +73,15 @@ def __init__( self._name: str = name self._ip_addr: str = ip_addr self._port: int = port - self._mac_addr = self._mac_addr_sub = mac_addr.replace(":", "").lower() - if "@" in mac_addr: - self._mac_addr_sub, self._mac_addr = mac_addr.lower().split("@", 1) + self._mac_addr = self._mac_addr_sub = ( + mac_addr.replace(":", "").replace("-", "").lower() + ) + if "@" in self._mac_addr: + self._mac_addr_sub, self._mac_addr = self._mac_addr.lower().split("@", 1) self._encryption_version: EncryptionVersion | None = encryption_version self._encryption_key: str = encryption_key self._uid: int = uid - self._firmware_version: str | None = None - self._firmware_code: str | None = None + self._raw_state: dict[GreeProp, int] = {} self._new_raw_state: dict[GreeProp, int] = {} self._is_bound: bool = False @@ -96,12 +99,17 @@ def __init__( self._temp_processor_outdoors: TempOffsetResolver | None = None self._beeper = False + self._raw_info: dict[str, str | None] = {} + self._firmware_version: str | None = None + self._firmware_code: str | None = None + self._subdevicesCount: int = 0 + async def bind_device(self) -> bool: """Setup the device (async).""" if not self._is_bound: try: # Used also as basic communication test - info = await gree_get_device_info( + self._raw_info = await gree_get_device_info( self._ip_addr, self._max_connection_attempts, self._timeout, @@ -112,13 +120,15 @@ async def bind_device(self) -> bool: f"Not able to connect to the device {self._ip_addr}" ) from e else: - if info.get("mac", "") != self._mac_addr: + if self._raw_info.get("mac", "") != self._mac_addr: raise CannotConnect( - f"Not able to connect to the device {self._ip_addr}. MAC mismatch {info.get('mac', '')} not {self._mac_addr}." + f"Not able to connect to the device {self._ip_addr}. MAC mismatch {self._raw_info.get('mac', '')} not {self._mac_addr}." ) - self._firmware_version = info.get("firmware_version") - self._firmware_code = info.get("firmware_code") - + self._firmware_version = self._raw_info.get("firmware_version") + self._firmware_code = self._raw_info.get("firmware_code") + self._subdevicesCount = int( + self._raw_info.get("subdevices_count", 0) or 0 + ) try: ( encryption_key, @@ -155,9 +165,60 @@ async def bind_device(self) -> bool: return self._is_bound + async def fetch_sub_devices(self) -> list[GreeDiscoveredDevice]: + """Get the sub devices list.""" + _LOGGER.debug("Trying to get subdevices") + + if not self._is_bound: + await self.bind_device() + + assert self._encryption_version is not None + + if not self._subdevicesCount: + return [] + + discovered_devices: list[GreeDiscoveredDevice] = [] + + try: + subs = await gree_get_sub_devices_list( + self._ip_addr, + self._mac_addr, + self._port, + self._uid, + self._encryption_key, + self._encryption_version, + self._max_connection_attempts, + self._timeout, + ) + except Exception as err: + self._is_available = False + raise ValueError("Error getting subdevices") from err + else: + for sub_device in subs: + sub_mac = sub_device.get("mac", "") + if sub_mac: + discovered_sub_device = GreeDiscoveredDevice( + name=f"{sub_device.get('name', '') or f'Gree {sub_mac[:4]}@{self.mac_address[-4:]}'}", + host=self._ip_addr, + mac=sub_mac, + port=self._port, + brand=sub_device.get("brand", "Gree"), + model=sub_device.get("mid", "HVAC"), + uid=self._uid, + subdevices=0, + ) + discovered_devices.append(discovered_sub_device) + _LOGGER.debug( + "Discovered sub-device: %s", + discovered_sub_device, + ) + _LOGGER.debug("Subdevices of '%s': %s", self._mac_addr, subs) + self._is_available = True + + return discovered_devices + async def fetch_device_status(self): """Get the device status (async).""" - _LOGGER.debug("Trying to get device status") if not self._is_bound: @@ -342,7 +403,7 @@ 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 + return property in self._raw_state if property is not GreeProp.BEEPER else True @property def name(self) -> str: @@ -364,6 +425,16 @@ def unique_id(self) -> str: """Return the unique ID of the device (MAC).""" return self._uniqueid + @property + def mac_address(self) -> str: + """Return the main MAC address of the device.""" + return self._mac_addr + + @property + def mac_address_sub(self) -> str: + """Return the secondary MAC address of the device. For non VRF is the same as MAC otherwise is the MAC of the subdevice (same as MAC for the main device).""" + return self._mac_addr_sub + @property def firmware_version(self) -> str | None: """Returns the firmware version.""" diff --git a/custom_components/gree_custom/select.py b/custom_components/gree_custom/select.py index 74d51cc..12313be 100644 --- a/custom_components/gree_custom/select.py +++ b/custom_components/gree_custom/select.py @@ -7,14 +7,19 @@ from attr import dataclass from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.const import EntityCategory +from homeassistant.const import CONF_MAC, 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 homeassistant.helpers.typing import UNDEFINED -from .const import CONF_DISABLE_AVAILABLE_CHECK, CONF_RESTORE_STATES, GATTR_TEMP_UNITS +from .const import ( + CONF_DEVICES, + CONF_DISABLE_AVAILABLE_CHECK, + CONF_RESTORE_STATES, + GATTR_TEMP_UNITS, +) from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription from .gree_api import GreeProp, TemperatureUnits @@ -32,30 +37,45 @@ async def async_setup_entry( ) -> None: """Set up switches from a config entry.""" - coordinator = entry.runtime_data - descriptions: list[GreeSelectDescription] = [] - - 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], - available_func=lambda device: (device.available - and device.supports_property(GreeProp.TARGET_TEMPERATURE_UNIT)), - 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, - ) - ) + entities: list[GreeSelect] = [] + + for d in entry.data.get(CONF_DEVICES, []): + coordinator: GreeCoordinator = entry.runtime_data[d.get(CONF_MAC, "")] + if not coordinator: + _LOGGER.error( + "Cannot create Gree Selectors. No coordinator found for device '%s'", + d.get(CONF_MAC, ""), + ) + + 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], + available_func=lambda device: ( + device.available + and device.supports_property(GreeProp.TARGET_TEMPERATURE_UNIT) + ), + 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, + ) + ) - _LOGGER.debug("Adding Select Entities: %s", [desc.key for desc in descriptions]) + _LOGGER.debug( + "Adding Select Entities for device '%s': %s", + coordinator.device.mac_address_sub, + [d.key for d in descriptions], + ) - async_add_entities( - [ - GreeSelectEntity( + entities.extend( + GreeSelect( description, coordinator, entry.data.get(CONF_RESTORE_STATES, True), @@ -64,8 +84,9 @@ async def async_setup_entry( ), ) for description in descriptions - ] - ) + ) + + async_add_entities(entities) @dataclass(frozen=True, kw_only=True) @@ -89,7 +110,7 @@ class GreeSelectDescription(GreeEntityDescription, SelectEntityDescription, Gene updates_device: bool = True -class GreeSelectEntity(GreeEntity, SelectEntity, RestoreEntity): # pyright: ignore[reportIncompatibleVariableOverride] +class GreeSelect(GreeEntity, SelectEntity, RestoreEntity): # pyright: ignore[reportIncompatibleVariableOverride] """A Gree select entity.""" entity_description: GreeSelectDescription @@ -114,7 +135,7 @@ def __init__( self._attr_current_option = self.entity_description.value_func(self.device) _LOGGER.debug( - "Initialized select: %s (check_availability=%s) Options:\n%s", + "Initialized select: %s (check_availability=%s) Options: %s", self._attr_unique_id, self.check_availability, self._attr_options, diff --git a/custom_components/gree_custom/sensor.py b/custom_components/gree_custom/sensor.py index 0e9d5f3..46524b5 100644 --- a/custom_components/gree_custom/sensor.py +++ b/custom_components/gree_custom/sensor.py @@ -4,19 +4,19 @@ from dataclasses import dataclass import logging -from config.custom_components.gree_custom.gree_api import GreeProp from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import PERCENTAGE, UnitOfTemperature +from homeassistant.const import CONF_MAC, PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .const import ( + CONF_DEVICES, CONF_DISABLE_AVAILABLE_CHECK, GATTR_HUMIDITY, GATTR_INDOOR_TEMPERATURE, @@ -24,6 +24,7 @@ ) from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription +from .gree_api import GreeProp from .gree_device import GreeDevice _LOGGER = logging.getLogger(__name__) @@ -36,72 +37,85 @@ async def async_setup_entry( ) -> None: """Set up sensors from a config entry.""" - coordinator = entry.runtime_data - - sensors: list[GreeSensorDescription] = [] - - if coordinator.device.supports_property(GreeProp.SENSOR_TEMPERATURE): - sensors.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, - available_func=lambda device: ( - device.available - and device.supports_property(GreeProp.SENSOR_TEMPERATURE) - ), + entities: list[GreeSensor] = [] + + for d in entry.data.get(CONF_DEVICES, []): + coordinator: GreeCoordinator = entry.runtime_data[d.get(CONF_MAC, "")] + if not coordinator: + _LOGGER.error( + "Cannot create Gree Sensors. No coordinator found for device '%s'", + d.get(CONF_MAC, ""), ) - ) - if coordinator.device.supports_property(GreeProp.SENSOR_OUTSIDE_TEMPERATURE): - sensors.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, - available_func=lambda device: ( - device.available - and device.supports_property(GreeProp.SENSOR_OUTSIDE_TEMPERATURE) - ), + + 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, + available_func=lambda device: ( + device.available + and device.supports_property(GreeProp.SENSOR_TEMPERATURE) + ), + ) ) - ) - if coordinator.device.supports_property(GreeProp.SENSOR_HUMIDITY): - sensors.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, - available_func=lambda device: ( - device.available - and device.supports_property(GreeProp.SENSOR_HUMIDITY) - ), + 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, + available_func=lambda device: ( + device.available + and device.supports_property( + GreeProp.SENSOR_OUTSIDE_TEMPERATURE + ) + ), + ) + ) + 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, + available_func=lambda device: ( + device.available + and device.supports_property(GreeProp.SENSOR_HUMIDITY) + ), + ) ) - ) - _LOGGER.debug("Adding Sensor Entities: %s", sensors) + _LOGGER.debug( + "Adding Sensor Entities for device '%s': %s", + coordinator.device.mac_address_sub, + [d.key for d in descriptions], + ) - entities = [ - GreeSensor( - description, - coordinator, - restore_state=True, - check_availability=( - entry.data.get(CONF_DISABLE_AVAILABLE_CHECK, False) is False - ), + entities.extend( + GreeSensor( + description, + coordinator, + restore_state=True, + check_availability=( + entry.data.get(CONF_DISABLE_AVAILABLE_CHECK, False) is False + ), + ) + for description in descriptions ) - for description in sensors - ] async_add_entities(entities) diff --git a/custom_components/gree_custom/switch.py b/custom_components/gree_custom/switch.py index 11b6498..474b345 100644 --- a/custom_components/gree_custom/switch.py +++ b/custom_components/gree_custom/switch.py @@ -10,7 +10,7 @@ SwitchEntity, SwitchEntityDescription, ) -from homeassistant.const import EntityCategory +from homeassistant.const import CONF_MAC, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -19,9 +19,11 @@ from .const import ( ATTR_AUTO_LIGHT, ATTR_AUTO_XFAN, + CONF_DEVICES, CONF_DISABLE_AVAILABLE_CHECK, CONF_FEATURES, CONF_RESTORE_STATES, + CONF_TO_PROP_FEATURE_MAP, DEFAULT_SUPPORTED_FEATURES, GATTR_ANTI_DIRECT_BLOW, GATTR_BEEPER, @@ -46,9 +48,9 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): """Description of a Gree switch.""" - set_func: Callable[[GreeDevice, bool], None] + set_func: Callable[[GreeDevice, GreeCoordinator, bool], None] device_class = SwitchDeviceClass.SWITCH - value_func: Callable[[GreeDevice], bool] + value_func: Callable[[GreeDevice, GreeCoordinator], bool] updates_device: bool = True @@ -59,8 +61,8 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): available_func=lambda device: ( device.available and device.supports_property(GreeProp.FEAT_FRESH_AIR) ), - value_func=lambda device: device.feature_fresh_air, - set_func=lambda device, value: device.set_feature_fresh_air(value), + value_func=lambda device, _: device.feature_fresh_air, + set_func=lambda device, _, value: device.set_feature_fresh_air(value), ), GreeSwitchDescription( key=GATTR_FEAT_XFAN, @@ -70,8 +72,8 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): and device.supports_property(GreeProp.FEAT_XFAN) and device.operation_mode in [OperationMode.Cool, OperationMode.Dry] ), - value_func=lambda device: device.feature_x_fan, - set_func=lambda device, value: device.set_feature_xfan(value), + value_func=lambda device, _: device.feature_x_fan, + set_func=lambda device, _, value: device.set_feature_xfan(value), ), GreeSwitchDescription( key=GATTR_FEAT_SLEEP_MODE, @@ -82,8 +84,8 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): and device.operation_mode in [OperationMode.Cool, OperationMode.Dry, 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, + set_func=lambda device, _, value: device.set_feature_sleep(value), ), GreeSwitchDescription( key=GATTR_FEAT_SMART_HEAT_8C, @@ -91,8 +93,8 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): available_func=lambda device: ( device.available and device.supports_property(GreeProp.FEAT_SMART_HEAT_8C) ), - value_func=lambda device: device.feature_smart_heat, - set_func=lambda device, value: device.set_feature_smart_heat(value), + value_func=lambda device, _: device.feature_smart_heat, + set_func=lambda device, _, value: device.set_feature_smart_heat(value), ), GreeSwitchDescription( key=GATTR_FEAT_HEALTH, @@ -100,8 +102,8 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): available_func=lambda device: ( device.available and device.supports_property(GreeProp.FEAT_HEALTH) ), - value_func=lambda device: device.feature_health, - set_func=lambda device, value: device.set_feature_health(value), + value_func=lambda device, _: device.feature_health, + set_func=lambda device, _, value: device.set_feature_health(value), ), GreeSwitchDescription( key=GATTR_ANTI_DIRECT_BLOW, @@ -110,8 +112,8 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): device.available and device.supports_property(GreeProp.FEAT_ANTI_DIRECT_BLOW) ), - value_func=lambda device: device.feature_anti_direct_blow, - set_func=lambda device, value: device.set_feature_anti_direct_blow(value), + value_func=lambda device, _: device.feature_anti_direct_blow, + set_func=lambda device, _, value: device.set_feature_anti_direct_blow(value), ), GreeSwitchDescription( key=GATTR_FEAT_ENERGY_SAVING, @@ -119,8 +121,8 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): available_func=lambda device: ( device.available and device.supports_property(GreeProp.FEAT_ENERGY_SAVING) ), - value_func=lambda device: device.feature_energy_saving, - set_func=lambda device, value: device.set_feature_energy_saving(value), + value_func=lambda device, _: device.feature_energy_saving, + set_func=lambda device, _, value: device.set_feature_energy_saving(value), ), GreeSwitchDescription( key=GATTR_FEAT_LIGHT, @@ -128,8 +130,8 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): available_func=lambda device: ( device.available and device.supports_property(GreeProp.FEAT_LIGHT) ), - value_func=lambda device: device.feature_light, - set_func=lambda device, value: device.set_feature_light(value), + value_func=lambda device, _: device.feature_light, + set_func=lambda device, _, value: device.set_feature_light(value), entity_category=EntityCategory.CONFIG, ), GreeSwitchDescription( @@ -141,16 +143,16 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): and device.supports_property(GreeProp.FEAT_LIGHT) and device.feature_light ), - value_func=lambda device: device.feature_light_sensor, - set_func=lambda device, value: device.set_feature_light_sensor(value), + value_func=lambda device, _: device.feature_light_sensor, + set_func=lambda device, _, value: device.set_feature_light_sensor(value), entity_category=EntityCategory.CONFIG, ), GreeSwitchDescription( key=GATTR_BEEPER, translation_key=GATTR_BEEPER, available_func=lambda device: device.available, - value_func=lambda device: device.beeper, - set_func=lambda device, value: device.set_beeper(value), + value_func=lambda device, _: device.beeper, + set_func=lambda device, _, value: device.set_beeper(value), entity_category=EntityCategory.CONFIG, updates_device=False, # Local entity ), @@ -164,77 +166,124 @@ async def async_setup_entry( ) -> None: """Set up switches from a config entry.""" - coordinator = entry.runtime_data - supported_features: list[str] - - if entry.data[CONF_FEATURES] is None: - _LOGGER.warning("Undefined supported features") - supported_features = DEFAULT_SUPPORTED_FEATURES - else: - supported_features = entry.data[CONF_FEATURES] - - _LOGGER.debug("Adding Switch Entities: %s", supported_features) - - entities = [ - GreeSwitch( - description, - coordinator, - restore_state=( - entry.data.get(CONF_RESTORE_STATES, True) - if description.key != GATTR_BEEPER # Always restore beeper - else True - ), - check_availability=( - entry.data.get(CONF_DISABLE_AVAILABLE_CHECK, False) is False - if description.key != GATTR_BEEPER # Beeper is always available - else False - ), + entities: list[GreeSwitch] = [] + + for d in entry.data.get(CONF_DEVICES, []): + coordinator: GreeCoordinator = entry.runtime_data[d.get(CONF_MAC, "")] + if not coordinator: + _LOGGER.error( + "Cannot create Gree Switches. No coordinator found for device '%s'", + d.get(CONF_MAC, ""), + ) + + descriptions: list[GreeSwitchDescription] = [] + + 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 = DEFAULT_SUPPORTED_FEATURES + else: + conf_supported_features = d.get(CONF_FEATURES, []) + + # Double check features with device support, just in case + 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 description in SWITCH_TYPES - if description.key in supported_features - ] - - if GATTR_FEAT_LIGHT in supported_features: - entities.append( - GreeSwitch( - GreeSwitchDescription( - key=ATTR_AUTO_LIGHT, - translation_key=ATTR_AUTO_LIGHT, - available_func=( - lambda device: device.available - and device.supports_property(GreeProp.FEAT_LIGHT) + + _LOGGER.debug( + "Adding Switch Entities for device '%s': %s", + coordinator.device.mac_address_sub, + [d.key for d in descriptions], + ) + + entities.extend( + [ + GreeSwitch( + description, + coordinator, + restore_state=( + entry.data.get(CONF_RESTORE_STATES, True) + if description.key != GATTR_BEEPER # Always restore beeper + else True ), - value_func=lambda _: coordinator.feature_auto_light, - set_func=lambda _, value: coordinator.set_feature_auto_light(value), - updates_device=False, - entity_category=EntityCategory.CONFIG, - ), - coordinator, - restore_state=True, - check_availability=True, - ) + check_availability=( + entry.data.get(CONF_DISABLE_AVAILABLE_CHECK, False) is False + if description.key != GATTR_BEEPER # Beeper is always available + else False + ), + ) + for description in descriptions + ] ) - if GATTR_FEAT_XFAN in supported_features: - entities.append( - GreeSwitch( - GreeSwitchDescription( - key=ATTR_AUTO_XFAN, - translation_key=ATTR_AUTO_XFAN, - available_func=lambda device: ( - device.available - and device.supports_property(GreeProp.FEAT_XFAN) + if GATTR_FEAT_LIGHT in supported_features: + entities.append( + GreeSwitch( + GreeSwitchDescription( + key=ATTR_AUTO_LIGHT, + translation_key=ATTR_AUTO_LIGHT, + available_func=( + lambda device: device.available + and device.supports_property(GreeProp.FEAT_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, ), - value_func=lambda _: coordinator.feature_auto_xfan, - set_func=lambda _, value: coordinator.set_feature_auto_xfan(value), - updates_device=False, - entity_category=EntityCategory.CONFIG, - ), - coordinator, - restore_state=True, - check_availability=True, + coordinator, + restore_state=True, + check_availability=True, + ) + ) + + if GATTR_FEAT_XFAN in supported_features: + entities.append( + GreeSwitch( + GreeSwitchDescription( + key=ATTR_AUTO_XFAN, + translation_key=ATTR_AUTO_XFAN, + available_func=lambda device: ( + device.available + and device.supports_property(GreeProp.FEAT_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, + ), + coordinator, + restore_state=True, + check_availability=True, + ) ) - ) async_add_entities(entities) @@ -264,7 +313,7 @@ def __init__( @property def is_on(self) -> bool | None: # pyright: ignore[reportIncompatibleVariableOverride] """Return true if the switch is on.""" - return self.entity_description.value_func(self.device) + return self.entity_description.value_func(self.device, self.coordinator) async def async_added_to_hass(self): """Handle entity which will be added.""" @@ -279,7 +328,9 @@ async def async_added_to_hass(self): if last_state.state in ("on", "off"): value: bool = last_state.state == "on" try: - self.entity_description.set_func(self.device, value) + self.entity_description.set_func( + self.device, self.coordinator, value + ) if self.entity_description.updates_device: await self.device.update_device_status() @@ -298,7 +349,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: raise HomeAssistantError("Entity unavailable") try: - self.entity_description.set_func(self.device, True) + self.entity_description.set_func(self.device, self.coordinator, True) if self.entity_description.updates_device: await self.device.update_device_status() @@ -321,7 +372,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: raise HomeAssistantError("Entity unavailable") try: - self.entity_description.set_func(self.device, False) + self.entity_description.set_func(self.device, self.coordinator, False) if self.entity_description.updates_device: await self.device.update_device_status() diff --git a/custom_components/gree_custom/translations/en.json b/custom_components/gree_custom/translations/en.json index beafa91..c04d3a6 100755 --- a/custom_components/gree_custom/translations/en.json +++ b/custom_components/gree_custom/translations/en.json @@ -29,7 +29,6 @@ "manual_add": { "title": "Device configuration", "data": { - "name": "Name", "host": "IP Address", "mac": "MAC Address" }, @@ -57,6 +56,7 @@ "title": "Device features", "description": "The Gree API doesn't have a reliable method of getting the supported features of a device. Please use the options bellow to the best of your knowledge about your device.", "data": { + "device_name": "Device Name", "hvac_modes": "HVAC Modes", "fan_modes": "Fan Speeds", "swing_modes": "Vertical Swing Modes", diff --git a/manual-configuration.yaml b/manual-configuration.yaml index 7a0a261..3adc0f3 100644 --- a/manual-configuration.yaml +++ b/manual-configuration.yaml @@ -4,101 +4,95 @@ # when using YAML configuration instead of the UI config flow. # # Copy the sections you need to your configuration.yaml file. +# +# If an option is not provided the default values will be used. +# For option lists, pass empty list ([]) to disable the option. +# +# MAC address Format can be XX:XX:XX:XX:XX:XX, XX-XX-XX-XX-XX-XX, xxxxxxxxxxxx +# +# For VRF units the MAC is usually xxxxxxxxxxxx@yyyyyyyyyyyy where +# the first part (x) is the sub device MAC and the second (y) the main device MAC +# In this case it is preferred to place the main device MAC on the top config and +# the sub device MAC on the device list config + + +gree_custom: + - host: "192.168.1.100" # IP Address of AC | required | str + mac: "20-FA-BB-12-34-56" # MAC Address of Main AC Unit | required | str + advanced: + port: 7000 # Port number to connect to the device | int | default = 7000 + encryption_version: 2 # The encryption version to use with the device | options = "Auto-Detect", 1, 2 | default = "Auto-Detect" + encryption_key: "my_device_key" # Custom encryption key | str | default = + uid: 0 # Device identifier which is not needed for all devices, can be sniffed if required | positive int | default = 0 + disable_available_check: false # boolean | default = false + max_online_attempts: 3 # Number connection attempts made with device before it is marked as unavailable | positive int | default = 5 + timeout: 10 # Seconds before a connection attempt times out | positive int (seconds) | default = 10 + devices: # List of the devices that will be created (optional if only one device and no configuration required) + - device_name: "Gree AC" # Name for the AC unit | str | default = "Gree AC ]" + mac: "20-FA-BB-12-34-56" # MAC Address of the sub AC unit (same as Main device if not VRF) | required | str + hvac_modes: # Standard Home Assistant HVAC Modes to enable | list | options = ["auto", "cool", "dry", "fan_only", "heat", "off"] | default = all options + - "auto" + - "cool" + - "dry" + - "fan_only" + - "heat" + - "off" + fan_modes: # Supported fan modes | list | options = ["Auto", "Low", "MediumLow", "Medium", "MediumHigh", "High", "turbo", "quiet"] | default = all options + - "Auto" + - "Low" + - "MediumLow" + - "Medium" + - "MediumHigh" + - "High" + - "turbo" + - "quiet" + swing_modes: # Supported vertical swing modes | list | options = ["Default", "FullSwing", "FixedUpper", "FixedUpperMiddle", "FixedMiddle", "FixedLowerMiddle", "FixedLower", "SwingLower", "SwingLowerMiddle", "SwingMiddle", "SwingUpperMiddle", "SwingUpper"] | default = all options + - "Default" + - "FullSwing" + - "FixedUpper" + - "FixedUpperMiddle" + - "FixedMiddle" + - "FixedLowerMiddle" + - "FixedLower" + - "SwingLower" + - "SwingLowerMiddle" + - "SwingMiddle" + - "SwingUpperMiddle" + - "SwingUpper" + swing_horizontal_modes: # Supported horizontal swing modes | list | options = ["Default", "FullSwing", "Left", "LeftCenter", "Center", "RightCenter", "Right"] | default = all options + - "Default" + - "FullSwing" + - "Left" + - "LeftCenter" + - "Center" + - "RightCenter" + - "Right" + features: # Supported device features | list | options = ["beeper", "air", "xfan", "sleep", "eightdegheat", "lights", "health", "anti_direct_blow", "powersave", "light_sensor"] | default = all options + - "beeper" + - "air" + - "xfan" + - "sleep" + - "eightdegheat" + - "lights" + - "health" + - "anti_direct_blow" + - "powersave" + - "light_sensor" + 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" + restore_states: true # Wether to restore the last HA state to device when HA starts | bool | default = true -gree: - # Name for the AC unit (required) - - name: "First AC" - - # IP Address of AC (required) - host: "192.168.1.101" - - # MAC address of the device (required) - # Format can be XX:XX:XX:XX:XX:XX, XX-XX-XX-XX-XX-XX, xxxxxxxxxxxx - # or xxxxxxxxxxxx@yyyyyyyyyyyy (for VRF units) depending on your model - mac: "20-FA-BB-12-34-56" - - # Encryption version (optional, defaults to 1) - encryption_version: 1 - - # Port number to connect to the device (optional, defaults to 7000) - # port: 7000 - - # Custom encryption key (optional, auto-fetched if empty) - # If you have extracted your encryption key, you can specify it here - # encryption_key: "A1B2C3D4E5F6" - - # Device identifier (optional) - # This is not needed for all devices, can be sniffed if required - # uid: 123 - - # Standard Home Assistant HVAC Modes to enable (optional) - # Default: ["auto", "cool", "dry", "fan_only", "heat", "off"] - # hvac_modes: - # - "auto" - # - "cool" - # - "dry" - # - "fan_only" - # - "heat" - # - "off" - - # Fan modes (optional) - # Default: ["auto", "low", "medium_low", "medium", "medium_high", "high", "turbo", "quiet"] - # fan_modes: - # - "auto" - # - "low" - # - "medium_low" - # - "medium" - # - "medium_high" - # - "high" - # - "turbo" - # - "quiet" - - # Fan vertical swing modes (optional) - # Pass empty list ([]) to disable vertical swing - # Default: ["default", "swing_full", "fixed_upmost", "fixed_middle_up", "fixed_middle", "fixed_middle_low", "fixed_lowest", "swing_downmost", "swing_middle_low", "swing_middle", "swing_middle_up", "swing_upmost"] - # swing_modes: - # - "default" - # - "swing_full" - # - "fixed_upmost" - # - "fixed_middle_up" - # - "fixed_middle" - # - "fixed_middle_low" - # - "fixed_lowest" - # - "swing_downmost" - # - "swing_middle_low" - # - "swing_middle" - # - "swing_middle_up" - # - "swing_upmost" - - # Fan horizontal swing modes (optional) - # Pass empty list ([]) to disable horizontal swing - # Default: ["default", "swing_full", "fixed_leftmost", "fixed_middle_left", "fixed_middle", "fixed_middle_right", "fixed_rightmost"] - # swing_horizontal_modes: - # - "default" - # - "swing_full" - # - "fixed_leftmost" - # - "fixed_middle_left" - # - "fixed_middle" - # - "fixed_middle_right" - # - "fixed_rightmost" - - # Keep AC always available in HA (optional, defaults to false) - # Disables connection checking - useful for devices that don't respond reliably - # disable_available_check: false - - # Display offset for temp sensor (optional, auto-detected if not set) - # Set to true to apply -40°C offset, false for no offset, or leave unset for auto-detection - # temp_sensor_offset: true # Example for multiple AC units: -# gree: -# - name: "Living Room AC" -# host: "192.168.1.101" +# gree_custom: +# - host: "192.168.1.101" # mac: "20-FA-BB-12-34-56" -# encryption_version: 2 -# -# - name: "Bedroom AC" -# host: "192.168.1.102" +# advanced: +# encryption_version: 2 + +# - host: "192.168.1.102" # mac: "20-FA-BB-12-34-57" -# encryption_version: 1 -# port: 7001 +# devices: +# - name: "Gree AC" +# mac: "20-FA-BB-12-34-57" From 513ca7a1764884364ddd55329baf5bc3e7018df5 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Thu, 6 Nov 2025 21:35:46 +0000 Subject: [PATCH 054/113] Move api code to its own namespace --- custom_components/gree_custom/__init__.py | 13 ++-- .../gree_custom/aiogree/__init__.py | 1 + .../{gree_api.py => aiogree/api.py} | 25 +++----- .../gree_custom/aiogree/const.py | 18 ++++++ .../{gree_device.py => aiogree/device.py} | 12 ++-- .../{gree_helpers.py => aiogree/helpers.py} | 0 custom_components/gree_custom/climate.py | 12 +--- custom_components/gree_custom/config_flow.py | 59 ++++++++++--------- custom_components/gree_custom/const.py | 2 +- custom_components/gree_custom/coordinator.py | 2 +- custom_components/gree_custom/entity.py | 2 +- custom_components/gree_custom/select.py | 4 +- custom_components/gree_custom/sensor.py | 4 +- custom_components/gree_custom/switch.py | 4 +- 14 files changed, 83 insertions(+), 75 deletions(-) create mode 100644 custom_components/gree_custom/aiogree/__init__.py rename custom_components/gree_custom/{gree_api.py => aiogree/api.py} (98%) create mode 100644 custom_components/gree_custom/aiogree/const.py rename custom_components/gree_custom/{gree_device.py => aiogree/device.py} (97%) rename custom_components/gree_custom/{gree_helpers.py => aiogree/helpers.py} (100%) diff --git a/custom_components/gree_custom/__init__.py b/custom_components/gree_custom/__init__.py index 030ce74..643e6dd 100755 --- a/custom_components/gree_custom/__init__.py +++ b/custom_components/gree_custom/__init__.py @@ -13,6 +13,13 @@ from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.typing import ConfigType +from .aiogree.const import ( + DEFAULT_CONNECTION_MAX_ATTEMPTS, + DEFAULT_DEVICE_PORT, + DEFAULT_DEVICE_UID, +) +from .aiogree.device import GreeDevice, GreeDeviceNotBoundError + # Local imports from .const import ( CONF_ADVANCED, @@ -28,12 +35,6 @@ # Home Assistant imports from .coordinator import GreeConfigEntry, GreeCoordinator -from .gree_api import ( - DEFAULT_CONNECTION_MAX_ATTEMPTS, - DEFAULT_DEVICE_PORT, - DEFAULT_DEVICE_UID, -) -from .gree_device import GreeDevice, GreeDeviceNotBoundError PLATFORMS = [ Platform.CLIMATE, diff --git a/custom_components/gree_custom/aiogree/__init__.py b/custom_components/gree_custom/aiogree/__init__.py new file mode 100644 index 0000000..0a6b296 --- /dev/null +++ b/custom_components/gree_custom/aiogree/__init__.py @@ -0,0 +1 @@ +"""aiogree provides an interface to comunicate with a Gree device.""" diff --git a/custom_components/gree_custom/gree_api.py b/custom_components/gree_custom/aiogree/api.py similarity index 98% rename from custom_components/gree_custom/gree_api.py rename to custom_components/gree_custom/aiogree/api.py index 892e4f6..c9834d8 100644 --- a/custom_components/gree_custom/gree_api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -14,24 +14,15 @@ from attr import dataclass from Crypto.Cipher import AES -_LOGGER = logging.getLogger(__name__) - -GCM_IV = b"\x54\x40\x78\x44\x49\x67\x5a\x51\x6c\x5e\x63\x13" -GCM_ADD = b"qualcomm-test" - -GREE_GENERIC_DEVICE_KEY = "a3K8Bx%2r8Y7#xDh" -GREE_GENERIC_DEVICE_KEY_GCM = "{yxAHAY_Lm6pbC/<" +from .const import ( + DEFAULT_DEVICE_PORT, + GCM_ADD, + GCM_IV, + GREE_GENERIC_DEVICE_KEY, + GREE_GENERIC_DEVICE_KEY_GCM, +) -MIN_TEMP_C = 16 -MAX_TEMP_C = 30 - -MIN_TEMP_F = 61 -MAX_TEMP_F = 86 - -DEFAULT_DEVICE_UID = 0 -DEFAULT_DEVICE_PORT = 7000 -DEFAULT_CONNECTION_MAX_ATTEMPTS = 5 -DEFAULT_CONNECTION_TIMEOUT = 10 +_LOGGER = logging.getLogger(__name__) class GreeProp(Enum): diff --git a/custom_components/gree_custom/aiogree/const.py b/custom_components/gree_custom/aiogree/const.py new file mode 100644 index 0000000..4cfe053 --- /dev/null +++ b/custom_components/gree_custom/aiogree/const.py @@ -0,0 +1,18 @@ +"""Constants for the aiogree.""" + +GCM_IV = b"\x54\x40\x78\x44\x49\x67\x5a\x51\x6c\x5e\x63\x13" +GCM_ADD = b"qualcomm-test" + +GREE_GENERIC_DEVICE_KEY = "a3K8Bx%2r8Y7#xDh" +GREE_GENERIC_DEVICE_KEY_GCM = "{yxAHAY_Lm6pbC/<" + +MIN_TEMP_C = 16 +MAX_TEMP_C = 30 + +MIN_TEMP_F = 61 +MAX_TEMP_F = 86 + +DEFAULT_DEVICE_UID = 0 +DEFAULT_DEVICE_PORT = 7000 +DEFAULT_CONNECTION_MAX_ATTEMPTS = 5 +DEFAULT_CONNECTION_TIMEOUT = 10 diff --git a/custom_components/gree_custom/gree_device.py b/custom_components/gree_custom/aiogree/device.py similarity index 97% rename from custom_components/gree_custom/gree_device.py rename to custom_components/gree_custom/aiogree/device.py index 03856fb..fb5359a 100755 --- a/custom_components/gree_custom/gree_device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -2,10 +2,7 @@ import logging -from .gree_api import ( - DEFAULT_CONNECTION_MAX_ATTEMPTS, - DEFAULT_CONNECTION_TIMEOUT, - DEFAULT_DEVICE_UID, +from .api import ( EncryptionVersion, FanSpeed, GreeDiscoveredDevice, @@ -20,7 +17,12 @@ gree_get_sub_devices_list, gree_set_status, ) -from .gree_helpers import ( +from .const import ( + DEFAULT_CONNECTION_MAX_ATTEMPTS, + DEFAULT_CONNECTION_TIMEOUT, + DEFAULT_DEVICE_UID, +) +from .helpers import ( TempOffsetResolver, gree_get_target_temp_props_from_c, gree_get_target_temp_props_from_f, diff --git a/custom_components/gree_custom/gree_helpers.py b/custom_components/gree_custom/aiogree/helpers.py similarity index 100% rename from custom_components/gree_custom/gree_helpers.py rename to custom_components/gree_custom/aiogree/helpers.py diff --git a/custom_components/gree_custom/climate.py b/custom_components/gree_custom/climate.py index 2390475..f1076af 100644 --- a/custom_components/gree_custom/climate.py +++ b/custom_components/gree_custom/climate.py @@ -37,6 +37,8 @@ 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 .const import ( ATTR_EXTERNAL_HUMIDITY_SENSOR, ATTR_EXTERNAL_TEMPERATURE_SENSOR, @@ -63,16 +65,6 @@ ) from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription -from .gree_api import ( - MAX_TEMP_C, - MAX_TEMP_F, - MIN_TEMP_C, - MIN_TEMP_F, - FanSpeed, - GreeProp, - HorizontalSwingMode, - VerticalSwingMode, -) _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/gree_custom/config_flow.py b/custom_components/gree_custom/config_flow.py index 05a3472..9cb9e5e 100644 --- a/custom_components/gree_custom/config_flow.py +++ b/custom_components/gree_custom/config_flow.py @@ -30,6 +30,24 @@ TextSelectorType, ) +from .aiogree.api import ( + EncryptionVersion, + GreeDiscoveredDevice, + GreeProp, + discover_gree_devices, +) +from .aiogree.const import ( + DEFAULT_CONNECTION_MAX_ATTEMPTS, + DEFAULT_CONNECTION_TIMEOUT, + DEFAULT_DEVICE_PORT, + DEFAULT_DEVICE_UID, +) +from .aiogree.device import ( + CannotConnect, + GreeDevice, + GreeDeviceNotBoundError, + GreeDeviceNotBoundErrorKey, +) from .const import ( ATTR_EXTERNAL_HUMIDITY_SENSOR, ATTR_EXTERNAL_TEMPERATURE_SENSOR, @@ -68,22 +86,6 @@ GATTR_FEAT_XFAN, ) from .coordinator import GreeConfigEntry -from .gree_api import ( - DEFAULT_CONNECTION_MAX_ATTEMPTS, - DEFAULT_CONNECTION_TIMEOUT, - DEFAULT_DEVICE_PORT, - DEFAULT_DEVICE_UID, - EncryptionVersion, - GreeDiscoveredDevice, - GreeProp, - discover_gree_devices, -) -from .gree_device import ( - CannotConnect, - GreeDevice, - GreeDeviceNotBoundError, - GreeDeviceNotBoundErrorKey, -) _LOGGER = logging.getLogger(__name__) @@ -685,18 +687,19 @@ async def async_step_manual_add( _main_device.mac_address_sub ].fetch_sub_devices() - # for d in self._discovered_subdevices: - # self._devices[d.mac] = GreeDevice( - # d.name, - # user_input[CONF_HOST], - # f"{d.mac}@{_main_device.mac_address}", - # user_input[CONF_ADVANCED][CONF_PORT], - # _main_device.encryption_key, - # _main_device.encryption_version, - # user_input[CONF_ADVANCED][CONF_UID], - # max_connection_attempts=2, # Use fewer attempts for testing the device - # timeout=2, # Use smaller timeout for testing the device - # ) + for d in self._discovered_subdevices: + subdev = GreeDevice( + d.name, + user_input[CONF_HOST], + f"{d.mac}@{_main_device.mac_address}", + user_input[CONF_ADVANCED][CONF_PORT], + _main_device.encryption_key, + _main_device.encryption_version, + user_input[CONF_ADVANCED][CONF_UID], + max_connection_attempts=2, # Use fewer attempts for testing the device + timeout=2, # Use smaller timeout for testing the device + ) + self._devices[subdev.mac_address_sub] = subdev await self._devices[_main_device.mac_address_sub].fetch_device_status() except CannotConnect: diff --git a/custom_components/gree_custom/const.py b/custom_components/gree_custom/const.py index 3a7e984..1603cb5 100755 --- a/custom_components/gree_custom/const.py +++ b/custom_components/gree_custom/const.py @@ -3,7 +3,7 @@ from homeassistant.components.climate import HVACMode from homeassistant.const import UnitOfTemperature -from .gree_api import ( +from .aiogree.api import ( FanSpeed, GreeProp, HorizontalSwingMode, diff --git a/custom_components/gree_custom/coordinator.py b/custom_components/gree_custom/coordinator.py index bd70ceb..89f233e 100644 --- a/custom_components/gree_custom/coordinator.py +++ b/custom_components/gree_custom/coordinator.py @@ -11,7 +11,7 @@ timedelta, ) -from .gree_device import GreeDevice, GreeDeviceNotBoundError +from .aiogree.device import GreeDevice, GreeDeviceNotBoundError _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/gree_custom/entity.py b/custom_components/gree_custom/entity.py index 7c95bf7..dc0f9f0 100755 --- a/custom_components/gree_custom/entity.py +++ b/custom_components/gree_custom/entity.py @@ -9,9 +9,9 @@ from homeassistant.helpers.entity import DeviceInfo, EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .aiogree.device import GreeDevice from .const import DOMAIN from .coordinator import GreeCoordinator -from .gree_device import GreeDevice class GreeEntity(CoordinatorEntity[GreeCoordinator]): diff --git a/custom_components/gree_custom/select.py b/custom_components/gree_custom/select.py index 12313be..bfbd053 100644 --- a/custom_components/gree_custom/select.py +++ b/custom_components/gree_custom/select.py @@ -14,6 +14,8 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import UNDEFINED +from .aiogree.api import GreeProp, TemperatureUnits +from .aiogree.device import GreeDevice from .const import ( CONF_DEVICES, CONF_DISABLE_AVAILABLE_CHECK, @@ -22,8 +24,6 @@ ) from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription -from .gree_api import GreeProp, TemperatureUnits -from .gree_device import GreeDevice _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/gree_custom/sensor.py b/custom_components/gree_custom/sensor.py index 46524b5..c7a2416 100644 --- a/custom_components/gree_custom/sensor.py +++ b/custom_components/gree_custom/sensor.py @@ -15,6 +15,8 @@ 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_DEVICES, CONF_DISABLE_AVAILABLE_CHECK, @@ -24,8 +26,6 @@ ) from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription -from .gree_api import GreeProp -from .gree_device import GreeDevice _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/gree_custom/switch.py b/custom_components/gree_custom/switch.py index 474b345..4c8b963 100644 --- a/custom_components/gree_custom/switch.py +++ b/custom_components/gree_custom/switch.py @@ -16,6 +16,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity +from .aiogree.api import GreeProp, OperationMode +from .aiogree.device import GreeDevice from .const import ( ATTR_AUTO_LIGHT, ATTR_AUTO_XFAN, @@ -38,8 +40,6 @@ ) from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription -from .gree_api import GreeProp, OperationMode -from .gree_device import GreeDevice _LOGGER = logging.getLogger(__name__) From 20d74e81ff281056d1b9cea3b4e7f9bbd8b27878 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Thu, 6 Nov 2025 22:18:26 +0000 Subject: [PATCH 055/113] Add support for deleting individual devices --- custom_components/gree_custom/__init__.py | 49 ++++++++++++++++++++--- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/custom_components/gree_custom/__init__.py b/custom_components/gree_custom/__init__.py index 643e6dd..bde7195 100755 --- a/custom_components/gree_custom/__init__.py +++ b/custom_components/gree_custom/__init__.py @@ -10,7 +10,7 @@ from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.typing import ConfigType from .aiogree.const import ( @@ -120,8 +120,45 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - _LOGGER.debug("Options updated for entry %s: %s", entry.entry_id, entry.options) - _LOGGER.debug("Reloading config entry %s after options update", entry.entry_id) - hass.config_entries.async_schedule_reload(entry.entry_id) +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: GreeConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove a config entry from a device.""" + + # Find MAC address for this device (from identifiers) + identifiers = device_entry.identifiers + mac: str | None = None + for domain, identifier in identifiers: + if domain == DOMAIN: + mac = identifier + break + + if mac is None: + return False + + runtime_data: GreeCoordinator | None = config_entry.runtime_data.pop(mac, None) + + if not runtime_data: + return False + + data: dict = dict(config_entry.data) + device_configs: list[dict] = data.get(CONF_DEVICES, []) + for dconf in list(device_configs): + if dconf.get(CONF_MAC, "") != mac: + continue + + device_configs.remove(dconf) + + data[CONF_DEVICES] = device_configs + + device_registry = dr.async_get(hass) + device_registry.async_remove_device(device_entry.id) + + if device_configs: + # There are still other devices, update the entry + hass.config_entries.async_update_entry(config_entry, data=data) + else: + # No other devices, remove the entry + await hass.config_entries.async_remove(config_entry.entry_id) + + return True From b391b16e8d440bfa0176e1e24fd0be1b38389936 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Thu, 6 Nov 2025 22:50:07 +0000 Subject: [PATCH 056/113] Fix climate init after move to new config schema --- custom_components/gree_custom/climate.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/custom_components/gree_custom/climate.py b/custom_components/gree_custom/climate.py index f1076af..7628ab9 100644 --- a/custom_components/gree_custom/climate.py +++ b/custom_components/gree_custom/climate.py @@ -136,20 +136,14 @@ async def async_setup_entry( fan_modes, swing_modes, swing_horizontal_modes, - temperature_step=entry.data.get( - CONF_TEMPERATURE_STEP, DEFAULT_TARGET_TEMP_STEP - ), - restore_state=(entry.data.get(CONF_RESTORE_STATES, True)), + temperature_step=d.get(CONF_TEMPERATURE_STEP, DEFAULT_TARGET_TEMP_STEP), + restore_state=(d.get(CONF_RESTORE_STATES, True)), check_availability=( entry.data[CONF_ADVANCED].get(CONF_DISABLE_AVAILABLE_CHECK, False) is False ), - external_temperature_sensor_id=entry.data.get( - ATTR_EXTERNAL_TEMPERATURE_SENSOR - ), - external_humidity_sensor_id=entry.data.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), ) ) From 3927f89951d6d16785a34e33cc0a1e6a70d89008 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Tue, 11 Nov 2025 23:28:27 +0000 Subject: [PATCH 057/113] Fix unique ids not being unique enough --- custom_components/gree_custom/entity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/gree_custom/entity.py b/custom_components/gree_custom/entity.py index dc0f9f0..2308d6a 100755 --- a/custom_components/gree_custom/entity.py +++ b/custom_components/gree_custom/entity.py @@ -38,7 +38,7 @@ def __init__( @property def unique_id(self) -> str | None: """Returns a unique id for the entity.""" - return self.entity_description.key + return f"{self.device.mac_address_sub}_{self.entity_description.key}" @property def device_info(self) -> DeviceInfo: From 4b5a672c2a12a72fd75bfc3f848c50fe5cd3cadf Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Tue, 11 Nov 2025 23:29:09 +0000 Subject: [PATCH 058/113] Fix for config_flow repeating fan modes between flows --- custom_components/gree_custom/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/gree_custom/config_flow.py b/custom_components/gree_custom/config_flow.py index 9cb9e5e..a3bf7be 100644 --- a/custom_components/gree_custom/config_flow.py +++ b/custom_components/gree_custom/config_flow.py @@ -206,7 +206,7 @@ def build_options_schema( valid_fan_modes = [] if device.supports_property(GreeProp.FAN_SPEED): - valid_fan_modes = DEFAULT_FAN_MODES + valid_fan_modes = list(DEFAULT_FAN_MODES) if device.supports_property(GreeProp.FEAT_TURBO_MODE): valid_fan_modes.append(GATTR_FEAT_TURBO) if device.supports_property(GreeProp.FEAT_QUIET_MODE): From 304d1b2a7b9c1c27fe8a8b767d639b87369d4481 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Tue, 20 Jan 2026 20:46:02 +0000 Subject: [PATCH 059/113] Add GitHub Actions workflow for hassfest validation --- .github/workflows/hassfest.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .github/workflows/hassfest.yaml diff --git a/.github/workflows/hassfest.yaml b/.github/workflows/hassfest.yaml new file mode 100644 index 0000000..07d1dda --- /dev/null +++ b/.github/workflows/hassfest.yaml @@ -0,0 +1,14 @@ +name: Validate with hassfest + +on: + push: + pull_request: + schedule: + - cron: "0 0 * * *" + +jobs: + validate: + runs-on: "ubuntu-latest" + steps: + - uses: "actions/checkout@v3" + - uses: home-assistant/actions/hassfest@master From 654039cf1169b2e2a066f0ae361ae8de0cd8977c Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Tue, 20 Jan 2026 22:24:26 +0000 Subject: [PATCH 060/113] Obtain key before device_info in case the latter requires the device key and version in the future --- .../gree_custom/aiogree/device.py | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index fb5359a..0c71270 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -109,28 +109,6 @@ def __init__( async def bind_device(self) -> bool: """Setup the device (async).""" if not self._is_bound: - try: - # Used also as basic communication test - self._raw_info = await gree_get_device_info( - self._ip_addr, - self._max_connection_attempts, - self._timeout, - ) - except Exception as e: - _LOGGER.exception("Could not retrieve basic device info") - raise CannotConnect( - f"Not able to connect to the device {self._ip_addr}" - ) from e - else: - if self._raw_info.get("mac", "") != self._mac_addr: - raise CannotConnect( - f"Not able to connect to the device {self._ip_addr}. MAC mismatch {self._raw_info.get('mac', '')} not {self._mac_addr}." - ) - self._firmware_version = self._raw_info.get("firmware_version") - self._firmware_code = self._raw_info.get("firmware_code") - self._subdevicesCount = int( - self._raw_info.get("subdevices_count", 0) or 0 - ) try: ( encryption_key, @@ -165,6 +143,28 @@ async def bind_device(self) -> bool: self._is_available = True self._is_bound = True + try: + # Used also as basic communication test + self._raw_info = await gree_get_device_info( + self._ip_addr, + self._max_connection_attempts, + self._timeout, + ) + except Exception as e: + _LOGGER.exception("Could not retrieve basic device info") + raise CannotConnect( + f"Not able to connect to the device {self._ip_addr}" + ) from e + else: + if self._raw_info.get("mac", "") != self._mac_addr: + raise CannotConnect( + f"Not able to connect to the device {self._ip_addr}. MAC mismatch {self._raw_info.get('mac', '')} not {self._mac_addr}." + ) + self._firmware_version = self._raw_info.get("firmware_version") + self._firmware_code = self._raw_info.get("firmware_code") + self._subdevicesCount = int( + self._raw_info.get("subdevices_count", 0) or 0 + ) return self._is_bound async def fetch_sub_devices(self) -> list[GreeDiscoveredDevice]: @@ -512,7 +512,7 @@ def set_power_mode(self, value: bool): def operation_mode(self) -> OperationMode: """Return the current operation mode.""" return OperationMode( - self._get_prop_raw(GreeProp.OP_MODE, OperationMode.Auto.value) + self._get_prop_raw(GreeProp.OP_MODE, OperationMode.auto.value) ) def set_operation_mode(self, mode: OperationMode): @@ -522,7 +522,7 @@ def set_operation_mode(self, mode: OperationMode): @property def fan_speed(self) -> FanSpeed: """Return the current fan speed.""" - return FanSpeed(self._get_prop_raw(GreeProp.FAN_SPEED, FanSpeed.Auto.value)) + 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.""" @@ -532,7 +532,7 @@ def set_fan_speed(self, speed: FanSpeed): def vertical_swing_mode(self) -> VerticalSwingMode: """Return the current vertical swing setting.""" return VerticalSwingMode( - self._get_prop_raw(GreeProp.SWING_VERTICAL, VerticalSwingMode.Default.value) + self._get_prop_raw(GreeProp.SWING_VERTICAL, VerticalSwingMode.default.value) ) def set_vertical_swing_mode(self, swing_mode: VerticalSwingMode): @@ -544,7 +544,7 @@ def horizontal_swing_mode(self) -> HorizontalSwingMode: """Return the current horizontal swing setting.""" return HorizontalSwingMode( self._get_prop_raw( - GreeProp.SWING_HORIZONTAL, HorizontalSwingMode.Default.value + GreeProp.SWING_HORIZONTAL, HorizontalSwingMode.default.value ) ) From 39a40bdd4e0a33ec2dff2e27af8e54e71610ed05 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Tue, 20 Jan 2026 22:25:25 +0000 Subject: [PATCH 061/113] Use HA convention for state names --- custom_components/gree_custom/__init__.py | 2 +- custom_components/gree_custom/aiogree/api.py | 62 +++++------ custom_components/gree_custom/const.py | 70 ++++++------ custom_components/gree_custom/icons.json | 57 +++++----- custom_components/gree_custom/switch.py | 4 +- .../gree_custom/translations/en.json | 102 +++++++++--------- 6 files changed, 147 insertions(+), 150 deletions(-) diff --git a/custom_components/gree_custom/__init__.py b/custom_components/gree_custom/__init__.py index bde7195..ebc36f0 100755 --- a/custom_components/gree_custom/__init__.py +++ b/custom_components/gree_custom/__init__.py @@ -123,7 +123,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: GreeConfigEntry, device_entry: dr.DeviceEntry ) -> bool: - """Remove a config entry from a device.""" + """Remove a device from a config entry.""" # Find MAC address for this device (from identifiers) identifiers = device_entry.identifiers diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index c9834d8..3ab4042 100644 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -105,54 +105,54 @@ class TemperatureUnits(IntEnum): class OperationMode(IntEnum): """Enumeration of HVAC modes.""" - Auto = 0 - Cool = 1 - Dry = 2 - Fan = 3 - Heat = 4 + auto = 0 + cool = 1 + dry = 2 + fan = 3 + heat = 4 @unique class FanSpeed(IntEnum): """Enumeration of fan speeds.""" - Auto = 0 - Low = 1 - MediumLow = 2 - Medium = 3 - MediumHigh = 4 - High = 5 + auto = 0 + low = 1 + medium_low = 2 + medium = 3 + medium_high = 4 + high = 5 @unique class HorizontalSwingMode(IntEnum): """Enumeration of horizontal swing modes.""" - Default = 0 - FullSwing = 1 - Left = 2 - LeftCenter = 3 - Center = 4 - RightCenter = 5 - Right = 6 + default = 0 + full_swing = 1 + left = 2 + left_center = 3 + center = 4 + right_center = 5 + right = 6 @unique class VerticalSwingMode(IntEnum): """Enumeration of vertical swing modes.""" - Default = 0 - FullSwing = 1 - FixedUpper = 2 - FixedUpperMiddle = 3 - FixedMiddle = 4 - FixedLowerMiddle = 5 - FixedLower = 6 - SwingUpper = 7 - SwingUpperMiddle = 8 - SwingMiddle = 9 - SwingLowerMiddle = 10 - SwingLower = 11 + default = 0 + full_swing = 1 + fixed_upper = 2 + fixed_upper_middle = 3 + fixed_middle = 4 + fixed_lower_middle = 5 + fixed_lower = 6 + swing_upper = 7 + swing_upper_middle = 8 + swing_middle = 9 + swing_lower_middle = 10 + swing_lower = 11 class GreeCommand(IntEnum): @@ -270,7 +270,7 @@ async def udp_request_async( ip_addr, attempt + 1, max_retries, - repr(err), + err, ) # raise ValueError(f"Error communicating with {ip_addr}", ip_addr) from err finally: diff --git a/custom_components/gree_custom/const.py b/custom_components/gree_custom/const.py index 1603cb5..e84b4b3 100755 --- a/custom_components/gree_custom/const.py +++ b/custom_components/gree_custom/const.py @@ -94,54 +94,54 @@ ] HVAC_MODES_HA_TO_GREE = { - HVACMode.AUTO: OperationMode.Auto, - HVACMode.COOL: OperationMode.Cool, - HVACMode.DRY: OperationMode.Dry, - HVACMode.FAN_ONLY: OperationMode.Fan, - HVACMode.HEAT: OperationMode.Heat, + HVACMode.AUTO: OperationMode.auto, + HVACMode.COOL: OperationMode.cool, + HVACMode.DRY: OperationMode.dry, + HVACMode.FAN_ONLY: OperationMode.fan, + HVACMode.HEAT: OperationMode.heat, } HVAC_MODES_GREE_TO_HA = { - OperationMode.Auto: HVACMode.AUTO, - OperationMode.Cool: HVACMode.COOL, - OperationMode.Dry: HVACMode.DRY, - OperationMode.Fan: HVACMode.FAN_ONLY, - OperationMode.Heat: HVACMode.HEAT, + OperationMode.auto: HVACMode.AUTO, + OperationMode.cool: HVACMode.COOL, + OperationMode.dry: HVACMode.DRY, + OperationMode.fan: HVACMode.FAN_ONLY, + OperationMode.heat: HVACMode.HEAT, } DEFAULT_FAN_MODES = [ - FanSpeed.Auto.name, - FanSpeed.Low.name, - FanSpeed.MediumLow.name, - FanSpeed.Medium.name, - FanSpeed.MediumHigh.name, - FanSpeed.High.name, + FanSpeed.auto.name, + FanSpeed.low.name, + FanSpeed.medium_low.name, + FanSpeed.medium.name, + FanSpeed.medium_high.name, + FanSpeed.high.name, # GATTR_FEAT_TURBO, # Special mode on Gree device # GATTR_FEAT_QUIET_MODE, # Special mode on Gree device ] DEFAULT_SWING_MODES = [ - VerticalSwingMode.Default.name, - VerticalSwingMode.FullSwing.name, - VerticalSwingMode.FixedUpper.name, - VerticalSwingMode.FixedUpperMiddle.name, - VerticalSwingMode.FixedMiddle.name, - VerticalSwingMode.FixedLowerMiddle.name, - VerticalSwingMode.FixedLower.name, - VerticalSwingMode.SwingLower.name, - VerticalSwingMode.SwingLowerMiddle.name, - VerticalSwingMode.SwingMiddle.name, - VerticalSwingMode.SwingUpperMiddle.name, - VerticalSwingMode.SwingUpper.name, + VerticalSwingMode.default.name, + VerticalSwingMode.full_swing.name, + VerticalSwingMode.fixed_upper.name, + VerticalSwingMode.fixed_upper_middle.name, + VerticalSwingMode.fixed_middle.name, + VerticalSwingMode.fixed_lower_middle.name, + VerticalSwingMode.fixed_lower.name, + VerticalSwingMode.swing_lower.name, + VerticalSwingMode.swing_lower_middle.name, + VerticalSwingMode.swing_middle.name, + VerticalSwingMode.swing_upper_middle.name, + VerticalSwingMode.swing_upper.name, ] DEFAULT_SWING_HORIZONTAL_MODES = [ - HorizontalSwingMode.Default.name, - HorizontalSwingMode.FullSwing.name, - HorizontalSwingMode.Left.name, - HorizontalSwingMode.LeftCenter.name, - HorizontalSwingMode.Center.name, - HorizontalSwingMode.RightCenter.name, - HorizontalSwingMode.Right.name, + HorizontalSwingMode.default.name, + HorizontalSwingMode.full_swing.name, + HorizontalSwingMode.left.name, + HorizontalSwingMode.left_center.name, + HorizontalSwingMode.center.name, + HorizontalSwingMode.right_center.name, + HorizontalSwingMode.right.name, ] DEFAULT_SUPPORTED_FEATURES = [ diff --git a/custom_components/gree_custom/icons.json b/custom_components/gree_custom/icons.json index 7ccd180..d8da3a7 100755 --- a/custom_components/gree_custom/icons.json +++ b/custom_components/gree_custom/icons.json @@ -4,45 +4,42 @@ "hvac": { "state_attributes": { "fan_mode": { - "default": "mdi:fan", "state": { - "Auto": "mdi:fan-auto", - "Low": "mdi:fan-chevron-down", - "MediumLow": "mdi:fan-minus", - "Medium": "mdi:fan", - "MediumHigh": "mdi:fan-plus", - "High": "mdi:fan-chevron-up", - "feat_turbo": "mdi:weather-windy", - "feat_quiet": "mdi:sleep" + "auto": "mdi:fan-auto", + "low": "mdi:fan-chevron-down", + "medium_low": "mdi:fan-minus", + "medium": "mdi:fan", + "medium_high": "mdi:fan-plus", + "high": "mdi:fan-chevron-up", + "turbo": "mdi:weather-windy", + "quiet": "mdi:sleep" } }, "swing_horizontal_mode": { - "default": "mdi:arrow-oscillating", "state": { - "Default": "mdi:arrow-oscillating-off", - "FulLSwing": "mdi:arrow-oscillating", - "Left": "mdi:arrow-left", - "LeftCenter": "mdi:arrow-bottom-left", - "Center": "mdi:arrow-down", - "RightCenter": "mdi:arrow-bottom-right", - "Right": "mdi:arrow-left" + "default": "mdi:arrow-oscillating", + "full_swing": "mdi:arrow-oscillating", + "left": "mdi:arrow-left", + "left_center": "mdi:arrow-bottom-left", + "center": "mdi:arrow-down", + "right_center": "mdi:arrow-bottom-right", + "right": "mdi:arrow-left" } }, "swing_mode": { - "default": "mdi:arrow-up-down", "state": { - "Default": "mdi:arrow-up-down", - "FullSwing": "mdi:arrow-up-down", - "FixedUpper": "mdi:arrow-up", - "FixedUpperMiddle": "mdi:arrow-top-left", - "FixedMiddle": "mdi:arrow-left", - "FixedLowerMiddle": "mdi:arrow-bottom-left", - "FixedLower": "mdi:arrow-down", - "SwingLower": "mdi:arrow-up-down", - "SwingLowerMiddle": "mdi:arrow-up-down", - "SwingMiddle": "mdi:arrow-up-down", - "SwingUpperMiddle": "mdi:arrow-up-down", - "SwingUpper": "mdi:arrow-up-down" + "default": "mdi:arrow-up-down", + "full_swing": "mdi:arrow-up-down", + "fixed_upper": "mdi:arrow-up", + "fixed_upper_middle": "mdi:arrow-top-left", + "fixed_middle": "mdi:arrow-left", + "fixed_lower_middle": "mdi:arrow-bottom-left", + "fixed_lower": "mdi:arrow-down", + "swing_lower": "mdi:arrow-up-down", + "swing_lower_middle": "mdi:arrow-up-down", + "swing_middle": "mdi:arrow-up-down", + "swing_upper_middle": "mdi:arrow-up-down", + "swing_upper": "mdi:arrow-up-down" } } } diff --git a/custom_components/gree_custom/switch.py b/custom_components/gree_custom/switch.py index 4c8b963..9d84962 100644 --- a/custom_components/gree_custom/switch.py +++ b/custom_components/gree_custom/switch.py @@ -70,7 +70,7 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): available_func=lambda device: ( device.available and device.supports_property(GreeProp.FEAT_XFAN) - and device.operation_mode in [OperationMode.Cool, OperationMode.Dry] + and device.operation_mode in [OperationMode.cool, OperationMode.dry] ), value_func=lambda device, _: device.feature_x_fan, set_func=lambda device, _, value: device.set_feature_xfan(value), @@ -82,7 +82,7 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): lambda device: device.available and device.supports_property(GreeProp.FEAT_SLEEP_MODE) and device.operation_mode - in [OperationMode.Cool, OperationMode.Dry, OperationMode.Heat] + in [OperationMode.cool, OperationMode.dry, OperationMode.heat] ), value_func=lambda device, _: device.feature_sleep, set_func=lambda device, _, value: device.set_feature_sleep(value), diff --git a/custom_components/gree_custom/translations/en.json b/custom_components/gree_custom/translations/en.json index c04d3a6..a26439c 100755 --- a/custom_components/gree_custom/translations/en.json +++ b/custom_components/gree_custom/translations/en.json @@ -122,41 +122,41 @@ }, "fan_modes": { "options": { - "Auto": "Auto", - "Low": "Low", - "MediumLow": "Medium-Low", - "Medium": "Medium", - "MediumHigh": "Medium-High", - "High": "High", + "auto": "Auto", + "low": "Low", + "medium_low": "Medium-Low", + "medium": "Medium", + "medium_high": "Medium-High", + "high": "High", "turbo": "Turbo", "quiet": "Quiet" } }, "swing_modes": { "options": { - "Default": "Default", - "FullSwing": "Swing in full range", - "FixedUpper": "Fixed in the upmost position", - "FixedUpperMiddle": "Fixed in the middle-up position", - "FixedMiddle": "Fixed in the middle position", - "FixedLowerMiddle": "Fixed in the middle-low position", - "FixedLower": "Fixed in the lowest position", - "SwingLower": "Swing in the downmost region", - "SwingLowerMiddle": "Swing in the middle-low region", - "SwingMiddle": "Swing in the middle region", - "SwingUpperMiddle": "Swing in the middle-up region", - "SwingUpper": "Swing in the upmost region" + "default": "Default", + "full_swing": "Swing in full range", + "fixed_upper": "Fixed in the upmost position", + "fixed_upper_middle": "Fixed in the middle-up position", + "fixed_middle": "Fixed in the middle position", + "fixed_lower_middle": "Fixed in the middle-low position", + "fixed_lower": "Fixed in the lowest position", + "swing_lower": "Swing in the downmost region", + "swing_lower_middle": "Swing in the middle-low region", + "swing_middle": "Swing in the middle region", + "swing_upper_middle": "Swing in the middle-up region", + "swing_upper": "Swing in the upmost region" } }, "swing_horizontal_modes": { "options": { - "Default": "Default", - "FullSwing": "Swing in full range", - "Left": "Fixed in the leftmost position", - "LeftCenter": "Fixed in the middle-left position", - "Center": "Fixed in the middle position", - "RightCenter": "Fixed in the middle-right position", - "Right": "Fixed in the rightmost position" + "default": "Default", + "full_swing": "Swing in full range", + "left": "Fixed in the leftmost position", + "left_center": "Fixed in the middle-left position", + "center": "Fixed in the middle position", + "right_center": "Fixed in the middle-right position", + "right": "Fixed in the rightmost position" } }, "features": { @@ -195,12 +195,12 @@ "default": "Fan Speed", "fan_mode": { "state": { - "Auto": "Auto", - "Low": "Low", - "MediumLow": "Medium-Low", - "Medium": "Medium", - "MediumHigh": "Medium-High", - "High": "High", + "auto": "Auto", + "low": "Low", + "medium_low": "Medium-Low", + "medium": "Medium", + "medium_high": "Medium-High", + "high": "High", "turbo": "Turbo", "quiet": "Quiet" } @@ -208,30 +208,30 @@ "swing_mode": { "default": "Vertical Swing", "state": { - "Default": "Default", - "FullSwing": "Swing in full range", - "FixedUpper": "Fixed in the upmost position", - "FixedUpperMiddle": "Fixed in the middle-up position", - "FixedMiddle": "Fixed in the middle position", - "FixedLowerMiddle": "Fixed in the middle-low position", - "FixedLower": "Fixed in the lowest position", - "SwingLower": "Swing in the downmost region", - "SwingLowerMiddle": "Swing in the middle-low region", - "SwingMiddle": "Swing in the middle region", - "SwingUpperMiddle": "Swing in the middle-up region", - "SwingUpper": "Swing in the upmost region" + "default": "Default", + "full_swing": "Swing in full range", + "fixed_upper": "Fixed in the upmost position", + "fixed_upper_middle": "Fixed in the middle-up position", + "fixed_middle": "Fixed in the middle position", + "fixed_lower_middle": "Fixed in the middle-low position", + "fixed_lower": "Fixed in the lowest position", + "swing_lower": "Swing in the downmost region", + "swing_lower_middle": "Swing in the middle-low region", + "swing_middle": "Swing in the middle region", + "swing_upper_middle": "Swing in the middle-up region", + "swing_upper": "Swing in the upmost region" } }, "swing_horizontal_mode": { "default": "Horizontal Swing", "state": { - "Default": "Default", - "FullSwing": "Swing in full range", - "Left": "Fixed in the leftmost position", - "LeftCenter": "Fixed in the middle-left position", - "Center": "Fixed in the middle position", - "RightCenter": "Fixed in the middle-right position", - "Right": "Fixed in the rightmost position" + "default": "Default", + "full_swing": "Swing in full range", + "left": "Fixed in the leftmost position", + "left_center": "Fixed in the middle-left position", + "center": "Fixed in the middle position", + "right_center": "Fixed in the middle-right position", + "right": "Fixed in the rightmost position" } } } @@ -302,7 +302,7 @@ }, "exceptions": { "turbo_availability": { - "message": "Trubo mode is not available in Dry and Fan-only modes." + "message": "Turbo mode is not available in Dry and Fan-only modes." }, "quiet_availability": { "message": "Quiet mode is only available in Dry and Cool modes." From 87c04c28bca329d58f5052931f729d39b08b8ea7 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Tue, 20 Jan 2026 22:29:48 +0000 Subject: [PATCH 062/113] Add pt translation --- .../gree_custom/translations/pt.json | 314 ++++++++++++++++++ 1 file changed, 314 insertions(+) create mode 100755 custom_components/gree_custom/translations/pt.json diff --git a/custom_components/gree_custom/translations/pt.json b/custom_components/gree_custom/translations/pt.json new file mode 100755 index 0000000..06f663d --- /dev/null +++ b/custom_components/gree_custom/translations/pt.json @@ -0,0 +1,314 @@ +{ + "config": { + "title": "Gree Climate", + "description": "Configure os seus equipamentos Gree", + "error": { + "unknown": "Ocorreu algo de errado, tente de novo. Se o problema persistir, verifique os registos.", + "cannot_connect": "Não foi possível ligar ao dispositivo. Verifique as configurações do dispositivo e de rede e tente de novo.ain. Se o problema persistir, verifique os registos.", + "cannot_connect_key": "Não foi possível ligar ao dispositivo. A chave de encriptação definida não corresponde à chave do dispositivo.", + "no_devices_found": "Não foram encontrados novos dispositivos Gree compatíveis na rede. Por favor, adicione o dispositivo manualmente." + }, + "abort": { + "already_configured": "Um dispositivo com este endereço MAC já foi configurado previamente." + }, + "step": { + "user": { + "title": "Configuração Gree", + "description": "Escolha como adicionar o seu dispositivo Gree", + "data": { + "discovery": "Método" + } + }, + "manual_discovery": { + "title": "Dispositivos Encontrados", + "description": "Foram encontrado(s) {devices_found} dispositivo(s) Gree. Selecione um para adicionar ou escolha a configuração manual.", + "data": { + "device": "Dispositivo" + } + }, + "manual_add": { + "title": "Configuração do dispositivo", + "data": { + "host": "Endereço IP", + "mac": "Endereço MAC" + }, + "sections": { + "advanced": { + "name": "Definições Avançadas", + "description": "Configure as definições avançadas do dispositivo", + "data": { + "port": "Porta", + "encryption_key": "Chave de Encriptação", + "encryption_version": "Versão de Encriptação", + "uid": "UID", + "disable_available_check": "Desativar Verificação de Disponibilidade", + "max_online_attempts": "Máximo de Tentativas de Ligação", + "timeout": "Tempo Limite de Ligação" + }, + "data_description": { + "max_online_attempts": "Número máximo de tentativas de comunicação com o dispositivo antes de ele ser marcado como indisponível", + "timeout": "Tempo limite de espera das respostas de cada ligação ao dispositivo" + } + } + } + }, + "device_options": { + "title": "Funcionalidades do dispositivo", + "description": "A API da Gree não fornece um método robusto para obter as funcionalidades de um dispositivo. Por favor, use as opções abaixo com base nos seus conhecimentos sobre o dispositivo.", + "data": { + "device_name": "Nome do Dispositivo", + "hvac_modes": "Modos de Climatização", + "fan_modes": "Velocidades da Ventoinha", + "swing_modes": "Modos de Oscilamento Vertical", + "swing_horizontal_modes": "Modos de Oscilamento Horizontal", + "features": "Outas Funcionalidades e Modos", + "external_temperature_sensor": "Sensor de Temperatura", + "external_humidity_sensor": "Sensor de Humidade", + "restore_states": "Restaurar Entidades", + "target_temp_step": "Incremento de Temperatura" + }, + "data_description": { + "external_temperature_sensor": "Se definido, substitui o sensor integrado de temperatura interior do dispositivo", + "external_humidity_sensor": "Se definido, substitui o sensor integrado de humidade interior do dispositivo", + "restore_states": "Se ativo, quando a integração é iniciada, o estado do dispositivo será reposto para o último estado observado na integração.", + "target_temp_step": "Define o incremento da temperatura quando esta é ajustada. Graus Fahrenheit são arredondados para às unidades." + } + }, + "reconfigure": { + "title": "Configuração do dispositivo", + "data": { + "name": "Nomw", + "host": "Endereço IP", + "mac": "Endereço MAC" + }, + "sections": { + "advanced": { + "name": "Definições Avançadas", + "description": "Configure as definições avançadas do dispositivo", + "data": { + "port": "Porta", + "encryption_key": "Chave de Encriptação", + "encryption_version": "Versão de Encriptação", + "uid": "UID", + "disable_available_check": "Desativar Verificação de Disponibilidade", + "max_online_attempts": "Máximo de Tentativas de Ligação", + "timeout": "Tempo Limite de Ligação" + }, + "data_description": { + "max_online_attempts": "Número máximo de tentativas de comunicação com o dispositivo antes de ele ser marcado como indisponível", + "timeout": "Tempo limite de espera das respostas de cada ligação ao dispositivo" + } + } + } + } + } + }, + "selector": { + "discovery_method": { + "options": { + "discover": "Procura automática de dispositivos", + "manual": "Adicionar manualmente" + } + }, + "hvac_modes": { + "options": { + "auto": "Automático", + "cool": "Arrefecer", + "dry": "Secar", + "fan_only": "Ventilação", + "heat": "Aquecer", + "off": "Desligar" + } + }, + "fan_modes": { + "options": { + "auto": "Automática", + "low": "Baixa", + "medium_low": "Média-Baixa", + "medium": "Média", + "medium_high": "Média-Alta", + "high": "Alta", + "turbo": "Turbo", + "quiet": "Silenciosa" + } + }, + "swing_modes": { + "options": { + "default": "Por defeito", + "full_swing": "Oscilação completa", + "fixed_upper": "Fixo no topo", + "fixed_upper_middle": "Fixo entre o meio e topo", + "fixed_middle": "Fixo no meio", + "fixed_lower_middle": "Fixo entre o meio e baixo", + "fixed_lower": "Fixo em baixo", + "swing_lower": "Oscilação na região inferior", + "swing_lower_middle": "Oscilação na região média-inferior", + "swing_middle": "Oscilação na região intermédia", + "swing_upper_middle": "Oscilação na região média-superior", + "swing_upper": "Oscilação na região superior" + } + }, + "swing_horizontal_modes": { + "options": { + "default": "Por defeito", + "full_Swing": "Oscilação completa", + "left": "Fixo à esquerda", + "left_center": "Fixo entre o meio e esquerda", + "center": "Fixo no meio", + "right_center": "Fixo entre o meio e a direita", + "right": "Fixo à direita" + } + }, + "features": { + "options": { + "beeper": "Aviso Sonoro", + "air": "Ar Fresco", + "xfan": "X-Fan", + "sleep": "Dormir", + "eightdegheat": "Fora de Casa", + "lights": "Visor", + "health": "Saúde", + "anti_direct_blow": "Anti Sopro Direto", + "powersave": "Poupança de Energia", + "light_sensor": "Brilho Automático do Visor" + } + } + }, + "entity": { + "sensor": { + "indoor_temperature": { + "name": "Temperatura Interior", + "description": "Temperatura reportada pelo sensor do dispositivo interior" + }, + "outdoor_temperature": { + "name": "Temperatura Exterior", + "description": "Temperatura reportada pelo sensor do dispositivo exterior" + }, + "room_humidity": { + "name": "Humidade Interior", + "description": "Humidade reportada pelo sensor do dispositivo interior" + } + }, + "climate": { + "hvac": { + "state_attributes": { + "fan_mode": { + "state": { + "auto": "Automática", + "low": "Baixa", + "medium_ow": "Média-Baixa", + "medium": "Média", + "medium_high": "Média-Alta", + "high": "Alta", + "turbo": "Turbo", + "quiet": "Silenciosa" + } + }, + "swing_mode": { + "state": { + "default": "Por defeito", + "full_swing": "Oscilação completa", + "fixed_upper": "Fixo no topo", + "fixed_upper_middle": "Fixo entre o meio e topo", + "fixed_middle": "Fixo no meio", + "fixed_lowerMiddle": "Fixo entre o meio e baixo", + "fixed_lower": "Fixo em baixo", + "swing_lower": "Oscilação na região inferior", + "swing_lower_middle": "Oscilação na região média-inferior", + "swing_middle": "Oscilação na região intermédia", + "swing_upper_middle": "Oscilação na região média-superior", + "swing_upper": "Oscilação na região superior" + } + }, + "swing_horizontal_mode": { + "state": { + "default": "Por defeito", + "full_swing": "Oscilação completa", + "left": "Fixo à esquerda", + "left_center": "Fixo entre o meio e esquerda", + "center": "Fixo no meio", + "right_center": "Fixo entre o meio e a direita", + "right": "Fixo à direita" + } + } + } + } + }, + "number": { + "target_temp_step": { + "name": "Incremento de Temperatura", + "description": "Define o incremento de temperatura usado para ajustar a temperatura." + } + }, + "select": { + "temperature_units": { + "name": "Unidade de Temperatura", + "description": "Seleciona a unidade de temperatura usada pelo dispositivo." + } + }, + "switch": { + "auto_light": { + "name": "Visor Automático", + "description": "Controla o funcionamento do visor do dispositivo com base no modo de operação. Se ativo, o visor irá ficar desligado no modo desligar e ligado nos restantes modos." + }, + "auto_xfan": { + "name": "X-Fan Automática", + "description": "Controla o modo X-Fan com base no modo de operação. Quando ativo, o modo X-Fan será ligado automaticamente nos modos de Refrigerar e Desumidificar." + }, + "lights": { + "name": "Visor", + "description": "Controla se o visor do dispositivo está ligado ou não." + }, + "xfan": { + "name": "X-Fan", + "description": "Evita condensação após desligar o dispositivo mantendo o ventilador ligado por uns momentos." + }, + "health": { + "name": "Saúde", + "description": "Purifica a ar através de ionização." + }, + "powersave": { + "name": "Poupança de Energia", + "description": "Reduz o consumo energético do dispositivo. Apenas disponível no modo de Refrigerar." + }, + "eightdegheat": { + "name": "Fora de Casa", + "description": "Mantém uma temperatura de 8°C para evitar o congelamento. Apenas disponível no modo de Aquecer." + }, + "sleep": { + "name": "Dormir", + "description": "Mantém uma operação noturna confortável. Apenas disponível nos modos de Refrigerar e Aquecer." + }, + "air": { + "name": "Ar Fresco", + "description": "Controla o modo circulação de Ar Fresco." + }, + "anti_direct_blow": { + "name": "Anti Sopro Direto", + "description": "Previne que o fluxo de ar seja direcionado diretamente para as pessoas através do ajuste das grelhas direcionais." + }, + "light_sensor": { + "name": "Brilho Automático do Visor", + "description": "Permite que o brilho do visor se adapte automáticamente ao ambiente. Requer o visor esteja ligado." + }, + "beeper": { + "name": "Aviso Sonoro", + "description": "Se ativo, o dispositivo irá produzir um 'beep' a cada comando." + } + } + }, + "exceptions": { + "turbo_availability": { + "message": "Modo Turbo não está disponível nos modo de Desumidificar e Ventoinha." + }, + "quiet_availability": { + "message": "Modo Silencioso apenas disponível nos modo de Desumidificar e Ventoinha." + }, + "entity_unavailable": { + "message": "A entidade não está disponível." + }, + "generic": { + "message": "Ocorreu um erro a realizar a ação pretendida, consulto os registos da integração." + } + } +} From 3ec5c5b86a0deafe6082725f4e687ffd9a577ee7 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Tue, 20 Jan 2026 22:41:36 +0000 Subject: [PATCH 063/113] Remove invalid translation keys and move unverified to a different folder for future review --- .../de.json | 0 .../he.json | 0 .../hu.json | 0 .../it.json | 0 .../pl.json | 0 .../pt-BR.json | 0 .../ro.json | 0 .../ru.json | 0 .../zh-Hans.json | 0 .../gree_custom/translations/en.json | 45 ++++++----------- .../gree_custom/translations/pt.json | 48 +++++++------------ 11 files changed, 31 insertions(+), 62 deletions(-) rename custom_components/gree_custom/{translations => translation-to-review}/de.json (100%) rename custom_components/gree_custom/{translations => translation-to-review}/he.json (100%) rename custom_components/gree_custom/{translations => translation-to-review}/hu.json (100%) rename custom_components/gree_custom/{translations => translation-to-review}/it.json (100%) rename custom_components/gree_custom/{translations => translation-to-review}/pl.json (100%) rename custom_components/gree_custom/{translations => translation-to-review}/pt-BR.json (100%) rename custom_components/gree_custom/{translations => translation-to-review}/ro.json (100%) rename custom_components/gree_custom/{translations => translation-to-review}/ru.json (100%) rename custom_components/gree_custom/{translations => translation-to-review}/zh-Hans.json (100%) diff --git a/custom_components/gree_custom/translations/de.json b/custom_components/gree_custom/translation-to-review/de.json similarity index 100% rename from custom_components/gree_custom/translations/de.json rename to custom_components/gree_custom/translation-to-review/de.json diff --git a/custom_components/gree_custom/translations/he.json b/custom_components/gree_custom/translation-to-review/he.json similarity index 100% rename from custom_components/gree_custom/translations/he.json rename to custom_components/gree_custom/translation-to-review/he.json diff --git a/custom_components/gree_custom/translations/hu.json b/custom_components/gree_custom/translation-to-review/hu.json similarity index 100% rename from custom_components/gree_custom/translations/hu.json rename to custom_components/gree_custom/translation-to-review/hu.json diff --git a/custom_components/gree_custom/translations/it.json b/custom_components/gree_custom/translation-to-review/it.json similarity index 100% rename from custom_components/gree_custom/translations/it.json rename to custom_components/gree_custom/translation-to-review/it.json diff --git a/custom_components/gree_custom/translations/pl.json b/custom_components/gree_custom/translation-to-review/pl.json similarity index 100% rename from custom_components/gree_custom/translations/pl.json rename to custom_components/gree_custom/translation-to-review/pl.json diff --git a/custom_components/gree_custom/translations/pt-BR.json b/custom_components/gree_custom/translation-to-review/pt-BR.json similarity index 100% rename from custom_components/gree_custom/translations/pt-BR.json rename to custom_components/gree_custom/translation-to-review/pt-BR.json diff --git a/custom_components/gree_custom/translations/ro.json b/custom_components/gree_custom/translation-to-review/ro.json similarity index 100% rename from custom_components/gree_custom/translations/ro.json rename to custom_components/gree_custom/translation-to-review/ro.json diff --git a/custom_components/gree_custom/translations/ru.json b/custom_components/gree_custom/translation-to-review/ru.json similarity index 100% rename from custom_components/gree_custom/translations/ru.json rename to custom_components/gree_custom/translation-to-review/ru.json diff --git a/custom_components/gree_custom/translations/zh-Hans.json b/custom_components/gree_custom/translation-to-review/zh-Hans.json similarity index 100% rename from custom_components/gree_custom/translations/zh-Hans.json rename to custom_components/gree_custom/translation-to-review/zh-Hans.json diff --git a/custom_components/gree_custom/translations/en.json b/custom_components/gree_custom/translations/en.json index a26439c..9d83613 100755 --- a/custom_components/gree_custom/translations/en.json +++ b/custom_components/gree_custom/translations/en.json @@ -192,7 +192,6 @@ "climate": { "hvac": { "state_attributes": { - "default": "Fan Speed", "fan_mode": { "state": { "auto": "Auto", @@ -206,7 +205,6 @@ } }, "swing_mode": { - "default": "Vertical Swing", "state": { "default": "Default", "full_swing": "Swing in full range", @@ -223,7 +221,6 @@ } }, "swing_horizontal_mode": { - "default": "Horizontal Swing", "state": { "default": "Default", "full_swing": "Swing in full range", @@ -239,64 +236,50 @@ }, "number": { "target_temp_step": { - "name": "Temperature Step", - "description": "Sets the increment step for adjusting the target temperature." + "name": "Temperature Step" } }, "select": { "temperature_units": { - "name": "Temperature Units", - "description": "Select the temperature units used by the device." + "name": "Temperature Units" } }, "switch": { "auto_light": { - "name": "Auto Display Light", - "description": "Automatically controls the display lights based on HVAC operations. When enabled, lights will turn on/off with the AC unit." + "name": "Auto Display Light" }, "auto_xfan": { - "name": "Auto X-Fan", - "description": "Automatically controls the X-Fan mode based on HVAC operations. When enabled, X-Fan will automatically turn on in cooling and dry modes." + "name": "Auto X-Fan" }, "lights": { - "name": "Display Light", - "description": "Controls the display lights on the air conditioner unit." + "name": "Display Light" }, "xfan": { - "name": "X-Fan", - "description": "Enables or disables the X-Fan mode for extra drying when turning off." + "name": "X-Fan" }, "health": { - "name": "Health", - "description": "Enables or disables the Health mode for air ionization and purification." + "name": "Health" }, "powersave": { - "name": "Power Save", - "description": "Enables or disables the power saving mode for energy efficiency. Only available in cooling mode." + "name": "Power Save" }, "eightdegheat": { - "name": "Smart Heat 8ºC", - "description": "Enables or disables the 8°C heating mode for frost protection. Only available in heating mode." + "name": "Smart Heat 8ºC" }, "sleep": { - "name": "Sleep", - "description": "Enables or disables the sleep mode for comfortable overnight operation. Only available in cooling or heating mode." + "name": "Sleep" }, "air": { - "name": "Fresh Air", - "description": "Enables or disables the fresh air circulation mode." + "name": "Fresh Air" }, "anti_direct_blow": { - "name": "Anti Direct Blow", - "description": "Prevents direct air flow from blowing on people by adjusting the air deflector position." + "name": "Anti Direct Blow" }, "light_sensor": { - "name": "Display Auto Brightness", - "description": "Enables or disables light sensor for automatic brightness. Requires lights to be enabled." + "name": "Display Auto Brightness" }, "beeper": { - "name": "Beeper", - "description": "Controls the beeper sounds from the air conditioner unit. When enabled, the unit will make sounds for button presses and status changes." + "name": "Beeper" } } }, diff --git a/custom_components/gree_custom/translations/pt.json b/custom_components/gree_custom/translations/pt.json index 06f663d..4eec0ab 100755 --- a/custom_components/gree_custom/translations/pt.json +++ b/custom_components/gree_custom/translations/pt.json @@ -151,7 +151,7 @@ "swing_horizontal_modes": { "options": { "default": "Por defeito", - "full_Swing": "Oscilação completa", + "full_swing": "Oscilação completa", "left": "Fixo à esquerda", "left_center": "Fixo entre o meio e esquerda", "center": "Fixo no meio", @@ -196,7 +196,7 @@ "state": { "auto": "Automática", "low": "Baixa", - "medium_ow": "Média-Baixa", + "medium_low": "Média-Baixa", "medium": "Média", "medium_high": "Média-Alta", "high": "Alta", @@ -211,7 +211,7 @@ "fixed_upper": "Fixo no topo", "fixed_upper_middle": "Fixo entre o meio e topo", "fixed_middle": "Fixo no meio", - "fixed_lowerMiddle": "Fixo entre o meio e baixo", + "fixed_lower_middle": "Fixo entre o meio e baixo", "fixed_lower": "Fixo em baixo", "swing_lower": "Oscilação na região inferior", "swing_lower_middle": "Oscilação na região média-inferior", @@ -236,64 +236,50 @@ }, "number": { "target_temp_step": { - "name": "Incremento de Temperatura", - "description": "Define o incremento de temperatura usado para ajustar a temperatura." + "name": "Incremento de Temperatura" } }, "select": { "temperature_units": { - "name": "Unidade de Temperatura", - "description": "Seleciona a unidade de temperatura usada pelo dispositivo." + "name": "Unidade de Temperatura" } }, "switch": { "auto_light": { - "name": "Visor Automático", - "description": "Controla o funcionamento do visor do dispositivo com base no modo de operação. Se ativo, o visor irá ficar desligado no modo desligar e ligado nos restantes modos." + "name": "Visor Automático" }, "auto_xfan": { - "name": "X-Fan Automática", - "description": "Controla o modo X-Fan com base no modo de operação. Quando ativo, o modo X-Fan será ligado automaticamente nos modos de Refrigerar e Desumidificar." + "name": "X-Fan Automática" }, "lights": { - "name": "Visor", - "description": "Controla se o visor do dispositivo está ligado ou não." + "name": "Visor" }, "xfan": { - "name": "X-Fan", - "description": "Evita condensação após desligar o dispositivo mantendo o ventilador ligado por uns momentos." + "name": "X-Fan" }, "health": { - "name": "Saúde", - "description": "Purifica a ar através de ionização." + "name": "Saúde" }, "powersave": { - "name": "Poupança de Energia", - "description": "Reduz o consumo energético do dispositivo. Apenas disponível no modo de Refrigerar." + "name": "Poupança de Energia" }, "eightdegheat": { - "name": "Fora de Casa", - "description": "Mantém uma temperatura de 8°C para evitar o congelamento. Apenas disponível no modo de Aquecer." + "name": "Fora de Casa" }, "sleep": { - "name": "Dormir", - "description": "Mantém uma operação noturna confortável. Apenas disponível nos modos de Refrigerar e Aquecer." + "name": "Dormir" }, "air": { - "name": "Ar Fresco", - "description": "Controla o modo circulação de Ar Fresco." + "name": "Ar Fresco" }, "anti_direct_blow": { - "name": "Anti Sopro Direto", - "description": "Previne que o fluxo de ar seja direcionado diretamente para as pessoas através do ajuste das grelhas direcionais." + "name": "Anti Sopro Direto" }, "light_sensor": { - "name": "Brilho Automático do Visor", - "description": "Permite que o brilho do visor se adapte automáticamente ao ambiente. Requer o visor esteja ligado." + "name": "Brilho Automático do Visor" }, "beeper": { - "name": "Aviso Sonoro", - "description": "Se ativo, o dispositivo irá produzir um 'beep' a cada comando." + "name": "Aviso Sonoro" } } }, From aeecaa7d4a0bde9e1bd113257ba8f4628fdfe460 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Tue, 20 Jan 2026 22:45:49 +0000 Subject: [PATCH 064/113] Remove more invalid translation keys --- custom_components/gree_custom/translations/en.json | 11 +++-------- custom_components/gree_custom/translations/pt.json | 11 +++-------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/custom_components/gree_custom/translations/en.json b/custom_components/gree_custom/translations/en.json index 9d83613..f113a75 100755 --- a/custom_components/gree_custom/translations/en.json +++ b/custom_components/gree_custom/translations/en.json @@ -1,7 +1,5 @@ { "config": { - "title": "Gree Climate", - "description": "Configure your Gree air conditioner", "error": { "unknown": "Something went wrong, please try again. If the issue persists, please check the logs.", "cannot_connect": "Unable to connect to the device. Please check the device configuration and network connection and try again. If the issue persists, please check the logs.", @@ -177,16 +175,13 @@ "entity": { "sensor": { "indoor_temperature": { - "name": "Indoor Temperature", - "description": "The temperature reported by the HVAC indoors unit" + "name": "Indoor Temperature" }, "outdoor_temperature": { - "name": "Outdoor Temperature", - "description": "The temperature reported by the HVAC outdoors unit" + "name": "Outdoor Temperature" }, "room_humidity": { - "name": "Indoor Humidity", - "description": "Shows the room humidity level measured by the air conditioner's internal sensor." + "name": "Indoor Humidity" } }, "climate": { diff --git a/custom_components/gree_custom/translations/pt.json b/custom_components/gree_custom/translations/pt.json index 4eec0ab..57829b4 100755 --- a/custom_components/gree_custom/translations/pt.json +++ b/custom_components/gree_custom/translations/pt.json @@ -1,7 +1,5 @@ { "config": { - "title": "Gree Climate", - "description": "Configure os seus equipamentos Gree", "error": { "unknown": "Ocorreu algo de errado, tente de novo. Se o problema persistir, verifique os registos.", "cannot_connect": "Não foi possível ligar ao dispositivo. Verifique as configurações do dispositivo e de rede e tente de novo.ain. Se o problema persistir, verifique os registos.", @@ -177,16 +175,13 @@ "entity": { "sensor": { "indoor_temperature": { - "name": "Temperatura Interior", - "description": "Temperatura reportada pelo sensor do dispositivo interior" + "name": "Temperatura Interior" }, "outdoor_temperature": { - "name": "Temperatura Exterior", - "description": "Temperatura reportada pelo sensor do dispositivo exterior" + "name": "Temperatura Exterior" }, "room_humidity": { - "name": "Humidade Interior", - "description": "Humidade reportada pelo sensor do dispositivo interior" + "name": "Humidade Interior" } }, "climate": { From 2b7617e9c3741558ddb8c2956e5a24958240b17a Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Tue, 20 Jan 2026 22:53:35 +0000 Subject: [PATCH 065/113] Remove unused aiofiles dependency --- custom_components/gree_custom/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/gree_custom/manifest.json b/custom_components/gree_custom/manifest.json index 7f7bb72..e370cad 100755 --- a/custom_components/gree_custom/manifest.json +++ b/custom_components/gree_custom/manifest.json @@ -5,7 +5,7 @@ "documentation": "https://github.com/RobHofmann/HomeAssistant-GreeClimateComponent", "dependencies": [], "codeowners": ["@robhofmann"], - "requirements": ["pycryptodome", "aiofiles", "asyncio_dgram"], + "requirements": ["pycryptodome", "asyncio_dgram"], "config_flow": true, "integration_type": "hub" } From 0b1cef16bb6372a92ee901ef7664d10463435e6e Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Tue, 20 Jan 2026 22:59:13 +0000 Subject: [PATCH 066/113] Add missing manifest iot_class and dependencies --- custom_components/gree_custom/manifest.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/custom_components/gree_custom/manifest.json b/custom_components/gree_custom/manifest.json index e370cad..72f1010 100755 --- a/custom_components/gree_custom/manifest.json +++ b/custom_components/gree_custom/manifest.json @@ -3,9 +3,10 @@ "name": "Gree A/C", "version": "4.0.0", "documentation": "https://github.com/RobHofmann/HomeAssistant-GreeClimateComponent", - "dependencies": [], + "dependencies": ["network"], "codeowners": ["@robhofmann"], "requirements": ["pycryptodome", "asyncio_dgram"], "config_flow": true, - "integration_type": "hub" + "integration_type": "hub", + "iot_class": "local_polling" } From 58c2518af742be2ec2c2b6e8322aba6cdc323c6e Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Tue, 20 Jan 2026 23:41:35 +0000 Subject: [PATCH 067/113] Updated pt Translation --- custom_components/gree_custom/translations/pt.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/gree_custom/translations/pt.json b/custom_components/gree_custom/translations/pt.json index 57829b4..5490341 100755 --- a/custom_components/gree_custom/translations/pt.json +++ b/custom_components/gree_custom/translations/pt.json @@ -115,7 +115,7 @@ "dry": "Secar", "fan_only": "Ventilação", "heat": "Aquecer", - "off": "Desligar" + "off": "Desligado" } }, "fan_modes": { @@ -151,7 +151,7 @@ "default": "Por defeito", "full_swing": "Oscilação completa", "left": "Fixo à esquerda", - "left_center": "Fixo entre o meio e esquerda", + "left_center": "Fixo entre o meio e a esquerda", "center": "Fixo no meio", "right_center": "Fixo entre o meio e a direita", "right": "Fixo à direita" @@ -220,7 +220,7 @@ "default": "Por defeito", "full_swing": "Oscilação completa", "left": "Fixo à esquerda", - "left_center": "Fixo entre o meio e esquerda", + "left_center": "Fixo entre o meio e a esquerda", "center": "Fixo no meio", "right_center": "Fixo entre o meio e a direita", "right": "Fixo à direita" From 88d772e64ae6147f26253b680bfa9aa907dd9137 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Tue, 20 Jan 2026 23:47:51 +0000 Subject: [PATCH 068/113] Sort manifest keys --- custom_components/gree_custom/manifest.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/custom_components/gree_custom/manifest.json b/custom_components/gree_custom/manifest.json index 72f1010..649e51f 100755 --- a/custom_components/gree_custom/manifest.json +++ b/custom_components/gree_custom/manifest.json @@ -1,12 +1,12 @@ { "domain": "gree_custom", "name": "Gree A/C", - "version": "4.0.0", - "documentation": "https://github.com/RobHofmann/HomeAssistant-GreeClimateComponent", - "dependencies": ["network"], "codeowners": ["@robhofmann"], - "requirements": ["pycryptodome", "asyncio_dgram"], "config_flow": true, + "dependencies": ["network"], + "documentation": "https://github.com/RobHofmann/HomeAssistant-GreeClimateComponent", "integration_type": "hub", - "iot_class": "local_polling" + "iot_class": "local_polling", + "requirements": ["pycryptodome", "asyncio_dgram"], + "version": "4.0.0" } From 1471b1cb30e8742300152fd04e9e059a15eca6c2 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Mon, 2 Mar 2026 16:24:31 +0000 Subject: [PATCH 069/113] Ignore and log case when temperature is set with AUTO mode --- custom_components/gree_custom/climate.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/custom_components/gree_custom/climate.py b/custom_components/gree_custom/climate.py index 7628ab9..b2bb6bf 100644 --- a/custom_components/gree_custom/climate.py +++ b/custom_components/gree_custom/climate.py @@ -830,8 +830,8 @@ async def async_set_temperature(self, **kwargs): temperature: float | None = kwargs.get(ATTR_TEMPERATURE) hvac_mode: HVACMode | None = kwargs.get(ATTR_HVAC_MODE) - if temperature is None: - _LOGGER.error("No temperature received to set as target") + if temperature is None and hvac_mode is None: + _LOGGER.error("No temperature or mode received to set") return if not self.available: @@ -839,9 +839,21 @@ async def async_set_temperature(self, **kwargs): translation_domain=DOMAIN, translation_key="entity_unavailable" ) + # Ignore temperature if mode is AUTO + if ( + temperature is not None + and hvac_mode is not None + and hvac_mode == HVACMode.AUTO + ): + temperature = None + _LOGGER.warning( + "Ignoring temperature when setting the device mode to AUTO. Will be overriden by the device factory settings" + ) + try: # TODO: Confirm that HA sends the values in this entity's temperature_unit which matches the device unit - self.device.set_target_temperature(temperature) + if temperature is not None: + self.device.set_target_temperature(temperature) if hvac_mode and hvac_mode in self._attr_hvac_modes: # This will call the set_hvac_mode which internally will send to device From 2c6d6d4964f5836764b7d38ffa4361718d43907b Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Mon, 2 Mar 2026 18:33:02 +0000 Subject: [PATCH 070/113] Abstract the encryption to a Cipher class Inspired by github.com/cmroche/greeclimate --- custom_components/gree_custom/aiogree/api.py | 121 ++++++------------ .../gree_custom/aiogree/cipher.py | 113 ++++++++++++++++ .../gree_custom/aiogree/device.py | 24 ++-- 3 files changed, 163 insertions(+), 95 deletions(-) create mode 100644 custom_components/gree_custom/aiogree/cipher.py diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index 3ab4042..9be28c6 100644 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -21,6 +21,7 @@ GREE_GENERIC_DEVICE_KEY, GREE_GENERIC_DEVICE_KEY_GCM, ) +from .cipher import CipherBase, CipherV1, CipherV2 _LOGGER = logging.getLogger(__name__) @@ -349,8 +350,7 @@ async def fetch_result( ip_addr: str, port: int, json_data: str, - cipher, - encryption_version: EncryptionVersion, + cipher: CipherBase, max_connection_attempts: int, timeout: int, ): @@ -372,7 +372,7 @@ async def fetch_result( # except Exception as err: # raise ValueError(f"Error communicating with {ip_addr}", ip_addr) from err - data = get_gree_response_data(received_json, cipher, encryption_version) + data = get_gree_response_data(received_json, cipher) _LOGGER.debug("Got data from %s: %s", ip_addr, data) @@ -381,27 +381,17 @@ async def fetch_result( def get_gree_response_data( received_json: str, - cipher, - encryption_version: EncryptionVersion, + cipher: CipherBase, ): """Decodes a response from a gree device.""" data = json.loads(received_json) - encodedPack = data.get("pack") - if encodedPack: - pack = base64.b64decode(encodedPack) - decryptedPack = cipher.decrypt(pack) - _LOGGER.debug("Decoding pack: %s", decryptedPack) - pack = decryptedPack.decode("utf-8") - replacedPack = pack.replace("\x0f", "").replace( - pack[pack.rindex("}") + 1 :], "" - ) - data["pack"] = json.loads(replacedPack) + encodedPack = data.get("pack", None) + tag = data.get("tag", None) - if encryption_version == EncryptionVersion.V2: - tag = data["tag"] - _LOGGER.debug("Verifying tag: %s", tag) - cipher.verify(base64.b64decode(tag)) + if encodedPack: + decodedPack = cipher.decrypt(encodedPack, tag) + data["pack"] = json.loads(decodedPack) return data @@ -410,8 +400,7 @@ async def get_result_pack( ip_addr: str, port: int, json_data: str, - cipher, - encryption_version: EncryptionVersion, + cipher: CipherBase, max_connection_attempts: int, timeout: int, ): @@ -422,7 +411,6 @@ async def get_result_pack( port, json_data, cipher, - encryption_version, max_connection_attempts, timeout, ) @@ -433,29 +421,27 @@ async def get_result_pack( raise ValueError("No pack received from device") -def get_cipher(key: str, encryption_version: EncryptionVersion): +def get_cipher(key: str, encryption_version: EncryptionVersion) -> CipherBase: """Get AES cipher object based on encryption version.""" if encryption_version == EncryptionVersion.V1: - return AES.new(key.encode("utf8"), AES.MODE_ECB) + return CipherV1(key) if encryption_version == EncryptionVersion.V2: - return AES.new(key.encode("utf8"), AES.MODE_GCM, nonce=GCM_IV).update( - assoc_data=GCM_ADD - ) + return CipherV2(key) _LOGGER.error("Unsupported encryption version: %d", encryption_version) return None -def gree_get_default_cipher(encryption_version: EncryptionVersion): +def gree_get_default_cipher(encryption_version: EncryptionVersion) -> CipherBase: """Get AES cipher object based on encryption version using default keys.""" if encryption_version == EncryptionVersion.V1: - return get_cipher(GREE_GENERIC_DEVICE_KEY, encryption_version) + return CipherV1() if encryption_version == EncryptionVersion.V2: - return get_cipher(GREE_GENERIC_DEVICE_KEY_GCM, encryption_version) + return CipherV2() _LOGGER.error("Unsupported encryption version: %d", encryption_version) return None @@ -463,29 +449,16 @@ def gree_get_default_cipher(encryption_version: EncryptionVersion): def gree_create_encrypted_pack( data: str, - cipher, - encryption_version: EncryptionVersion, -) -> tuple[str, str]: + cipher: CipherBase, +) -> tuple[str, str | None]: """Create an encrypted pack to send to the device.""" if cipher is None: raise ValueError("Cipher must not be None") - if encryption_version == EncryptionVersion.V1: - encrypted_data = cipher.encrypt(pad(data).encode("utf-8")) - return ( - base64.b64encode(encrypted_data).decode("utf-8"), - "", - ) - - if encryption_version == EncryptionVersion.V2: - encrypted_data, tag = cipher.encrypt_and_digest(data.encode("utf-8")) - return ( - base64.b64encode(encrypted_data).decode("utf-8"), - base64.b64encode(tag).decode("utf-8"), - ) + encrypted_data, tag = cipher.encrypt(data) - raise ValueError(f"Unsupported encryption version: {encryption_version}") + return (encrypted_data, tag) def gree_create_bind_pack( @@ -544,8 +517,7 @@ def gree_create_payload( i_command: GreeCommand, mac_addr: str, uid: int, - encryption_version: EncryptionVersion, - tag: str, + tag: str | None, ) -> str: """Create the full payload to send to the device.""" @@ -558,7 +530,7 @@ def gree_create_payload( "uid": uid, } - if encryption_version == EncryptionVersion.V2 and tag is not None: + if tag is not None: base_payload["tag"] = tag _LOGGER.debug("Payload: %s", base_payload) @@ -585,13 +557,13 @@ async def gree_get_device_key( else [encryption_version] ): _LOGGER.info("Trying to retrieve device encryption key v%d", enc_version) + + cipher = gree_get_default_cipher(enc_version) pack, tag = gree_create_encrypted_pack( - gree_create_bind_pack(mac_addr, uid, enc_version), - gree_get_default_cipher(enc_version), - enc_version, + gree_create_bind_pack(mac_addr, uid, enc_version), cipher ) jsonPayloadToSend = gree_create_payload( - pack, "pack", GreeCommand.BIND, mac_addr, uid, enc_version, tag + pack, "pack", GreeCommand.BIND, mac_addr, uid, tag ) try: @@ -599,8 +571,7 @@ async def gree_get_device_key( ip_addr, port, jsonPayloadToSend, - gree_get_default_cipher(enc_version), - enc_version, + cipher, max_connection_attempts, timeout, ) @@ -635,8 +606,7 @@ async def gree_get_status( mac_addr_sub: str, port: int, uid: int, - encryption_key: str, - encryption_version: EncryptionVersion, + cipher: CipherBase, props: list[GreeProp], max_connection_attempts: int, timeout: int, @@ -648,12 +618,10 @@ async def gree_get_status( status_values_raw: dict[GreeProp, int | None] = {} pack, tag = gree_create_encrypted_pack( - gree_create_status_pack(mac_addr_sub, [prop.value for prop in props]), - get_cipher(encryption_key, encryption_version), - encryption_version, + gree_create_status_pack(mac_addr_sub, [prop.value for prop in props]), cipher ) jsonPayloadToSend = gree_create_payload( - pack, "pack", GreeCommand.STATUS, mac_addr, uid, encryption_version, tag + pack, "pack", GreeCommand.STATUS, mac_addr, uid, tag ) try: @@ -661,8 +629,7 @@ async def gree_get_status( ip_addr, port, jsonPayloadToSend, - get_cipher(encryption_key, encryption_version), - encryption_version, + cipher, max_connection_attempts, timeout, ) @@ -688,8 +655,7 @@ async def gree_set_status( mac_addr_sub: str, port: int, uid: int, - encryption_key: str, - encryption_version: EncryptionVersion, + cipher: CipherBase, props: dict[GreeProp, int], max_connection_attempts: int, timeout: int, @@ -699,14 +665,10 @@ async def gree_set_status( _LOGGER.debug("Trying to set device status") set_pack = gree_create_set_pack(mac_addr_sub, props) - pack, tag = gree_create_encrypted_pack( - set_pack, - get_cipher(encryption_key, encryption_version), - encryption_version, - ) + pack, tag = gree_create_encrypted_pack(set_pack, cipher) jsonPayloadToSend = gree_create_payload( - pack, "pack", GreeCommand.STATUS, mac_addr, uid, encryption_version, tag + pack, "pack", GreeCommand.STATUS, mac_addr, uid, tag ) try: @@ -714,8 +676,7 @@ async def gree_set_status( ip_addr, port, jsonPayloadToSend, - get_cipher(encryption_key, encryption_version), - encryption_version, + cipher, max_connection_attempts, timeout, ) @@ -767,7 +728,6 @@ async def gree_get_device_info( DEFAULT_DEVICE_PORT, json.dumps({"t": "scan"}), gree_get_default_cipher(EncryptionVersion.V1), - EncryptionVersion.V1, max_connection_attempts, timeout, ) @@ -813,7 +773,6 @@ async def discover_gree_devices( data = get_gree_response_data( response, gree_get_default_cipher(EncryptionVersion.V1), - EncryptionVersion.V1, ) if data is not None: pack = data.get("pack") @@ -888,8 +847,7 @@ async def gree_get_sub_devices_list( mac_addr: str, port: int, uid: int, - encryption_key: str, - encryption_version: EncryptionVersion, + cipher: CipherBase, max_connection_attempts: int, timeout: int, ) -> list: @@ -897,8 +855,7 @@ async def gree_get_sub_devices_list( try: pack, tag = gree_create_encrypted_pack( gree_create_sub_bind_pack(mac_addr), - gree_get_default_cipher(encryption_version), - encryption_version, + cipher, ) jsonPayloadToSend = gree_create_payload( @@ -907,7 +864,6 @@ async def gree_get_sub_devices_list( GreeCommand.BIND, mac_addr, uid, - encryption_version, tag, ) @@ -915,8 +871,7 @@ async def gree_get_sub_devices_list( ip_addr, port, jsonPayloadToSend, - gree_get_default_cipher(encryption_version), - encryption_version, + cipher, max_connection_attempts, timeout, ) diff --git a/custom_components/gree_custom/aiogree/cipher.py b/custom_components/gree_custom/aiogree/cipher.py new file mode 100644 index 0000000..9fdda3b --- /dev/null +++ b/custom_components/gree_custom/aiogree/cipher.py @@ -0,0 +1,113 @@ +"""Encapsulates device encryption.""" + +import base64 +import logging + +from Crypto.Cipher import AES + +from .const import GCM_ADD, GCM_IV, GREE_GENERIC_DEVICE_KEY, GREE_GENERIC_DEVICE_KEY_GCM + +_LOGGER = logging.getLogger(__name__) + + +class CipherBase: + """Base class for the encryprion module.""" + + def __init__(self, key: str) -> None: + """Initialize the class.""" + self.key = key + + @property + def key(self) -> str: + """The encryprion key.""" + return self._key.decode() + + @key.setter + def key(self, value: str) -> None: + self._key = value.encode() + + def encrypt(self, data: str) -> tuple[str, str | None]: + """Encrypts the data. Returns the encrypted data and an optional tag.""" + raise NotImplementedError + + def decrypt(self, data: str, tag: str | None) -> str: + """Decrypts the data. Optionally checks integrity if tag is provided.""" + raise NotImplementedError + + +class CipherV1(CipherBase): + """Implements the V1 type encryption used by Gree.""" + + def __init__(self, key: str = GREE_GENERIC_DEVICE_KEY) -> None: + """Initialize V1 Encryption.""" + super().__init__(key) + + def __create_cipher(self) -> AES: + return AES.new(self._key, AES.MODE_ECB) + + def __pad(self, s) -> str: + aesBlockSize = 16 + requiredPaddingSize = aesBlockSize - len(s) % aesBlockSize + return s + requiredPaddingSize * chr(requiredPaddingSize) + + def encrypt(self, data: str) -> tuple[str, str | None]: + """Encrypt data with V1.""" + _LOGGER.debug("Encrypting data: %s", data) + cipher = self.__create_cipher() + padded = self.__pad(data).encode("utf-8") + encrypted = cipher.encrypt(padded) + encoded = base64.b64encode(encrypted).decode("utf-8") + _LOGGER.debug("Encrypted data: %s", encoded) + return encoded, None + + def decrypt(self, data: str, tag: None) -> str: + """Decrypt data with V1.""" + _LOGGER.debug("Decrypting data: %s", data) + cipher = self.__create_cipher() + decoded = base64.b64decode(data) + decrypted = cipher.decrypt(decoded).decode("utf-8") + t = decrypted.replace("\x0f", "").replace( + decrypted[decrypted.rindex("}") + 1 :], "" + ) + _LOGGER.debug("Decrypted data: %s", t) + return t + + +class CipherV2(CipherBase): + """Implements the V2 type encryption used by Gree.""" + + def __init__(self, key: str = GREE_GENERIC_DEVICE_KEY_GCM) -> None: + """Initialize V2 Encryption.""" + super().__init__(key) + + def __create_cipher(self) -> AES: + cipher = AES.new(self._key, AES.MODE_GCM, nonce=GCM_IV) + cipher.update(GCM_ADD) + return cipher + + def encrypt(self, data: str) -> tuple[str, str]: + """Encrypt data with V2 and return the data with a tag.""" + _LOGGER.debug("Encrypting data: %s", data) + cipher = self.__create_cipher() + encrypted, tag = cipher.encrypt_and_digest(data.encode("utf-8")) + encoded = base64.b64encode(encrypted).decode("utf-8") + tag = base64.b64encode(tag).decode("utf-8") + _LOGGER.debug("Encrypted data: %s", encoded) + _LOGGER.debug("Cipher digest: %s", tag) + return encoded, tag + + def decrypt(self, data: str, tag: str) -> str: + """Decrypt data with V2 and verify the data with the tag.""" + _LOGGER.debug("Decrypting data: %s", data) + cipher = self.__create_cipher() + decoded = base64.b64decode(data) + decrypted = cipher.decrypt(decoded).decode("utf-8") + t = decrypted.replace("\x0f", "").replace( + decrypted[decrypted.rindex("}") + 1 :], "" + ) + + _LOGGER.debug("Verifying tag: %s", tag) + cipher.verify(base64.b64decode(tag)) + + _LOGGER.debug("Decrypted data: %s", t) + return t diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index 0c71270..feeb7e5 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -11,12 +11,15 @@ OperationMode, TemperatureUnits, VerticalSwingMode, + get_cipher, + gree_get_default_cipher, gree_get_device_info, gree_get_device_key, gree_get_status, gree_get_sub_devices_list, gree_set_status, ) +from .cipher import CipherBase from .const import ( DEFAULT_CONNECTION_MAX_ATTEMPTS, DEFAULT_CONNECTION_TIMEOUT, @@ -82,6 +85,7 @@ def __init__( self._mac_addr_sub, self._mac_addr = self._mac_addr.lower().split("@", 1) self._encryption_version: EncryptionVersion | None = encryption_version self._encryption_key: str = encryption_key + self._cipher: CipherBase | None = None self._uid: int = uid self._raw_state: dict[GreeProp, int] = {} @@ -122,7 +126,6 @@ async def bind_device(self) -> bool: self._max_connection_attempts, self._timeout, ) - self._is_bound = True except Exception as e: raise GreeDeviceNotBoundError("Unable to obtain device key") from e else: @@ -140,6 +143,7 @@ async def bind_device(self) -> bool: self._encryption_version, ) + self._cipher = get_cipher(encryption_key, encryption_version) self._is_available = True self._is_bound = True @@ -174,7 +178,7 @@ async def fetch_sub_devices(self) -> list[GreeDiscoveredDevice]: if not self._is_bound: await self.bind_device() - assert self._encryption_version is not None + assert self._cipher is not None if not self._subdevicesCount: return [] @@ -187,8 +191,7 @@ async def fetch_sub_devices(self) -> list[GreeDiscoveredDevice]: self._mac_addr, self._port, self._uid, - self._encryption_key, - self._encryption_version, + gree_get_default_cipher(self._encryption_version), self._max_connection_attempts, self._timeout, ) @@ -226,7 +229,7 @@ async def fetch_device_status(self): if not self._is_bound: await self.bind_device() - assert self._encryption_version is not None + assert self._cipher is not None try: state, props_not_present = await gree_get_status( @@ -235,8 +238,7 @@ async def fetch_device_status(self): self._mac_addr_sub, self._port, self._uid, - self._encryption_key, - self._encryption_version, + self._cipher, self._props_to_update, self._max_connection_attempts, self._timeout, @@ -250,8 +252,7 @@ async def fetch_device_status(self): self._mac_addr, self._port, self._uid, - self._encryption_key, - self._encryption_version, + self._cipher, props_not_present, self._max_connection_attempts, self._timeout, @@ -270,7 +271,7 @@ async def update_device_status(self): if not self._is_bound: await self.bind_device() - assert self._encryption_version is not None + assert self._cipher is not None # If there is no change in the properties, do nothing has_updated_states = any( @@ -290,8 +291,7 @@ async def update_device_status(self): self._mac_addr_sub, self._port, self._uid, - self._encryption_key, - self._encryption_version, + self._cipher, self._new_raw_state, self._max_connection_attempts, self._timeout, From 9d2b3130ae34975ae0200ebb8c133f3fceae3966 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Thu, 12 Mar 2026 17:52:44 +0000 Subject: [PATCH 071/113] Add suport for diagnostics --- custom_components/gree_custom/aiogree/api.py | 1 + .../gree_custom/aiogree/device.py | 26 +++++++++ custom_components/gree_custom/coordinator.py | 11 ++++ custom_components/gree_custom/diagnostics.py | 58 +++++++++++++++++++ 4 files changed, 96 insertions(+) create mode 100644 custom_components/gree_custom/diagnostics.py diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index 9be28c6..52f9d32 100644 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -737,6 +737,7 @@ async def gree_get_device_info( else: _LOGGER.debug("Got device info: %s", data) info: dict[str, str | None] = {} + info["raw"] = data info["firmware_version"], info["firmware_code"] = extract_version(data) info["mac"] = data.get("mac", "") info["subdevices_count"] = data.get("subCnt", 0) diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index feeb7e5..bcee26d 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -1,6 +1,7 @@ """Contains the API to interface with the Gree device.""" import logging +from typing import Any from .api import ( EncryptionVersion, @@ -401,6 +402,31 @@ def log_device_info(self): ) _LOGGER.info("Mode: %s", self.operation_mode.name) + def gather_diagnostics(self) -> dict[str, Any]: + """Returns diagnostic info for the device.""" + data: dict[str, Any] = {} + + info = { + "ip": self._ip_addr, + "mac": self._mac_addr, + "mac_sub": self._mac_addr_sub, + "port": self._port, + "timeout": self._timeout, + "max_connections": self._max_connection_attempts, + "is_bound": self._is_bound, + "is_available": self._is_available, + "beeper": self.beeper, + "encryption": str(self.encryption_version), + "key": self.encryption_key[:5] + "[redacted]", + } + + data["info"] = info + data["raw_info"] = self._raw_info + data["state"] = {str(k): v for k, v in self._raw_state.items()} + data["state_unsaved"] = {str(k): v for k, v in self._new_raw_state.items()} + + return data + 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 diff --git a/custom_components/gree_custom/coordinator.py b/custom_components/gree_custom/coordinator.py index 89f233e..6f67a39 100644 --- a/custom_components/gree_custom/coordinator.py +++ b/custom_components/gree_custom/coordinator.py @@ -1,6 +1,7 @@ """Data update coordinator for Gree integration.""" import logging +from typing import Any from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -67,6 +68,16 @@ async def _async_update_data(self): _LOGGER.exception("Error getting state from device") raise UpdateFailed("Error getting state from device") from err + def get_coordinator_diagnostics(self) -> dict[str, Any]: + """Returns diagnostic data for the coordinator.""" + data = self.device.gather_diagnostics() + data["coordinator_props"] = { + "auto_light": self.feature_auto_light, + "auto_xfan": self.feature_auto_xfan, + } + + return data + @property def feature_auto_light(self) -> bool: """Returns the state of the Auto Display Light Feature.""" diff --git a/custom_components/gree_custom/diagnostics.py b/custom_components/gree_custom/diagnostics.py new file mode 100644 index 0000000..27e996c --- /dev/null +++ b/custom_components/gree_custom/diagnostics.py @@ -0,0 +1,58 @@ +"""Provide diagnostics support for entries and devices.""" + +import logging +from typing import Any + +from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntry + +from .const import ( + DOMAIN, +) +from .coordinator import GreeConfigEntry, GreeCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: GreeConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + _LOGGER.debug("Getting entry diagnostics") + + coordinators: dict[str, GreeCoordinator] = entry.runtime_data + + data: dict[str, Any] = {} + for i, c in coordinators.items(): + data[i] = c.get_coordinator_diagnostics() + + diagnostics = {"entry_data": dict(entry.data), "data": data} + diagnostics["entry_data"]["advanced"]["encryption_key"] = ( + diagnostics["entry_data"]["advanced"]["encryption_key"][:5] + "[redacted]" + ) + return diagnostics + + +async def async_get_device_diagnostics( + hass: HomeAssistant, entry: GreeConfigEntry, device: DeviceEntry +) -> dict[str, Any]: + """Return diagnostics for a device.""" + _LOGGER.debug("Getting device diagnostics") + + # Find MAC address for this device (from identifiers) + identifiers = device.identifiers + mac: str | None = None + for domain, identifier in identifiers: + if domain == DOMAIN: + mac = identifier + break + + coordinator = entry.runtime_data.get(mac, None) + + diagnostics = { + "device": device.dict_repr, + "data": coordinator.get_coordinator_diagnostics() if coordinator else "", + } + + return diagnostics From a6b64ef7ae4bfefacaa37297df2255d3e2bbd3a9 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Thu, 12 Mar 2026 18:08:08 +0000 Subject: [PATCH 072/113] Add custom branding --- custom_components/gree_custom/brand/icon.png | Bin 0 -> 2947 bytes custom_components/gree_custom/brand/icon@2x.png | Bin 0 -> 5358 bytes custom_components/gree_custom/brand/logo.png | Bin 0 -> 10370 bytes custom_components/gree_custom/brand/logo@2x.png | Bin 0 -> 21702 bytes 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 custom_components/gree_custom/brand/icon.png create mode 100644 custom_components/gree_custom/brand/icon@2x.png create mode 100644 custom_components/gree_custom/brand/logo.png create mode 100644 custom_components/gree_custom/brand/logo@2x.png diff --git a/custom_components/gree_custom/brand/icon.png b/custom_components/gree_custom/brand/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..88c4f5dc4d73cfa9e120cad27f9a50c9ef1fcb69 GIT binary patch literal 2947 zcmZ8jc{G&$9)D)Tkc_cZ)>%w6WG6c_5i-NrUW8KCP_ie>*s_b>w^!C0iZ(QsN-KHC zR@YKVmh5ZEV3HEXeWuR6=bn4c?>yh{@B4hV=l92Rewj2|OJPANK>z@Rt*y)*0008E z5I_J1mY`}^C$PZ%NmLR5)a40nokZ~7hC5i2f!ZFKPhcNPqc~ZBLx5Vo6A3;mU=#*m z*|bc-EmQC?Dg&T3!6@b60<0^5qYNmO0rpUE@}Fjlx*wzgoyeev)edQnGtA?3ALZLRev|GJrwXlh5;n2LfCOrA|u6?PkLCKYZ008-i7oaA(V zc&QBOB4sR|c7t}s{v|^ErSMfEL)=CD!Lgd4(df3(Cr%?z-VdyOpv!&x+K(vwm%`(s zA|q3P9dEb4>*KS>Df#BO%T@~L%>ONGb-p?miN)Jm?seZJ9!Z-{L_NJ99v&{#aYS;f zoA7PZbHnzluiW;&yPUkXI)f`54SHw?sYpLeEr_^J&50@5dcD|?IKEGRu;RPcmlm733u-mhjZ9G1_$yloM^w}G_p-feN??OMM-hD_^^ zZ#jPaB1H5kgw+zUitu@EPI8QfuHvw2dGH0<@XF3{!rSELnf9&B?je>{^V|Dgduy{d z5mxk!m*3OTvfk`BUy%Vs+8Shx1;NWTdkPCEjn*5zIHP<&UK( zBXz5^synkMF6MTj%l`58Kcq|4(ynTao*rz9kaRVq=Xh=m^z5*=Y2(%v?-n+^Wj;7- zEVZ}$TcktV26xSzP7owA^#y{QyhKYbGOHdil)a=N%*B$l@8d?nIH}C_kS~Atr*qR$3^%cqRK=toT52mu5-Lp#ZgI9u|H?ZL=W_7Q=+O<_fC*qyg z)5-7V@}?05F$fB&CJT>h`xkWLagGkN@Xpr`c3gHp&}ahRINKnUc=$BJRXv`or&g9Z z2X#-Z)Q+7flz`J5q^&;J5q?|=6WRMP4LA_ZdMDW>zvqRgd_!9e&Md2v$L@ zXTs9fe{$X9WFgEAW|}>`ll(#4l^DQvE9J=#7L8kLs`W;b`#`O=WB`N zld*^}gWbZJ#dH%d39`W44*{BDAh+~V%9O7C*JzHuh>B=(}hZTeiSAZbN9-Hsv1qh!H}!~7Ek7%Rvz)X&aN^i8hg9_d7AoUb1W%Y>6` zlb51*dVTO_r;vdNPdKV1GM|NpyGhIW^y?(C(Apz_0YA){(dX(kklk8hM~bL_2(QFK3h3Qm98uba0vEef zJ7P={k>@(+IOh@IPyZwT*p?XI^L2=FYV?f>8MJ?Jt?eJ8#os3HVhwlFNHrI&L?<7q z#CiGubMTsf(vJ<-8s@QGZP(+{0vDD!Vu1v$vqOkPHIsX0Ix!S$Dh#A~$IMzN8i(a2#KdP2|H>}M?s zONbvWY#ft@6)Nb$EEShXzg8p(KXM|hE40u z#|Q#`Vzh>`@>}`s%I5F_hGhaj37(-Ic$+jv2tX_BT+0cy&?yrQSU|%y5eBxJ{D0i> zC194(aEIL@s`rpJDK!LvddY`(RdqS4BQu`K53!~8>BN+V%e5D!p1?^mnP)xj=CVH* zmlGByE%q|RBNiRyPlyejWn{Z1$|lP8X<8?mBU%qmZ8dD%PmVw!{5l}tzKnRUVb2QEc; z?j&ifgb|gS%jbLmwSW1laY+8w`vWx6*1Z>uAH6$&LHErkNpPaNNW4Z$P^-2IXD)?Ss@ok_=zVknogQ(udkEG%F71ulB{8$Y=*L;dOGr zb4wGJuz&hF7)B$9m9_*l)$~Xj(W_uy(Qok=eyZ@B^|T<-@_sPPtuvj#CoO5c9R-YWBSp93e@F|xW3z5Uw=W_~7 zjNMn#XFj7eF%dEvIyWwlcjnX1vDOmw%bLuOSlq|9YRIo*@mhCB(PEUw5GlirSk;h$ z3q4lO0YiEoo;^qAu$yt=6H2Ql;qMAd2Yfb%>qLsgdpB0EFE3eknJ?xQ+d*hb%%G8mT$m@PRsvzlwc}lwtu1WLYsp^8{|18z8ruK> literal 0 HcmV?d00001 diff --git a/custom_components/gree_custom/brand/icon@2x.png b/custom_components/gree_custom/brand/icon@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..bcefd72bd1fe98b04146adbaba9ffdc373e5c1a9 GIT binary patch literal 5358 zcmb7I`#+Qa`@ipP*5=%{oaHo!qH;bKbI2hZQ_51bken(-C}dkgr3?#+Ov+n1jU>Gb zb5<&(l5)%;heF6+X@kNY~&#X(G1P8a|HF(*g6 zg8%>#KnQ?<3QSWWwq0Ogr>zOr08o)4vT;&K;DccY9c+NI_X<-2EKbC`?-vADi0S|# zABq<&reFu$3g-xF5Ihrbdj>c^09^6`mqI~&4zD_kSN(t5CZ7qzGZ*lxFhV{=pmNRo zFZ)1%1x&#%SWrQVAgIaw8~j^DkQJ=?%@+Cdn*}!sg2*QMzX4|PYES}mj<8AlTLr6hh>;iCIl7)Wk~O1`Hoq(p;8M5jA2QqN`1<#y8ETTd%=3lza1fPIYpl5VhV{o5j2wnd&9U*nw^dtA-`{785u zZA|0tVsvlhsrRNPckwPVn`ivLKh0Ya`wxz;^|gNJ?->cI*dBbjcA!4$)gb97lceL~ z|FrMcIoB}f?(mbPwe%bDjY+wmEC2a=ZSqLOx$vUk%x=!K*DE)Y>x)zo&hX8up5IMS z*Gx^@ich5hb(r5Ob;4-;cxGiq88N$WHRQD-j`c}Z;pjc{kcdYkeJ_KnQsF@{Git%k z>G#XfQ68BK$P)3KlC?(xpTAv{>Kr@{9vueq|3uXm3lD~b??n*CxZ~wRO5t}3EHfi* z<$u3?^})TOSf}E}Gf6{K;!D=Z)DxH3$G1AEN#j>`dd>wkcKNAM397xfuIw26IlX`J zibAZd_wYEY6-w+ZTZN%xRD0@A8X#P)S-1{CQ#d8X$ zhST@)qpF>+e!7?}8MCqUW5&xo*_fplW2-Un^MlXi4xv7N$%fqEZflKiU!?1*HXdNd z<0hCCwWeXlKh9a*@L#`El?Up1R*H?L1Amx%2;RI*OwB)+yk&M9s_^D^H7L|#5LFV& zdq*&ir8eP>!QhG%jPD?f-`clzRc?C`ckb!~!)tXo3-}`|CE50SgNorwHsgExwDURW zA6@-as!#}*b4)DT%ovk%wVJzqYgP@0B!7)1_5KFBP?2*M=N??!P~+xBHBwk7@x@C< zI`d4A+KMB-qPsp!Tv0y5-Ux-?H!e!}&3SEz5chIZzSCrDs_lSAt5g@~N59O+Afht8bvn=D5T`^a z)|Kwu>`Bc0%!-!qK{kB*Ji3@Nkf)nrxGIX7Uz2WsIQ^2!M%kfW%9DueKP7yJ9>``N zuGxWUTyTX%zVTK*Fx>h)hs%5*LPcrt{dU0^OESmWo+$-JVRB@0uj%Mt-&u_jwadSL zyqe)D4F68FGcFePQbaZ_Hi6;{GX>$et8%wP;F!D8e@^9JGl+8T%3F;HRpj*eYe{gg zrzh|}9<#Y!#?P-`)DdFw3kdy>MG;KCtvM93g4j1D${x4+DoeNj;&hrVM=Qe|s{B!} z%X9r#iO$}Tp1RMJe_p@MZtw-J@OFT9JH4c%AOO(5;qakW&yeEYqJ7F90Ue(Hz|hq+ zy)DLU{CLgE1l1s<`{;E-1;~7t^99Wov&#y%EuyYINnu9-EtgRrHRg{1@*f8|Lx&xV&i-Vj7zRtnma?myv7Ee@P0iq@Y4e;-RFmG`Uz+|di z1AXB1genQlZg}|4AIQN4m_T)f$fD?oS>bEv(ftY;Ay^!E(jY)FfO{VYKZZ*;t;zwS zL_h14Sn5gBr0GX7&`Gen^=ta#*&6G6_HWYyfwQ!%wvsy#iU#YFVXPKVrh4IgSRcoXA|ov+g2+mb>CAI8NA`ybS8tBPGl@glND~H4MIOciHs#_ zve=EZS;)~vTkx%+?=S@R%(1m7jN+z*d6snaeODkNXj=i^`jzaay}u>d#`VJ%YZ;hI z9WvS0wL>A>5b-_X{t@K8Dr7(HeumTCB_TFe=9fAfK2~+xX>oW*d*EG)b!MZ7isrA~ zIOGM-Hs#YbsvoNwGR%-HaicTj+eX8nMnec)uT54*!pP}4H9HIT^r~4otkVW>A+u=r z5hloej_KDfm_yXm7l$(PVA7lT)BbTd8prd$c1)?zvZ(2qQKjZj1ESI z!!AV|a8XY*Z8aJO#c6MC2M0^Cs*q8^NcXG%a_KSK;ktg?=S$Uz+rBS1$r?;}uUa@O zmw@ZvFUgbJwcX})s4V!;o0WQ+;M^|ih8pp?#Ll-251OSXM1AF0$M`*QRDQ4J{CgPJ z>N)JfzKx>e)>E!E9NBy)G>Vz1PW-imxHEhrImTqvCrT*;_p+m>Yh6v#zY63$g?-)g zgSl$4ozw+z*Q;R%&X(a8P26vpidK`6RWGmaoX^%n_ve=!g?u`zyndpZP_xbN6KWEr zd9B=&${7-UeirF&k+@KQSC6dFoS;rGhD^nsFX8*@jC-n6Anbt+@^B)uBR zo;hzSJ%zNcuS>LEd3F4QV;r>b8G)j5_MC0%EPQ*Qg+yrEJI&Op+%7%splkqn#pN#J zTuSGpIVz&Pd*`35Pzd9qqOjL+7Qkq;bv05H$r}sIz`sC;KAvi!pTCd9UD_7aDrY`@ z;s?k0qtMCqtk`{C-#@R)O`r$i*uH-gIBll&V*6KUY=p0foCc0s&Wpd-ptx&DZ2LIZ z7&GAPW0JhK+3{pA8g75={BqmZI8?nA~M*vFehh1&XoGTUfk!+2{!t3l>@Gug*-7 zbG;?@z~iyN;bP`Od0u(9G1)7L=hsFnuD^1HbngtCngwAOS!!NfSYHVaQ(#CJSba)m zjqtDVb2K<6O*9l`k{B>vKUCgve6mEDn;xGCdOJ?#^W$BKkmG0qWrhzK?iu~no}kQq zyd7S)mv@~a)ww0C5@w?|--N=Ydk-_TxQ~~q8GL(OVk!WplbgQ*&+4}#0_ng?170Sc0?7+kgji302;0IPQL&&2h*E(@bRevf@?dG)L)1 zPu`@)@ah>!_Z8t|l3Bo&G-T+SG2(`m%Dx@MIO*2~%$BilT3S~Wba(55+a0ASQ;}{6 z?O2DJ?ELivLNQ7(Qa)jyHrK?gJWSNl(FYwZ<-(U#L}jm~#EYo@`KNEo zs|}f?l1fer$4&!qy4Te|+lCvvL!eYSas$1E&3F#Vv?t<;i_c+~o6Oe2fKT zY1Pfsu9YH0E>^~JTPIZ22Tggf?;~sGX@HigOts39xU?*1W>jDac#8pZ}Mu6#g zZMH9>zZcU)fRiRFM=D@8;~p~U2wTR$-%zj6j@K%6htgjQ|d(9Ydw*y-6Z~) z>TS^78@@ehHvrq^(-|Fa_c$|tk@n}bNdTnQ;gPn@I~JLXybp`0TS;=lsf-RE?G~bt z<>xC;6F*+E^l8*I2PfBTy}g!1ZG>O(D>o;!7l_v>GM)M$V5A_W3zj%;s4lpQYBSMrxLvnFvdy%e`E~Y`6V$#F4 zx4(v(#~$ZLO|a9^InHy|RnMUUU&0pEO zEcArUpKZh6?6q<#7K;hlb3j}y5m^npd@`BA({_q>6SfFDq`T8Q&WAvGGNAh?XI>0# zg|PW`V6uXXGt-mDWDry*sLxbF(Abc}3cLR|*VE(Y8m0-QJ}1beq-SGyx1Tt-~%rCpV= z{P+=-)T$FOi;t3t-47zW?9oZC(8(h<3Rkz7dqTPcAYN})ZGFzpdI){?+j!nFF=N#_ zCOOhCdE>R-J^>Gy8+`9a%Z zGdR)WCYyn46{qRk*T2Z>MWy*58HVAK*vJldRi8FIQE)MM+zTd_-}Qu*o`XLmL4IXl zQViw9Keqp}FpAO_TpRzyXP?&-dVS17k>P{%8;c!I)hpE4;j9Tgs6)jo2zifX4w4dgv~h;e4>jf%$w;bS=* z(e?_Mekt&_CQB-LrOskC9zyn6<3E+3oKj82fx1C|_#^RA%QJ;UDIs>y;^{?N;C`RZ zKxgFwVfH84o?D^e?MI!J(}me@clXdgQqwbV@NJY4sUGyuM|xNWZURF&kQV6tWR{^$ z*ApT?dJ{fzLL1zXfh)vN?qYj#njfMwa0wVnu~bhUu0eYU)t&@l)jaJ;Ru0$vw&@JV zkz0x8^kAeP4$QKuCBwJ6L}NW6vP58~=3CwV$OX7{)gB;M%7A|D0oGZ0Km>R8TT3#Q zu5`x-`7jbB<(QppO*`rUPG0;O_YN3Decu6&EtT*e^6KQS+M;V?nDZcnP`%d2I7MTVtA#11b;aJm`?iSuG~AP* zxV6Y-l~mIEZL_Nb?!U*S3C;t0oqAO<*wBZCoLoW(=ixlFkh419@c{D2so={f{btWM z;)$I*YsE4xyj!2R>QC<#?d}Ua*`}#vbelSn(#9H#ObGlj!|Pt|6N(Q_icM_E8{BI< zp!qNCxS3@vWz{9@W!Nm+SU0jR?E1Mn{dG!W&zn2=Uzz2nT0{o;EI zI`t?r6W-a|aCPK7lO zsZw4;WMN8Vb>^{k(y08xDbqfymtK|w{g3+<2Q>h;6aL0+?}?#yF;P4TcHL1 z^!>j7&b@PI*6i~vUwdVrlVr|{)KF8v$DzRi008((ieN1O0DTVtKmj~MLrUI^wIm}i z=yuW&X#k)m9`~;W29ihd&{B{ARE^Q?BeAlEs*W5|cnV=dg&;7L^H3rAXetFrj0FEM zl2r2nkb-}!dLf!B0uU4gP|roO*7Pj^wLGNcKgtJaRiYxbkpI|!>wjKIfDEOYk5nPe z{)PR2ga1_M|G6OL|3m-GRP)i1+5fKu2>`0dGXBNH_|F9y^S}6oNC40%0w`r8Bma}A zAnX5^=v0*zN$AKpNMGc3{;3dWs;uV_Hst!G!ilswgZ#5WM)(h~ovQ$mEK+-+%7R>x z^gryb=0E)Z5Qb{OKMeU_2|%R?X?6_~z#h(oT=a&ZVV6COxS_?1Bytx3U;-$C zrFDFkj!o?JX_cl^iKRJFWU$#1!T`h&GHS5!;KG8)W^mt$U!VBX(;@WK@7ARHo8okn z>~+bBsQ*}?0Q}&jMcZUkkEz?7p9&8%wCDeG*$nFEougzIF5Au5Ff&`-Xi`cc&`CTMKuRHG5Se8<{Dsj1CwK~Z6@QaWs#pi8-A)3qJ5ESF#X`b z3-3w}A7^SjOpGjDZY2fc3;kO@pO4gd;hO!$yHjzN^^B0~Gi4zvJXs!n26Zu`1je^N zop?ffCgPU9*RrSmp{TK{vf`&QO3a{&f4!8L`Ff|wj-Sf!Yi))%UjAo^jcX)VMx2=o zQ)Ouv=_$p=49zD&%hdcKGPTIO_K(AiY}olKi#pijxki8)%FLM97b5#C)nbhV$z=R0 zVdpr!s7eJ`ilC4~SL9-hQby)2!- zb~^+Q6HQ8?%?jnaVPSHQdBoP%Pz~C70EHQ{9F~ZfVmfF7UmcY|XwO+pWW5!+n7+?A z6<#<8o{4B9Gegu_rvO#VFU>Jg3dF#jK4$174?!CJYvL2DFUW*1KQ@+Fy};p-88PRl zDYzY~q245lPeXpD`24fS262NZYjT*On<1*4M+WhGc&==rJyHLH6Ux{LRsC648?jB9K z72izZ!iDf~r-&p_C~mEaPPye+?GApk6#D^rY-J#SsvhSwvkwdNNoGF1ty|WlXSE#7 zTK2%yR*TLzs0-P)g#M{}cK~f!?~XuVSS)X?jJUQ6gFL1@Q`xQGW7&@Cq@{qMyyLB_ z$Mtj+`|9sa#(BRv8w`1R3pF|qYL42@a{B^93dk)@<<$N{6#v51M&TvVy)M>s4$@bN z>@7K^qxC>sLwoa~aJf&2m(^3%(IYX$DfO$w9nK?oB=xqlJCVCtY5mL=W-dYfIiBK< z<|BE!UsyyNt2l8qI}y}OaN;^!aiuA^Act{~$dojkr)gQY-jSY`IMlQCFZmr#W#mVJ zGK({7)o!?S*UeMG}KmDE5d z$j-@9di`{*ff|uji|S(MIdm7_scgwda4Yko_Ttc^P|}2fRisB?YHnq9t>vJe-#*qq z)<%KHxKDm8z9>m#71v$ZvrJ}r?CY0(ANrX}(G7sfD&egdGn{sGu{kW(r7EMO$d0w0 zFCS1^kldP4*ID~(tTCcaM};(7JBEMM!k+E)mCfm9NuIz;GVoqrn9s;-%Hh;uL{;ns z0nIE-!g($Z&sE0?T4w;>L|vST&i+bxbLx8aHh;@YpU!5+!B>Mw(2&!x?R}AaqoGMm z4uxxETv)|G4rONjuK^tBNtuaRywGHkRk6cKx2?^t{HuBRsv|KgVvC&~<2BxjD+bh$ zJ!6;7Zu+dUfu_Otnr8KV@e4_MVE*aG$>$e0*^MC}UE3DEXSwk07SA?G;pEt(!@38b z7+j}=uOQ<2#IW3o!TF|NOy78J?LPNr#VOg&Tx>}9WtmLg|9rQ8S$wkWVUi1_%U-Tu zgLD6C?$Q8#^LUsW{{B)~g<&B`OpBo*bIfUor?Md!L_gxTPDC%*=RvEvtY5V`>e+|& zqwlTIUOgm}X7u+Eyj89ygGI>rlAs+GjCpL<9R32TA`2YdWN(+iFxOayw44nC^lH!tjNp&OTkel~|k(l4OO*qgo;l=}8Bx<~Rp%T9; zsGK zZp*R1D%AKd9HtqqQ&T%lNpn0E9UE>+u2ah8P^?gaA0IA!$%#hNk3PMa55OPzT0GGJ zU5a6@B7Y=|x;Jh`D|LuCDL*=s4rZrd8s@c@khjFVZb)n3m-WvWd8URvmtyNTvfM+E zEt>8kmE#p~RTlgF$3rCR-ZZO_9)RJuwJc_9a0au<33J`MX3$4!94j0@QJGt-DD~;3 z>VYH`b^I#L+#dX*CS)3JdC1F@uTPtHY!Nh9yEP zl!Itw)TSRb=;r>N7YXzJ_Vm~}=HS&NCo_c9(|P`JN-q*~;9cqg11fYF1PS)nO)maWXc zB)08<;7jMX3Yb#+Mr^rtlSq+&DVSp9zJy`RI+NhQ1_UX`H zAEauC<__69ZMNVJOpx9Yz9eksa&Zw$TgqGkHb6^2AhF^YfiKu{S(_7k437~ilqk>% zOp{=;eqT$e>yqI{%P40RkXB~6ku0a2o{%1o_>>&2qsl%>GX=O7{`J!?>U>10{_`Ar zU5rT!%k$*^q}|UT|C9=WFXJ=h_BI=WI3M#MH|%*UkIXd%LA&e5ePva^So;%fK`f1S8o%Et9rjvOaI;*$#l2kNH8LQ1Q$K30h;UFD6~Z1j z9_DH}&|KCYfeRabr^-C2TkI82H;|tunr_UX``~UGH4>|Q!_PW#3yoPyBY$r_8qAdw z?b^gYX``7#+c;)zKVU2_AG3Fo@LohAMpZl7F#diJUcrUPgj(ceMm5omTaZAq9dk+* z3~bEXPuAO{+UL&5YUp2|rGkpxt2RH(?X*x)lO$smKuU^uTZA9sgi5 z6>I0Li9WNJqg{CE!TA^L`P!8`B35X|%V|=7ZiITPMfvc;n6Ko~&d!ZA$=(R)eGk=o zkoDA5BweeT?hiIQR-|QV{Wu2{bE~(o`>tG`uZ*F4ZPQz%i?L5Y-=JkN0w;BxGp5>V zS)AJut#~%Qzt%A2c$CNG7(+u6Clfp9^+B)HnaX-pq~%gy7z`tQG3;SLTudriGkql6 zzlYi~ZKmE0r7M*-$ZxcGti_DZiYWMOZ-_txsnn*y@nh*1SZ*~u*S3Y&l$5G4Etrlt zZf6Xh^jmCvs~8V=%<8Xj6N7C5TnA4iuFB=<7~$_R-9gV1KvypG)Iz$jebr$SdiKT) z#@d-9CQDD_M7q@9swLTsPgHDXDpi>I6c`nT2=B*0!JiYOO+4eC=D4q{%2zcC^2>}c z-U0uJ?etzoe1y*2Ky&iY;<{s+-QG-OJx$S+=(I%agNWFi~+VkgkRW4ng$TyLT2yDb3_P=ia<0N#`5${Ec7@(0Z zxu!3iQ8l#hY63+=ty2eQ)1>A!VyeNaXT}$_KPB&Sfj->MEj6b@DGlPEbOlN?s&L=K z*n|E;C-(TwUL`}^fvPYbp9*p}p60Ne*5Ul2Ph&SOXy}T~>DMZPmDU9>OV`)!i z)py$AgQDia;`YcNUAvxAUx07BD9Pwrw?A}%UxiXt6+Yo+Nah};`p+*T8ch;FYz>~j z@QGF>;|8^%5hq1iv*Ak;l?=d_yF@=fptIz>{gTf};>bjq6kNf~bGX;-p7>xtL;n9AeFNx$UkR0b+BlDiNf~N^TJibPhH1j+e^n z{$;T~5pnf3#FEWkZM@K${fqa!Z0EpB*1_oe$dgawo5QbiOGsB0xT5hP%(bmILrb<2cOKW`BSL07##kkH>muw8m&P4|>;0c3?MYX;siFAfnd<_uA(DzDcN?pPqccAo zE5(Y}N>z*`HS7$mzNolMktcB)JIQ98q^qaj`_Pp#Sz!_h;N&BX2Qi<$L#`NRmelC& z8>n$Dt1Q5GRb0RIQZq1c_cUtLq-5D+*fOV&%^l$$x}3qosj8=}XnRps-Khfhv?yqe z9Fw!%`zBNizSZf~k`00p&LA@j?dKem4a|oWjJ5DRCViYL$&Sy9JVRpLgZDp`DHFUy zR=k+!z@^XG4}h(RDhkvt{I~MWg)U_EB8~(Trp2qK{oiE@`E2=|IC|fm9pxr{r)0Q zYvz_})LWo~iLyx6--J1XMJoNB;`FGeUh47F{!>&8m49^awBrGKtYxqMTyuRortP&v z_@_jd?Qo^M17N}tqlS`KiG%AZqB;RQ-Od~uxZQ#;r{h9W8j)L})Jk3*d*~l$etF8# zQf8qM&x@&Bj{0gos<^{b3D=4Q^3;K09c62Z@G;btu2-tKAEKF;$rg2v-<0z_6liGU zF5$;>C{g(4N$vj=E<6t$g@ycGtzRi3^QJJyeXr+S6kc0h;oz}c;Ncu!v$2C;#4dt2 z78Cr2Ln`ws9GT-#fnM(MEdH)MEOP79O6j5CvQa#7HS$Z%#75KIaMSU=2)04S&l%Rq z{Ma@O7xq@rU2BSCoy~tDcRbZ^?!eR(FpXd)J(>Exq{Ty;vo=B42|vWarWV@@sH!O} z9`#I&31x#H+yv>ux}-y6EwSFIlv;FRRNT=%z;h6*nPU?`0favaF+CZhJ>}DCZIw_X zVo$~BmWBuq8W4nVj!pAj*0OyzA6r4N=s5b}klp6xx%c8S_x*xCy}8DWmG)n6&yT5> zyyF0TiVDf6L!;e4(~AbTLiSvsnRxcfw~nLMp(e^u->CX|7l@IGxj_fNUc+nVzio*r z#X`T*ct0#j9llD#-)l48S%No5Rv0SdFH(G!_h!zvTb=y!=6OM=d#=T~K!#B3n1L?w z;5z6Ds*-GSUTL!9%!es3fjVbT5jNn@i{Y;lwHNp?!UfN;HR<>1$)1?iVN`FJ_vq8o z+*Vn5HT=jBZ*dS$ZW_Z*tYVVx>rEB9no7VW8W^SllWKqBCYkl@I@APTTw->Oo%f{L zQu+<6If^)9Kpz?HXc$azA+h;rH{GQQnWvcs|kn&xOrI1kn0%ZiPvxPo9(6CP>N z=G+FC)ykKgD2)%qrw;}4pBgymKDP*{T+A~^vZn{)7P&*tcO2AP>g2dH@|X4JUJ7ul zbyym@54a=zF^TD6lmoa=PV$+GJAdz59#(5NaGpN^mrBPINR&f4Ng7M^QM z3>J%G@5ezoDP34}uVi`UE-+OyW!gKLQk!KyWeFF8T{1^mSs0FCW+)_=hr=1T`EbOh zI9d}Ja%PNq&?iRnXyV-Zl3dQzX2Cfn$-1T3?RIKzvvtFsza?fAp2me_U3i^eQhRmv~YNPX~}uo|2|Pw|u_ zCxqe-=p0T2IB=%fU{^mApH@)@-(uCbF94xZS9X-J^Q_EA3Y}zftstjM0qj$C@Za0z z{TdW=NSyF^DZx~1wD|P+Ble_pOGM#Wb40@%SE8!tZkf^3rTj-Ma#dVxn-BZC?uV~1 zy5Xqr5ZZr_CCPn@;~yvOLu!|R&R_B%SH!PP5G;z?j(yPV7_o>M;ZFD9l^^61!V9|O zQ2uyT9pLy2Y>Da9FrO0>R3lSPy~cuLvWn+!QF-F??gboaEb}ivRnz6c8s*~g0nqP} zq}fZIfi;7?xzC7Kq1yT%_(Z-uHFzwVDIv^ZMfG6B`p`5cIp zXdiU)^k5^UFRf$2Zt{+LN@oWr1V9I02EwA^p@^zj25P`uSP5x%?@c4ZVD7BJ^}egS zYQ)4uL2BX7z0^KD>>a!X3x6;H8X~(VC0MN*k6YYdIhe#D=J|6RL(=dxz{3NhTm_aL zj7u(Hps1TyS{_UWY!1kt)0M$b6g7C95PKOqMvb_uCGu~Zwm{hp_id>?bZilk>Pn8O2M$6*TcaN ziTYt=aMNJiXpsB}bSco{U6UGcaDe^3Yg^u=5l<71G~0{Q9xWPISXH~VrBm3)C2Y7- zLbkoR9|*=$dg(i0IY@;;#0bzMv=3d+Q$X)d_npB+Xfs)`EZut@(QcCPY;Q&g$ zfvlS5m=4*X?>CU1j^{1B1Tq-W*M;mA64p}8F8Q5L@P(GYL*P0t-bb8e9%$`iK~k@; zhO!1D_*luKw{(XC&?BYV0ahzGh~YvYY4)CqUp&Eh$B%S!Qa$-Z64E_l_Uz zMKLIFBD1a#FS}rC%b`VN3hP(xQDgfm;@6N&ZZ+70k(X&!GKEe%UmWS$_0`{HK{2Z6 zUc`0x6;2eqeXCM@)r%;5JEI%136zn8sgwRL!+gOcW)Y8UQd8{bW8Hp@p&8P1cl~pw z&1cdK2INm~Y>v!ZeKW*4j=qy9%l+lC(A3`}&GzXjS24#W@wgTnX?(@8*&p|e@NoHp z9CQ`QxA~DC%mGs7pt)$y7izihK>tKgQ7|n7+ma~d{iq#ktN){PUa%V9hNYhQSwG!JrvQq3M6_gi%t=!GQ!M9>RxhYDFWMJ67 z4KT!1Y18R@9ogGmZpF@awj>=X#fjK}#WrFsK?ejz^_C$t>LX0*u=_I*%wflVre7#$ zMF>mTV`xvQD^6ItjSPoSx>8h@G!sONVoJzie-Y;0lS1qDM^_mK#D*xnjUX6keor`Q zdxcJqe?6a+M%JpcZ;wJsSk#8zkVafChpIm<7+0D1*G#@0JIs6u#A;fR9J3$;dvJd_ zdx^lDE`8w(V|7rvjHYS38Dn)DJjmF)R*?S1DRHW#Zg=t->Jy6yg@Tfr>Gbr-=p$ryCpq>4_H zHh)H=qx1XNf8gPr{zw8jb;koRU!Fg&lWko#4mJ2b$$EsLm^O@1MN8*1JkDiM!S@im z)TbmDm4Y;WXf2y0oDHu0ORb8P^J5Fa-wiHNOey8i;$!AkQy-n;uQwyr)^KB2uKw|Fsvh4d=iqWb8y@ii;gnU)z z?aBeS=1n;AQf1!STh&7@I;fL7;LaC+Qko3xT;!U8L+Fkg9zi~GSKjMdgUxT)w}eFZ z`ZbHrKex%kG=feA%|}b{($%(kBU89f6IcP;YE9O}lNf4=2DNV5!=%qi3HWxZY-um% zO2_7-RdlxnpC=_4So!eqeb`Gv&8G}oN|v2jdop1PqJL8txu3vS;6@P{MYQTM814I^ z-IME^0O7ge2VRdlFQRCDB9D_{w|%gqU&14$U+hD2Fvvrg*hF2f>(EaL9C4C*H5%|Z-2VF5Kg=0$qxFG#W>(NNYuUNAv*Z=&^ z@MGJPJ*6cdr&Yq|lpfd`X}%&byLs!&b1699d0_cxHMig}H=CD<%%6VXq}7IRd(C(S zL_-x@bUv~|zF1ss3GsyPDX&47zX$A~o3PFu8OXsbb%}e0en#MlzAv!m%J;hT`sj~W z&-}YPcU0-HQtIu{8kl`DyCz;RZ+TZ1^fm~ zFeO_1hky)78LC$wl@QZV$|FgDRQCCp6#w zOcDxagR|9R8zVd1u77Fq0N(94eH0ylzi8vFMiagM@srk6YokT|Rhb?M&PK^$846h5 zxXhHO)Qs9D6zv^0IKABWVG*=<+uL97E8WoO(DwOcrhpEt_WN@uOSi`!BJ}E7Ou6eUa2UcI3 zRso~7TBuQaDBQ~L*Zn!g8$_OHpHwJBGpxiUcKTf0V}tJ`L96%>tffs*a;T z{U&p_C6}C!b5&_m$f_q9F3w|E+V5K@j7j3~7I}KoTg22A> z7DJ!U@*KVUtVa>(Q(;4Fw_#PsC9szWxOZJ8b4=(762!8s9As(@ng6`#MfNoLMsR+f z8Cx`4x~;0(IkvB2t0*Tt^&?}zM14f`?`V$dw|**#1!%LA15TKy$lK(Sy_RX!I!lb& zZ`8Lft2HwG)^n30O{MRFh_9ijhVA`H`VRO&@Yy)IA6bpBmM#1QEbkh$R_>je=x$R* zL@czQ$Z|3-YC}2=0l8V-hCN+C>q2_d@8`}mHE zQ?2ozbj8+Z<)o$(%;$3|njv==zKxLwV=JNay+$&83^cRcEt4(bu>2P>k|5T`Jo^E4j8s|+Q>IX3-Q zP8o30MNREod?5e;Q|sR>fX2Q<{h!0wlbp=!6juDrlg0C%o|ev0jgs>6!>^T<;t~>* zlauG#XWVmty0(tSpK=fO3^IAw$HVWvZZ^R1HZg_^(`}nveQMI4tsIq}G3%3e4laSs zvMBIhS&t=PXq3k+hfz3hz1L%XWwe3gG71ZGql4q25hjT+0=0v$hjjuXA)o zn`Z5LT+FeNzns09&sNN#d<1%|nvJ`l$$xbPjTf!BVk^!n8w5W4@`|s=`t%w-QnyV~ z|Eb0{p%tW}jOuwkR{I-Zs=aDPcuJkzpHY{wM`ORks#%j1(vXv2QFlAnHb8Rj+U0z#(I4Hu&wx8o%-O>FMyUmlJ%`G@2nI#VOfNpAl^%@6!RO4ZadZa<4Kw&)pV`m zt#r|QUeaU|f+}MgYVz|v5p$~BpI#&;ta7*9mc%>N7!oANx41GBg)KcEfv+1?7vNF7 zuRIUddp^I}l(Z`Rb9kE>{0?u|X}d-KzAKWh=ciGc`R|hLGVKG#3bD>nDidh!^Ys~n zbxAc7V*~C9@a=rTE(%UbZbxyf7V8go#fxU;XOZ%9Cp?iJwdD3Ju1;wcps3{#N*qMs znI!+j!}Srn6k6hoWgp)4ua9(P#K!s!Q`Fc>vNbBhG@4x!FK&8w9gIkICG1(WO@IC5=Hs9^9W0him+Ogd_wb6Uhl`X?+s*C-vULGAtWm*lN2dnklX~E z7r)xkl9ew|Ht??!-}r+&bzXQJ;)OV+E5fl6?d}79>fw6CG`i^$h#to_1u5f$62@Xw$YaIkQF z&M-*VKW=8Ak=yhau0gGmfuS&w=oinQ%OQG#NkyLKKO!%)#=HU@ zds{nb{>xnGh;J>i#1F6eX&we6AD(rbJ@1zv+>f7s5+bI~t6_e4z3jXiD5;-42q z=-{UKy0nWzPN}zFh0pQ{KJP`5BZe-zJYS|>`&P|t0^|M4-EZdNeEfHu7wsx>&!A|D zJk3!UC;Dof`{3!GLDwl#;j7(IhC!jGJGUH=y{1vM4=#SK|Hm7$mWBWFc7D|{$9i&B zE2s{WiSaHf4MtV==mWVxwHL`@-M&lU zy<3+ify2eUUzmvJYRnQZHyX7=C=LQ zGV+gEeHAodJT3N*OvCPnYr$QrS2YGewZCLYWI@C@b)vKT}VU;VR zjiNBEj6q@GnhA*Y+GyS@fLD1-)T-xVa%?TaHhe+9zAVvPw7zb>auF`QRDB++6;Q11 z>Vn!SHQv4S_m|HXVO1}M0@iOsde<{;Oz6m(^L;H?N#DwpCV!3cDj4x{b1vYn7xn5e zzN^JPj;hJ?asa&*lKq4KSi+BTexStCJwbqck+hM({)(|P=pjo&6v>e{slVy+ zBP9DQe!p|=;kAt%1i}bh-8s+HckPfnWOx&BbACt4%WT0q aBVi5TTXzKnl8T&g11QOPh^d~j6#ibNVi@RHcwzvj&EAASs#oFS~;;zM=Lh#^jA$TCT26u-RHhh0O zJ3IT|W-{;H&pG$#J@34i$xXslfwHf#-(aJmp}mrqlTt@R!-AoqJw?NOhN6U=WM-jG z&#gZxe?mj6j>h?G`T~WYfYfCr&?;e+J1DTLs-!86IzLm+IZ|PGs+9XoG4H8zu7!H! zGt^iq_l4r`XG%FJF6#JHIR{O%3=N2)JhIS$C`#_rNBVzIAsbBz1^*i>W}_zjN6$f1 z%KML)gF2!nJkn7L|M%c={{L{FqqwSt|HFO$Xzo$`k%dzJ-}^|J=DAYNu@de7ek(r? zj{Xn)zsJh7C=f?&?4dv~TaLKXyF%t;!j*dO!aB z-PJAju#xi~Cdte>0aAN`y!F_FE`d2MywSW*RuDJHR;0MxM>1athxcrH6QuMvr+I)Y z_PdzBl^#hYv(tcX9U9tuGgQO{ofCJPbEq`S@UZezW9E; zepLL~4wo~eXsnrRuRD(VnUsi?r364gQPPH?NVGc#9%+JjcC?77%K%vK_(2 z8ejI#Kw2bAn1=fx$8}ESr-?0Yw;iA8KC|IG><8+wyzq(Mg`( z-9zAkfYwXk43ih?0{Zq-l^jOqtRd%7|0T@*2};GmdGeLhq~fbcT-0>+kzH4ze{Sv9 z+c5aH+@r}73roj(&vYyy95TLqRI0xv#HP74`~l_>Z*GhVN}K6KZxL3SGxg=gu#>Oy-3zONykL_DTg=2ugl09+s66R1jxf>p9j z;oM{`(2J+RkIL-(7KCKx`1TL-_)zE@_g2Fv!O*qG;j4jE916GI-;Z^MW5|t0c)@$+-J-Cjlm(k{`Jr{R#t_N%2~( zaQz;=>ED23ruXrBgeUCdN+lbV}g&0lYp&$*f-DmBc4|}=aQ2;-D!4)H2 zK|Y$A;1LB+>%C;*S5$I+tiUnoMOEQBpN-z5!>dfAs?SXGXny3FJWdQ55(!BlA>G}k zMIohOe;n2!+yiz#LKG^bk^P52a1&FCsFz0;6KB@y>A!!GYwi(V^=xx`EApfa(-p?y`AAi^BRlvjab9y1ycfAa43 zCG&gqWEnvInf^vH9qTdXV~Q^d`u|v5T4k&s<=WZEs$>4<)NF}LkpEjFg z^4O8!lw$#p>npNW_5DQx2k<}DJkowtcZ`k>TVwX9==N2!dT-_5nG;i>mY#Qmw90Q{ z0Dl@)WdqoXx?(#PWQ&hubjkg!1oak)=FEx3IcMBtQmZS4U~Pvzqa5}@1&}|loGh_|UEVa4D2)LNalP|3r`nwm)0L=wChS zYvU6QJ%hP*w279F0WMwGG68!=xGL%aB9z82$+$@GJEV}iyZ1NDIlshhLJeU;d9CWB z%m+d1mbJ}QQTl_%zaLzBu=^xD?rp>?oFIvT>%pD`P(7# zcs2(7(6^C}WWL|`Zs{YSukI)8lX1NNE94m6c6&cg<)3}0M?6%R{!Xw8e8gz+gqCwb zX{_gEYH_js zf(Ht@)?fBvaREe(1iaN}<)8Q6fit`JM^nA4BF0VOY|K*e&262~ET~m+ujt3U8nZ*4 zs(u<0v6owb;5B-?a*ud=rJh`alrzBY*OX-jG8%l)(}o3!NDsU&>0-Y3#NxbsOwO-H z;4i7+*Wq&)r6Yt4J-5G#qT3G-ad9KQy`;Q~KYZtW{2r(-VCfdl+e2@q*Jy4N#-9AV zH|&=KE1T($E`V@nlA<6E5O3V_2Zj9dv=r-cNJn>0I)lnkP?(UZO)|Ywf_lwiaazPUpav);N$5Y`lsYqU^teo?&OvU7iLEskC(WeLoP^=9)gxqlOLi4e;dJ-u%0 zT?v7NPZo#XhvvD;1kiNm-#mi^{O~l|5}_~pl6Ws672Ks>AZt@LaeXhS z&d2uYS<@D#(e3wv0(3CT_$P}&jEe-bSR7$*?AbN)*P>w6tzq^}@(;f;U&bel3~`H6 z8x4i@G(q+6^G8L`iw$Y#yI-kkcp-HqlA#nn=YMk{8bOx_74#5lzobvV%h^^_Ib=(D6Yw zA&l?=E0R&emxd}|K(Uf(wlJ=x`Ye(Hf8CVTwvD$ssNur)|I$&WVLVr{Mcf()0jk_yCQI>(F_$Bf9w(hm!`v)*_} zj_E8lW4z#JeM6cMd_oqKXl5qviP%8Ia=UV8DkgZ0ickQ4HfD$C|CDtFvfF9<1@jYg zvr?=HndS9eW<}34ndIA6$q588uXvL49%lFK(%;(RIQ%F!%i}Y-A}&`H*j-Ke^@gH| z`(19jG@Orvv{3)+oO9QKqel?^l_*)LFCJE`#;%H9w*vxXPdB1B7tl_btBgDTdQgSL z&eEVm5Q)_w_SO9(M--BqVEX-l>vzmlk1TOPvt#3oskQz0jr^lOhF~%h6~}`8J$en} zPhZ6fZ?b_y6i64^UJOg>PxcJ~e}Yq~(W#CMY*_-NVqXLvx$_t=rp@ip&oAsD97#Wy4eUx0TQrQ+~ML z=_YfeO|U6VW)SwJxbK(&=ZG`Z-@KDIH++B2U%}i})kYBM)fSF~3L>5#QZE!Jzg!sn z3c!IICP!$~UVXOk7+$rkThCHlIi5Y5y~yH&B_+)u>u)z@5p>S=!y45MUP5B9Y=;V~ zoEtcUEZ%}LX<8y%oZaW3?P|C^={Rn4wez%5yfl6*!u4$BSgjAhk>)O!F;dPBub~Si z2$pbJj_3U+Y+4bR@(aJUDVWQVu6;Wtu&Jrc7j$ED=HB-Cl*O_C00+>prLxr2v@8BY z7T|NkQc<%ijs9z2@sOY~nmmfy#p7NEPh$sNAE~@jzN`5w2=o6eLkPMsJY6ZUnYJR&FXVH`W~6qCD@rY-g#&%4eDc$ z)CboS2$!5a@OkddI=AbAv?VAh>{C@NGrEnxhc1F_J&gb8{n@UpvrdjW!yq}Wcs4OD zP2P_rHTRHt&@b`OupA*#!JD;^sc720Bn5W>FyeLPijbUSul{^-zGM>b`k4;@ID@s{ zwc_U9e~K}zA5o5)7{-2CD@h~lR$*;yDAfO*^lG{4MPM5piVL%y-rV5;}fX&vF| zj}=;HOH%p;4sL0hV@RJUEm#EX(u5yzEnzNX4EB`+^5Rvo=Wir|1y$QD2A<^{gO&AU zM%1|Wu#d|2CxoL5nPBeEBc?VF!%yn5m6kY5<~c0mD{8Jn@&lw!rFWzRYnz$qOMtRh z`&%#Fix=~_2N$=Bzt?BhkZc^|oo581e2i@=suB{rYe|hruu;ZjUw{<&?r^*e8T z-;u4&8lPa!ECi677k%XN)W|!x)PNhBrtZ;jz&y-qjad^Wdvj<-wPn~3E-7PDJva^4 zIHp^PERG}7&Az2R?X)SPr?e@>HuFmccAn(aOe!L0kX04>>Hjd>A@lIVQS_)YA3c)M z<|RF8^mdn@iBCCzmgP;&qM+BH>NcQ+_zNmj5`JC0_N6EiDUrU{YxUVl$ z9y|Ud#{BzQArv;hBRHdv1cxR1EeYY}r2QZ?O*x2j4}_=fm)wnOt9fGnpb)z65RNF> z&NsCpm2yb=**7cYaH zIc)bnP2?ZsHyxIO>r2gV@4T1GEJpgC6@EZ|P>_HKg?}Zy z9dEJvy6W!$d?S*^NU}zakSp4Sy7?}hXz!W+?$E^39_7F9k(u1TzisDJXr__9xwg4=!&!%k#G8fh++${5IT584?f%v4BXoKk7k zZfmuweof^|#+pZ&Jb>iz1Y1YBTfN6NPX2mpaOsAzj|-2ph#hV z2($uQk4!T;!`PZMo?+`QO5-!PSSZ}Hsdp2BfVyxugSEkn_d`zK&UELzOq}-+yj%4I zK2`vp-vN8)ZAwvK4qmsxY^}NT?u@+#_vYG;yoXOLUBnv~9D4zb7>F8V#ChuaD-dutFTXo|&s03d<)>35#li%yaz;bYRLziw)*pXa3qQK>N-v4&JhYge z?MxwWX3FQb)39*Z0X*QOl8S98dE$s{Gil1k|lrKh}^(w-jsy|NN5BSSA zs6->=@ze>#egY(pXakkEJY&9UFLO}1ZQ7qyDJ4NFl@Vh_9)t^hnYICWTvLz^;Mdot zJ~hhP4`RR?YnW?}70ExoW{)^ALaTRw0^UE*`&Rh&l-`{`fhoiHwJGi?)ga4N^~7KX ze?#aL?@*fIl>*9#H2TbS{;f$NtT%6Ji|&F$A*=DvjE(Rnr#EftSlHS-F=DXQ4S9#; z?%ff;W3|>2EGAklGR^$ta3ar7_wz#a#s-l;rrVSEd-W9o`B}=c?(!91e-_ARX=Qr< zQy3%N57`mIrX;qE%jevA4s>sJ2%m0$h@h~5Wf(~AA}kClz7*+#25F%?CuMsM^WNA# zPAbVO3Tq3`5!=Rn1tdui#+5wQBNd<`E21xJ3-oDQXhy`v2Qv~_`j<*tH)S=$kr)nn zq2qNKW#bmmjn$$bo)QSDtPi35$=a;Fqf9aHll{_cYO8S9LX6J2x`e{0%O1W>@_|Hy ze6HWDYxY`#RxTdQ}pW?qL%b#4ObsjK9m&Y~QD2d>F_8;qHm0Q-)te zv0w^fc7ODzH`IEr$S0kg6k?@|2S32Et(ZMnesvt(+jdyc5JY!Ljj7D^vqCqR!gAQBlSS)oj~ z5&Wm0pSbs?8m& zNjq88Vj3`$o3U>iLe9Gt)@mKw{X=sH<`&jLuE@jfBe1HdhDGp!U3n|RzzQeT1I7)r zeJYHaxs8`9U0)zHQ+V`U(*0Mm@18b;9M8-B!lXnXsZEe6+;yn@0xLln0Y(boe?bSt z?k;>gHwr!X_;Fd@9!xXHwM}>s|6oI!xt{QH1Iy5QIIX$$;|95JO>H8JPQFH*Vj9n< zN8@o8uWPr8pn%Y4HZ|TdyV7h>ft$TC7>=U*}TsH6MRu z=;8MqdycdK57t~04d~7Qh-m^fOBE8+x0;TKjRHDBquL6pRJCu9i!xWycp@G2?XeKE zqJL-0SI}+iR3Z_^K*U7&Tn&~tN_gXCWtbDNvDs(|s$KS=V`+PC{rcJ?G|x^`udn6x*yz=i9Rnvz&WF zbL?X?4uoY|7Tq`_&-ucj?*$cpv5xl)v3K<;W;NZDRKvfOfWYue(fTsPIx#}YidGSB z80l1r$jfyo*ENtrb0YA3U4C7s9|^sRYkbzAj~ry8mpnWx*a)revQPR37Ytfl#tYptr{j?l4$S5_RJ@;^ic& zx7Le`a2hW7EUuh%-#Bug+Jg(ORxD(p?i&#HIot|ST`e`h?5GDfCdas?GZF&B#N~tg@f4C+X{9wH%6Z`CM|GzH!_5!7Z8(2Nvf+h@AtFXfD0JL z>|MF#y>#`^yi6y9VDGLJw_+dJblSI0rkIv8FIC zRrx)B>zY0&{Wg#k2tkZQ@izq4)@+rjdE#3c*M57E@$hNj0AWOf$gJ(WOzq(YS3}i3 zp-u57?SHv@hKZ)qw*o2kNLx*a+W3Ib&xn0+-}jbwhKY{?NvR`0`5UhGLd9>y!;JY-6XkrXANY>*ap-sE|y2E{G6|Z~P>XG^kZ$Hj?PCsOkUx9|^DXh^?1&@nVG} zFj+~O!WZHqmHCn5>AyzN#OHXkV)0Dmg9%R!q8z{M|Kq>Zfr8}5Aq2drmJT+J&;b92*|21PJmF+w!uqahO+ zc1cXSI)ZxTiG?6f^9qZ~=QO;23jSV6s#pZ#M?aab3RRac0S3g-f#-+gL7Zy-rX4vV zU|m+CrGq|wTXOsP1oEmt zx-g;&i5`$ZuKwtaa*3zk@5u&UASsp(gs3msa8OdKqgB4$+ zwsE^(1VPe7ABYuVtMX*NKw^J8E!T%5RE5<`z}VymAOcGRs9Xj7%Bp0&kZ_1(PH`&>hPmF1q;N{ zadG=Pk^`M;eth3A6SA5yoN?C?jb#!_0a30d+GjMT{aBOno?_k5qB9LACFOp`hj54H zbUe!HD8*m&BMp_F5hNdvsEuU^($Ms|T}`{)9aaB9<XH!wbHiLop-s$$%plJ2CYhCZ@?KKd zmC&Xx0XLs>1*WZbqP5G%t(k~jSs2)df!dzBrh5*94u`i*pwJ*L*?ar%JDl-@?el{) zbLk+C4yQJIKfh03(T`sIO$C*{^M)R#2C-3mNv*>hP)CP0+1AjwWp5j<-RXwq2>!tc z`vr!DG%U%RR#%7yKC^=DAWzj=`C_XT1nB-er+-FkKr#7eDE>;d>gbrUyiN{nwi?(5t`hY=Ln665DoIj z2u+v1AosPsC->|l6%@3PXs&JWg*6P+c@f{a*K_v8n&UN%|vZ( z&9@~B(;dTkrBGHXuwVIYTci`wOFi0Go+T6E|=SLmGQ{{olT8D zHGT}$P$XD|dJ%5O==Nq4M~QD4+EnS3rWQ^l-B+o!i)8TK1d^6oMXfLoNomHj_6gqp zMSZ{X-(G-AREJm|7(fSc^fd~fz-_(FJ{ARo!pKPyq_fMbu}tznirmSzamiZarn|}e zeJ666-5HVG+@e=+Xh%3fz~qNTYOddBayxI_bkmOc-v}tZ?ex*8{b$G=mt~nKd8*F6 z)#K1#Ev2&bx!}eE_RIaYzT-QMxf+89FHzeNXuZCgsF8PsZR*+dpZpqx6i^eaXL-QA z0b#6QhC3C_*iiX5gE#bh z#CxXs*A`j#Yrkz{Bq6`@__1>5872>WI_q}{&kHAxfXHu z8rW72M6sGVtIaHXn<0o?s8?E!h#Sldttp||58DK!&K}`lyw#2~t@U=R_7tfB7}$}E ze~Tp32(KzX<#IB`Q1WFbiG794NO)}k5%Z5IK!yTz;ES#lad^gxGlj40VLLnwN2uo{ zmL;nU=DUF62))vqmqe-p7&M3}Fk3nga^WGYu8DC~TP4Kz;LbW+YMsrfSzzDZa3+h^ zc~r%LPDmYg-ra-q1J>XS)`VfHTKg+8L*SAj0ldan%{sblXAY3_C-H zrdN2>yE?pn6eNX?58U0a4l{mBa`m$%dTe<@Mw--HEJHY}r=K1hX03C_FfSBr1fK28 z)ln?5cT;(-EQZ`n1>0CRe4Zv38&(HweZ4j3YBkf3yjk*%tQJVMwgVlq&@ZiYWE9%Z zguLCUVZQ6-#cO-v%---8>z?lgT*VD8A{JdIo^ zP+FC$h9~H~G<7sv^f*)PoaSmy9(TE=DV$?}?I${jZNLkS)A=(O6y9#~>ac0BMQx^T zx12(mbD}K4aXyCDk41>}KF4}pfwRnO9C^;Ou!imh9JB3?WJ@gZ%5A;9x7g`DV!ioU zClLF`40I?_h15JkP&Y~I zB4wJ@>&Bi=l6CcN_r3r_<%;sN_Uq$(oX7O|Reh5CU@$IfTNX?ZH) zpEmzBNTk=fDL2+PmDw6b=+mdp$J^}Di78Yx#X2{SE)PXpG-5TpgnY8@9Skh5tnqj? z5?YH4YPA9@@Vi9NidyUsA!in%_}aor;~-+>3qs%9^N|^ql|^tZUuOm;j{O*zEK!@6LRUbCElCfNFUDfLps;(NU+?kM1SRNXwkQK<5syPOQae6b zbeKwgFSc&x>68XCk({&`p4DVB()U)=QAW*5KOJBd5!S_AW~nV;8z96$eogZQc08b+SyRe> zvouAI2rXwEzkt0=S&e(OCBr$#`*n*ngGXBGdp#%$OVssV#6vOGLULv%#lh3scV{l) zC;f7|5NpoSd?epW$^2W374*5iJBSbO5b?n~?D8ZsMpNv53BI_NPvWNF)VJ8w4jOK; z+-LmcaLLNR$_IzB*FNZdu}(4vd5f!r4PRA|Hdx!&wo)Do?HlnZ8M8;39M<;IN~Sh1 zZbxCSPC?!_{6;y(1EUL!*gFm?xA5jokw%Bx@+5jOX?$ew+q9cv&=4d6(3C#7l%rCOpjxROOEX}< zJs$fNOBg!4yghf0j`uuaQY5BU;O8ntuRj-W|k$m9Cs+yrrGhY zbmp4-S)Vz1A@e4IWH}vjeyg!OSEm$Tm0_QMzFPuIP_xEMMPdncy8$d?Ju$>>=ra?5 zMR6F{EValXh!wsysyAf%x$LLE<(j4yDxj4YT(kS#>W;%yCIhiy|>K|w1Wg`}*WmWtWswb_(CBnTDo<8FjdT9{M_tNB>!i?N| zXV^21iiK}7dpdB7_GRbgWlLNiqT||SOw8(1*&G(9N*2RWF(%t-zM46bm=S?+T{s^U z^$v4W1msC0>dfPU%i8XWX7s+ySJVe8R zV?kmd;xNMWE6tZBqr%4Oq$#unV!ThhO#M6V4TBK?cmJ6tiu*o!T%rQ}t5d_*_9P+I zr$0sy;1iw^(_NWnkO2u=&8~n^a{>}8mO)!S^G|PiEX}%kSdmfNeuNWhINV1YGzDtm zg9@0YWc6(;pvf!arLz%iOhJbE`A=u9Bk@x6tXZJgW^+K~<#$FtTIYf?&GPQHc+~R= zJ}E2pkdU*M%1P@^I)n%&L|Bh}MTW!VgV;drW>f>}=8+?IW0+gZ)5}wM?Z&tC3v%UJ zHVOqFFrf8!*TuYBxb9yYXs;K#r+W1z#rlT8b3cG`cYV#2)n@Bf4&}q2wr9^@ z7wtv;dVQ(>Cd9kEsY!m>yq-J*iyGPjAoO**J?)G17=>O}F>^G9sF>M0Bb%Kx)0mGA zKaBb=CK^SV&nhx=wa*MYMP<&+I$j-XrwDnKXtc0)ZefX$vVas%v%*Js_BnOKE9-S=BMoti_hBtXEFSi0gMUHRpgz17Y#9ZbC; z!=If&Ue){BMf|{FMDR;6Uxfo7``jp)DP7l0)L_X3LsX||ly|=#^?|Sk86^3FBoY5+ zeu@Crua~@uPuit;G*?M1RDdX0kM#GXvPhKu%uiM}+7()`b?33-@bXq;U%*E>NX0#( zs5nT@#Ln%6Yf55!ZrWKEKRZ+P=JOb9c{0)q*w8<0&zJ`}MvtonA8j_?K6_sxSemGJ zU2cs3Oowrt3|jBw&7f&wnyuKW6qfjD7uqD#m^j8C9i{Hg&!Vq)NhB;7Ll>~}GhH0B z?WUbK`e+aY8^T#OZ6H_5B=Q8YpFAhAeTM_c~8t)F|6km zeyB^I-ZU@#wX&=k>9RiWH38}TmYN3y8f^u&ZC(&(y*LbPqF?@z1S0TLvjV^K;&tRP zbUnfNtVWYgPAVp1e3feA(g1YkUVy%G&=NS5Gnec57uFX8Py(9@WEKM}auV}ubVk90 zE*-t^(3Gt+%}K?&e5$wq@artN)hhvR6J{x9Su$R|CqGH#JFLie@yMkO_;pRfo0;=7 zKl-hln14_2yKjJS2e|GvrnksIw`tRj$*iN-nIWQ6{=-^XYFqqAd1;`*g-`+`I4qBPFJBRZFNm=2+LIgpZr z@$M&^^@)>{GBvpJXPGoJ{`jXE`4zMT_euL}@Y{wWia0@^dY`(&dq1%`FWn$Bz3SFXDJDh_H46;v1@0Xis~?zwgL zzHyqZ=Mbe!VmrX$W!(OxtsH{h&}IaR@=zH(;d#qJk*?Ny%f`%I6Y9NBa+GGx^Okz+OGX(TDfhxmk2;-dFRRz4Y!XNS4XP3(ln@%H^Yc@9POpZN(BaAH zpSC}q8Y0&p8k`1BxFZu3m&*Y&R*QMnM|u1@E<*0dfTZ+R-7=0{PamH+h{i{*Kkp59 z7vhw*LEP*Y#a5fdiz9`D9lEN`VCp~IcC{WWIe>Elyk~LEI-5Hfd=9MJ@ z+#6iZ5{sqqgBMMHqR+(JYZ(!b>+jKN;fAw(J@2* z1)s&9TS_d4J67MlW!>7w2jOyUh1J^uydWR*VT&V-C3CwRm%6uua}r}be1^Ljl^KA( zZym9TAosD6+(DO~OlNI@gc2!FkoSyyxTyeH^cvo|T%jfKzQ;iSlL7OeY> z<^`+ujwPzDEh~L%z8V3fi5himb0txnfA_<#y^J44&Qi4ziJ6?WHN|K*ha<&(1K;1DgMI`1wa8ge;6{a zr@q2mna|>jQk*W)eMRFKdU1}yNAkRT_*v-%wg+1(o7+*#r zg)PYYTy7?!E*?C>X){2{;Zey!VcbsiQ0km2uS-g5H_F_WOl{&h0toT#?X(*q134{M zZ~f5S%k)hsD+ij-Xw=`lpoY4XfFQTu>Ao7Iq$aXcUIYWIlTnd411@^fK$(q-?h7DoALywJG zzGm2hIE+fBEI)<2G$vr_9ejimwfU+@+JN5vq{f55Dw=UAxG0tHw$>UX{~qT| z-_etqfPVLWg-PqqxH@K4eaoUx#q8>#IyGL4e^*frcVZokC#9AXW`vSjw(D=<7c3Sw zjwefT@;XU=;*mL7viha;@?R_`D&G!+JuKqGR&x!4Qh9B}3||w6#qi@`S+pogfsu3& zY>{RZRnjYEFC0nIK*J?-mp=sPQ3jJVwjuhwp!!PNS{3zj!!0oCDtm^KVUb>k8(L4F zM6`6f*B~UWDaeFG7nCO}kI4ME-&$x?mkug-4)K>$R_>(@dc;*bl5pI;-)FV}^+NiC zF0{K~8e_}mkW>fiznLvSyqSsG9g@x59QCasutf~r-g6FUM&+CAnjV?{XT)J|mxjd5 zts_=4rwqkQZG^lSIcuC?n_s%I)VF{Ww%ZQ60Aqtt>&(b&5snf~fq7;XKtyT_k zQ^;axO$L@xZ$@M=_;=!gRk4W>ZM;{mGNQ63J@s!U_uwC^Sh`8x@9YAk>;|A-_L<=m z()p2c5H5&=idgKAOXsd=Vk6O^y{pycw~EUbw~V&Y{K!(gANj-lw8CrV@G5{8@+&t} z5Z8W&Pi-`S3OCA!jL&542S$H9J|xYQpox?n4Uea3XLW+e-B81zqqI>>Y@V>Nz-|7Hri+WK#QAXv6WfGH|* z!opu6anzPdFQtiQTyl1^6lQ@6Rh}c1wf&V^$HeiSY=nsNnnPDNi?KA;4*`4=1sk}= z1{0GkNJER~jtUVu6Jre)T~v@5$tK6m{DW!Ul&(rhG;jU1<6YN!giyeU~q6Kq+p;?)k&3GxAyhbwz?a} zJEf8mSWYz*=#{zCLVKnSAneUbtf^mbJW4JUJ%2INqKBZYIj;VCuZY?#0Gd&Z@rgRQ z#2C#^QJ=hle&8aM9Oo0!#6yDFa=bcYnSfBr`gA7`RU(;pZX@Z5BTsSOv4Z-_n?B84 zVEguY-^Huc*J=tve3|L*3{B~RhkuTf3&T$7P!BEjy|D)LY%F4NB^94^V+RUHy80ul zjk^wo$6I>6YlN5f-nqZ^m^k%q!7n#z^jv|QvRUWI3ZHqy7VK6bYZN$!r=Mc@#+c(~ zUvnmw(%V<=`bNyl{uCU38x+7rt1(bp3(!49+A<8Yu{Oo`6{J49>L?+#TZkiZxNP2Nr{fmVud_uoOb?|?DtFY zaiw*yy-UbY+eV{6fVPus68QW(Wra4~$#K6_nWTy8K@vv=J5-Z!cN8(y{>YIEaLdG{ zhr4&rBI8h9=?kftRpovPC>bVx{|ugfArvM%1OvCa@4HH;$D!^xV7|f9VO;q#l*w8n zPsbVJrE$3rm}G>ADH}RUL2D~$lk`mXeGq9Ea_@bc?20>!9EY=oU+^D(@9mE%b)%b{ zS_uw6Uj${CsTwz!!Cq-kR)jS)Wb%0ZwUE*)lj<_>moZViGCdbeac)1G$p5kXW;CY!oBD#cM2z?;iY^k2Km2eTV&W z6vN-;XitCJZa;HUcOhc3Vs-RBQw#_~=b=ezZt}bw$t7@NTsXF}lw5Ru3N4N%zEb!5 zeuI=PzKE@Qi#Qm#t6;a)AY|F9VxxH9N*6O+M-GA+bton{tJF?cI=p@v8&sx6>2v`2 zBQ_-3?|+t4dg1I|QF*rH>^L}^ta={{Xn2_W z=WY6X&Kv9t;=RFq=x$Tu^=et3-CHV8E1v~P(xxi2A~Tx1jK}$Y>K%I3fNeF3QJY~$?EK7h0Xmjxfa6w-l6-&^leJGnO6F__UPV2R@k#y4kSH@Y7tweP zYr4DAQuUU@+W}yF)Scbt%xjlR>$>~sA4-CMM3>=l$aff~{(a3fR^=%m*vmP~ z@v9M+c2sX+R?L15S)cMVMXc-Sy0jnvNM2uL^Jyz10r7M<@mW^)C$TJOIO}n5p&JRB zl<&Srn2#mvA*OE2{Rkb(Hg=Xs?w>q6BwKo|@>w^@$UO`1xRUkspu2!&urI~fO0^=u zdLJ2RX}*wVcw}V>>X~D7JCB_6aTI=-V64TzG@}gqySeRmnvt(8&lGJX&0w~uW%5|e zT5m;5wAe%6JW5UGMl10o7v@J>yAI!k`gi@fqjx>b^B7^<1NZ%-T46tF44oTm=E1@V z6n#u-*-lTtgL%z>1qeFF_+vxP;~i{Bv^(gI^WDCEp%Y!)GW#Y8KV7S8yJixV9$^Hd z4%iGvvN#p^Q**ih{7EyIIR=sP66dKYYdNeeRh+f@PK z9GCfM6PE1IZnEH`fYsY8LkV1+EqR5*X3VzkpfeWNL+34J70;1hqDwGr^PJbEJHgO@ z`W1@w?v8yK_8(j)_Rw3#D&dt516hh;{QW-`Ko8-l92T!Hw6hIFr6ski^h-WAJ?S9u zhFskYa_Xn(7TreOgnUEOF0T-Rn8`^wjK1NAyZGBw(yoV1{?V5{EN{r-U@JL7$A2`n zCf-I^@|B_%dch)pbg0ew*YIMwgOl!G3nt-LW0>g26B5_rEq-g^`c%74q5&A*igO7; zERGY2&j&PU1uIfOP7J@d-%PALO$(}Y28NE~V~u<0t*o^b2BVCz2YT=S(%;ojycX^- zh;^C8x%em?7d){^-RR#R0hn>)-YqNhN#<^vK1{!fn^p;9_u7j5z5bFt$d5Jfy0iK) z&EV9XRKtsc(`Q?U;=f3{#BX+W zHrtLjXG`xIBlobAXKahJAr)~BgFk)`y+GAt{0#zfJ2?M@{L0Ew!@%N($gFp;+kZtY zOd4l3Tf}EA8F=cJK`5^?=Cb(SVm;U)Us?c_O<-wbuxO~0myMY9lrpf9f3UeGGYL9x zk`r7NDqV4#Y(Cwu^DoXUN8*N?m8a7<74>+nC;G~&Hp~hR`W!I!Y@=J z)L4IcZIJ1sTF|7|QprZ*alYQM3l6J9axnwjSh@T75KOqL$v^%ui} zxWJyfT4%@u6H078ccUPbQ+9CyKEGDyHB67{u^ey~3imowY27oceE>!c5pKeAga5$d zJ=z3$S0MINxd`8`)Ttkb<*4$kmL60|cm+{dQg&)3C|7TUz%$socx!3OpYb9WAkU| zuip<9#A8;P zCz2o%9+Bv55G@wbJCSHXvO)Bg=)F9vuM)jR@4bt%x>c5y5JX!o>as*xZPmppiP zV^Hcl0m?!cJ(7ogaI*D{4L;uYYCQ9t>R@C+&4ibM0QR2+Z|~ak*s>57G9g&FXp}K6 z&Ru6y_RUn}0N+Wbh`#7>Iv5feU|$Wr>b)zQe__D}V40VE{g8PU9F$m3Lks4v#V}9y z&?!M=he)&4s@s81e&s zt+H4L;ORmK#p!SVC8adKZjJr1brmI&Mz1Z+H9(PB_xJS=1jjVH#rEv{YxZvA=q}XD zo1J9L9%rCNG{Dq<9Fuzhf0ueG{1D-W@WI;26MMAG^=HoMP zjU{VUJ}R=sFu1{{+hd`iXrE~>DwA{;JNBM~WlKP&)Gi2oU~o9&7kFT`dXL&aN0xBic@oOR}?OaAM` z1L`i*yka$~I9^6!DQZBhyN07hH~cFqQDcGG@Mh*Q=Ue14zFQAIL`(j{dQ9S zog2;en5&RK5 zsLz5TvmoWwa5hlAnfbgbg)V>H!ySeEUZZ(Q6`wHPSy&(0*|`Ecw?ds=x8i?{+*L(et9vR+E?S8uCcQU!9g3H-_t$rbFV(l z^IuNno;?a%9c5n%{w62{0qqAbTC{lYo0e;Li>PtFxBb3r2IFV6WI#)BQkf0bGxVy+ z!S=*9R1X1$LY6}F=l4ZF!kl_@m~mi$pV#d6@ejVI++^M_1sV zf(A_9Bap_<>qkk~GdM&0A;8Lm^Pi*H4)lY2rMh7cI1qwb1;ORhFJxoVs@NRdEYAMv&*1048)*+}HB??v9?RY1Y$*35aTSqOGjXfh7nSUu}e!NSMnyOX#!Y;cKqclkW> zX;7Wz?4LRGZ>!!G29I-M3$>-^e)5zGQNdSb&Cjh^+D`}Sxlk$Iljug)#lm2-u0!dI z-xUUFo_f>bdZPYIRY3I%#kY7l!6IA-+WN(t+Af)V4`HR>RMPP>TRUPjQds?j{jlH~ zW8T^%v)XnlTN|{owWayzF+xIvwDH}A*B^n&{bwd1IcpBgq9gdDO5~wJQq%Wz{Og9D z7stLMcfy*Y8w;iyp}#^I8}}6{rhcj*|)}ekJ&O@HM;1Hij=Bg{ZNz7 zZm^Z9L4s?4&-+Fl3n4}Ja&K_MykbGhjjLLXrNrs0FB;c*VKDpe&>m*?z{13RE&snsW555ur;J<=2teVZ6`Pfd)pLWlu?gM^m(_ zN~R}+Gs=-;eycbPs-EE^{A7q^n76h)VZ$4o6&vIZY<)^-tU2QBze_U%rk`SsgchIA z!gDv8GF&RNSVaxY6xjNaR%`lGVh_u%=1X}3Ih4rSzr~R@S_MliLmyhPkveLhz`l<8`kqHB+-#fkWTcj_~|@I4A=Gsku0c0!W1$-g^43o+1T>#r(5 zU)a9dXiCX0sbC;G{TIR)6q7jYQe{4(_<42G-5_HI{Q9H%3A-V#10n03Y)lPdq^s^% zrpf>^Sx*3(_zW`^+LkboI=ZfgK!+jCeXJyJp4`0q>#-Ib5j41=I|98HI~&U-F#G%~ zPu`z76|(7N){|`4C(fmMoXumW_FPD>>0taO;sTIderDp#EH@x-jPa8wVXEWpO5F;d zCim0aE~Rwc6F*0%2*Gs36XOCjAdIP{0r<_oL}N*bOLz+#gszqSbAJg3u2N4GkqWrd zRHhr6zX>)TyVEr(inaoW7HEAi5tZ;E3M*P(3C-*j3$z`+jO!o0^`ci8gc>0X+)bCD z)?|@G;Uo z2}$w;f*MR-rGNa`v}Jd5SBL2t9PGlJEE<&JMmA?pj^7yDvmYi?{4_KI&zgT)#=6LJ zA4(SxO@aW)ep*{Z3{%5x@KC9Oz0p5L@Np#{5?X)Ko~zV$wuo`6?0kUhuw{ZIsT;YC z;>i7k`@JIstu12)O;ZniPs)QVhXLKF21q^#{KIw=;#(wY*acqVbiVl@-q9n1?^~Gu zd#aK?_s&hAe=FWo=8rAILWm)`T9B?+>ih6+(M!vRtffUe$DwnVjy$lf-GFYm`cY<4 z{&DqJo|?Xp$O0^akL>xmo<(ZI7|U{mb%uU+n|kJND|%6xP7QwlO?ff4)B81>B5C>q zLW;5R_acjgk5rFWE`frE0tX7^l8@;f6NshmJ1G~&k%WPMX6}jE`4Ku9nkL+@@qkX% z>yW7tS|_~gDCSY!<&@EKiS)BdjY#|o+ZcS9Ui`8Jy1y43QA2BetJdS<0=6ChBh$uJyLfk3b^W1t*QcJZ z$>T0XnAiZf)C$r8NrZICJ=PTm0UQsdm3bZJTrhZbR5z4a-v%wPeOd&Mi6H5%dw#b$ zGMMjQwapd{-R9`#Ollv1CbRXW0;$5+^?SpW%hqhUxo_*=72~d69u?mAlXbnE1d9g- zy<#ov0qU0bf3cgVz8`h6YTLF_W%_FBuFOtaL`(0`2X)1*deNt1vU#Nsta7mu0V>su z0^y%oWi&nvO0ZXRlamlOS!cD2nLAb7WwuRe?E=}VrLs9rSfPvY>_JSYRY>9o1aQ!N z_ZU#h`EJ_Fl&b9dQz{!53grftyfUxiX|+|U#}{r=5pkg{Rjs^}G^b*J2MyZYJBIjR z%35Vf;3J@SxcOn*PQRjnMv4Ti=eSrw znmt5i8H8!^DcTna6?)EP{VjNak}~k)C-V;yN#3fs&5x@*jzn$CDrX0r(u4kGj+lPm zcV#1sBgkibXc9t77y3nVL+^2mDI11<8KxH_VO1)L)0!9s7awaxZU;Q^?Apm+^C>Sew`%p-4p7Ve<^ns(UBr6TmsxJ~HCoLA zCx>kPk8S@{d!Va|7JQH=Mix^_s=g`d@ei;ZVacIr;9CO33BNjn!KFZ>#{ivgo0u

})yQ3JJZ){a1jDD786Yon%J_}kBN20W?CJFe?Ks4qvWLh&FI+h)-)pMSgj-i! zcTF8wdH}3?x0{sVWOSmhYo^gbA$FOmL<53j z#fP^>b!dQDvU2mo{Mezw%?;dH|CEC%v%w=1AFuZ_%yjS?ftN+z?QYlO^qk@-H??E! z?afaM|7mF&273f>ZyHSR!j1(#c1L|GRJwhrdi)Y z(17J+xMQidtH?yk5Z}5O{BL7&LGvs^xniz^)+AN%4~Jcby$GT1Waxrxo2Hn{VgG6lP?>zu5BTOAHL3H;xZFz8Bc~F}?>s^yxGz~b*#R#TV#`7)I{g{-JwXZJ zQ&NBG5Znyyh002*=-hjHz2wd+3#xpwXyD~jv%7WnGz;YvQmukFiJ=+prg3{IFR86! zBMIAF=e=Z~&6M}i3^G~B)^)&l%O;Qcn3K3Z>k^<_*|sos7^joT8K)8CorVP3<^0}3 zxn0e?>AW-F2#|NkDS67!c5XuX%5?q`8r^9LhK;GDIUrjU-{PM1F~0bV*HdS^fOltP zlpGAX;J4;1buMDIY;yX8s;BsSah|PmWbbNs8c4^Cp$>kF#9+h(DCbF;vgk9XkR}fKT**BubTwx*)&c1|BU?O{RMb~ zmBN{a+;ZApg-YP3A|Shy4byop1GiSyLAy00drhJX6U}>dt2Z5Y3g_Ga2w5;XaCfs| z8ek$@N_C4Cdo3;PjbHc{7D*Vg2A`r*#IIfuk=WOqnS5p*7Tvh4wv$HNWlAR>BCP@dd{RaeLL z{wA)^)0Cv*c8Hn3{b_vx9T%quYwMsq*^X0WxHI80?xx!5dv>q_??fzx-Y)(BTU2Wf0BuFR{#J2 literal 0 HcmV?d00001 From 1e630ddad84be78fcc2038cfde3220e7e396f5a2 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Thu, 12 Mar 2026 18:26:26 +0000 Subject: [PATCH 073/113] General cleanup --- README.md | 2 +- custom_components/gree_custom/aiogree/api.py | 20 +++++++++---------- .../gree_custom/aiogree/cipher.py | 10 +++++++--- .../gree_custom/aiogree/const.py | 6 ------ custom_components/gree_custom/diagnostics.py | 5 +---- 5 files changed, 18 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 612e878..ec3658d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ [![HACS](https://img.shields.io/badge/HACS-Default-orange.svg)](https://hacs.xyz) -[![Home Assistant](https://img.shields.io/badge/Compatible-Home_Assistant_2025.9.4-blue.svg)](https://www.home-assistant.io) +[![Home Assistant](https://img.shields.io/badge/Compatible-Home_Assistant_2026.3.1-blue.svg)](https://www.home-assistant.io) # HomeAssistant-GreeClimateComponent diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index 52f9d32..d38a241 100644 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -1,7 +1,6 @@ """Contains the API to interface with the Gree device.""" import asyncio -import base64 from enum import Enum, IntEnum, unique import json import logging @@ -12,16 +11,9 @@ import asyncio_dgram from attr import dataclass -from Crypto.Cipher import AES - -from .const import ( - DEFAULT_DEVICE_PORT, - GCM_ADD, - GCM_IV, - GREE_GENERIC_DEVICE_KEY, - GREE_GENERIC_DEVICE_KEY_GCM, -) + from .cipher import CipherBase, CipherV1, CipherV2 +from .const import DEFAULT_DEVICE_PORT _LOGGER = logging.getLogger(__name__) @@ -374,7 +366,13 @@ async def fetch_result( data = get_gree_response_data(received_json, cipher) - _LOGGER.debug("Got data from %s: %s", ip_addr, data) + # Do not modify the original data + redacted = data.copy() + if "pack" in redacted and "key" in redacted["pack"]: + redacted["pack"] = redacted["pack"].copy() + redacted["pack"]["key"] = str(redacted["pack"]["key"])[:5] + "[redacted]" + + _LOGGER.debug("Got data from %s: %s", ip_addr, redacted) return data diff --git a/custom_components/gree_custom/aiogree/cipher.py b/custom_components/gree_custom/aiogree/cipher.py index 9fdda3b..3f261b8 100644 --- a/custom_components/gree_custom/aiogree/cipher.py +++ b/custom_components/gree_custom/aiogree/cipher.py @@ -5,10 +5,14 @@ from Crypto.Cipher import AES -from .const import GCM_ADD, GCM_IV, GREE_GENERIC_DEVICE_KEY, GREE_GENERIC_DEVICE_KEY_GCM - _LOGGER = logging.getLogger(__name__) +GCM_IV = b"\x54\x40\x78\x44\x49\x67\x5a\x51\x6c\x5e\x63\x13" +GCM_ADD = b"qualcomm-test" + +GREE_GENERIC_DEVICE_KEY = "a3K8Bx%2r8Y7#xDh" +GREE_GENERIC_DEVICE_KEY_GCM = "{yxAHAY_Lm6pbC/<" + class CipherBase: """Base class for the encryprion module.""" @@ -109,5 +113,5 @@ def decrypt(self, data: str, tag: str) -> str: _LOGGER.debug("Verifying tag: %s", tag) cipher.verify(base64.b64decode(tag)) - _LOGGER.debug("Decrypted data: %s", t) + _LOGGER.debug("Decrypted data successfully") return t diff --git a/custom_components/gree_custom/aiogree/const.py b/custom_components/gree_custom/aiogree/const.py index 4cfe053..71d3127 100644 --- a/custom_components/gree_custom/aiogree/const.py +++ b/custom_components/gree_custom/aiogree/const.py @@ -1,11 +1,5 @@ """Constants for the aiogree.""" -GCM_IV = b"\x54\x40\x78\x44\x49\x67\x5a\x51\x6c\x5e\x63\x13" -GCM_ADD = b"qualcomm-test" - -GREE_GENERIC_DEVICE_KEY = "a3K8Bx%2r8Y7#xDh" -GREE_GENERIC_DEVICE_KEY_GCM = "{yxAHAY_Lm6pbC/<" - MIN_TEMP_C = 16 MAX_TEMP_C = 30 diff --git a/custom_components/gree_custom/diagnostics.py b/custom_components/gree_custom/diagnostics.py index 27e996c..b4ab267 100644 --- a/custom_components/gree_custom/diagnostics.py +++ b/custom_components/gree_custom/diagnostics.py @@ -3,13 +3,10 @@ import logging from typing import Any -from homeassistant.components.diagnostics.util import async_redact_data from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry -from .const import ( - DOMAIN, -) +from .const import DOMAIN from .coordinator import GreeConfigEntry, GreeCoordinator _LOGGER = logging.getLogger(__name__) From 3581ec00955b68896bd1e6e9ca5fd85af8dc0fb3 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Fri, 13 Mar 2026 11:02:13 +0000 Subject: [PATCH 074/113] Fix Beeper on newer firmwares --- custom_components/gree_custom/aiogree/api.py | 2 ++ custom_components/gree_custom/aiogree/device.py | 7 ++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index d38a241..e0466ca 100644 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -76,6 +76,8 @@ class GreeProp(Enum): _UNKNOWN_HEAT_COOL_TYPE = "HeatCoolType" # If set to 0 the unit will beep on every command BEEPER = "Buzzer_ON_OFF" + # If set to 1 the unit will beep on every command (available on newer firmwares) + BEEPER_NEW = "BuzzerCtrl" @unique diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index bcee26d..380b79e 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -98,9 +98,9 @@ def __init__( self._timeout: int = timeout self._props_to_update: list[GreeProp] = list(GreeProp) - self._props_to_update.remove( - GreeProp.BEEPER # We don't need to poll the beeper state - ) + # Don't poll the beeper state + self._props_to_update.remove(GreeProp.BEEPER) + self._props_to_update.remove(GreeProp.BEEPER_NEW) self._temp_processor_indoors: TempOffsetResolver | None = None self._temp_processor_outdoors: TempOffsetResolver | None = None @@ -283,6 +283,7 @@ async def update_device_status(self): return self._new_raw_state[GreeProp.BEEPER] = 0 if self._beeper else 1 + self._new_raw_state[GreeProp.BEEPER_NEW] = 1 if self._beeper else 0 try: self._raw_state.update( From f52c4acd06412544f2ba0cbf640b2b9cc767b85e Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Fri, 13 Mar 2026 18:02:56 +0000 Subject: [PATCH 075/113] Fix: Don't change config entry on diagnostics download --- custom_components/gree_custom/diagnostics.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/custom_components/gree_custom/diagnostics.py b/custom_components/gree_custom/diagnostics.py index b4ab267..bf53309 100644 --- a/custom_components/gree_custom/diagnostics.py +++ b/custom_components/gree_custom/diagnostics.py @@ -24,11 +24,13 @@ async def async_get_config_entry_diagnostics( for i, c in coordinators.items(): data[i] = c.get_coordinator_diagnostics() - diagnostics = {"entry_data": dict(entry.data), "data": data} - diagnostics["entry_data"]["advanced"]["encryption_key"] = ( + diagnostics = {"entry_data": dict(entry.data.copy()), "data": data} + redacted = diagnostics + redacted["entry_data"]["advanced"] = diagnostics["entry_data"]["advanced"].copy() + redacted["entry_data"]["advanced"]["encryption_key"] = ( diagnostics["entry_data"]["advanced"]["encryption_key"][:5] + "[redacted]" ) - return diagnostics + return redacted async def async_get_device_diagnostics( From 04681239d553dd4bc0ce05ebdb4766e65f092ccf Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Sun, 15 Mar 2026 22:58:12 +0000 Subject: [PATCH 076/113] Improve error handling and file organization --- custom_components/gree_custom/__init__.py | 6 +- custom_components/gree_custom/aiogree/api.py | 403 ++++-------------- .../gree_custom/aiogree/cipher.py | 140 ++++-- .../gree_custom/aiogree/device.py | 189 ++++---- .../gree_custom/aiogree/errors.py | 21 + .../gree_custom/aiogree/transport.py | 132 ++++++ custom_components/gree_custom/config_flow.py | 32 +- custom_components/gree_custom/coordinator.py | 7 +- 8 files changed, 469 insertions(+), 461 deletions(-) create mode 100644 custom_components/gree_custom/aiogree/errors.py create mode 100644 custom_components/gree_custom/aiogree/transport.py diff --git a/custom_components/gree_custom/__init__.py b/custom_components/gree_custom/__init__.py index ebc36f0..1e21120 100755 --- a/custom_components/gree_custom/__init__.py +++ b/custom_components/gree_custom/__init__.py @@ -18,7 +18,9 @@ DEFAULT_DEVICE_PORT, DEFAULT_DEVICE_UID, ) -from .aiogree.device import GreeDevice, GreeDeviceNotBoundError +from .aiogree.device import GreeDevice +from .aiogree.errors import GreeAuthenticationError +from .config_flow import build_main_schema # Local imports from .const import ( @@ -105,7 +107,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool except TimeoutError as err: _LOGGER.debug("Conection to %s timed out", mac) raise ConfigEntryNotReady from err - except GreeDeviceNotBoundError as err: + except GreeAuthenticationError as err: _LOGGER.debug("Failed to bind to device %s", mac) raise ConfigEntryNotReady from err diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index e0466ca..5b6de08 100644 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -1,19 +1,17 @@ """Contains the API to interface with the Gree device.""" -import asyncio from enum import Enum, IntEnum, unique import json import logging import re -import socket -import time from typing import Any -import asyncio_dgram from attr import dataclass -from .cipher import CipherBase, CipherV1, CipherV2 +from .cipher import CipherBase, EncryptionVersion, get_cipher from .const import DEFAULT_DEVICE_PORT +from .errors import GreeAuthenticationError, GreeProtocolError +from .transport import udp_broadcast_request, udp_request _LOGGER = logging.getLogger(__name__) @@ -80,14 +78,6 @@ class GreeProp(Enum): BEEPER_NEW = "BuzzerCtrl" -@unique -class EncryptionVersion(IntEnum): - """Available encryption versions for the device.""" - - V1 = 1 - V2 = 2 - - @unique class TemperatureUnits(IntEnum): """Enumeration of temperature units.""" @@ -174,173 +164,7 @@ class GreeDiscoveredDevice: propkey_to_enum = {prop.value: prop for prop in GreeProp} -def pad(s: str): - """Pads a string so its length becomes a multiple of 16. For PKCS#7 padding.""" - aesBlockSize = 16 - requiredPaddingSize = aesBlockSize - len(s) % aesBlockSize - return s + requiredPaddingSize * chr(requiredPaddingSize) - - -def udp_broadcast_request( - addresses: list[str], port: int, json_data: str, timeout: int -): - """Sends a UDP message to the bradcast address and returns the responses.""" - # Create UDP socket manually so we can enable broadcast - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) - sock.settimeout(timeout) - sock.bind(("", 0)) - - responses: dict = {} - - # Default broadcast addresses to try - default_broadcast_addresses = [ - "255.255.255.255", # Limited broadcast - "192.168.255.255", # /16 broadcast for 192.168.x.x networks - "10.255.255.255", # /8 broadcast for 10.x.x.x networks - "172.31.255.255", # /12 broadcast for 172.16-31.x.x networks - ] - addresses.extend(default_broadcast_addresses) - - # Remove duplicates - broadcast_addresses = list(dict.fromkeys(addresses)) - - try: - for broadcast_addr in broadcast_addresses: - try: - _LOGGER.debug("Sending broadcast to %s", broadcast_addr) - sock.sendto(json_data.encode("utf-8"), (broadcast_addr, port)) - except Exception: - _LOGGER.exception("Failed to send to %s", broadcast_addr) - - # Send broadcast - _LOGGER.debug("Sent broadcast packets, waiting for replies... ") - - start_time: float = time.time() - while time.time() - start_time < timeout: - try: - response, addr = sock.recvfrom(1024) - - try: - response = response.decode(errors="ignore") - except Exception: - _LOGGER.exception("Could not parse response from %s", addr) - else: - responses[addr[0]] = response - except TimeoutError: - break - except Exception: - _LOGGER.exception("Error sending broadcast packet") - finally: - sock.close() - - _LOGGER.debug( - "Got %d responses in %d seconds: %s", len(responses), timeout, responses - ) - return responses - - -async def udp_request_async( - ip_addr: str, - port: int, - json_data: str, - max_retries: int, - timeout: int, -) -> str: - """Send a payload JSON data to the device and reads the response (async).""" - # _LOGGER.info( - # "%s:%d max_r=%d t=%d json:\n%s", ip_addr, port, max_retries, timeout, json_data - # ) - - for attempt in range(max_retries): - stream: asyncio_dgram.DatagramClient | None = None - try: - stream = await asyncio_dgram.connect((ip_addr, port)) - await stream.send(json_data.encode("utf-8")) - received_json, _ = await asyncio.wait_for(stream.recv(), timeout) - return received_json.decode("utf-8") - except Exception as err: # noqa: BLE001 - _LOGGER.warning( - "Error communicating with %s. Attempt %d/%d | %s", - ip_addr, - attempt + 1, - max_retries, - err, - ) - # raise ValueError(f"Error communicating with {ip_addr}", ip_addr) from err - finally: - if stream: - try: - stream.close() - except Exception as cerr: # noqa: BLE001 - _LOGGER.warning( - "Error communicating with %s. Attempt %d/%d | %s", - ip_addr, - attempt + 1, - max_retries, - repr(cerr), - ) - - # Apply backoff before retrying - if attempt < max_retries - 1: - backoff = 0.5 + attempt * 0.3 # 0.5s, 0.8s, 1.1s, ... - await asyncio.sleep(backoff) - - raise ValueError( - f"Failed to communicate with device '{ip_addr}:{port}' after {max_retries} attempts" - ) - - -async def udp_request_blocking( - ip_addr: str, port: int, json_data: str, max_retries: int, timeout: int -) -> str: - """Send a payload JSON data to the device and reads the response (blocking).""" - _LOGGER.debug("Fetching(%s, %s, %s, %s)", ip_addr, port, timeout, json_data) - - for attempt in range(max_retries): - clientSock = None - - try: - clientSock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - clientSock.settimeout(timeout) - - clientSock.sendto(bytes(json_data, "utf-8"), (ip_addr, port)) - - data, _ = await asyncio.wait_for( - asyncio.get_event_loop().run_in_executor( - None, clientSock.recvfrom, 64000 - ), - timeout=timeout, - ) - - return data.decode("utf-8") - - except Exception: # noqa: BLE001 - _LOGGER.warning( - "Error communicating with %s. Attempt %d/%d", - ip_addr, - attempt + 1, - max_retries, - ) - - finally: - if clientSock: - try: - clientSock.close() - except Exception: # noqa: BLE001 - _LOGGER.error("Error closing socket to %s", ip_addr) - - if attempt < max_retries - 1: - await asyncio.sleep( - 0.5 * (attempt * 0.3) - ) # 0.5s, 0.8s, 1.1s, 1.4s, 1.7s, 2.0s, 2.3s - - raise ValueError( - f"Failed to communicate with device '{ip_addr}' after multiple attempts" - ) - - -async def fetch_result( +async def get_result_pack( ip_addr: str, port: int, json_data: str, @@ -348,35 +172,26 @@ async def fetch_result( max_connection_attempts: int, timeout: int, ): - """Send a payload JSON data to the device and reads the response (async).""" + """Get the result pack from the device (async).""" - _LOGGER.debug("Fetching data from %s", ip_addr) + raw = await udp_request(ip_addr, port, json_data, max_connection_attempts, timeout) - received_json: str = "" + data = get_gree_response_data(raw, cipher) - try: - received_json = await udp_request_async( - ip_addr, port, json_data, max_connection_attempts, timeout - ) - except Exception as err: - raise ValueError(f"Error communicating with {ip_addr}: {err}") from err + pack = data.get("pack", None) - # try: - # received_json = await udp_request_blocking(ip_addr, port, json_data) - # except Exception as err: - # raise ValueError(f"Error communicating with {ip_addr}", ip_addr) from err - - data = get_gree_response_data(received_json, cipher) + if pack is None: + raise GreeProtocolError("Device response missing 'pack' field") # Do not modify the original data redacted = data.copy() - if "pack" in redacted and "key" in redacted["pack"]: + if "key" in redacted["pack"] and redacted["pack"]["key"]: redacted["pack"] = redacted["pack"].copy() redacted["pack"]["key"] = str(redacted["pack"]["key"])[:5] + "[redacted]" _LOGGER.debug("Got data from %s: %s", ip_addr, redacted) - return data + return pack def get_gree_response_data( @@ -384,69 +199,23 @@ def get_gree_response_data( cipher: CipherBase, ): """Decodes a response from a gree device.""" - data = json.loads(received_json) - encodedPack = data.get("pack", None) + try: + data = json.loads(received_json) + except json.JSONDecodeError as err: + raise GreeProtocolError("Invalid JSON response from device") from err + + encoded_pack = data.get("pack", None) tag = data.get("tag", None) - if encodedPack: - decodedPack = cipher.decrypt(encodedPack, tag) - data["pack"] = json.loads(decodedPack) + if encoded_pack: + decrypted_pack = cipher.decrypt(encoded_pack, tag) + # Replace encrypted pack with decrypted data + data["pack"] = json.loads(decrypted_pack) return data -async def get_result_pack( - ip_addr: str, - port: int, - json_data: str, - cipher: CipherBase, - max_connection_attempts: int, - timeout: int, -): - """Get the result pack from the device (async).""" - - data = await fetch_result( - ip_addr, - port, - json_data, - cipher, - max_connection_attempts, - timeout, - ) - - if data is not None and data["pack"] is not None: - return data["pack"] - - raise ValueError("No pack received from device") - - -def get_cipher(key: str, encryption_version: EncryptionVersion) -> CipherBase: - """Get AES cipher object based on encryption version.""" - - if encryption_version == EncryptionVersion.V1: - return CipherV1(key) - - if encryption_version == EncryptionVersion.V2: - return CipherV2(key) - - _LOGGER.error("Unsupported encryption version: %d", encryption_version) - return None - - -def gree_get_default_cipher(encryption_version: EncryptionVersion) -> CipherBase: - """Get AES cipher object based on encryption version using default keys.""" - - if encryption_version == EncryptionVersion.V1: - return CipherV1() - - if encryption_version == EncryptionVersion.V2: - return CipherV2() - - _LOGGER.error("Unsupported encryption version: %d", encryption_version) - return None - - def gree_create_encrypted_pack( data: str, cipher: CipherBase, @@ -521,7 +290,7 @@ def gree_create_payload( ) -> str: """Create the full payload to send to the device.""" - base_payload: dict[str, Any] = { + payload: dict[str, Any] = { "cid": "app", "i": i_command.value, "pack": pack, @@ -531,10 +300,10 @@ def gree_create_payload( } if tag is not None: - base_payload["tag"] = tag + payload["tag"] = tag - _LOGGER.debug("Payload: %s", base_payload) - return json.dumps(base_payload) + _LOGGER.debug("Payload: %s", payload) + return json.dumps(payload) async def gree_get_device_key( @@ -549,7 +318,7 @@ async def gree_get_device_key( """Get the device key by sending a bind request to the device using a generic key (async).""" key = "" - error: Exception = ValueError("Unknown error getting device encryption key") + error: Exception = Exception("Unknown error getting device encryption key") for enc_version in ( [EncryptionVersion.V1, EncryptionVersion.V2] @@ -558,11 +327,11 @@ async def gree_get_device_key( ): _LOGGER.info("Trying to retrieve device encryption key v%d", enc_version) - cipher = gree_get_default_cipher(enc_version) - pack, tag = gree_create_encrypted_pack( - gree_create_bind_pack(mac_addr, uid, enc_version), cipher - ) - jsonPayloadToSend = gree_create_payload( + cipher = get_cipher(enc_version) + + raw_pack = gree_create_bind_pack(mac_addr, uid, enc_version) + pack, tag = gree_create_encrypted_pack(raw_pack, cipher) + json_payload = gree_create_payload( pack, "pack", GreeCommand.BIND, mac_addr, uid, tag ) @@ -570,34 +339,36 @@ async def gree_get_device_key( result = await get_result_pack( ip_addr, port, - jsonPayloadToSend, + json_payload, cipher, max_connection_attempts, timeout, ) key = result.get("key", "") - except Exception as err: # noqa: BLE001 - error = err - _LOGGER.error( - "Error getting device encryption key with version %d:\n%s", + + except Exception as err: + _LOGGER.exception( + "Error getting device encryption key with version %d", enc_version, - err, ) - # raise ValueError("Error getting device encryption key") from err + error = err continue if key.strip() == "": - error = ValueError("Received empty encryption key from device") + error = Exception("Received empty encryption key from device") continue _LOGGER.info( "Fetched device encryption key with version %d with success", enc_version ) + _LOGGER.debug("Fetched encryption key: %s[omitted]", key[:5]) return key, enc_version - raise ValueError("Error getting device encryption key") from error + raise GreeAuthenticationError( + f"Error getting device encryption key from {ip_addr}" + ) from error async def gree_get_status( @@ -617,10 +388,9 @@ async def gree_get_status( status_values_raw: dict[GreeProp, int | None] = {} - pack, tag = gree_create_encrypted_pack( - gree_create_status_pack(mac_addr_sub, [prop.value for prop in props]), cipher - ) - jsonPayloadToSend = gree_create_payload( + raw_pack = gree_create_status_pack(mac_addr_sub, [prop.value for prop in props]) + pack, tag = gree_create_encrypted_pack(raw_pack, cipher) + json_payload = gree_create_payload( pack, "pack", GreeCommand.STATUS, mac_addr, uid, tag ) @@ -628,16 +398,20 @@ async def gree_get_status( result = await get_result_pack( ip_addr, port, - jsonPayloadToSend, + json_payload, cipher, max_connection_attempts, timeout, ) + + except GreeProtocolError: + raise + except Exception as err: - raise ValueError("Error getting device status") from err + raise GreeProtocolError("Error getting device status") from err if result["cols"] is None or result["dat"] is None: - raise ValueError("Error getting device status, no data received") + raise GreeProtocolError("No data received while getting device status") cols = [propkey_to_enum[c] for c in result["cols"] if c in propkey_to_enum] values = [int(x) if x != "" else None for x in result["dat"]] @@ -666,8 +440,7 @@ async def gree_set_status( set_pack = gree_create_set_pack(mac_addr_sub, props) pack, tag = gree_create_encrypted_pack(set_pack, cipher) - - jsonPayloadToSend = gree_create_payload( + json_payload = gree_create_payload( pack, "pack", GreeCommand.STATUS, mac_addr, uid, tag ) @@ -675,37 +448,43 @@ async def gree_set_status( result = await get_result_pack( ip_addr, port, - jsonPayloadToSend, + json_payload, cipher, max_connection_attempts, timeout, ) + + except GreeProtocolError: + raise + except Exception as err: - raise ValueError("Error getting device status") from err + raise GreeProtocolError("Error getting device status") from err if result["r"] is None or result["r"] != 200: - raise ValueError(f"Error setting device status, response code: {result['r']}") + raise GreeProtocolError( + f"Error setting device status, response code: {result['r']}" + ) options_set = [propkey_to_enum[c] for c in result["opt"] if c in propkey_to_enum] if options_set is None or len(options_set) == 0: - raise ValueError("No options were set, something went wrong") + raise GreeProtocolError("No options were set, something went wrong") values_set_1 = result.get("p", None) values_set_2 = result.get("val", None) # this one is optional if values_set_1 is None: - raise ValueError("No values were set, something went wrong") + raise GreeProtocolError("No values were set, something went wrong") values_set_1 = list(map(int, values_set_1)) if values_set_2 is not None: values_set_2 = list(map(int, values_set_2)) if len(values_set_1) != len(values_set_2): - raise ValueError( + raise GreeProtocolError( f"Wrong option values received: {values_set_1} {values_set_2}" ) if len(values_set_1) != len(options_set): - raise ValueError( + raise GreeProtocolError( f"Options and values set mismatch {options_set} {values_set_1}" ) @@ -722,26 +501,24 @@ async def gree_get_device_info( timeout: int, ) -> dict[str, str | None]: """Tries to retrive the device info.""" - try: - data: dict = await get_result_pack( - ip_addr, - DEFAULT_DEVICE_PORT, - json.dumps({"t": "scan"}), - gree_get_default_cipher(EncryptionVersion.V1), - max_connection_attempts, - timeout, - ) - except Exception as err: - _LOGGER.exception("Error retrieving basic device info") - raise ValueError("Error retrieving basic device info") from err - else: - _LOGGER.debug("Got device info: %s", data) - info: dict[str, str | None] = {} - info["raw"] = data - info["firmware_version"], info["firmware_code"] = extract_version(data) - info["mac"] = data.get("mac", "") - info["subdevices_count"] = data.get("subCnt", 0) - return info + + data: dict = await get_result_pack( + ip_addr, + DEFAULT_DEVICE_PORT, + json.dumps({"t": "scan"}), + get_cipher(EncryptionVersion.V1), + max_connection_attempts, + timeout, + ) + + _LOGGER.debug("Got device info: %s", data) + + info: dict[str, str | None] = {} + info["raw"] = data + info["firmware_version"], info["firmware_code"] = extract_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]: @@ -773,7 +550,7 @@ async def discover_gree_devices( for address, response in responses.items(): data = get_gree_response_data( response, - gree_get_default_cipher(EncryptionVersion.V1), + get_cipher(EncryptionVersion.V1), ) if data is not None: pack = data.get("pack") @@ -880,7 +657,9 @@ async def gree_get_sub_devices_list( return result.get("list", []) except Exception as err: - raise ValueError(f"Error fetching sub-device list for '{mac_addr}'") from err + raise GreeProtocolError( + f"Error fetching sub-device list for '{mac_addr}'" + ) from err async def get_sub_devices( @@ -900,7 +679,7 @@ async def get_sub_devices( pack, tag = gree_create_encrypted_pack( gree_create_sub_bind_pack(mac_addr), - gree_get_default_cipher(version), + get_cipher(version), version, ) @@ -918,7 +697,7 @@ async def get_sub_devices( ip_addr, DEFAULT_DEVICE_PORT, jsonPayloadToSend, - gree_get_default_cipher(version), + get_cipher(version), version, max_connection_attempts, timeout, diff --git a/custom_components/gree_custom/aiogree/cipher.py b/custom_components/gree_custom/aiogree/cipher.py index 3f261b8..19e67e5 100644 --- a/custom_components/gree_custom/aiogree/cipher.py +++ b/custom_components/gree_custom/aiogree/cipher.py @@ -1,9 +1,11 @@ """Encapsulates device encryption.""" import base64 +from enum import IntEnum, unique import logging from Crypto.Cipher import AES +from Crypto.Util.Padding import pad, unpad _LOGGER = logging.getLogger(__name__) @@ -13,6 +15,16 @@ GREE_GENERIC_DEVICE_KEY = "a3K8Bx%2r8Y7#xDh" GREE_GENERIC_DEVICE_KEY_GCM = "{yxAHAY_Lm6pbC/<" +AES_BLOCK_SIZE = 16 + + +@unique +class EncryptionVersion(IntEnum): + """Available encryption versions for the device.""" + + V1 = 1 + V2 = 2 + class CipherBase: """Base class for the encryprion module.""" @@ -21,9 +33,14 @@ def __init__(self, key: str) -> None: """Initialize the class.""" self.key = key + @property + def version(self) -> EncryptionVersion: + """The encryption version of this cypher.""" + raise NotImplementedError + @property def key(self) -> str: - """The encryprion key.""" + """The encryption key.""" return self._key.decode() @key.setter @@ -40,78 +57,125 @@ def decrypt(self, data: str, tag: str | None) -> str: class CipherV1(CipherBase): - """Implements the V1 type encryption used by Gree.""" + """Implements the V1 (AES-ECB) type encryption used by Gree.""" - def __init__(self, key: str = GREE_GENERIC_DEVICE_KEY) -> None: + def __init__(self, key: str | None) -> None: """Initialize V1 Encryption.""" - super().__init__(key) + super().__init__(key or GREE_GENERIC_DEVICE_KEY) - def __create_cipher(self) -> AES: + def _create_cipher(self): return AES.new(self._key, AES.MODE_ECB) - def __pad(self, s) -> str: - aesBlockSize = 16 - requiredPaddingSize = aesBlockSize - len(s) % aesBlockSize - return s + requiredPaddingSize * chr(requiredPaddingSize) + @property + def version(self) -> EncryptionVersion: + """The encryption version of this cypher.""" + return EncryptionVersion.V1 def encrypt(self, data: str) -> tuple[str, str | None]: """Encrypt data with V1.""" - _LOGGER.debug("Encrypting data: %s", data) - cipher = self.__create_cipher() - padded = self.__pad(data).encode("utf-8") + _LOGGER.debug("Encrypting data (V1): %s", data) + + cipher = self._create_cipher() + padded = pad(data.encode("utf-8"), AES_BLOCK_SIZE) + encrypted = cipher.encrypt(padded) encoded = base64.b64encode(encrypted).decode("utf-8") - _LOGGER.debug("Encrypted data: %s", encoded) + + _LOGGER.debug("Encrypted data (V1): %s", encoded) + return encoded, None def decrypt(self, data: str, tag: None) -> str: """Decrypt data with V1.""" - _LOGGER.debug("Decrypting data: %s", data) - cipher = self.__create_cipher() + _LOGGER.debug("Decrypting data (V1): %s", data) + + cipher = self._create_cipher() decoded = base64.b64decode(data) - decrypted = cipher.decrypt(decoded).decode("utf-8") - t = decrypted.replace("\x0f", "").replace( - decrypted[decrypted.rindex("}") + 1 :], "" - ) - _LOGGER.debug("Decrypted data: %s", t) - return t + + decrypted = cipher.decrypt(decoded) + + try: + plaintext = unpad(decrypted, AES_BLOCK_SIZE).decode() + except ValueError: + # Fallback for some devices sending malformed padding + plaintext = decrypted.decode(errors="ignore") + + _LOGGER.debug("Decrypted data successfully (V1)") + + return _trim_json_payload(plaintext) class CipherV2(CipherBase): """Implements the V2 type encryption used by Gree.""" - def __init__(self, key: str = GREE_GENERIC_DEVICE_KEY_GCM) -> None: + def __init__(self, key: str | None) -> None: """Initialize V2 Encryption.""" - super().__init__(key) + super().__init__(key or GREE_GENERIC_DEVICE_KEY_GCM) - def __create_cipher(self) -> AES: + def _create_cipher(self) -> AES: cipher = AES.new(self._key, AES.MODE_GCM, nonce=GCM_IV) cipher.update(GCM_ADD) return cipher + @property + def version(self) -> EncryptionVersion: + """The encryption version of this cypher.""" + return EncryptionVersion.V2 + def encrypt(self, data: str) -> tuple[str, str]: """Encrypt data with V2 and return the data with a tag.""" - _LOGGER.debug("Encrypting data: %s", data) - cipher = self.__create_cipher() + _LOGGER.debug("Encrypting data (V2): %s", data) + + cipher = self._create_cipher() + encrypted, tag = cipher.encrypt_and_digest(data.encode("utf-8")) + encoded = base64.b64encode(encrypted).decode("utf-8") - tag = base64.b64encode(tag).decode("utf-8") - _LOGGER.debug("Encrypted data: %s", encoded) - _LOGGER.debug("Cipher digest: %s", tag) - return encoded, tag + tag_encoded = base64.b64encode(tag).decode("utf-8") + + _LOGGER.debug("Encrypted data (V2): %s, tag='%s'", encoded, tag) + return encoded, tag_encoded def decrypt(self, data: str, tag: str) -> str: """Decrypt data with V2 and verify the data with the tag.""" - _LOGGER.debug("Decrypting data: %s", data) - cipher = self.__create_cipher() + _LOGGER.debug("Decrypting data (V2): %s", data) + + cipher = self._create_cipher() + decoded = base64.b64decode(data) - decrypted = cipher.decrypt(decoded).decode("utf-8") - t = decrypted.replace("\x0f", "").replace( - decrypted[decrypted.rindex("}") + 1 :], "" - ) + decrypted = cipher.decrypt(decoded) _LOGGER.debug("Verifying tag: %s", tag) cipher.verify(base64.b64decode(tag)) - _LOGGER.debug("Decrypted data successfully") - return t + plaintext = decrypted.decode("utf-8") + + _LOGGER.debug("Decrypted data successfully (V2)") + return _trim_json_payload(plaintext) + + +def _trim_json_payload(data: str) -> str: + """Trims JSON garbage. + + Some devices append garbage after JSON payload. + This safely trims everything after the final '}'. + """ + + end = data.rfind("}") + if end != -1: + return data[: end + 1] + return data + + +def get_cipher( + encryption_version: EncryptionVersion, key: str | None = None +) -> CipherBase: + """Get AES cipher object based on encryption version using default keys.""" + + if encryption_version == EncryptionVersion.V1: + return CipherV1(key) + + if encryption_version == EncryptionVersion.V2: + return CipherV2(key) + + raise ValueError(f"Unsupported encryption version: {encryption_version}") diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index 380b79e..315c190 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -12,20 +12,24 @@ OperationMode, TemperatureUnits, VerticalSwingMode, - get_cipher, - gree_get_default_cipher, gree_get_device_info, gree_get_device_key, gree_get_status, gree_get_sub_devices_list, gree_set_status, ) -from .cipher import CipherBase +from .cipher import CipherBase, get_cipher from .const import ( DEFAULT_CONNECTION_MAX_ATTEMPTS, DEFAULT_CONNECTION_TIMEOUT, DEFAULT_DEVICE_UID, ) +from .errors import ( + GreeAuthenticationError, + GreeAuthenticationErrorBadKey, + GreeError, + GreeProtocolError, +) from .helpers import ( TempOffsetResolver, gree_get_target_temp_props_from_c, @@ -37,18 +41,6 @@ _LOGGER = logging.getLogger(__name__) -class GreeDeviceNotBoundError(BaseException): - """Raised when the device binding fails.""" - - -class GreeDeviceNotBoundErrorKey(BaseException): - """Raised when the device binding fails because of wrong key.""" - - -class CannotConnect(BaseException): - """Error to indicate we cannot connect.""" - - class GreeDevice: """Representation of a Gree device.""" @@ -113,65 +105,72 @@ def __init__( async def bind_device(self) -> bool: """Setup the device (async).""" - if not self._is_bound: - try: - ( - encryption_key, - encryption_version, - ) = await gree_get_device_key( - self._ip_addr, - self._mac_addr, - self._port, - self._uid, - self._encryption_version, - self._max_connection_attempts, - self._timeout, - ) - except Exception as e: - raise GreeDeviceNotBoundError("Unable to obtain device key") from e - else: - if not self._encryption_key.strip() or not self._encryption_version: - _LOGGER.info("Using the obtained encryption key and version") - self._encryption_key = encryption_key - self._encryption_version = encryption_version - else: - if encryption_key != self._encryption_key: - raise GreeDeviceNotBoundErrorKey( - "Wrong encryption key provided" - ) - _LOGGER.info( - "Using the provided encryption key with version %d", - self._encryption_version, - ) - self._cipher = get_cipher(encryption_key, encryption_version) - self._is_available = True - self._is_bound = True + if self._is_bound: + return True - try: - # Used also as basic communication test - self._raw_info = await gree_get_device_info( - self._ip_addr, - self._max_connection_attempts, - self._timeout, - ) - except Exception as e: - _LOGGER.exception("Could not retrieve basic device info") - raise CannotConnect( - f"Not able to connect to the device {self._ip_addr}" - ) from e + try: + key, version = await gree_get_device_key( + self._ip_addr, + self._mac_addr, + self._port, + self._uid, + self._encryption_version, + self._max_connection_attempts, + self._timeout, + ) + + except GreeAuthenticationError: + raise + + except Exception as e: + raise GreeError(f"Failed binding to device {self._ip_addr}") from e + + else: + if not self._encryption_key.strip() or not self._encryption_version: + _LOGGER.info("Using the obtained encryption key and version") + self._encryption_key = key + self._encryption_version = version else: - if self._raw_info.get("mac", "") != self._mac_addr: - raise CannotConnect( - f"Not able to connect to the device {self._ip_addr}. MAC mismatch {self._raw_info.get('mac', '')} not {self._mac_addr}." - ) - self._firmware_version = self._raw_info.get("firmware_version") - self._firmware_code = self._raw_info.get("firmware_code") - self._subdevicesCount = int( - self._raw_info.get("subdevices_count", 0) or 0 + if key != self._encryption_key: + raise GreeAuthenticationErrorBadKey("Wrong encryption key provided") + _LOGGER.info( + "Using the provided encryption key with version %d", + self._encryption_version, ) + + self._cipher = get_cipher(version, key) + self._is_available = True + self._is_bound = True + + # Used also as basic communication test + await self.fetch_device_info() + return self._is_bound + async def fetch_device_info(self): + """Updates the device info fields.""" + try: + self._raw_info = await gree_get_device_info( + self._ip_addr, + self._max_connection_attempts, + self._timeout, + ) + + except Exception as e: + raise GreeProtocolError( + f"Failed fetching device info for {self._ip_addr}" + ) from e + + else: + if self._raw_info.get("mac", "") != self._mac_addr: + raise GreeProtocolError( + f"Wrong device info for {self._ip_addr}. MAC mismatch {self._raw_info.get('mac', '')} not {self._mac_addr}." + ) + self._firmware_version = self._raw_info.get("firmware_version") + self._firmware_code = self._raw_info.get("firmware_code") + self._subdevicesCount = int(self._raw_info.get("subdevices_count", 0) or 0) + async def fetch_sub_devices(self) -> list[GreeDiscoveredDevice]: """Get the sub devices list.""" _LOGGER.debug("Trying to get subdevices") @@ -192,13 +191,18 @@ async def fetch_sub_devices(self) -> list[GreeDiscoveredDevice]: self._mac_addr, self._port, self._uid, - gree_get_default_cipher(self._encryption_version), + get_cipher(self._encryption_version), self._max_connection_attempts, self._timeout, ) + except GreeProtocolError: + self._is_available = False + raise + except Exception as err: self._is_available = False - raise ValueError("Error getting subdevices") from err + raise GreeError("Error getting subdevices") from err + else: for sub_device in subs: sub_mac = sub_device.get("mac", "") @@ -218,8 +222,9 @@ async def fetch_sub_devices(self) -> list[GreeDiscoveredDevice]: "Discovered sub-device: %s", discovered_sub_device, ) - _LOGGER.debug("Subdevices of '%s': %s", self._mac_addr, subs) - self._is_available = True + + _LOGGER.debug("Subdevices of '%s': %s", self._mac_addr, subs) + self._is_available = True return discovered_devices @@ -233,7 +238,7 @@ async def fetch_device_status(self): assert self._cipher is not None try: - state, props_not_present = await gree_get_status( + state, _ = await gree_get_status( self._ip_addr, self._mac_addr, self._mac_addr_sub, @@ -246,24 +251,28 @@ async def fetch_device_status(self): ) self._raw_state.update(state) - if self._mac_addr != self._mac_addr_sub: - sub_state, _ = await gree_get_status( - self._ip_addr, - self._mac_addr, - self._mac_addr, - self._port, - self._uid, - self._cipher, - props_not_present, - self._max_connection_attempts, - self._timeout, - ) - # self._raw_state.update(sub_state) + # if self._mac_addr != self._mac_addr_sub: + # sub_state, _ = await gree_get_status( + # self._ip_addr, + # self._mac_addr, + # self._mac_addr, + # self._port, + # self._uid, + # self._cipher, + # props_not_present, + # self._max_connection_attempts, + # self._timeout, + # ) + # self._raw_state.update(sub_state) self._is_available = True + + except GreeProtocolError: + raise + except Exception as err: self._is_available = False - raise ValueError("Error getting device status") from err + raise GreeError("Error getting device status") from err self._remove_unsupported_props() @@ -301,9 +310,13 @@ async def update_device_status(self): ) self._new_raw_state.clear() self._is_available = True + + except GreeProtocolError: + raise + except Exception as err: self._is_available = False - raise ValueError("Error setting device status") from err + raise GreeError("Error setting device status") from err def _set_device_status(self, props: dict[GreeProp, int]) -> None: """Sets a new local device status. Use 'update_device_status' to update the device.""" diff --git a/custom_components/gree_custom/aiogree/errors.py b/custom_components/gree_custom/aiogree/errors.py new file mode 100644 index 0000000..6b4d811 --- /dev/null +++ b/custom_components/gree_custom/aiogree/errors.py @@ -0,0 +1,21 @@ +"""Errors raised by the integration.""" + + +class GreeError(Exception): + """Base error for the Gree integration.""" + + +class GreeConnectionError(GreeError): + """Network communication with device failed.""" + + +class GreeProtocolError(GreeError): + """Device returned invalid data.""" + + +class GreeAuthenticationError(GreeError): + """Failed to obtain encryption key.""" + + +class GreeAuthenticationErrorBadKey(GreeError): + """Provided encryption key wrong.""" diff --git a/custom_components/gree_custom/aiogree/transport.py b/custom_components/gree_custom/aiogree/transport.py new file mode 100644 index 0000000..741b85c --- /dev/null +++ b/custom_components/gree_custom/aiogree/transport.py @@ -0,0 +1,132 @@ +"""Handles network connections.""" + +import asyncio +import logging +import socket +import time + +import asyncio_dgram + +from .errors import GreeConnectionError + +_LOGGER = logging.getLogger(__name__) + + +async def udp_request( + ip_addr: str, + port: int, + data: str, + max_retries: int, + timeout: int, +) -> str: + """Send a payload data to the device and reads the response (async).""" + + last_error: Exception = None + + for attempt in range(max_retries): + stream: asyncio_dgram.DatagramClient | None = None + + try: + stream = await asyncio_dgram.connect((ip_addr, port)) + + await stream.send(data.encode("utf-8")) + + recv_task = asyncio.create_task(stream.recv()) + + try: + received_json, _ = await asyncio.wait_for(recv_task, timeout) + except TimeoutError: + recv_task.cancel() + raise + + return received_json.decode("utf-8") + + except Exception as err1: # noqa: BLE001 + _LOGGER.warning( + "Error communicating with %s. Attempt %d/%d", + ip_addr, + attempt + 1, + max_retries, + ) + last_error = err1 + + finally: + if stream: + try: + stream.close() + except Exception as err2: # noqa: BLE001 + _LOGGER.warning( + "Error communicating with %s. Attempt %d/%d", + ip_addr, + attempt + 1, + max_retries, + ) + last_error = err2 + + # Apply backoff before retrying + await asyncio.sleep(0.5 + attempt * 0.3) # 0.5s, 0.8s, 1.1s, ... + + raise GreeConnectionError( + f"Failed to communicate with device '{ip_addr}:{port}' after {max_retries} attempts" + ) from last_error + + +def udp_broadcast_request( + addresses: list[str], port: int, json_data: str, timeout: int +) -> dict: + """Sends a UDP message to the bradcast address and returns the responses.""" + # Create UDP socket manually so we can enable broadcast + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + sock.settimeout(timeout) + sock.bind(("", 0)) + + responses: dict = {} + + # Default broadcast addresses to try + default_broadcast_addresses = [ + "255.255.255.255", # Limited broadcast + "192.168.255.255", # /16 broadcast for 192.168.x.x networks + "10.255.255.255", # /8 broadcast for 10.x.x.x networks + "172.31.255.255", # /12 broadcast for 172.16-31.x.x networks + ] + addresses.extend(default_broadcast_addresses) + + # Remove duplicates + broadcast_addresses = list(dict.fromkeys(addresses)) + + try: + for broadcast_addr in broadcast_addresses: + try: + _LOGGER.debug("Sending broadcast to %s", broadcast_addr) + sock.sendto(json_data.encode("utf-8"), (broadcast_addr, port)) + except Exception: + _LOGGER.exception("Failed to send to %s", broadcast_addr) + + # Send broadcast + _LOGGER.debug( + "Sent broadcast packets, waiting %d seconds for replies... ", timeout + ) + + start_time: float = time.time() + while time.time() - start_time < timeout: + try: + response, addr = sock.recvfrom(1024) + + try: + response = response.decode(errors="ignore") + except Exception: + _LOGGER.exception("Could not parse response from %s", addr) + else: + responses[addr[0]] = response + except TimeoutError: + break + except Exception: + _LOGGER.exception("Error sending broadcast packet") + finally: + sock.close() + + _LOGGER.debug( + "Got %d responses in %d seconds: %s", len(responses), timeout, responses + ) + return responses diff --git a/custom_components/gree_custom/config_flow.py b/custom_components/gree_custom/config_flow.py index a3bf7be..2f77f25 100644 --- a/custom_components/gree_custom/config_flow.py +++ b/custom_components/gree_custom/config_flow.py @@ -30,23 +30,19 @@ TextSelectorType, ) -from .aiogree.api import ( - EncryptionVersion, - GreeDiscoveredDevice, - GreeProp, - discover_gree_devices, -) +from .aiogree.api import GreeDiscoveredDevice, GreeProp, discover_gree_devices +from .aiogree.cipher import EncryptionVersion from .aiogree.const import ( DEFAULT_CONNECTION_MAX_ATTEMPTS, DEFAULT_CONNECTION_TIMEOUT, DEFAULT_DEVICE_PORT, DEFAULT_DEVICE_UID, ) -from .aiogree.device import ( - CannotConnect, - GreeDevice, - GreeDeviceNotBoundError, - GreeDeviceNotBoundErrorKey, +from .aiogree.device import GreeDevice +from .aiogree.errors import ( + GreeAuthenticationError, + GreeAuthenticationErrorBadKey, + GreeConnectionError, ) from .const import ( ATTR_EXTERNAL_HUMIDITY_SENSOR, @@ -702,15 +698,15 @@ async def async_step_manual_add( self._devices[subdev.mac_address_sub] = subdev await self._devices[_main_device.mac_address_sub].fetch_device_status() - except CannotConnect: - errors["base"] = "cannot_connect" - _LOGGER.exception("Cannot connect") - except GreeDeviceNotBoundError: - errors["base"] = "cannot_connect" - _LOGGER.exception("Error while binding") - except GreeDeviceNotBoundErrorKey: + except GreeAuthenticationErrorBadKey: errors["base"] = "cannot_connect_key" _LOGGER.exception("Error while binding with wrong key") + except GreeAuthenticationError: + errors["base"] = "cannot_connect" + _LOGGER.exception("Error while binding") + except GreeConnectionError: + errors["base"] = "cannot_connect" + _LOGGER.exception("Cannot connect") except Exception: errors["base"] = "unknown" _LOGGER.exception("Unknown error while binding") diff --git a/custom_components/gree_custom/coordinator.py b/custom_components/gree_custom/coordinator.py index 6f67a39..11aeee4 100644 --- a/custom_components/gree_custom/coordinator.py +++ b/custom_components/gree_custom/coordinator.py @@ -12,7 +12,8 @@ timedelta, ) -from .aiogree.device import GreeDevice, GreeDeviceNotBoundError +from .aiogree.device import GreeDevice +from .aiogree.errors import GreeAuthenticationError _LOGGER = logging.getLogger(__name__) @@ -61,10 +62,10 @@ async def _async_update_data(self): """ try: await self.device.fetch_device_status() - except GreeDeviceNotBoundError as err: + except GreeAuthenticationError as err: _LOGGER.exception("Failed to initiate Gree device") raise ConfigEntryAuthFailed("Failed to initiate Gree device") from err - except ValueError as err: + except Exception as err: _LOGGER.exception("Error getting state from device") raise UpdateFailed("Error getting state from device") from err From ca7c9f6ec2b50573db9cb426b235653e4ff4edd5 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Mon, 16 Mar 2026 23:37:19 +0000 Subject: [PATCH 077/113] Bump checkout action version --- .github/workflows/hassfest.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/hassfest.yaml b/.github/workflows/hassfest.yaml index 07d1dda..ec7fcd6 100644 --- a/.github/workflows/hassfest.yaml +++ b/.github/workflows/hassfest.yaml @@ -10,5 +10,5 @@ jobs: validate: runs-on: "ubuntu-latest" steps: - - uses: "actions/checkout@v3" + - uses: "actions/checkout@v6" - uses: home-assistant/actions/hassfest@master From 8753890539b235e78a52afe5c2201c3d1e4d5e75 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Tue, 17 Mar 2026 00:57:02 +0000 Subject: [PATCH 078/113] Fix restore state config keys --- custom_components/gree_custom/__init__.py | 1 - custom_components/gree_custom/climate.py | 4 +-- custom_components/gree_custom/select.py | 9 +++--- custom_components/gree_custom/sensor.py | 9 +++--- custom_components/gree_custom/switch.py | 39 +++++++++++++---------- 5 files changed, 35 insertions(+), 27 deletions(-) diff --git a/custom_components/gree_custom/__init__.py b/custom_components/gree_custom/__init__.py index 1e21120..a355d9d 100755 --- a/custom_components/gree_custom/__init__.py +++ b/custom_components/gree_custom/__init__.py @@ -20,7 +20,6 @@ ) from .aiogree.device import GreeDevice from .aiogree.errors import GreeAuthenticationError -from .config_flow import build_main_schema # Local imports from .const import ( diff --git a/custom_components/gree_custom/climate.py b/custom_components/gree_custom/climate.py index b2bb6bf..2c8fb50 100644 --- a/custom_components/gree_custom/climate.py +++ b/custom_components/gree_custom/climate.py @@ -229,7 +229,7 @@ def __init__( self._update_attributes() _LOGGER.debug( "Initialized climate: %s (check_availability=%s) Features:\n%s", - self._attr_unique_id, + self.unique_id, self.check_availability, repr(self._attr_supported_features), ) @@ -285,7 +285,7 @@ async def _restore_entity_state(self): if last_state is not None: _LOGGER.debug( "Restoring state for %s:\n%s", - self.entity_id, + self.unique_id, last_state, ) diff --git a/custom_components/gree_custom/select.py b/custom_components/gree_custom/select.py index bfbd053..f7bb27e 100644 --- a/custom_components/gree_custom/select.py +++ b/custom_components/gree_custom/select.py @@ -17,6 +17,7 @@ from .aiogree.api import GreeProp, TemperatureUnits from .aiogree.device import GreeDevice from .const import ( + CONF_ADVANCED, CONF_DEVICES, CONF_DISABLE_AVAILABLE_CHECK, CONF_RESTORE_STATES, @@ -78,9 +79,9 @@ async def async_setup_entry( GreeSelect( description, coordinator, - entry.data.get(CONF_RESTORE_STATES, True), + d.get(CONF_RESTORE_STATES, True), check_availability=( - entry.data.get(CONF_DISABLE_AVAILABLE_CHECK, False) is False + entry.data[CONF_ADVANCED].get(CONF_DISABLE_AVAILABLE_CHECK, False) ), ) for description in descriptions @@ -136,7 +137,7 @@ def __init__( self._attr_current_option = self.entity_description.value_func(self.device) _LOGGER.debug( "Initialized select: %s (check_availability=%s) Options: %s", - self._attr_unique_id, + self.unique_id, self.check_availability, self._attr_options, ) @@ -194,7 +195,7 @@ async def async_added_to_hass(self): last_state = await self.async_get_last_state() if last_state is not None: _LOGGER.debug( - "Restoring state for %s: %s", self.entity_id, last_state.state + "Restoring state for %s: %s", self.unique_id, last_state.state ) if last_state.state not in ("unknown", "unavailable"): try: diff --git a/custom_components/gree_custom/sensor.py b/custom_components/gree_custom/sensor.py index c7a2416..6413413 100644 --- a/custom_components/gree_custom/sensor.py +++ b/custom_components/gree_custom/sensor.py @@ -18,6 +18,7 @@ from .aiogree.api import GreeProp from .aiogree.device import GreeDevice from .const import ( + CONF_ADVANCED, CONF_DEVICES, CONF_DISABLE_AVAILABLE_CHECK, GATTR_HUMIDITY, @@ -109,9 +110,9 @@ async def async_setup_entry( GreeSensor( description, coordinator, - restore_state=True, + restore_state=False, check_availability=( - entry.data.get(CONF_DISABLE_AVAILABLE_CHECK, False) is False + entry.data[CONF_ADVANCED].get(CONF_DISABLE_AVAILABLE_CHECK, False) ), ) for description in descriptions @@ -145,7 +146,7 @@ def __init__( self.entity_description = description # pyright: ignore[reportIncompatibleVariableOverride] _LOGGER.debug( "Initialized sensor: %s (check_availability=%s)", - self._attr_unique_id, + self.unique_id, self.check_availability, ) @@ -162,7 +163,7 @@ async def async_added_to_hass(self): last_state = await self.async_get_last_state() if last_state is not None: _LOGGER.debug( - "Restoring state for %s: %s", self.entity_id, last_state.state + "Restoring state for %s: %s", self.unique_id, last_state.state ) if last_state.state not in (None, "unknown", "unavailable"): try: diff --git a/custom_components/gree_custom/switch.py b/custom_components/gree_custom/switch.py index 9d84962..6a0c276 100644 --- a/custom_components/gree_custom/switch.py +++ b/custom_components/gree_custom/switch.py @@ -21,6 +21,7 @@ from .const import ( ATTR_AUTO_LIGHT, ATTR_AUTO_XFAN, + CONF_ADVANCED, CONF_DEVICES, CONF_DISABLE_AVAILABLE_CHECK, CONF_FEATURES, @@ -79,10 +80,12 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): key=GATTR_FEAT_SLEEP_MODE, translation_key=GATTR_FEAT_SLEEP_MODE, available_func=( - lambda device: device.available - and device.supports_property(GreeProp.FEAT_SLEEP_MODE) - and device.operation_mode - in [OperationMode.cool, OperationMode.dry, OperationMode.heat] + lambda device: ( + device.available + and device.supports_property(GreeProp.FEAT_SLEEP_MODE) + and device.operation_mode + in [OperationMode.cool, OperationMode.dry, OperationMode.heat] + ) ), value_func=lambda device, _: device.feature_sleep, set_func=lambda device, _, value: device.set_feature_sleep(value), @@ -221,12 +224,14 @@ async def async_setup_entry( description, coordinator, restore_state=( - entry.data.get(CONF_RESTORE_STATES, True) + d.get(CONF_RESTORE_STATES, True) if description.key != GATTR_BEEPER # Always restore beeper else True ), check_availability=( - entry.data.get(CONF_DISABLE_AVAILABLE_CHECK, False) is False + entry.data[CONF_ADVANCED].get( + CONF_DISABLE_AVAILABLE_CHECK, False + ) if description.key != GATTR_BEEPER # Beeper is always available else False ), @@ -242,16 +247,18 @@ async def async_setup_entry( key=ATTR_AUTO_LIGHT, translation_key=ATTR_AUTO_LIGHT, available_func=( - lambda device: device.available - and device.supports_property(GreeProp.FEAT_LIGHT) + lambda device: ( + device.available + and device.supports_property(GreeProp.FEAT_LIGHT) + ) ), value_func=( lambda _, coordinator: coordinator.feature_auto_light ), set_func=( - lambda _, - coordinator, - value: coordinator.set_feature_auto_light(value) + lambda _, coordinator, value: ( + coordinator.set_feature_auto_light(value) + ) ), updates_device=False, entity_category=EntityCategory.CONFIG, @@ -273,9 +280,9 @@ async def async_setup_entry( and device.supports_property(GreeProp.FEAT_XFAN) ), value_func=lambda _, coordinator: coordinator.feature_auto_xfan, - set_func=lambda _, - coordinator, - value: coordinator.set_feature_auto_xfan(value), + set_func=lambda _, coordinator, value: ( + coordinator.set_feature_auto_xfan(value) + ), updates_device=False, entity_category=EntityCategory.CONFIG, ), @@ -306,7 +313,7 @@ def __init__( self.entity_description = description # pyright: ignore[reportIncompatibleVariableOverride] _LOGGER.debug( "Initialized switch: %s (check_availability=%s)", - self._attr_unique_id, + self.unique_id, self.check_availability, ) @@ -323,7 +330,7 @@ async def async_added_to_hass(self): last_state = await self.async_get_last_state() if last_state is not None: _LOGGER.debug( - "Restoring state for %s: %s", self.entity_id, last_state.state + "Restoring state for %s: %s", self.unique_id, last_state.state ) if last_state.state in ("on", "off"): value: bool = last_state.state == "on" From da8dcdcff2ddafb460922cab0a6f3248ed3b83f4 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Tue, 17 Mar 2026 00:57:30 +0000 Subject: [PATCH 079/113] Add alpha tag and version --- custom_components/gree_custom/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/gree_custom/manifest.json b/custom_components/gree_custom/manifest.json index 649e51f..3e7b487 100755 --- a/custom_components/gree_custom/manifest.json +++ b/custom_components/gree_custom/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "local_polling", "requirements": ["pycryptodome", "asyncio_dgram"], - "version": "4.0.0" + "version": "4.0.0-alpha.77" } From 4def4fc9d34e12e6c40b5968a987b6eea1759531 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Tue, 17 Mar 2026 15:13:25 +0000 Subject: [PATCH 080/113] Improve binding logic Do not error if the key is wrong. If so, try the generic keys first before erroring. --- custom_components/gree_custom/__init__.py | 4 +- custom_components/gree_custom/aiogree/api.py | 115 +++++++++++++----- .../gree_custom/aiogree/cipher.py | 2 +- .../gree_custom/aiogree/device.py | 48 ++++---- .../gree_custom/aiogree/errors.py | 6 +- custom_components/gree_custom/config_flow.py | 13 +- custom_components/gree_custom/coordinator.py | 4 +- .../gree_custom/translations/en.json | 2 +- .../gree_custom/translations/pt.json | 2 +- 9 files changed, 115 insertions(+), 81 deletions(-) diff --git a/custom_components/gree_custom/__init__.py b/custom_components/gree_custom/__init__.py index a355d9d..2baec03 100755 --- a/custom_components/gree_custom/__init__.py +++ b/custom_components/gree_custom/__init__.py @@ -19,7 +19,7 @@ DEFAULT_DEVICE_UID, ) from .aiogree.device import GreeDevice -from .aiogree.errors import GreeAuthenticationError +from .aiogree.errors import GreeBindingError # Local imports from .const import ( @@ -106,7 +106,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool except TimeoutError as err: _LOGGER.debug("Conection to %s timed out", mac) raise ConfigEntryNotReady from err - except GreeAuthenticationError as err: + except GreeBindingError as err: _LOGGER.debug("Failed to bind to device %s", mac) raise ConfigEntryNotReady from err diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index 5b6de08..83a107a 100644 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -10,7 +10,7 @@ from .cipher import CipherBase, EncryptionVersion, get_cipher from .const import DEFAULT_DEVICE_PORT -from .errors import GreeAuthenticationError, GreeProtocolError +from .errors import GreeBindingError, GreeProtocolError from .transport import udp_broadcast_request, udp_request _LOGGER = logging.getLogger(__name__) @@ -230,16 +230,14 @@ def gree_create_encrypted_pack( return (encrypted_data, tag) -def gree_create_bind_pack( - mac_addr: str, uid: int, encryption_version: EncryptionVersion -) -> str: +def gree_create_bind_pack(mac_addr: str, uid: int, cipher: CipherBase) -> str: """Create a bind pack to send to the device.""" pack: str = "" - if encryption_version == EncryptionVersion.V1: + if cipher.version == EncryptionVersion.V1: pack = json.dumps({"mac": mac_addr, "t": "bind", "uid": uid}) - elif encryption_version == EncryptionVersion.V2: + elif cipher.version == EncryptionVersion.V2: pack = json.dumps({"cid": mac_addr, "mac": mac_addr, "t": "bind", "uid": uid}) _LOGGER.debug("Bind Pack: %s", pack) @@ -306,30 +304,63 @@ def gree_create_payload( return json.dumps(payload) -async def gree_get_device_key( +async def gree_try_bind( ip_addr: str, mac_addr: str, port: int, uid: int, - encryption_version: EncryptionVersion | None, + version: EncryptionVersion | None, + key: str | None, max_connection_attempts: int, timeout: int, ) -> tuple[str, EncryptionVersion]: - """Get the device key by sending a bind request to the device using a generic key (async).""" + """Perform bind request to the device and return the valid version and key (async). + + 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. + """ + + ret_key: str = "" + error: Exception | None = Exception("Binding failed") - key = "" - error: Exception = Exception("Unknown error getting device encryption key") + has_version = version is not None + has_key = key is not None and bool(key.strip()) - for enc_version in ( - [EncryptionVersion.V1, EncryptionVersion.V2] - if encryption_version is None - else [encryption_version] - ): - _LOGGER.info("Trying to retrieve device encryption key v%d", enc_version) + ciphers: list[CipherBase] = [] + + if has_version: + ciphers.append(get_cipher(version)) + if has_key: + _LOGGER.info( + "Trying to perform binding. Prefer provided version (%s) and key (%s)", + version, + key[:5] + "[redacted]", + ) + else: + _LOGGER.info( + "Trying to perform binding. Prefer provided version (%s) and generic key ", + version, + ) + elif has_key: + _LOGGER.info( + "Trying to perform binding. Prefering provided key (%s)", + key[:5] + "[redacted]", + ) + else: + _LOGGER.info( + "Trying to perform binding. Testing both versions with generic keys" + ) - cipher = get_cipher(enc_version) + # Fallback to both default ciphers + ciphers.append(get_cipher(EncryptionVersion.V1)) + ciphers.append(get_cipher(EncryptionVersion.V2)) - raw_pack = gree_create_bind_pack(mac_addr, uid, enc_version) + for cipher in ciphers: + _LOGGER.debug( + "Requesting bind to device with encryption key v%d", cipher.version + ) + + raw_pack = gree_create_bind_pack(mac_addr, uid, cipher) pack, tag = gree_create_encrypted_pack(raw_pack, cipher) json_payload = gree_create_payload( pack, "pack", GreeCommand.BIND, mac_addr, uid, tag @@ -344,30 +375,48 @@ async def gree_get_device_key( max_connection_attempts, timeout, ) - key = result.get("key", "") except Exception as err: _LOGGER.exception( - "Error getting device encryption key with version %d", - enc_version, + "Error in bind request using encryption key with version %d", + cipher.version, ) + + # In case we are testing multiple ciphers, don't raise, + # just save the error so we can continue testing the other ciphers error = err continue - if key.strip() == "": - error = Exception("Received empty encryption key from device") - continue + else: + ret_key = result.get("key", "") - _LOGGER.info( - "Fetched device encryption key with version %d with success", enc_version - ) + if ret_key.strip() == "": + raise GreeBindingError( + "Binding failed: Received empty encryption key from device" + ) + + if has_key and ret_key != key: + _LOGGER.warning( + "Binding successful with different key. Using retrieved key. Expected '%s', got '%s'", + key[:5] + "[redacted]", + ret_key[:5] + "[redacted]", + ) + + if has_version and cipher.version != version: + _LOGGER.warning( + "Binding successful with different version. Using retrieved version. Expected '%s', got '%s'", + version, + cipher.version, + ) + + _LOGGER.info("Bind request with version %d was successful", cipher.version) - _LOGGER.debug("Fetched encryption key: %s[omitted]", key[:5]) + _LOGGER.debug("Fetched encryption key: %s[omitted]", ret_key[:5]) - return key, enc_version + return ret_key, cipher.version - raise GreeAuthenticationError( - f"Error getting device encryption key from {ip_addr}" + raise GreeBindingError( + f"Binding failed: Unable to obtain valid encryption version and key pair for {ip_addr}" ) from error @@ -667,7 +716,7 @@ async def get_sub_devices( ) -> list: """Fetch the list of sub-devices for a Gree device.""" try: - _, version = await gree_get_device_key( + _, version = await gree_try_bind( ip_addr, mac_addr, DEFAULT_DEVICE_PORT, diff --git a/custom_components/gree_custom/aiogree/cipher.py b/custom_components/gree_custom/aiogree/cipher.py index 19e67e5..312e3ef 100644 --- a/custom_components/gree_custom/aiogree/cipher.py +++ b/custom_components/gree_custom/aiogree/cipher.py @@ -133,7 +133,7 @@ def encrypt(self, data: str) -> tuple[str, str]: encoded = base64.b64encode(encrypted).decode("utf-8") tag_encoded = base64.b64encode(tag).decode("utf-8") - _LOGGER.debug("Encrypted data (V2): %s, tag='%s'", encoded, tag) + _LOGGER.debug("Encrypted data (V2): %s, tag='%s'", encoded, tag_encoded) return encoded, tag_encoded def decrypt(self, data: str, tag: str) -> str: diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index 315c190..dca6ad7 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -13,10 +13,10 @@ TemperatureUnits, VerticalSwingMode, gree_get_device_info, - gree_get_device_key, gree_get_status, gree_get_sub_devices_list, gree_set_status, + gree_try_bind, ) from .cipher import CipherBase, get_cipher from .const import ( @@ -24,12 +24,7 @@ DEFAULT_CONNECTION_TIMEOUT, DEFAULT_DEVICE_UID, ) -from .errors import ( - GreeAuthenticationError, - GreeAuthenticationErrorBadKey, - GreeError, - GreeProtocolError, -) +from .errors import GreeBindingError, GreeError, GreeProtocolError from .helpers import ( TempOffsetResolver, gree_get_target_temp_props_from_c, @@ -109,44 +104,45 @@ async def bind_device(self) -> bool: if self._is_bound: return True + # Use fetch info as basic communication test since it does not require V2 + try: + await self.fetch_device_info() + except Exception as err: + raise GreeBindingError( + "Could not fetch device info before binding" + ) from err + try: - key, version = await gree_get_device_key( + key, version = await gree_try_bind( self._ip_addr, self._mac_addr, self._port, self._uid, self._encryption_version, + self._encryption_key, self._max_connection_attempts, self._timeout, ) - except GreeAuthenticationError: + except GreeBindingError: raise - except Exception as e: - raise GreeError(f"Failed binding to device {self._ip_addr}") from e + raise GreeBindingError(f"Failed binding to device {self._ip_addr}") from e else: - if not self._encryption_key.strip() or not self._encryption_version: - _LOGGER.info("Using the obtained encryption key and version") - self._encryption_key = key - self._encryption_version = version - else: - if key != self._encryption_key: - raise GreeAuthenticationErrorBadKey("Wrong encryption key provided") - _LOGGER.info( - "Using the provided encryption key with version %d", - self._encryption_version, - ) + self._encryption_key = key + self._encryption_version = version + _LOGGER.info( + "Device is bound with version %s and key %s", + version, + key[:5] + "[redacted]", + ) self._cipher = get_cipher(version, key) self._is_available = True self._is_bound = True - # Used also as basic communication test - await self.fetch_device_info() - - return self._is_bound + return True async def fetch_device_info(self): """Updates the device info fields.""" diff --git a/custom_components/gree_custom/aiogree/errors.py b/custom_components/gree_custom/aiogree/errors.py index 6b4d811..196ac93 100644 --- a/custom_components/gree_custom/aiogree/errors.py +++ b/custom_components/gree_custom/aiogree/errors.py @@ -13,9 +13,5 @@ class GreeProtocolError(GreeError): """Device returned invalid data.""" -class GreeAuthenticationError(GreeError): +class GreeBindingError(GreeError): """Failed to obtain encryption key.""" - - -class GreeAuthenticationErrorBadKey(GreeError): - """Provided encryption key wrong.""" diff --git a/custom_components/gree_custom/config_flow.py b/custom_components/gree_custom/config_flow.py index 2f77f25..ccfd458 100644 --- a/custom_components/gree_custom/config_flow.py +++ b/custom_components/gree_custom/config_flow.py @@ -39,11 +39,7 @@ DEFAULT_DEVICE_UID, ) from .aiogree.device import GreeDevice -from .aiogree.errors import ( - GreeAuthenticationError, - GreeAuthenticationErrorBadKey, - GreeConnectionError, -) +from .aiogree.errors import GreeBindingError, GreeConnectionError from .const import ( ATTR_EXTERNAL_HUMIDITY_SENSOR, ATTR_EXTERNAL_TEMPERATURE_SENSOR, @@ -698,11 +694,8 @@ async def async_step_manual_add( self._devices[subdev.mac_address_sub] = subdev await self._devices[_main_device.mac_address_sub].fetch_device_status() - except GreeAuthenticationErrorBadKey: - errors["base"] = "cannot_connect_key" - _LOGGER.exception("Error while binding with wrong key") - except GreeAuthenticationError: - errors["base"] = "cannot_connect" + except GreeBindingError: + errors["base"] = "cannot_bind" _LOGGER.exception("Error while binding") except GreeConnectionError: errors["base"] = "cannot_connect" diff --git a/custom_components/gree_custom/coordinator.py b/custom_components/gree_custom/coordinator.py index 11aeee4..d3e3030 100644 --- a/custom_components/gree_custom/coordinator.py +++ b/custom_components/gree_custom/coordinator.py @@ -13,7 +13,7 @@ ) from .aiogree.device import GreeDevice -from .aiogree.errors import GreeAuthenticationError +from .aiogree.errors import GreeBindingError _LOGGER = logging.getLogger(__name__) @@ -62,7 +62,7 @@ async def _async_update_data(self): """ try: await self.device.fetch_device_status() - except GreeAuthenticationError as err: + except GreeBindingError as err: _LOGGER.exception("Failed to initiate Gree device") raise ConfigEntryAuthFailed("Failed to initiate Gree device") from err except Exception as err: diff --git a/custom_components/gree_custom/translations/en.json b/custom_components/gree_custom/translations/en.json index f113a75..27610e1 100755 --- a/custom_components/gree_custom/translations/en.json +++ b/custom_components/gree_custom/translations/en.json @@ -3,7 +3,7 @@ "error": { "unknown": "Something went wrong, please try again. If the issue persists, please check the logs.", "cannot_connect": "Unable to connect to the device. Please check the device configuration and network connection and try again. If the issue persists, please check the logs.", - "cannot_connect_key": "Unable to connect to the device. The provided encryption key does not match the device key.", + "cannot_bind": "Unable to bind the device. It was not possible to find the device encryption version or key. If the issue persists, please check the logs.", "no_devices_found": "Couldn't find any Gree device in the network. Please add your device manually." }, "abort": { diff --git a/custom_components/gree_custom/translations/pt.json b/custom_components/gree_custom/translations/pt.json index 5490341..2b95bf0 100755 --- a/custom_components/gree_custom/translations/pt.json +++ b/custom_components/gree_custom/translations/pt.json @@ -3,7 +3,7 @@ "error": { "unknown": "Ocorreu algo de errado, tente de novo. Se o problema persistir, verifique os registos.", "cannot_connect": "Não foi possível ligar ao dispositivo. Verifique as configurações do dispositivo e de rede e tente de novo.ain. Se o problema persistir, verifique os registos.", - "cannot_connect_key": "Não foi possível ligar ao dispositivo. A chave de encriptação definida não corresponde à chave do dispositivo.", + "cannot_bind": "Não foi possível encontrar uma combinação da versão de encriptação e chave do dispositivo válida. Se o problema persistir, verifique os registos.", "no_devices_found": "Não foram encontrados novos dispositivos Gree compatíveis na rede. Por favor, adicione o dispositivo manualmente." }, "abort": { From d8d6126f234c3891749dae357891a15764d5acbb Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Tue, 17 Mar 2026 15:14:51 +0000 Subject: [PATCH 081/113] Bump version --- custom_components/gree_custom/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/gree_custom/manifest.json b/custom_components/gree_custom/manifest.json index 3e7b487..158623a 100755 --- a/custom_components/gree_custom/manifest.json +++ b/custom_components/gree_custom/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "local_polling", "requirements": ["pycryptodome", "asyncio_dgram"], - "version": "4.0.0-alpha.77" + "version": "4.0.0-alpha.78" } From d9aa76465c25f4e5f22116d0e2e5dd8872c85f2b Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Tue, 17 Mar 2026 16:40:37 +0000 Subject: [PATCH 082/113] Encapsulate communication in GreeTransport Allows for less parameter repetition in API calls., which also use JSON dict for payloads up until being sent. --- custom_components/gree_custom/aiogree/api.py | 256 +++++------------- .../gree_custom/aiogree/device.py | 37 +-- .../gree_custom/aiogree/transport.py | 119 ++++---- custom_components/gree_custom/manifest.json | 2 +- 4 files changed, 146 insertions(+), 268 deletions(-) diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index 83a107a..4d528b5 100644 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -10,8 +10,8 @@ from .cipher import CipherBase, EncryptionVersion, get_cipher from .const import DEFAULT_DEVICE_PORT -from .errors import GreeBindingError, GreeProtocolError -from .transport import udp_broadcast_request, udp_request +from .errors import GreeBindingError, GreeError, GreeProtocolError +from .transport import GreeTransport, udp_broadcast_request _LOGGER = logging.getLogger(__name__) @@ -165,18 +165,16 @@ class GreeDiscoveredDevice: async def get_result_pack( - ip_addr: str, - port: int, - json_data: str, - cipher: CipherBase, - max_connection_attempts: int, - timeout: int, -): + json_data: dict, cipher: CipherBase, transport: GreeTransport +) -> dict: """Get the result pack from the device (async).""" - raw = await udp_request(ip_addr, port, json_data, max_connection_attempts, timeout) + try: + recv_json = await transport.request_json(json_data) + except json.JSONDecodeError as err: + raise GreeProtocolError("Invalid JSON response from device") from err - data = get_gree_response_data(raw, cipher) + data = get_gree_response_data(recv_json, cipher) pack = data.get("pack", None) @@ -189,90 +187,83 @@ async def get_result_pack( redacted["pack"] = redacted["pack"].copy() redacted["pack"]["key"] = str(redacted["pack"]["key"])[:5] + "[redacted]" - _LOGGER.debug("Got data from %s: %s", ip_addr, redacted) + _LOGGER.debug("Got data from %s: %s", transport.ip_addr, redacted) return pack def get_gree_response_data( - received_json: str, + recv_json: dict, cipher: CipherBase, -): +) -> dict: """Decodes a response from a gree device.""" - try: - data = json.loads(received_json) - except json.JSONDecodeError as err: - raise GreeProtocolError("Invalid JSON response from device") from err - - encoded_pack = data.get("pack", None) - tag = data.get("tag", None) + encoded_pack = recv_json.get("pack") + tag = recv_json.get("tag") if encoded_pack: decrypted_pack = cipher.decrypt(encoded_pack, tag) # Replace encrypted pack with decrypted data - data["pack"] = json.loads(decrypted_pack) + recv_json["pack"] = json.loads(decrypted_pack) - return data + return recv_json -def gree_create_encrypted_pack( - data: str, +def gree_encrypt_pack( + pack: dict, cipher: CipherBase, ) -> tuple[str, str | None]: """Create an encrypted pack to send to the device.""" if cipher is None: - raise ValueError("Cipher must not be None") + raise GreeError("Cipher must not be None") - encrypted_data, tag = cipher.encrypt(data) + encrypted_data, tag = cipher.encrypt(json.dumps(pack)) return (encrypted_data, tag) -def gree_create_bind_pack(mac_addr: str, uid: int, cipher: CipherBase) -> str: +def gree_create_bind_pack(mac_addr: str, uid: int, cipher: CipherBase) -> dict: """Create a bind pack to send to the device.""" - pack: str = "" + pack: dict = {} if cipher.version == EncryptionVersion.V1: - pack = json.dumps({"mac": mac_addr, "t": "bind", "uid": uid}) + pack = {"mac": mac_addr, "t": "bind", "uid": uid} elif cipher.version == EncryptionVersion.V2: - pack = json.dumps({"cid": mac_addr, "mac": mac_addr, "t": "bind", "uid": uid}) + pack = {"cid": mac_addr, "mac": mac_addr, "t": "bind", "uid": uid} _LOGGER.debug("Bind Pack: %s", pack) return pack -def gree_create_sub_bind_pack(mac_addr: str) -> str: +def gree_create_sub_bind_pack(mac_addr: str) -> dict: """Create a bind pack to send to the device.""" - pack: str = json.dumps({"mac": mac_addr, "i": 1}) + pack: dict = {"mac": mac_addr, "i": 1} _LOGGER.debug("Sub Bind Pack: %s", pack) return pack -def gree_create_status_pack(mac_addr: str, props: list[str]) -> str: +def gree_create_status_pack(mac_addr: str, props: list[str]) -> dict: """Create a status pack to send to the device.""" - pack: str = json.dumps({"cols": props, "mac": mac_addr, "t": "status"}) + pack: dict = {"cols": props, "mac": mac_addr, "t": "status"} _LOGGER.debug("Status Pack: %s", pack) return pack -def gree_create_set_pack(mac_addr: str, props: dict[GreeProp, int]) -> str: +def gree_create_set_pack(mac_addr: str, props: dict[GreeProp, int]) -> dict: """Create a set pack to send to the device.""" - pack: str = json.dumps( - { - "opt": [prop.value for prop in props], - "p": list(props.values()), - "t": "cmd", - "sub": mac_addr, - } - ) + pack: dict = { + "opt": [prop.value for prop in props], + "p": list(props.values()), + "t": "cmd", + "sub": mac_addr, + } _LOGGER.debug("Status Pack: %s", pack) return pack @@ -285,7 +276,7 @@ def gree_create_payload( mac_addr: str, uid: int, tag: str | None, -) -> str: +) -> dict: """Create the full payload to send to the device.""" payload: dict[str, Any] = { @@ -301,18 +292,15 @@ def gree_create_payload( payload["tag"] = tag _LOGGER.debug("Payload: %s", payload) - return json.dumps(payload) + return payload async def gree_try_bind( - ip_addr: str, mac_addr: str, - port: int, uid: int, version: EncryptionVersion | None, key: str | None, - max_connection_attempts: int, - timeout: int, + transport: GreeTransport, ) -> tuple[str, EncryptionVersion]: """Perform bind request to the device and return the valid version and key (async). @@ -360,21 +348,14 @@ async def gree_try_bind( "Requesting bind to device with encryption key v%d", cipher.version ) - raw_pack = gree_create_bind_pack(mac_addr, uid, cipher) - pack, tag = gree_create_encrypted_pack(raw_pack, cipher) + pack = gree_create_bind_pack(mac_addr, uid, cipher) + encrypted_pack, tag = gree_encrypt_pack(pack, cipher) json_payload = gree_create_payload( - pack, "pack", GreeCommand.BIND, mac_addr, uid, tag + encrypted_pack, "pack", GreeCommand.BIND, mac_addr, uid, tag ) try: - result = await get_result_pack( - ip_addr, - port, - json_payload, - cipher, - max_connection_attempts, - timeout, - ) + result = await get_result_pack(json_payload, cipher, transport) except Exception as err: _LOGGER.exception( @@ -416,20 +397,17 @@ async def gree_try_bind( return ret_key, cipher.version raise GreeBindingError( - f"Binding failed: Unable to obtain valid encryption version and key pair for {ip_addr}" + f"Binding failed: Unable to obtain valid encryption version and key pair for {mac_addr} at {transport.ip_addr}" ) from error async def gree_get_status( - ip_addr: str, mac_addr: str, mac_addr_sub: str, - port: int, uid: int, - cipher: CipherBase, props: list[GreeProp], - max_connection_attempts: int, - timeout: int, + cipher: CipherBase, + transport: GreeTransport, ) -> tuple[dict[GreeProp, int], list[GreeProp]]: """Get the status of the device by sending a status request to the device (async). Also returns the props not present.""" @@ -437,21 +415,14 @@ async def gree_get_status( status_values_raw: dict[GreeProp, int | None] = {} - raw_pack = gree_create_status_pack(mac_addr_sub, [prop.value for prop in props]) - pack, tag = gree_create_encrypted_pack(raw_pack, cipher) + pack = gree_create_status_pack(mac_addr_sub, [prop.value for prop in props]) + encrypted_pack, tag = gree_encrypt_pack(pack, cipher) json_payload = gree_create_payload( - pack, "pack", GreeCommand.STATUS, mac_addr, uid, tag + encrypted_pack, "pack", GreeCommand.STATUS, mac_addr, uid, tag ) try: - result = await get_result_pack( - ip_addr, - port, - json_payload, - cipher, - max_connection_attempts, - timeout, - ) + result = await get_result_pack(json_payload, cipher, transport) except GreeProtocolError: raise @@ -473,35 +444,25 @@ async def gree_get_status( async def gree_set_status( - ip_addr: str, mac_addr: str, mac_addr_sub: str, - port: int, uid: int, - cipher: CipherBase, props: dict[GreeProp, int], - max_connection_attempts: int, - timeout: int, + cipher: CipherBase, + transport: GreeTransport, ) -> dict[GreeProp, int]: """Set the status of the device by sending a status request to the device (async).""" _LOGGER.debug("Trying to set device status") - set_pack = gree_create_set_pack(mac_addr_sub, props) - pack, tag = gree_create_encrypted_pack(set_pack, cipher) + pack = gree_create_set_pack(mac_addr_sub, props) + encrypted_pack, tag = gree_encrypt_pack(pack, cipher) json_payload = gree_create_payload( - pack, "pack", GreeCommand.STATUS, mac_addr, uid, tag + encrypted_pack, "pack", GreeCommand.STATUS, mac_addr, uid, tag ) try: - result = await get_result_pack( - ip_addr, - port, - json_payload, - cipher, - max_connection_attempts, - timeout, - ) + result = await get_result_pack(json_payload, cipher, transport) except GreeProtocolError: raise @@ -544,20 +505,13 @@ async def gree_set_status( return updated_props -async def gree_get_device_info( - ip_addr: str, - max_connection_attempts: int, - timeout: int, -) -> dict[str, str | None]: +async def gree_get_device_info(transport: GreeTransport) -> dict[str, str | None]: """Tries to retrive the device info.""" data: dict = await get_result_pack( - ip_addr, - DEFAULT_DEVICE_PORT, - json.dumps({"t": "scan"}), + {"t": "scan"}, get_cipher(EncryptionVersion.V1), - max_connection_attempts, - timeout, + transport, ) _LOGGER.debug("Got device info: %s", data) @@ -670,23 +624,18 @@ async def discover_gree_devices( async def gree_get_sub_devices_list( - ip_addr: str, - mac_addr: str, - port: int, - uid: int, - cipher: CipherBase, - max_connection_attempts: int, - timeout: int, + mac_addr: str, uid: int, cipher: CipherBase, transport: GreeTransport ) -> list: """Fetch the list of sub-devices for a Gree device.""" try: - pack, tag = gree_create_encrypted_pack( - gree_create_sub_bind_pack(mac_addr), + pack = gree_create_sub_bind_pack(mac_addr) + encrypted_pack, tag = gree_encrypt_pack( + pack, cipher, ) - jsonPayloadToSend = gree_create_payload( - pack, + json_payload = gree_create_payload( + encrypted_pack, "subList", GreeCommand.BIND, mac_addr, @@ -694,14 +643,7 @@ async def gree_get_sub_devices_list( tag, ) - result = await get_result_pack( - ip_addr, - port, - jsonPayloadToSend, - cipher, - max_connection_attempts, - timeout, - ) + result = await get_result_pack(json_payload, cipher, transport) return result.get("list", []) @@ -709,71 +651,3 @@ async def gree_get_sub_devices_list( raise GreeProtocolError( f"Error fetching sub-device list for '{mac_addr}'" ) from err - - -async def get_sub_devices( - mac_addr: str, ip_addr: str, uid: int, max_connection_attempts: int, timeout: int -) -> list: - """Fetch the list of sub-devices for a Gree device.""" - try: - _, version = await gree_try_bind( - ip_addr, - mac_addr, - DEFAULT_DEVICE_PORT, - uid, - None, - max_connection_attempts, - timeout, - ) - - pack, tag = gree_create_encrypted_pack( - gree_create_sub_bind_pack(mac_addr), - get_cipher(version), - version, - ) - - jsonPayloadToSend = gree_create_payload( - pack, - "subList", - GreeCommand.BIND, - mac_addr, - uid, - version, - tag, - ) - - result = await get_result_pack( - ip_addr, - DEFAULT_DEVICE_PORT, - jsonPayloadToSend, - get_cipher(version), - version, - max_connection_attempts, - timeout, - ) - - except Exception as err: - raise ValueError(f"Error fetching sub-device list for '{mac_addr}'") from err - else: - discovered_devices: list[GreeDiscoveredDevice] = [] - - for sub_device in result.get("list", []): - sub_mac = sub_device.get("mac", "") - if sub_mac: - discovered_sub_device = GreeDiscoveredDevice( - name=f"{sub_device.get('name', '') or f'Gree {sub_mac[:4]}@{mac_addr[-4:]}'}", - host=ip_addr, - mac=sub_mac, - port=DEFAULT_DEVICE_PORT, - brand=sub_device.get("brand", "Gree"), - model=sub_device.get("mid", "HVAC"), - uid=0, - subdevices=0, - ) - discovered_devices.append(discovered_sub_device) - _LOGGER.debug( - "Discovered sub-device: %s", - discovered_sub_device, - ) - - return discovered_devices diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index dca6ad7..589ebd4 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -32,6 +32,7 @@ gree_get_target_temperature_c, gree_get_target_temperature_f, ) +from .transport import GreeTransport _LOGGER = logging.getLogger(__name__) @@ -66,11 +67,15 @@ def __init__( self._name: str = name self._ip_addr: str = ip_addr self._port: int = port + self._max_connection_attempts: int = max_connection_attempts + self._timeout: int = timeout self._mac_addr = self._mac_addr_sub = ( mac_addr.replace(":", "").replace("-", "").lower() ) if "@" in self._mac_addr: self._mac_addr_sub, self._mac_addr = self._mac_addr.lower().split("@", 1) + self._transport = GreeTransport(ip_addr, port, max_connection_attempts, timeout) + self._encryption_version: EncryptionVersion | None = encryption_version self._encryption_key: str = encryption_key self._cipher: CipherBase | None = None @@ -81,8 +86,6 @@ def __init__( self._is_bound: bool = False self._is_available: bool = False self._uniqueid: str = self._mac_addr_sub - self._max_connection_attempts: int = max_connection_attempts - self._timeout: int = timeout self._props_to_update: list[GreeProp] = list(GreeProp) # Don't poll the beeper state @@ -114,14 +117,11 @@ async def bind_device(self) -> bool: try: key, version = await gree_try_bind( - self._ip_addr, self._mac_addr, - self._port, self._uid, self._encryption_version, self._encryption_key, - self._max_connection_attempts, - self._timeout, + self._transport, ) except GreeBindingError: @@ -148,9 +148,7 @@ async def fetch_device_info(self): """Updates the device info fields.""" try: self._raw_info = await gree_get_device_info( - self._ip_addr, - self._max_connection_attempts, - self._timeout, + self._transport, ) except Exception as e: @@ -183,13 +181,10 @@ async def fetch_sub_devices(self) -> list[GreeDiscoveredDevice]: try: subs = await gree_get_sub_devices_list( - self._ip_addr, self._mac_addr, - self._port, self._uid, - get_cipher(self._encryption_version), - self._max_connection_attempts, - self._timeout, + self._cipher, # TODO: Check if this should use the generic or the device key + self._transport, ) except GreeProtocolError: self._is_available = False @@ -235,15 +230,12 @@ async def fetch_device_status(self): try: state, _ = await gree_get_status( - self._ip_addr, self._mac_addr, self._mac_addr_sub, - self._port, self._uid, - self._cipher, self._props_to_update, - self._max_connection_attempts, - self._timeout, + self._cipher, + self._transport, ) self._raw_state.update(state) @@ -293,15 +285,12 @@ async def update_device_status(self): try: self._raw_state.update( await gree_set_status( - self._ip_addr, self._mac_addr, self._mac_addr_sub, - self._port, self._uid, - self._cipher, self._new_raw_state, - self._max_connection_attempts, - self._timeout, + self._cipher, + self._transport, ) ) self._new_raw_state.clear() diff --git a/custom_components/gree_custom/aiogree/transport.py b/custom_components/gree_custom/aiogree/transport.py index 741b85c..cb0385f 100644 --- a/custom_components/gree_custom/aiogree/transport.py +++ b/custom_components/gree_custom/aiogree/transport.py @@ -1,6 +1,7 @@ """Handles network connections.""" import asyncio +import json import logging import socket import time @@ -12,68 +13,82 @@ _LOGGER = logging.getLogger(__name__) -async def udp_request( - ip_addr: str, - port: int, - data: str, - max_retries: int, - timeout: int, -) -> str: - """Send a payload data to the device and reads the response (async).""" +class GreeTransport: + """Handles the connection with the Gree device.""" - last_error: Exception = None + def __init__( + self, ip_addr: str, port: int, max_retries: int = 3, timeout: float = 2.0 + ) -> None: + """Initialize the connection object.""" + self.ip_addr = ip_addr + self.port = port + self.max_retries = max_retries + self.timeout = timeout - for attempt in range(max_retries): - stream: asyncio_dgram.DatagramClient | None = None + async def udp_request( + self, + data: bytes, + ) -> bytes: + """Send a payload data to the device and reads the response.""" - try: - stream = await asyncio_dgram.connect((ip_addr, port)) + last_error: Exception = None - await stream.send(data.encode("utf-8")) - - recv_task = asyncio.create_task(stream.recv()) + for attempt in range(self.max_retries): + stream: asyncio_dgram.DatagramClient | None = None try: - received_json, _ = await asyncio.wait_for(recv_task, timeout) - except TimeoutError: - recv_task.cancel() - raise - - return received_json.decode("utf-8") - - except Exception as err1: # noqa: BLE001 - _LOGGER.warning( - "Error communicating with %s. Attempt %d/%d", - ip_addr, - attempt + 1, - max_retries, - ) - last_error = err1 - - finally: - if stream: - try: - stream.close() - except Exception as err2: # noqa: BLE001 - _LOGGER.warning( - "Error communicating with %s. Attempt %d/%d", - ip_addr, - attempt + 1, - max_retries, - ) - last_error = err2 + stream = await asyncio_dgram.connect((self.ip_addr, self.port)) - # Apply backoff before retrying - await asyncio.sleep(0.5 + attempt * 0.3) # 0.5s, 0.8s, 1.1s, ... + await stream.send(data) - raise GreeConnectionError( - f"Failed to communicate with device '{ip_addr}:{port}' after {max_retries} attempts" - ) from last_error + recv_task = asyncio.create_task(stream.recv()) + + try: + received_data, _ = await asyncio.wait_for(recv_task, self.timeout) + except TimeoutError: + recv_task.cancel() + raise + else: + return received_data + + except Exception as err1: # noqa: BLE001 + _LOGGER.warning( + "Error communicating with %s. Attempt %d/%d", + self.ip_addr, + attempt + 1, + self.max_retries, + ) + last_error = err1 + + finally: + if stream: + try: + stream.close() + except Exception as err2: # noqa: BLE001 + _LOGGER.warning( + "Error communicating with %s. Attempt %d/%d", + self.ip_addr, + attempt + 1, + self.max_retries, + ) + last_error = err2 + + # Apply backoff before retrying + await asyncio.sleep(0.5 + attempt * 0.3) # 0.5s, 0.8s, 1.1s, ... + + raise GreeConnectionError( + f"Failed to communicate with device '{self.ip_addr}:{self.port}' after {self.max_retries} attempts" + ) from last_error + + async def request_json(self, payload: dict) -> dict: + """Send and receive a JSON payload.""" + raw = await self.udp_request(json.dumps(payload).encode("utf-8")) + return json.loads(raw.decode("utf-8")) def udp_broadcast_request( addresses: list[str], port: int, json_data: str, timeout: int -) -> dict: +) -> dict[str, dict]: """Sends a UDP message to the bradcast address and returns the responses.""" # Create UDP socket manually so we can enable broadcast sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) @@ -81,7 +96,7 @@ def udp_broadcast_request( sock.settimeout(timeout) sock.bind(("", 0)) - responses: dict = {} + responses: dict[str, dict] = {} # Default broadcast addresses to try default_broadcast_addresses = [ @@ -114,7 +129,7 @@ def udp_broadcast_request( response, addr = sock.recvfrom(1024) try: - response = response.decode(errors="ignore") + response = json.loads(response.decode(errors="ignore")) except Exception: _LOGGER.exception("Could not parse response from %s", addr) else: diff --git a/custom_components/gree_custom/manifest.json b/custom_components/gree_custom/manifest.json index 158623a..446c5c4 100755 --- a/custom_components/gree_custom/manifest.json +++ b/custom_components/gree_custom/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "local_polling", "requirements": ["pycryptodome", "asyncio_dgram"], - "version": "4.0.0-alpha.78" + "version": "4.0.0-alpha.79" } From c82732154bd8e4beeb54bea67ca73e16c1551bce Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Tue, 17 Mar 2026 18:38:45 +0000 Subject: [PATCH 083/113] Implement async device discovery Also disallow the list of default broadcast addresses and use only the one provided by HA. Revise this if people report problems. --- custom_components/gree_custom/aiogree/api.py | 4 +- .../gree_custom/aiogree/device.py | 2 +- .../gree_custom/aiogree/transport.py | 115 ++++++++++-------- custom_components/gree_custom/config_flow.py | 19 ++- custom_components/gree_custom/manifest.json | 2 +- 5 files changed, 84 insertions(+), 58 deletions(-) diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index 4d528b5..0da382a 100644 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -11,7 +11,7 @@ from .cipher import CipherBase, EncryptionVersion, get_cipher from .const import DEFAULT_DEVICE_PORT from .errors import GreeBindingError, GreeError, GreeProtocolError -from .transport import GreeTransport, udp_broadcast_request +from .transport import GreeTransport, async_udp_broadcast_request _LOGGER = logging.getLogger(__name__) @@ -546,7 +546,7 @@ async def discover_gree_devices( discovered_devices: list[GreeDiscoveredDevice] = [] - responses = udp_broadcast_request( + responses = await async_udp_broadcast_request( broadcast_addresses, DEFAULT_DEVICE_PORT, json.dumps({"t": "scan"}), timeout ) diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index 589ebd4..72d7132 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -183,7 +183,7 @@ async def fetch_sub_devices(self) -> list[GreeDiscoveredDevice]: subs = await gree_get_sub_devices_list( self._mac_addr, self._uid, - self._cipher, # TODO: Check if this should use the generic or the device key + self._cipher, # NOTE: Check if this should use the generic or the device key self._transport, ) except GreeProtocolError: diff --git a/custom_components/gree_custom/aiogree/transport.py b/custom_components/gree_custom/aiogree/transport.py index cb0385f..e5a7fbb 100644 --- a/custom_components/gree_custom/aiogree/transport.py +++ b/custom_components/gree_custom/aiogree/transport.py @@ -3,8 +3,6 @@ import asyncio import json import logging -import socket -import time import asyncio_dgram @@ -86,62 +84,83 @@ async def request_json(self, payload: dict) -> dict: return json.loads(raw.decode("utf-8")) -def udp_broadcast_request( - addresses: list[str], port: int, json_data: str, timeout: int -) -> dict[str, dict]: - """Sends a UDP message to the bradcast address and returns the responses.""" - # Create UDP socket manually so we can enable broadcast - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) - sock.settimeout(timeout) - sock.bind(("", 0)) +class UDPDiscoveryProtocol(asyncio.DatagramProtocol): + """Helper Protocol to handle incoming UDP discovery responses. + + Responses will be added to a 'responses' field which can be queried. + """ + + def __init__(self, responses: dict[str, dict]) -> None: + """Setup Discovery Transport. Use the responses to query the received data.""" + self.responses = responses + self.transport = None + + def connection_made(self, transport: asyncio.DatagramTransport): + """Called when the UDP socket is set up.""" + self.transport = transport + + def datagram_received(self, data: bytes, addr: tuple[str, int]): + """Called when a UDP packet is received.""" + try: + # Decode the payload + payload = json.loads(data.decode("utf-8", errors="ignore")) + ip_address = addr[0] + + self.responses[ip_address] = payload + _LOGGER.debug("Received reply from %s", ip_address) + + except json.JSONDecodeError: + _LOGGER.exception("Could not parse JSON response from %s: %s", addr, data) + except Exception: + _LOGGER.exception("Unexpected error processing packet from %s", addr) + + def error_received(self, exc): + """Called on underlying network errors.""" + _LOGGER.error("UDP network error received: %s", exc) + + def connection_lost(self, exc): + """Called when the socket is closed.""" - responses: dict[str, dict] = {} - # Default broadcast addresses to try - default_broadcast_addresses = [ - "255.255.255.255", # Limited broadcast - "192.168.255.255", # /16 broadcast for 192.168.x.x networks - "10.255.255.255", # /8 broadcast for 10.x.x.x networks - "172.31.255.255", # /12 broadcast for 172.16-31.x.x networks - ] - addresses.extend(default_broadcast_addresses) +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.""" + loop = asyncio.get_running_loop() + responses: dict[str, dict] = {} # Remove duplicates - broadcast_addresses = list(dict.fromkeys(addresses)) + broadcast_addresses = list(dict.fromkeys(broadcast_addresses)) try: - for broadcast_addr in broadcast_addresses: - try: - _LOGGER.debug("Sending broadcast to %s", broadcast_addr) - sock.sendto(json_data.encode("utf-8"), (broadcast_addr, port)) - except Exception: - _LOGGER.exception("Failed to send to %s", broadcast_addr) - - # Send broadcast - _LOGGER.debug( - "Sent broadcast packets, waiting %d seconds for replies... ", timeout + transport, _ = await loop.create_datagram_endpoint( + lambda: UDPDiscoveryProtocol(responses), + local_addr=( + "0.0.0.0", + 0, + ), # Listen on all interfaces, random ephemeral port + allow_broadcast=True, ) + except OSError as err: + _LOGGER.error("Failed to bind UDP socket: %s", err) + return responses - start_time: float = time.time() - while time.time() - start_time < timeout: + try: + # Send out the broadcast payload + payload = json_data.encode("utf-8") + for addr in broadcast_addresses: try: - response, addr = sock.recvfrom(1024) + _LOGGER.debug("Sending broadcast to %s:%s", addr, port) + transport.sendto(payload, (addr, port)) + except Exception: + _LOGGER.exception("Failed sending to %s", addr) + + # Wait for devices to reply asynchronously + _LOGGER.debug("Waiting %d seconds for UDP replies... ", timeout) + await asyncio.sleep(timeout) - try: - response = json.loads(response.decode(errors="ignore")) - except Exception: - _LOGGER.exception("Could not parse response from %s", addr) - else: - responses[addr[0]] = response - except TimeoutError: - break - except Exception: - _LOGGER.exception("Error sending broadcast packet") finally: - sock.close() + transport.close() - _LOGGER.debug( - "Got %d responses in %d seconds: %s", len(responses), timeout, responses - ) + _LOGGER.debug("Discovery finished. Got %d responses", len(responses)) return responses diff --git a/custom_components/gree_custom/config_flow.py b/custom_components/gree_custom/config_flow.py index ccfd458..dc93b4a 100644 --- a/custom_components/gree_custom/config_flow.py +++ b/custom_components/gree_custom/config_flow.py @@ -9,10 +9,7 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.network import ( - IPv4Address, - async_get_ipv4_broadcast_addresses, -) +from homeassistant.components import network from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT, CONF_TIMEOUT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import section @@ -868,8 +865,8 @@ async def _discover_devices( broadcast_addresses: list[str] = [] try: ha_broadcast_addresses: set[ - IPv4Address - ] = await async_get_ipv4_broadcast_addresses(hass) + network.IPv4Address + ] = await network.async_get_ipv4_broadcast_addresses(hass) ha_broadcast_strings: list[str] = [ str(addr) for addr in ha_broadcast_addresses ] @@ -879,6 +876,16 @@ async def _discover_devices( except Exception: _LOGGER.exception("Could not get HA broadcast addresses") + # Default broadcast addresses to try + # default_broadcast_addresses = [ + # "255.255.255.255", # Limited broadcast + # "192.168.255.255", # /16 broadcast for 192.168.x.x networks + # "10.255.255.255", # /8 broadcast for 10.x.x.x networks + # "172.31.255.255", # /12 broadcast for 172.16-31.x.x networks + # ] + # broadcast_addresses.extend(default_broadcast_addresses) + # NOTE: Try to use the ones from HA only. Uncomment if people report bugs. + return await discover_gree_devices(broadcast_addresses, 5) def _create_final_entry(self): diff --git a/custom_components/gree_custom/manifest.json b/custom_components/gree_custom/manifest.json index 446c5c4..f44a560 100755 --- a/custom_components/gree_custom/manifest.json +++ b/custom_components/gree_custom/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "local_polling", "requirements": ["pycryptodome", "asyncio_dgram"], - "version": "4.0.0-alpha.79" + "version": "4.0.0-alpha.80" } From 5e3c35f657b765eb81a4b539647e1af376556360 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Tue, 17 Mar 2026 22:01:13 +0000 Subject: [PATCH 084/113] Add debug for fetch_device_info() --- custom_components/gree_custom/__init__.py | 8 +++++- custom_components/gree_custom/aiogree/api.py | 11 +++++--- .../gree_custom/aiogree/cipher.py | 5 ++++ .../gree_custom/aiogree/device.py | 27 ++++++++++++++----- custom_components/gree_custom/config_flow.py | 4 +++ custom_components/gree_custom/manifest.json | 2 +- 6 files changed, 45 insertions(+), 12 deletions(-) diff --git a/custom_components/gree_custom/__init__.py b/custom_components/gree_custom/__init__.py index 2baec03..407ed50 100755 --- a/custom_components/gree_custom/__init__.py +++ b/custom_components/gree_custom/__init__.py @@ -6,6 +6,7 @@ import asyncio import logging +from homeassistant.components.diagnostics.util import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT, Platform from homeassistant.core import HomeAssistant @@ -72,7 +73,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool entry.data[CONF_MAC], entry.data[CONF_HOST], ) - _LOGGER.debug("Entry '%s' data: %s\n%s", entry.entry_id, entry, entry.data) + _LOGGER.debug( + "Entry '%s' data: %s\n%s", + entry.entry_id, + entry, + async_redact_data(entry.data, ["encryption_key"]), + ) conf = entry.data if conf is None or conf[CONF_ADVANCED] is None: diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index 0da382a..d092059 100644 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -171,10 +171,11 @@ async def get_result_pack( try: recv_json = await transport.request_json(json_data) + data = get_gree_response_data(recv_json, cipher) except json.JSONDecodeError as err: raise GreeProtocolError("Invalid JSON response from device") from err - - data = get_gree_response_data(recv_json, cipher) + except Exception as err: + raise GreeProtocolError("Error in device response") from err pack = data.get("pack", None) @@ -505,12 +506,14 @@ async def gree_set_status( return updated_props -async def gree_get_device_info(transport: GreeTransport) -> dict[str, str | None]: +async def gree_get_device_info( + transport: GreeTransport, cipher: CipherBase | None = None +) -> dict[str, str | None]: """Tries to retrive the device info.""" data: dict = await get_result_pack( {"t": "scan"}, - get_cipher(EncryptionVersion.V1), + cipher or get_cipher(EncryptionVersion.V1), transport, ) diff --git a/custom_components/gree_custom/aiogree/cipher.py b/custom_components/gree_custom/aiogree/cipher.py index 312e3ef..09102f2 100644 --- a/custom_components/gree_custom/aiogree/cipher.py +++ b/custom_components/gree_custom/aiogree/cipher.py @@ -7,6 +7,8 @@ from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad +from .errors import GreeError + _LOGGER = logging.getLogger(__name__) GCM_IV = b"\x54\x40\x78\x44\x49\x67\x5a\x51\x6c\x5e\x63\x13" @@ -145,6 +147,9 @@ def decrypt(self, data: str, tag: str) -> str: decoded = base64.b64decode(data) decrypted = cipher.decrypt(decoded) + if not tag: + raise GreeError("Decrypting data (V2) failed: tag is needed") + _LOGGER.debug("Verifying tag: %s", tag) cipher.verify(base64.b64decode(tag)) diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index 72d7132..55ecf96 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -108,12 +108,11 @@ async def bind_device(self) -> bool: return True # Use fetch info as basic communication test since it does not require V2 + # TODO: Bug with device not fetching info with normal V1 cipher, temporary not raise try: await self.fetch_device_info() - except Exception as err: - raise GreeBindingError( - "Could not fetch device info before binding" - ) from err + except Exception: + _LOGGER.exception("Could not fetch device info before binding") try: key, version = await gree_try_bind( @@ -142,13 +141,29 @@ async def bind_device(self) -> bool: self._is_available = True self._is_bound = True + # Tests for fetch_device_info() + for c in ( + self._cipher, + get_cipher(EncryptionVersion.V1), + get_cipher(EncryptionVersion.V2), + ): + try: + _LOGGER.debug( + "Trying fetch_device_info with cipher v%s and key '%s'", + c.version, + c.key[:5], + ) + await self.fetch_device_info(c) + except Exception: + _LOGGER.exception("Could not fetch device info") + return True - async def fetch_device_info(self): + async def fetch_device_info(self, cipher: CipherBase = None): """Updates the device info fields.""" try: self._raw_info = await gree_get_device_info( - self._transport, + self._transport, cipher or self._cipher ) except Exception as e: diff --git a/custom_components/gree_custom/config_flow.py b/custom_components/gree_custom/config_flow.py index dc93b4a..d2d7346 100644 --- a/custom_components/gree_custom/config_flow.py +++ b/custom_components/gree_custom/config_flow.py @@ -672,6 +672,10 @@ async def async_step_manual_add( # self._discovered_subdevices = await get_sub_devices( # _main_device.mac_address, user_input[CONF_HOST], 0, 2, 2 # ) + self._discovered_subdevices = await self._devices[ + _main_device.mac_address_sub + ].bind_device() + self._discovered_subdevices = await self._devices[ _main_device.mac_address_sub ].fetch_sub_devices() diff --git a/custom_components/gree_custom/manifest.json b/custom_components/gree_custom/manifest.json index f44a560..3f96294 100755 --- a/custom_components/gree_custom/manifest.json +++ b/custom_components/gree_custom/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "local_polling", "requirements": ["pycryptodome", "asyncio_dgram"], - "version": "4.0.0-alpha.80" + "version": "4.0.0-alpha.81" } From 6ef40a1779390f5c2a50f76ee3adb0169e808c55 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Tue, 24 Mar 2026 14:51:02 +0000 Subject: [PATCH 085/113] Revert fetch_device_info tests and prevent device key in logs --- custom_components/gree_custom/aiogree/api.py | 2 +- .../gree_custom/aiogree/device.py | 28 +++++-------------- custom_components/gree_custom/config_flow.py | 5 +++- custom_components/gree_custom/manifest.json | 2 +- 4 files changed, 13 insertions(+), 24 deletions(-) diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index d092059..fe2b1a7 100644 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -393,7 +393,7 @@ async def gree_try_bind( _LOGGER.info("Bind request with version %d was successful", cipher.version) - _LOGGER.debug("Fetched encryption key: %s[omitted]", ret_key[:5]) + _LOGGER.debug("Fetched encryption key: %s[redacted]", ret_key[:5]) return ret_key, cipher.version diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index 55ecf96..17af891 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -61,7 +61,7 @@ def __init__( port, ) _LOGGER.debug( - "Version: %s, Key: %s[omitted]", encryption_version, encryption_key[:5] + "Version: %s, Key: %s[redacted]", encryption_version, encryption_key[:5] ) self._name: str = name @@ -107,12 +107,14 @@ async def bind_device(self) -> bool: if self._is_bound: return True - # Use fetch info as basic communication test since it does not require V2 - # TODO: Bug with device not fetching info with normal V1 cipher, temporary not raise + # Use a targeted fetch_device_info (scan) to the device + # since binding only succeeds after a scan try: await self.fetch_device_info() - except Exception: - _LOGGER.exception("Could not fetch device info before binding") + except Exception as err: + raise GreeBindingError( + "Could not fetch device info before binding" + ) from err try: key, version = await gree_try_bind( @@ -141,22 +143,6 @@ async def bind_device(self) -> bool: self._is_available = True self._is_bound = True - # Tests for fetch_device_info() - for c in ( - self._cipher, - get_cipher(EncryptionVersion.V1), - get_cipher(EncryptionVersion.V2), - ): - try: - _LOGGER.debug( - "Trying fetch_device_info with cipher v%s and key '%s'", - c.version, - c.key[:5], - ) - await self.fetch_device_info(c) - except Exception: - _LOGGER.exception("Could not fetch device info") - return True async def fetch_device_info(self, cipher: CipherBase = None): diff --git a/custom_components/gree_custom/config_flow.py b/custom_components/gree_custom/config_flow.py index d2d7346..045dd24 100644 --- a/custom_components/gree_custom/config_flow.py +++ b/custom_components/gree_custom/config_flow.py @@ -906,7 +906,10 @@ def _create_final_entry(self): data[CONF_DEVICES] = devices - _LOGGER.debug("New entry with config: %s", data) + _LOGGER.debug( + "New entry with config: %s", + async_redact_data(data, ["encryption_key"]), + ) return self.async_create_entry( title=f"Gree System at {data[CONF_HOST]}", data=data ) diff --git a/custom_components/gree_custom/manifest.json b/custom_components/gree_custom/manifest.json index 3f96294..a475b59 100755 --- a/custom_components/gree_custom/manifest.json +++ b/custom_components/gree_custom/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "local_polling", "requirements": ["pycryptodome", "asyncio_dgram"], - "version": "4.0.0-alpha.81" + "version": "4.0.0-alpha.82" } From 6951d75bbe13430880e3c16d3fb874ace85d7bf8 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Tue, 24 Mar 2026 14:52:59 +0000 Subject: [PATCH 086/113] Add initial support for Fault Detection feature Adds a binary sensor that shows if there is a fault reported by the device --- custom_components/gree_custom/__init__.py | 1 + custom_components/gree_custom/aiogree/api.py | 5 + .../gree_custom/aiogree/device.py | 9 +- .../gree_custom/binary_sensor.py | 160 ++++++++++++++++++ custom_components/gree_custom/config_flow.py | 4 + custom_components/gree_custom/const.py | 4 + custom_components/gree_custom/manifest.json | 2 +- custom_components/gree_custom/select.py | 2 +- .../gree_custom/translations/en.json | 11 +- .../gree_custom/translations/pt.json | 18 +- 10 files changed, 209 insertions(+), 7 deletions(-) create mode 100644 custom_components/gree_custom/binary_sensor.py diff --git a/custom_components/gree_custom/__init__.py b/custom_components/gree_custom/__init__.py index 407ed50..34cfd1f 100755 --- a/custom_components/gree_custom/__init__.py +++ b/custom_components/gree_custom/__init__.py @@ -43,6 +43,7 @@ Platform.SELECT, Platform.SENSOR, Platform.SWITCH, + Platform.BINARY_SENSOR, ] _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index fe2b1a7..f5438be 100644 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -77,6 +77,11 @@ class GreeProp(Enum): # If set to 1 the unit will beep on every command (available on newer firmwares) BEEPER_NEW = "BuzzerCtrl" + # EXPERIMENTAL + # error display. 0 if no error, otherwise error + FAULT = "FaultDisplay" + # MODEL = "ModelType" + @unique class TemperatureUnits(IntEnum): diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index 17af891..f58bc6d 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -308,7 +308,9 @@ 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) - def _bool_from_raw_state(self, prop: GreeProp) -> bool: + def _bool_from_raw_state( + self, prop: GreeProp, default: int | None = 0 + ) -> bool | None: return self._get_prop_raw(prop, 0) != 0 def _remove_unsupported_props(self): @@ -479,6 +481,11 @@ def available(self) -> bool: """Return True if the device is bouund and last connection was successful.""" return self._is_bound and self._is_available + @property + def has_hvac_error(self) -> bool | None: + """Return if there is an error with the device.""" + return self._bool_from_raw_state(GreeProp.FAULT, None) + @property def beeper(self) -> bool: """Return True if the device beeper is enabled.""" diff --git a/custom_components/gree_custom/binary_sensor.py b/custom_components/gree_custom/binary_sensor.py new file mode 100644 index 0000000..d7fbf5b --- /dev/null +++ b/custom_components/gree_custom/binary_sensor.py @@ -0,0 +1,160 @@ +"""Gree Binary Sensor Entity for Home Assistant.""" + +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.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import UNDEFINED + +from .aiogree.api import GreeProp +from .aiogree.device import GreeDevice +from .const import ( + CONF_ADVANCED, + CONF_DEVICES, + CONF_DISABLE_AVAILABLE_CHECK, + CONF_FEATURES, + CONF_TO_PROP_FEATURE_MAP, + DEFAULT_SUPPORTED_FEATURES, + GATTR_FAULTS, +) +from .coordinator import GreeConfigEntry, GreeCoordinator +from .entity import GreeEntity, GreeEntityDescription + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class GreeBinarySensorDescription(GreeEntityDescription, BinarySensorEntityDescription): + """Description of a Gree binary sensor.""" + + value_func: Callable[[GreeDevice], bool | None] + + +SENSOR_TYPES: list[GreeBinarySensorDescription] = [ + GreeBinarySensorDescription( + key=GATTR_FAULTS, + translation_key=GATTR_FAULTS, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + available_func=lambda device: ( + device.available and device.supports_property(GreeProp.FAULT) + ), + 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, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: GreeConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary sensors from a config entry.""" + + entities: list[GreeBinarySensor] = [] + + for d in entry.data.get(CONF_DEVICES, []): + coordinator: GreeCoordinator = entry.runtime_data[d.get(CONF_MAC, "")] + if not coordinator: + _LOGGER.error( + "Cannot create Gree Binary Sensors. No coordinator found for device '%s'", + d.get(CONF_MAC, ""), + ) + + 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 = DEFAULT_SUPPORTED_FEATURES + else: + conf_supported_features = d.get(CONF_FEATURES, []) + + # Double check features with device support, just in case + for feature in conf_supported_features: + # 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 SENSOR_TYPES + if description.key in supported_features + ] + ) + + _LOGGER.debug( + "Adding Binary Sensor Entities for device '%s': %s", + coordinator.device.mac_address_sub, + [d.key for d in descriptions], + ) + + entities.extend( + [ + GreeBinarySensor( + description, + coordinator, + check_availability=( + entry.data[CONF_ADVANCED].get( + CONF_DISABLE_AVAILABLE_CHECK, False + ) + ), + ) + for description in descriptions + ] + ) + + async_add_entities(entities) + + +class GreeBinarySensor(GreeEntity, BinarySensorEntity): # pyright: ignore[reportIncompatibleVariableOverride] + """Defines a Gree Binary Sensor entity.""" + + entity_description: GreeBinarySensorDescription + + def __init__( + self, + description: GreeBinarySensorDescription, + coordinator: GreeCoordinator, + check_availability: bool = True, + ) -> None: + """Initialize binary sensor.""" + super().__init__( + description, + coordinator, + restore_state=False, + check_availability=check_availability, + ) + + self.entity_description = description # pyright: ignore[reportIncompatibleVariableOverride] + _LOGGER.debug( + "Initialized binary sensor: %s (check_availability=%s)", + self.unique_id, + self.check_availability, + ) + + @property + def is_on(self) -> bool | None: + """Return the state of the sensor.""" + return self.entity_description.value_func(self.device) diff --git a/custom_components/gree_custom/config_flow.py b/custom_components/gree_custom/config_flow.py index 045dd24..46605dd 100644 --- a/custom_components/gree_custom/config_flow.py +++ b/custom_components/gree_custom/config_flow.py @@ -10,6 +10,7 @@ from homeassistant import config_entries from homeassistant.components import network +from homeassistant.components.diagnostics.util import async_redact_data from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT, CONF_TIMEOUT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import section @@ -63,6 +64,7 @@ DOMAIN, GATTR_ANTI_DIRECT_BLOW, GATTR_BEEPER, + GATTR_FAULTS, GATTR_FEAT_ENERGY_SAVING, GATTR_FEAT_FRESH_AIR, GATTR_FEAT_HEALTH, @@ -280,6 +282,8 @@ def build_options_schema( valid_features.append(GATTR_ANTI_DIRECT_BLOW) if device.supports_property(GreeProp.FEAT_ENERGY_SAVING): valid_features.append(GATTR_FEAT_ENERGY_SAVING) + if device.supports_property(GreeProp.FAULT): + valid_features.append(GATTR_FAULTS) schema.update( { diff --git a/custom_components/gree_custom/const.py b/custom_components/gree_custom/const.py index e84b4b3..6623568 100755 --- a/custom_components/gree_custom/const.py +++ b/custom_components/gree_custom/const.py @@ -65,6 +65,8 @@ GATTR_OUTDOOR_TEMPERATURE = "outdoor_temperature" GATTR_HUMIDITY = "rooom_humidity" +GATTR_FAULTS = "faults" + ATTR_EXTERNAL_TEMPERATURE_SENSOR = "external_temperature_sensor" ATTR_EXTERNAL_HUMIDITY_SENSOR = "external_humidity_sensor" ATTR_AUTO_XFAN = "auto_xfan" @@ -81,6 +83,7 @@ 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.FAULT, } # HVAC modes - these come from Home Assistant and are standard @@ -155,6 +158,7 @@ GATTR_ANTI_DIRECT_BLOW, GATTR_FEAT_ENERGY_SAVING, GATTR_FEAT_SENSOR_LIGHT, + GATTR_FAULTS, ] UNITS_GREE_TO_HA = { diff --git a/custom_components/gree_custom/manifest.json b/custom_components/gree_custom/manifest.json index a475b59..04d8cda 100755 --- a/custom_components/gree_custom/manifest.json +++ b/custom_components/gree_custom/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "local_polling", "requirements": ["pycryptodome", "asyncio_dgram"], - "version": "4.0.0-alpha.82" + "version": "4.0.0-alpha.83" } diff --git a/custom_components/gree_custom/select.py b/custom_components/gree_custom/select.py index f7bb27e..ae516e0 100644 --- a/custom_components/gree_custom/select.py +++ b/custom_components/gree_custom/select.py @@ -101,7 +101,7 @@ class GreeSelectDescription(GreeEntityDescription, SelectEntityDescription, Gene force_update = False icon = None has_entity_name = True - name = UNDEFINED + name = None translation_key = None translation_placeholders = None unit_of_measurement = None diff --git a/custom_components/gree_custom/translations/en.json b/custom_components/gree_custom/translations/en.json index 27610e1..2edae66 100755 --- a/custom_components/gree_custom/translations/en.json +++ b/custom_components/gree_custom/translations/en.json @@ -63,7 +63,8 @@ "external_temperature_sensor": "External Temperature Sensor", "external_humidity_sensor": "External Humidity Sensor", "restore_states": "Restore Entities", - "target_temp_step": "Temperature Step" + "target_temp_step": "Temperature Step", + "faults": "Fault Detection" }, "data_description": { "external_temperature_sensor": "If set will replace the built-in HVAC temperature sensor", @@ -168,7 +169,8 @@ "health": "Health", "anti_direct_blow": "Anti Direct Blow", "powersave": "Energy Saving", - "light_sensor": "Display Auto Brightness" + "light_sensor": "Display Auto Brightness", + "faults": "Fault Detection" } } }, @@ -184,6 +186,11 @@ "name": "Indoor Humidity" } }, + "binary_sensor": { + "faults": { + "name": "Fault Detection" + } + }, "climate": { "hvac": { "state_attributes": { diff --git a/custom_components/gree_custom/translations/pt.json b/custom_components/gree_custom/translations/pt.json index 2b95bf0..1ecd0cf 100755 --- a/custom_components/gree_custom/translations/pt.json +++ b/custom_components/gree_custom/translations/pt.json @@ -168,7 +168,8 @@ "health": "Saúde", "anti_direct_blow": "Anti Sopro Direto", "powersave": "Poupança de Energia", - "light_sensor": "Brilho Automático do Visor" + "light_sensor": "Brilho Automático do Visor", + "faults": "Falha de Operação" } } }, @@ -184,8 +185,21 @@ "name": "Humidade Interior" } }, + "binary_sensor": { + "faults": { + "name": "Falha de Operação" + } + }, "climate": { "hvac": { + "state": { + "auto": "Automático", + "cool": "Arrefecer", + "dry": "Secar", + "fan_only": "Ventilação", + "heat": "Aquecer", + "off": "Desligado" + }, "state_attributes": { "fan_mode": { "state": { @@ -292,4 +306,4 @@ "message": "Ocorreu um erro a realizar a ação pretendida, consulto os registos da integração." } } -} +} \ No newline at end of file From 21f74e163cb8d896ff32319156b7554550ee1bc2 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Tue, 24 Mar 2026 14:59:14 +0000 Subject: [PATCH 087/113] Code cleanup --- custom_components/gree_custom/__init__.py | 2 +- custom_components/gree_custom/binary_sensor.py | 1 - custom_components/gree_custom/manifest.json | 2 +- custom_components/gree_custom/select.py | 1 - custom_components/gree_custom/switch.py | 12 ++++++------ 5 files changed, 8 insertions(+), 10 deletions(-) diff --git a/custom_components/gree_custom/__init__.py b/custom_components/gree_custom/__init__.py index 34cfd1f..9a3f5b7 100755 --- a/custom_components/gree_custom/__init__.py +++ b/custom_components/gree_custom/__init__.py @@ -39,11 +39,11 @@ from .coordinator import GreeConfigEntry, GreeCoordinator PLATFORMS = [ + Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, - Platform.BINARY_SENSOR, ] _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/gree_custom/binary_sensor.py b/custom_components/gree_custom/binary_sensor.py index d7fbf5b..44e7aad 100644 --- a/custom_components/gree_custom/binary_sensor.py +++ b/custom_components/gree_custom/binary_sensor.py @@ -13,7 +13,6 @@ from homeassistant.const import CONF_MAC, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import UNDEFINED from .aiogree.api import GreeProp from .aiogree.device import GreeDevice diff --git a/custom_components/gree_custom/manifest.json b/custom_components/gree_custom/manifest.json index 04d8cda..2391d7b 100755 --- a/custom_components/gree_custom/manifest.json +++ b/custom_components/gree_custom/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "local_polling", "requirements": ["pycryptodome", "asyncio_dgram"], - "version": "4.0.0-alpha.83" + "version": "4.0.0-alpha.84" } diff --git a/custom_components/gree_custom/select.py b/custom_components/gree_custom/select.py index ae516e0..131983b 100644 --- a/custom_components/gree_custom/select.py +++ b/custom_components/gree_custom/select.py @@ -12,7 +12,6 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import UNDEFINED from .aiogree.api import GreeProp, TemperatureUnits from .aiogree.device import GreeDevice diff --git a/custom_components/gree_custom/switch.py b/custom_components/gree_custom/switch.py index 6a0c276..e7adf1a 100644 --- a/custom_components/gree_custom/switch.py +++ b/custom_components/gree_custom/switch.py @@ -364,9 +364,9 @@ async def async_turn_on(self, **kwargs: Any) -> None: # notify coordinator listeners of state change so that dependent entities are updated immediately self.coordinator.async_update_listeners() - if self.entity_description.key not in [ - GATTR_BEEPER - ]: # ignore HA-only dependent entities + if ( + self.entity_description.key != GATTR_BEEPER + ): # ignore HA-only dependent entities await self.coordinator.async_request_refresh() except Exception as err: raise HomeAssistantError("Failed to turn on switch") from err @@ -387,9 +387,9 @@ async def async_turn_off(self, **kwargs: Any) -> None: # notify coordinator listeners of state change so that dependent entities are updated immediately self.coordinator.async_update_listeners() - if self.entity_description.key not in [ - GATTR_BEEPER - ]: # ignore HA-only dependent entities + if ( + self.entity_description.key != GATTR_BEEPER + ): # ignore HA-only dependent entities await self.coordinator.async_request_refresh() except Exception as err: raise HomeAssistantError("Failed to turn off switch") from err From fb7d5a19e9c54fd4c46eaf5ac49eb7a8f046a887 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Tue, 24 Mar 2026 15:05:24 +0000 Subject: [PATCH 088/113] Update readme on UDP protocol requirement --- README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ec3658d..2b713f5 100644 --- a/README.md +++ b/README.md @@ -3,17 +3,22 @@ # HomeAssistant-GreeClimateComponent -Custom Gree inetgration for Home Assistant written in Python3. Controls ACs supporting the Gree protocol. +Custom Gree integration for Home Assistant written in Python3. Controls ACs supporting the Gree protocol. This integration connects directly to your HVAC devices via their IP address on the local network, unlike the official mobile app, which establish a direct connection only during initial setup and subsequently operate through Gree’s servers. +> [!NOTE] +> This integration only supports the Gree UDP protocol. If you have a newer firmware/device that only communicates using the new MQTT protocol, this integration will not work. + +For a comprehensive list of tested devices, see [Supported Devices](supported-devices.md). + The integration attempts to obtain the encryption key by the initial setup protocol, which has been reverse-engineered. > [!WARNING] > If your HVAC device was previously set up for remote access using a mobile app, the integration may fail to retrieve the encryption key automatically. Find out more on methods of obtaining your device key bellow. -For a comprehensive list of tested devices, see [Supported Devices](supported-devices.md). + **If you are experiencing issues please read the [Debugging](#debugging) section** @@ -153,4 +158,5 @@ The integration exposes various entities to configure additional features of you This project is based on the work of several contributors and projects: - [gree-remote](https://github.com/tomikaa87/gree-remote) - Gree air conditioner remote control protocol +- [greeclimate](https://github.com/cmroche/greeclimate) - Python package for controlling Gree based minisplit systems - [Home Assistant Developer Documentation](https://developers.home-assistant.io/) - Official development guidelines and best practices From d52318e014f0023e2de8cfb7d1b2438334573d78 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Thu, 26 Mar 2026 23:35:15 +0000 Subject: [PATCH 089/113] Fix config timeout and use full mac --- custom_components/gree_custom/__init__.py | 50 ++++++++++++------- .../gree_custom/binary_sensor.py | 5 +- custom_components/gree_custom/climate.py | 5 +- custom_components/gree_custom/manifest.json | 2 +- custom_components/gree_custom/select.py | 5 +- custom_components/gree_custom/sensor.py | 5 +- custom_components/gree_custom/switch.py | 5 +- 7 files changed, 49 insertions(+), 28 deletions(-) diff --git a/custom_components/gree_custom/__init__.py b/custom_components/gree_custom/__init__.py index 9a3f5b7..b21e10d 100755 --- a/custom_components/gree_custom/__init__.py +++ b/custom_components/gree_custom/__init__.py @@ -8,7 +8,7 @@ from homeassistant.components.diagnostics.util import async_redact_data from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT, Platform +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT, CONF_TIMEOUT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr @@ -16,6 +16,7 @@ from .aiogree.const import ( DEFAULT_CONNECTION_MAX_ATTEMPTS, + DEFAULT_CONNECTION_TIMEOUT, DEFAULT_DEVICE_PORT, DEFAULT_DEVICE_UID, ) @@ -69,52 +70,67 @@ async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool """Set up Gree from a config entry.""" _LOGGER.info( - "Setting up entry '%s' for: %s at %s", + "Setup entry '%s': %s at %s", entry.entry_id, entry.data[CONF_MAC], entry.data[CONF_HOST], ) _LOGGER.debug( - "Entry '%s' data: %s\n%s", + "Setup entry '%s': %s\ndata=%s", entry.entry_id, entry, async_redact_data(entry.data, ["encryption_key"]), ) conf = entry.data - if conf is None or conf[CONF_ADVANCED] is None: + if ( + conf is None + or conf[CONF_MAC] is None + or conf[CONF_HOST] is None + or conf[CONF_ADVANCED] is None + ): _LOGGER.error("Bad config entry, this should not happen") return False coordinators: dict[str, GreeCoordinator] = {} for d in conf.get(CONF_DEVICES, []): - mac = str(d.get(CONF_MAC, "")) + mac = str(d.get(CONF_MAC, "")) + "@" + conf.get(CONF_MAC) device = GreeDevice( - d.get(CONF_DEV_NAME, "Gree HVAC"), - conf.get(CONF_HOST, ""), - mac, - conf[CONF_ADVANCED].get(CONF_PORT, DEFAULT_DEVICE_PORT), - conf[CONF_ADVANCED].get(CONF_ENCRYPTION_KEY, ""), - conf[CONF_ADVANCED].get( + name=d.get(CONF_DEV_NAME, "Gree HVAC"), + ip_addr=conf.get(CONF_HOST), + mac_addr=mac, + port=conf[CONF_ADVANCED].get(CONF_PORT, DEFAULT_DEVICE_PORT), + encryption_key=conf[CONF_ADVANCED].get(CONF_ENCRYPTION_KEY, ""), + encryption_version=conf[CONF_ADVANCED].get( CONF_ENCRYPTION_VERSION, DEFAULT_ENCRYPTION_VERSION ), - conf[CONF_ADVANCED].get(CONF_UID, DEFAULT_DEVICE_UID), + uid=conf[CONF_ADVANCED].get(CONF_UID, DEFAULT_DEVICE_UID), max_connection_attempts=conf[CONF_ADVANCED].get( CONF_MAX_ONLINE_ATTEMPTS, DEFAULT_CONNECTION_MAX_ATTEMPTS ), + timeout=conf[CONF_ADVANCED].get(CONF_TIMEOUT, DEFAULT_CONNECTION_TIMEOUT), ) try: - async with asyncio.timeout(30): - await device.bind_device() + _LOGGER.debug( + "Setup entry '%s': GreeDevice(%s, %s)", + entry.entry_id, + mac, + conf.get(CONF_HOST), + ) + await device.bind_device() # TODO: Add scan interval to config coordinators[mac] = GreeCoordinator(hass, entry, device) await coordinators[mac].async_config_entry_first_refresh() - _LOGGER.debug("Bound to device %s", mac) + _LOGGER.debug("Setup entry '%s': Bound to device %s", entry.entry_id, mac) except TimeoutError as err: - _LOGGER.debug("Conection to %s timed out", mac) + _LOGGER.exception( + "Setup entry '%s': Conection to %s timed out", entry.entry_id, mac + ) raise ConfigEntryNotReady from err except GreeBindingError as err: - _LOGGER.debug("Failed to bind to device %s", mac) + _LOGGER.exception( + "Setup entry '%s': Failed to bind to device %s", entry.entry_id, mac + ) raise ConfigEntryNotReady from err entry.runtime_data = coordinators diff --git a/custom_components/gree_custom/binary_sensor.py b/custom_components/gree_custom/binary_sensor.py index 44e7aad..3aeed33 100644 --- a/custom_components/gree_custom/binary_sensor.py +++ b/custom_components/gree_custom/binary_sensor.py @@ -70,11 +70,12 @@ async def async_setup_entry( entities: list[GreeBinarySensor] = [] for d in entry.data.get(CONF_DEVICES, []): - coordinator: GreeCoordinator = entry.runtime_data[d.get(CONF_MAC, "")] + mac = d.get(CONF_MAC, "") + "@" + entry.data.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'", - d.get(CONF_MAC, ""), + mac, ) descriptions: list[GreeBinarySensorDescription] = [] diff --git a/custom_components/gree_custom/climate.py b/custom_components/gree_custom/climate.py index 2c8fb50..f4cb059 100644 --- a/custom_components/gree_custom/climate.py +++ b/custom_components/gree_custom/climate.py @@ -81,11 +81,12 @@ async def async_setup_entry( entities: list[GreeClimate] = [] for d in entry.data.get(CONF_DEVICES, []): - coordinator: GreeCoordinator = entry.runtime_data[d.get(CONF_MAC, "")] + mac = d.get(CONF_MAC, "") + "@" + entry.data.get(CONF_MAC) + coordinator: GreeCoordinator = entry.runtime_data[mac] if not coordinator: _LOGGER.error( "Cannot create Gree Climate. No coordinator found for device '%s'", - d.get(CONF_MAC, ""), + mac, ) hvac_modes: list[HVACMode] = [ diff --git a/custom_components/gree_custom/manifest.json b/custom_components/gree_custom/manifest.json index 2391d7b..2aa5d14 100755 --- a/custom_components/gree_custom/manifest.json +++ b/custom_components/gree_custom/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "local_polling", "requirements": ["pycryptodome", "asyncio_dgram"], - "version": "4.0.0-alpha.84" + "version": "4.0.0-alpha.85" } diff --git a/custom_components/gree_custom/select.py b/custom_components/gree_custom/select.py index 131983b..e1194ef 100644 --- a/custom_components/gree_custom/select.py +++ b/custom_components/gree_custom/select.py @@ -40,11 +40,12 @@ async def async_setup_entry( entities: list[GreeSelect] = [] for d in entry.data.get(CONF_DEVICES, []): - coordinator: GreeCoordinator = entry.runtime_data[d.get(CONF_MAC, "")] + mac = d.get(CONF_MAC, "") + "@" + entry.data.get(CONF_MAC) + coordinator: GreeCoordinator = entry.runtime_data[mac] if not coordinator: _LOGGER.error( "Cannot create Gree Selectors. No coordinator found for device '%s'", - d.get(CONF_MAC, ""), + mac, ) descriptions: list[GreeSelectDescription] = [] diff --git a/custom_components/gree_custom/sensor.py b/custom_components/gree_custom/sensor.py index 6413413..fa648c7 100644 --- a/custom_components/gree_custom/sensor.py +++ b/custom_components/gree_custom/sensor.py @@ -41,11 +41,12 @@ async def async_setup_entry( entities: list[GreeSensor] = [] for d in entry.data.get(CONF_DEVICES, []): - coordinator: GreeCoordinator = entry.runtime_data[d.get(CONF_MAC, "")] + mac = d.get(CONF_MAC, "") + "@" + entry.data.get(CONF_MAC) + coordinator: GreeCoordinator = entry.runtime_data[mac] if not coordinator: _LOGGER.error( "Cannot create Gree Sensors. No coordinator found for device '%s'", - d.get(CONF_MAC, ""), + mac, ) descriptions: list[GreeSensorDescription] = [] diff --git a/custom_components/gree_custom/switch.py b/custom_components/gree_custom/switch.py index e7adf1a..9855b1d 100644 --- a/custom_components/gree_custom/switch.py +++ b/custom_components/gree_custom/switch.py @@ -172,11 +172,12 @@ async def async_setup_entry( entities: list[GreeSwitch] = [] for d in entry.data.get(CONF_DEVICES, []): - coordinator: GreeCoordinator = entry.runtime_data[d.get(CONF_MAC, "")] + mac = d.get(CONF_MAC, "") + "@" + entry.data.get(CONF_MAC) + coordinator: GreeCoordinator = entry.runtime_data[mac] if not coordinator: _LOGGER.error( "Cannot create Gree Switches. No coordinator found for device '%s'", - d.get(CONF_MAC, ""), + mac, ) descriptions: list[GreeSwitchDescription] = [] From 54ea6330b558420e7596cbd1ca24eae98c13c822 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Thu, 26 Mar 2026 23:35:31 +0000 Subject: [PATCH 090/113] Updated PT translation --- custom_components/gree_custom/translations/pt.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/gree_custom/translations/pt.json b/custom_components/gree_custom/translations/pt.json index 1ecd0cf..fe77106 100755 --- a/custom_components/gree_custom/translations/pt.json +++ b/custom_components/gree_custom/translations/pt.json @@ -294,10 +294,10 @@ }, "exceptions": { "turbo_availability": { - "message": "Modo Turbo não está disponível nos modo de Desumidificar e Ventoinha." + "message": "Modo Turbo não está disponível nos modos de Secar e Ventilação." }, "quiet_availability": { - "message": "Modo Silencioso apenas disponível nos modo de Desumidificar e Ventoinha." + "message": "Modo Silencioso apenas disponível nos modos de Secar e Ventilação." }, "entity_unavailable": { "message": "A entidade não está disponível." From fd95bb503228cb852643828c605ee0f6f22e2a4f Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Fri, 27 Mar 2026 00:08:01 +0000 Subject: [PATCH 091/113] Change mac address naming to clarify functions --- custom_components/gree_custom/__init__.py | 2 +- custom_components/gree_custom/aiogree/api.py | 12 ++--- .../gree_custom/aiogree/device.py | 47 ++++++++++++------- .../gree_custom/binary_sensor.py | 2 +- custom_components/gree_custom/climate.py | 2 +- custom_components/gree_custom/config_flow.py | 30 ++++++------ custom_components/gree_custom/entity.py | 10 ++-- custom_components/gree_custom/manifest.json | 2 +- custom_components/gree_custom/select.py | 2 +- custom_components/gree_custom/sensor.py | 2 +- custom_components/gree_custom/switch.py | 2 +- 11 files changed, 61 insertions(+), 52 deletions(-) diff --git a/custom_components/gree_custom/__init__.py b/custom_components/gree_custom/__init__.py index b21e10d..fc87ba5 100755 --- a/custom_components/gree_custom/__init__.py +++ b/custom_components/gree_custom/__init__.py @@ -112,7 +112,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool ) try: _LOGGER.debug( - "Setup entry '%s': GreeDevice(%s, %s)", + "Setup entry '%s': Configuring Gree Device (%s, %s)", entry.entry_id, mac, conf.get(CONF_HOST), diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index f5438be..191e183 100644 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -408,8 +408,8 @@ async def gree_try_bind( async def gree_get_status( + mac_addr_controller: str, mac_addr: str, - mac_addr_sub: str, uid: int, props: list[GreeProp], cipher: CipherBase, @@ -421,10 +421,10 @@ async def gree_get_status( status_values_raw: dict[GreeProp, int | None] = {} - pack = gree_create_status_pack(mac_addr_sub, [prop.value for prop in props]) + pack = gree_create_status_pack(mac_addr, [prop.value for prop in props]) encrypted_pack, tag = gree_encrypt_pack(pack, cipher) json_payload = gree_create_payload( - encrypted_pack, "pack", GreeCommand.STATUS, mac_addr, uid, tag + encrypted_pack, "pack", GreeCommand.STATUS, mac_addr_controller, uid, tag ) try: @@ -450,8 +450,8 @@ async def gree_get_status( async def gree_set_status( + mac_addr_controller: str, mac_addr: str, - mac_addr_sub: str, uid: int, props: dict[GreeProp, int], cipher: CipherBase, @@ -461,10 +461,10 @@ async def gree_set_status( _LOGGER.debug("Trying to set device status") - pack = gree_create_set_pack(mac_addr_sub, props) + pack = gree_create_set_pack(mac_addr, props) encrypted_pack, tag = gree_encrypt_pack(pack, cipher) json_payload = gree_create_payload( - encrypted_pack, "pack", GreeCommand.STATUS, mac_addr, uid, tag + encrypted_pack, "pack", GreeCommand.STATUS, mac_addr_controller, uid, tag ) try: diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index f58bc6d..6144b4c 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -69,11 +69,19 @@ def __init__( self._port: int = port self._max_connection_attempts: int = max_connection_attempts self._timeout: int = timeout - self._mac_addr = self._mac_addr_sub = ( - mac_addr.replace(":", "").replace("-", "").lower() - ) - if "@" in self._mac_addr: - self._mac_addr_sub, self._mac_addr = self._mac_addr.lower().split("@", 1) + + # For VRF units, the mac will be in the sub_device@main_device format + # where the sub_device is the device we are controling and + # main_device is the controller for that sub_device + mac_addr = mac_addr.replace(":", "").replace("-", "").lower() + + if "@" in mac_addr: + self._mac_addr, self._mac_addr_controller = self._mac_addr_controller.split( + "@", 1 + ) + else: + self._mac_addr = self._mac_addr_controller = mac_addr + self._transport = GreeTransport(ip_addr, port, max_connection_attempts, timeout) self._encryption_version: EncryptionVersion | None = encryption_version @@ -85,7 +93,7 @@ def __init__( self._new_raw_state: dict[GreeProp, int] = {} self._is_bound: bool = False self._is_available: bool = False - self._uniqueid: str = self._mac_addr_sub + self._uniqueid: str = self._mac_addr self._props_to_update: list[GreeProp] = list(GreeProp) # Don't poll the beeper state @@ -118,7 +126,7 @@ async def bind_device(self) -> bool: try: key, version = await gree_try_bind( - self._mac_addr, + self._mac_addr_controller, self._uid, self._encryption_version, self._encryption_key, @@ -158,9 +166,9 @@ async def fetch_device_info(self, cipher: CipherBase = None): ) from e else: - if self._raw_info.get("mac", "") != self._mac_addr: + if self._raw_info.get("mac", "") != self._mac_addr_controller: raise GreeProtocolError( - f"Wrong device info for {self._ip_addr}. MAC mismatch {self._raw_info.get('mac', '')} not {self._mac_addr}." + f"Wrong device info for {self._ip_addr}. MAC mismatch {self._raw_info.get('mac', '')} not {self._mac_addr_controller}." ) self._firmware_version = self._raw_info.get("firmware_version") self._firmware_code = self._raw_info.get("firmware_code") @@ -178,11 +186,14 @@ async def fetch_sub_devices(self) -> list[GreeDiscoveredDevice]: if not self._subdevicesCount: return [] + if self._mac_addr != self._mac_addr_controller: + return [] # For VRF, a non main device does not have subdevices + discovered_devices: list[GreeDiscoveredDevice] = [] try: subs = await gree_get_sub_devices_list( - self._mac_addr, + self._mac_addr_controller, self._uid, self._cipher, # NOTE: Check if this should use the generic or the device key self._transport, @@ -200,7 +211,7 @@ async def fetch_sub_devices(self) -> list[GreeDiscoveredDevice]: sub_mac = sub_device.get("mac", "") if sub_mac: discovered_sub_device = GreeDiscoveredDevice( - name=f"{sub_device.get('name', '') or f'Gree {sub_mac[:4]}@{self.mac_address[-4:]}'}", + name=f"{sub_device.get('name', '') or f'Gree {sub_mac[:4]}@{self.mac_address_controller[-4:]}'}", host=self._ip_addr, mac=sub_mac, port=self._port, @@ -215,7 +226,7 @@ async def fetch_sub_devices(self) -> list[GreeDiscoveredDevice]: discovered_sub_device, ) - _LOGGER.debug("Subdevices of '%s': %s", self._mac_addr, subs) + _LOGGER.debug("Subdevices of '%s': %s", self._mac_addr_controller, subs) self._is_available = True return discovered_devices @@ -231,8 +242,8 @@ async def fetch_device_status(self): try: state, _ = await gree_get_status( + self._mac_addr_controller, self._mac_addr, - self._mac_addr_sub, self._uid, self._props_to_update, self._cipher, @@ -286,8 +297,8 @@ async def update_device_status(self): try: self._raw_state.update( await gree_set_status( + self._mac_addr_controller, self._mac_addr, - self._mac_addr_sub, self._uid, self._new_raw_state, self._cipher, @@ -411,7 +422,7 @@ def gather_diagnostics(self) -> dict[str, Any]: info = { "ip": self._ip_addr, "mac": self._mac_addr, - "mac_sub": self._mac_addr_sub, + "mac_controller": self._mac_addr_controller, "port": self._port, "timeout": self._timeout, "max_connections": self._max_connection_attempts, @@ -461,9 +472,9 @@ def mac_address(self) -> str: return self._mac_addr @property - def mac_address_sub(self) -> str: - """Return the secondary MAC address of the device. For non VRF is the same as MAC otherwise is the MAC of the subdevice (same as MAC for the main device).""" - return self._mac_addr_sub + def mac_address_controller(self) -> str: + """Return the secondary MAC address of the device. For non VRF is the same as MAC otherwise is the MAC of the main controller (same as MAC for the main device).""" + return self._mac_addr_controller @property def firmware_version(self) -> str | None: diff --git a/custom_components/gree_custom/binary_sensor.py b/custom_components/gree_custom/binary_sensor.py index 3aeed33..7ac02fd 100644 --- a/custom_components/gree_custom/binary_sensor.py +++ b/custom_components/gree_custom/binary_sensor.py @@ -106,7 +106,7 @@ async def async_setup_entry( _LOGGER.debug( "Adding Binary Sensor Entities for device '%s': %s", - coordinator.device.mac_address_sub, + coordinator.device.mac_address, [d.key for d in descriptions], ) diff --git a/custom_components/gree_custom/climate.py b/custom_components/gree_custom/climate.py index f4cb059..986ecac 100644 --- a/custom_components/gree_custom/climate.py +++ b/custom_components/gree_custom/climate.py @@ -122,7 +122,7 @@ async def async_setup_entry( _LOGGER.debug( "Adding Climate Entity for device '%s'", - coordinator.device.mac_address_sub, + coordinator.device.mac_address, ) entities.append( diff --git a/custom_components/gree_custom/config_flow.py b/custom_components/gree_custom/config_flow.py index 46605dd..077d56e 100644 --- a/custom_components/gree_custom/config_flow.py +++ b/custom_components/gree_custom/config_flow.py @@ -497,7 +497,7 @@ async def async_step_import( ) await device.fetch_device_status() - data[CONF_MAC] = device.mac_address + data[CONF_MAC] = device.mac_address_controller data[CONF_ADVANCED][CONF_ENCRYPTION_VERSION] = ( int(device.encryption_version) if device.encryption_version else 0 ) @@ -507,9 +507,9 @@ async def async_step_import( # add the main device to the configs if not present if not self._get_device_conf( - import_config, device.mac_address_sub + import_config, device.mac_address ) and not self._get_device_conf(import_config, import_config[CONF_MAC]): - device_configs.append({CONF_MAC: device.mac_address_sub}) + device_configs.append({CONF_MAC: device.mac_address}) data[CONF_DEVICES] = [] for dev_config in device_configs: @@ -536,11 +536,11 @@ async def async_step_import( data[CONF_DEVICES].append( { **apply_schema_defaults(schema_dev, import_config), - CONF_MAC: dev.mac_address_sub, + CONF_MAC: dev.mac_address, } ) - unique_id = format_mac_id(device.mac_address) + unique_id = format_mac_id(device.mac_address_controller) entry = next( ( e @@ -663,7 +663,7 @@ async def async_step_manual_add( max_connection_attempts=2, # Use fewer attempts for testing the device timeout=2, # Use smaller timeout for testing the device ) - self._main_mac = _main_device.mac_address + self._main_mac = _main_device.mac_address_controller await self.async_set_unique_id(format_mac_id(self._main_mac)) if self._is_reconfigure: @@ -671,24 +671,24 @@ async def async_step_manual_add( else: self._abort_if_unique_id_configured() - self._devices[_main_device.mac_address_sub] = _main_device + self._devices[_main_device.mac_address] = _main_device # self._discovered_subdevices = await get_sub_devices( # _main_device.mac_address, user_input[CONF_HOST], 0, 2, 2 # ) self._discovered_subdevices = await self._devices[ - _main_device.mac_address_sub + _main_device.mac_address ].bind_device() self._discovered_subdevices = await self._devices[ - _main_device.mac_address_sub + _main_device.mac_address ].fetch_sub_devices() for d in self._discovered_subdevices: subdev = GreeDevice( d.name, user_input[CONF_HOST], - f"{d.mac}@{_main_device.mac_address}", + f"{d.mac}@{_main_device.mac_address_controller}", user_input[CONF_ADVANCED][CONF_PORT], _main_device.encryption_key, _main_device.encryption_version, @@ -696,9 +696,9 @@ async def async_step_manual_add( max_connection_attempts=2, # Use fewer attempts for testing the device timeout=2, # Use smaller timeout for testing the device ) - self._devices[subdev.mac_address_sub] = subdev + self._devices[subdev.mac_address] = subdev - await self._devices[_main_device.mac_address_sub].fetch_device_status() + await self._devices[_main_device.mac_address].fetch_device_status() except GreeBindingError: errors["base"] = "cannot_bind" _LOGGER.exception("Error while binding") @@ -713,7 +713,7 @@ async def async_step_manual_add( self._step_main_data.update(user_input) else: self._step_main_data = user_input - self._step_main_data[CONF_MAC] = _main_device.mac_address + self._step_main_data[CONF_MAC] = _main_device.mac_address_controller self._step_main_data[CONF_ADVANCED].update( { CONF_ENCRYPTION_VERSION: _main_device.encryption_version, @@ -811,9 +811,7 @@ async def async_step_device_options( conf_input = user_input if self._is_reconfigure: - conf_input = self._get_device_conf( - self._step_main_data, device.mac_address_sub - ) + conf_input = self._get_device_conf(self._step_main_data, device.mac_address) schema = build_options_schema(self.hass, device, conf_input) diff --git a/custom_components/gree_custom/entity.py b/custom_components/gree_custom/entity.py index 2308d6a..449c9fa 100755 --- a/custom_components/gree_custom/entity.py +++ b/custom_components/gree_custom/entity.py @@ -38,22 +38,22 @@ def __init__( @property def unique_id(self) -> str | None: """Returns a unique id for the entity.""" - return f"{self.device.mac_address_sub}_{self.entity_description.key}" + return f"{self.device.mac_address}_{self.entity_description.key}" @property def device_info(self) -> DeviceInfo: """Return the device info.""" - if self.device.mac_address_sub != self.device.mac_address: + if self.device.mac_address != self.device.mac_address_controller: return DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, self.device.mac_address_sub)}, + connections={(CONNECTION_NETWORK_MAC, self.device.mac_address)}, identifiers={(DOMAIN, self.device.unique_id)}, name=self.device.name, manufacturer="Gree", sw_version=self.device.firmware_version, - via_device=(DOMAIN, self.device.mac_address), + via_device=(DOMAIN, self.device.mac_address_controller), ) return DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, self.device.mac_address_sub)}, + connections={(CONNECTION_NETWORK_MAC, self.device.mac_address)}, identifiers={(DOMAIN, self.device.unique_id)}, name=self.device.name, manufacturer="Gree", diff --git a/custom_components/gree_custom/manifest.json b/custom_components/gree_custom/manifest.json index 2aa5d14..8008ac2 100755 --- a/custom_components/gree_custom/manifest.json +++ b/custom_components/gree_custom/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "local_polling", "requirements": ["pycryptodome", "asyncio_dgram"], - "version": "4.0.0-alpha.85" + "version": "4.0.0-alpha.86" } diff --git a/custom_components/gree_custom/select.py b/custom_components/gree_custom/select.py index e1194ef..a60bdbf 100644 --- a/custom_components/gree_custom/select.py +++ b/custom_components/gree_custom/select.py @@ -71,7 +71,7 @@ async def async_setup_entry( _LOGGER.debug( "Adding Select Entities for device '%s': %s", - coordinator.device.mac_address_sub, + coordinator.device.mac_address, [d.key for d in descriptions], ) diff --git a/custom_components/gree_custom/sensor.py b/custom_components/gree_custom/sensor.py index fa648c7..058ae09 100644 --- a/custom_components/gree_custom/sensor.py +++ b/custom_components/gree_custom/sensor.py @@ -103,7 +103,7 @@ async def async_setup_entry( _LOGGER.debug( "Adding Sensor Entities for device '%s': %s", - coordinator.device.mac_address_sub, + coordinator.device.mac_address, [d.key for d in descriptions], ) diff --git a/custom_components/gree_custom/switch.py b/custom_components/gree_custom/switch.py index 9855b1d..81044c9 100644 --- a/custom_components/gree_custom/switch.py +++ b/custom_components/gree_custom/switch.py @@ -215,7 +215,7 @@ async def async_setup_entry( _LOGGER.debug( "Adding Switch Entities for device '%s': %s", - coordinator.device.mac_address_sub, + coordinator.device.mac_address, [d.key for d in descriptions], ) From 9e79d2a6b5414fc93868834abd0984b7c5d9226e Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Fri, 27 Mar 2026 00:11:21 +0000 Subject: [PATCH 092/113] Fix mac split variable --- custom_components/gree_custom/aiogree/device.py | 4 +--- custom_components/gree_custom/manifest.json | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index 6144b4c..54d707b 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -76,9 +76,7 @@ def __init__( mac_addr = mac_addr.replace(":", "").replace("-", "").lower() if "@" in mac_addr: - self._mac_addr, self._mac_addr_controller = self._mac_addr_controller.split( - "@", 1 - ) + self._mac_addr, self._mac_addr_controller = mac_addr.split("@", 1) else: self._mac_addr = self._mac_addr_controller = mac_addr diff --git a/custom_components/gree_custom/manifest.json b/custom_components/gree_custom/manifest.json index 8008ac2..2f24189 100755 --- a/custom_components/gree_custom/manifest.json +++ b/custom_components/gree_custom/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "local_polling", "requirements": ["pycryptodome", "asyncio_dgram"], - "version": "4.0.0-alpha.86" + "version": "4.0.0-alpha.87" } From cde3856410db0651df2970fea33fc6f609c96871 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Fri, 27 Mar 2026 00:40:09 +0000 Subject: [PATCH 093/113] Improve device removal and use only device mac for coordinator --- custom_components/gree_custom/__init__.py | 38 ++++++++++--------- .../gree_custom/binary_sensor.py | 2 +- custom_components/gree_custom/climate.py | 2 +- custom_components/gree_custom/manifest.json | 2 +- custom_components/gree_custom/select.py | 2 +- custom_components/gree_custom/sensor.py | 2 +- custom_components/gree_custom/switch.py | 2 +- 7 files changed, 27 insertions(+), 23 deletions(-) diff --git a/custom_components/gree_custom/__init__.py b/custom_components/gree_custom/__init__.py index fc87ba5..911ed83 100755 --- a/custom_components/gree_custom/__init__.py +++ b/custom_components/gree_custom/__init__.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import Any, ConfigType from .aiogree.const import ( DEFAULT_CONNECTION_MAX_ATTEMPTS, @@ -119,8 +119,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool ) await device.bind_device() # TODO: Add scan interval to config - coordinators[mac] = GreeCoordinator(hass, entry, device) - await coordinators[mac].async_config_entry_first_refresh() + coordinators[device.mac_address] = GreeCoordinator(hass, entry, device) + await coordinators[device.mac_address].async_config_entry_first_refresh() _LOGGER.debug("Setup entry '%s': Bound to device %s", entry.entry_id, mac) except TimeoutError as err: _LOGGER.exception( @@ -150,12 +150,14 @@ async def async_remove_config_entry_device( """Remove a device from a config entry.""" # Find MAC address for this device (from identifiers) - identifiers = device_entry.identifiers - mac: str | None = None - for domain, identifier in identifiers: - if domain == DOMAIN: - mac = identifier - break + mac: str | None = next( + ( + identifier + for domain, identifier in device_entry.identifiers + if domain == DOMAIN + ), + None, + ) if mac is None: return False @@ -165,22 +167,24 @@ async def async_remove_config_entry_device( if not runtime_data: return False - data: dict = dict(config_entry.data) + await runtime_data.async_shutdown() + + data: dict[str, Any] = dict(config_entry.data) device_configs: list[dict] = data.get(CONF_DEVICES, []) - for dconf in list(device_configs): - if dconf.get(CONF_MAC, "") != mac: - continue + new_device_configs = [d for d in device_configs if d.get(CONF_MAC) != mac] - device_configs.remove(dconf) + if len(new_device_configs) == len(device_configs): + # Nothing to remove + return False - data[CONF_DEVICES] = device_configs + data[CONF_DEVICES] = new_device_configs device_registry = dr.async_get(hass) device_registry.async_remove_device(device_entry.id) - if device_configs: + if new_device_configs: # There are still other devices, update the entry - hass.config_entries.async_update_entry(config_entry, data=data) + await hass.config_entries.async_update_entry(config_entry, data=data) else: # No other devices, remove the entry await hass.config_entries.async_remove(config_entry.entry_id) diff --git a/custom_components/gree_custom/binary_sensor.py b/custom_components/gree_custom/binary_sensor.py index 7ac02fd..786e79f 100644 --- a/custom_components/gree_custom/binary_sensor.py +++ b/custom_components/gree_custom/binary_sensor.py @@ -70,7 +70,7 @@ async def async_setup_entry( entities: list[GreeBinarySensor] = [] for d in entry.data.get(CONF_DEVICES, []): - mac = d.get(CONF_MAC, "") + "@" + entry.data.get(CONF_MAC) + mac = d.get(CONF_MAC, "") coordinator: GreeCoordinator = entry.runtime_data[mac] if not coordinator: _LOGGER.error( diff --git a/custom_components/gree_custom/climate.py b/custom_components/gree_custom/climate.py index 986ecac..98e84d0 100644 --- a/custom_components/gree_custom/climate.py +++ b/custom_components/gree_custom/climate.py @@ -81,7 +81,7 @@ async def async_setup_entry( entities: list[GreeClimate] = [] for d in entry.data.get(CONF_DEVICES, []): - mac = d.get(CONF_MAC, "") + "@" + entry.data.get(CONF_MAC) + mac = d.get(CONF_MAC, "") coordinator: GreeCoordinator = entry.runtime_data[mac] if not coordinator: _LOGGER.error( diff --git a/custom_components/gree_custom/manifest.json b/custom_components/gree_custom/manifest.json index 2f24189..58d6fd7 100755 --- a/custom_components/gree_custom/manifest.json +++ b/custom_components/gree_custom/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "local_polling", "requirements": ["pycryptodome", "asyncio_dgram"], - "version": "4.0.0-alpha.87" + "version": "4.0.0-alpha.88" } diff --git a/custom_components/gree_custom/select.py b/custom_components/gree_custom/select.py index a60bdbf..8ebe48e 100644 --- a/custom_components/gree_custom/select.py +++ b/custom_components/gree_custom/select.py @@ -40,7 +40,7 @@ async def async_setup_entry( entities: list[GreeSelect] = [] for d in entry.data.get(CONF_DEVICES, []): - mac = d.get(CONF_MAC, "") + "@" + entry.data.get(CONF_MAC) + mac = d.get(CONF_MAC, "") coordinator: GreeCoordinator = entry.runtime_data[mac] if not coordinator: _LOGGER.error( diff --git a/custom_components/gree_custom/sensor.py b/custom_components/gree_custom/sensor.py index 058ae09..68e5067 100644 --- a/custom_components/gree_custom/sensor.py +++ b/custom_components/gree_custom/sensor.py @@ -41,7 +41,7 @@ async def async_setup_entry( entities: list[GreeSensor] = [] for d in entry.data.get(CONF_DEVICES, []): - mac = d.get(CONF_MAC, "") + "@" + entry.data.get(CONF_MAC) + mac = d.get(CONF_MAC, "") coordinator: GreeCoordinator = entry.runtime_data[mac] if not coordinator: _LOGGER.error( diff --git a/custom_components/gree_custom/switch.py b/custom_components/gree_custom/switch.py index 81044c9..87e93c1 100644 --- a/custom_components/gree_custom/switch.py +++ b/custom_components/gree_custom/switch.py @@ -172,7 +172,7 @@ async def async_setup_entry( entities: list[GreeSwitch] = [] for d in entry.data.get(CONF_DEVICES, []): - mac = d.get(CONF_MAC, "") + "@" + entry.data.get(CONF_MAC) + mac = d.get(CONF_MAC, "") coordinator: GreeCoordinator = entry.runtime_data[mac] if not coordinator: _LOGGER.error( From 28ee5eb5c16434d6e02b96dd6fdc66af0258a003 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Fri, 3 Apr 2026 13:12:01 +0100 Subject: [PATCH 094/113] Fix entity creation, availability and restore configs --- .../gree_custom/binary_sensor.py | 19 ++- custom_components/gree_custom/climate.py | 46 ++++--- custom_components/gree_custom/config_flow.py | 11 +- custom_components/gree_custom/const.py | 3 +- custom_components/gree_custom/entity.py | 31 +++-- custom_components/gree_custom/manifest.json | 2 +- custom_components/gree_custom/select.py | 14 +- custom_components/gree_custom/sensor.py | 20 +-- custom_components/gree_custom/switch.py | 127 +++++++----------- 9 files changed, 126 insertions(+), 147 deletions(-) diff --git a/custom_components/gree_custom/binary_sensor.py b/custom_components/gree_custom/binary_sensor.py index 786e79f..2f4278d 100644 --- a/custom_components/gree_custom/binary_sensor.py +++ b/custom_components/gree_custom/binary_sensor.py @@ -22,6 +22,7 @@ CONF_DISABLE_AVAILABLE_CHECK, CONF_FEATURES, CONF_TO_PROP_FEATURE_MAP, + DEFAULT_DISABLE_AVAILABLE_CHECK, DEFAULT_SUPPORTED_FEATURES, GATTR_FAULTS, ) @@ -35,6 +36,7 @@ class GreeBinarySensorDescription(GreeEntityDescription, BinarySensorEntityDescription): """Description of a Gree binary sensor.""" + additional_available_func = lambda _: True value_func: Callable[[GreeDevice], bool | None] @@ -44,9 +46,6 @@ class GreeBinarySensorDescription(GreeEntityDescription, BinarySensorEntityDescr translation_key=GATTR_FAULTS, device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, - available_func=lambda device: ( - device.available and device.supports_property(GreeProp.FAULT) - ), value_func=lambda device: device.has_hvac_error, entity_registry_enabled_default=True, entity_registry_visible_default=True, @@ -77,6 +76,7 @@ async def async_setup_entry( "Cannot create Gree Binary Sensors. No coordinator found for device '%s'", mac, ) + continue descriptions: list[GreeBinarySensorDescription] = [] @@ -85,13 +85,11 @@ async def async_setup_entry( if d.get(CONF_FEATURES, None) is None: _LOGGER.warning("Undefined supported features") - conf_supported_features = DEFAULT_SUPPORTED_FEATURES - else: - conf_supported_features = d.get(CONF_FEATURES, []) - # Double check features with device support, just in case + conf_supported_features = d.get(CONF_FEATURES, DEFAULT_SUPPORTED_FEATURES) + + # Check features with device support before addinig entities for feature in conf_supported_features: - # 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) @@ -116,8 +114,9 @@ async def async_setup_entry( description, coordinator, check_availability=( - entry.data[CONF_ADVANCED].get( - CONF_DISABLE_AVAILABLE_CHECK, False + not entry.data[CONF_ADVANCED].get( + CONF_DISABLE_AVAILABLE_CHECK, + DEFAULT_DISABLE_AVAILABLE_CHECK, ) ), ) diff --git a/custom_components/gree_custom/climate.py b/custom_components/gree_custom/climate.py index 98e84d0..8a1cb50 100644 --- a/custom_components/gree_custom/climate.py +++ b/custom_components/gree_custom/climate.py @@ -51,8 +51,10 @@ 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, @@ -71,6 +73,24 @@ GATTR_CLIMATE = "hvac" +@dataclass(frozen=True, kw_only=True) +class GreeClimateDescription(GreeEntityDescription, ClimateEntityDescription): + """Description of a Gree Climate entity.""" + + additional_available_func = lambda _: True + 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, entry: GreeConfigEntry, @@ -88,6 +108,7 @@ async def async_setup_entry( "Cannot create Gree Climate. No coordinator found for device '%s'", mac, ) + continue hvac_modes: list[HVACMode] = [ HVACMode[mode.upper()] @@ -130,7 +151,6 @@ async def async_setup_entry( GreeClimateDescription( key=GATTR_CLIMATE, translation_key=GATTR_CLIMATE, - available_func=lambda device: device.available, ), coordinator, hvac_modes, @@ -138,10 +158,11 @@ async def async_setup_entry( swing_modes, swing_horizontal_modes, temperature_step=d.get(CONF_TEMPERATURE_STEP, DEFAULT_TARGET_TEMP_STEP), - restore_state=(d.get(CONF_RESTORE_STATES, True)), + restore_state=d.get(CONF_RESTORE_STATES, DEFAULT_RESTORE_STATES), check_availability=( - entry.data[CONF_ADVANCED].get(CONF_DISABLE_AVAILABLE_CHECK, False) - is False + not entry.data[CONF_ADVANCED].get( + CONF_DISABLE_AVAILABLE_CHECK, DEFAULT_DISABLE_AVAILABLE_CHECK + ) ), external_temperature_sensor_id=d.get(ATTR_EXTERNAL_TEMPERATURE_SENSOR), external_humidity_sensor_id=d.get(ATTR_EXTERNAL_HUMIDITY_SENSOR), @@ -151,23 +172,6 @@ async def async_setup_entry( async_add_entities(entities) -@dataclass(frozen=True, kw_only=True) -class GreeClimateDescription(GreeEntityDescription, ClimateEntityDescription): - """Description of a Gree Climate entity.""" - - 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 - - class GreeClimate(GreeEntity, ClimateEntity, RestoreEntity): # pyright: ignore[reportIncompatibleVariableOverride] """Climate Entity.""" diff --git a/custom_components/gree_custom/config_flow.py b/custom_components/gree_custom/config_flow.py index 077d56e..ddc3c79 100644 --- a/custom_components/gree_custom/config_flow.py +++ b/custom_components/gree_custom/config_flow.py @@ -56,8 +56,10 @@ CONF_SWING_MODES, CONF_TEMPERATURE_STEP, CONF_UID, + DEFAULT_DISABLE_AVAILABLE_CHECK, DEFAULT_FAN_MODES, DEFAULT_HVAC_MODES, + DEFAULT_RESTORE_STATES, DEFAULT_SWING_HORIZONTAL_MODES, DEFAULT_SWING_MODES, DEFAULT_TARGET_TEMP_STEP, @@ -133,7 +135,10 @@ def build_main_schema(data: Mapping | None) -> vol.Schema: CONF_DISABLE_AVAILABLE_CHECK, default=False if data is None - else data.get(CONF_DISABLE_AVAILABLE_CHECK, False), + else data.get( + CONF_DISABLE_AVAILABLE_CHECK, + DEFAULT_DISABLE_AVAILABLE_CHECK, + ), ): cv.boolean, vol.Required( CONF_MAX_ONLINE_ATTEMPTS, @@ -356,7 +361,9 @@ def build_options_schema( ), vol.Required( CONF_RESTORE_STATES, - default=True if data is None else data.get(CONF_RESTORE_STATES, True), + default=True + if data is None + else data.get(CONF_RESTORE_STATES, DEFAULT_RESTORE_STATES), ): cv.boolean, } ) diff --git a/custom_components/gree_custom/const.py b/custom_components/gree_custom/const.py index 6623568..63f94fd 100755 --- a/custom_components/gree_custom/const.py +++ b/custom_components/gree_custom/const.py @@ -32,7 +32,8 @@ DEFAULT_TARGET_TEMP_STEP = 1 DEFAULT_ENCRYPTION_VERSION = None - +DEFAULT_DISABLE_AVAILABLE_CHECK = False +DEFAULT_RESTORE_STATES = True # OPTIONAL FEATURES/MODES # use the device beeper on commands diff --git a/custom_components/gree_custom/entity.py b/custom_components/gree_custom/entity.py index 449c9fa..f8c048a 100755 --- a/custom_components/gree_custom/entity.py +++ b/custom_components/gree_custom/entity.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, field from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.entity import DeviceInfo, EntityDescription @@ -62,13 +62,23 @@ def device_info(self) -> DeviceInfo: @property def available(self): # pyright: ignore[reportIncompatibleVariableOverride] - """Return True if entity is available.""" - if self.check_availability: - return ( - self.coordinator.last_update_success - and self.entity_description.available_func(self.device) - ) - return True + """Return True if entity is available. + + If entity has 'check_availability' enabled this uses the device available state + Otherwise, it only uses the 'additional_available_func' + """ + + custom_available = self.entity_description.additional_available_func( + self.device + ) + + if not self.check_availability: + return custom_available + + coordinator_ok = self.coordinator.last_update_success + device_ok = self.device.available + + return custom_available and coordinator_ok and device_ok @dataclass(frozen=True, kw_only=True) @@ -80,4 +90,7 @@ class GreeEntityDescription(EntityDescription): # This will be overridden by entry configuration # restore_state: bool = True - available_func: Callable[[GreeDevice], bool] + # Use this to conditionally block the entity availability independent of the device availability + additional_available_func: Callable[[GreeDevice], bool] = field( + default=lambda _: True + ) diff --git a/custom_components/gree_custom/manifest.json b/custom_components/gree_custom/manifest.json index 58d6fd7..8dabf97 100755 --- a/custom_components/gree_custom/manifest.json +++ b/custom_components/gree_custom/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "local_polling", "requirements": ["pycryptodome", "asyncio_dgram"], - "version": "4.0.0-alpha.88" + "version": "4.0.0-alpha.89" } diff --git a/custom_components/gree_custom/select.py b/custom_components/gree_custom/select.py index 8ebe48e..0c6f3fb 100644 --- a/custom_components/gree_custom/select.py +++ b/custom_components/gree_custom/select.py @@ -20,6 +20,8 @@ CONF_DEVICES, CONF_DISABLE_AVAILABLE_CHECK, CONF_RESTORE_STATES, + DEFAULT_DISABLE_AVAILABLE_CHECK, + DEFAULT_RESTORE_STATES, GATTR_TEMP_UNITS, ) from .coordinator import GreeConfigEntry, GreeCoordinator @@ -47,6 +49,7 @@ async def async_setup_entry( "Cannot create Gree Selectors. No coordinator found for device '%s'", mac, ) + continue descriptions: list[GreeSelectDescription] = [] @@ -57,10 +60,6 @@ async def async_setup_entry( translation_key=GATTR_TEMP_UNITS, entity_category=EntityCategory.CONFIG, options=[f"º{member.name}" for member in TemperatureUnits], - available_func=lambda device: ( - device.available - and device.supports_property(GreeProp.TARGET_TEMPERATURE_UNIT) - ), value_func=lambda device: f"º{device.target_temperature_unit.name}", set_func=lambda device, value: device.set_target_temperature_unit( TemperatureUnits[value.replace("º", "")] @@ -79,9 +78,11 @@ async def async_setup_entry( GreeSelect( description, coordinator, - d.get(CONF_RESTORE_STATES, True), + d.get(CONF_RESTORE_STATES, DEFAULT_RESTORE_STATES), check_availability=( - entry.data[CONF_ADVANCED].get(CONF_DISABLE_AVAILABLE_CHECK, False) + not entry.data[CONF_ADVANCED].get( + CONF_DISABLE_AVAILABLE_CHECK, DEFAULT_DISABLE_AVAILABLE_CHECK + ) ), ) for description in descriptions @@ -94,6 +95,7 @@ async def async_setup_entry( class GreeSelectDescription(GreeEntityDescription, SelectEntityDescription, Generic[T]): """Description of a Gree switch.""" + additional_available_func = lambda _: True device_class = None entity_category = None entity_registry_enabled_default = True diff --git a/custom_components/gree_custom/sensor.py b/custom_components/gree_custom/sensor.py index 68e5067..8c728dd 100644 --- a/custom_components/gree_custom/sensor.py +++ b/custom_components/gree_custom/sensor.py @@ -21,6 +21,7 @@ CONF_ADVANCED, CONF_DEVICES, CONF_DISABLE_AVAILABLE_CHECK, + DEFAULT_DISABLE_AVAILABLE_CHECK, GATTR_HUMIDITY, GATTR_INDOOR_TEMPERATURE, GATTR_OUTDOOR_TEMPERATURE, @@ -48,6 +49,7 @@ async def async_setup_entry( "Cannot create Gree Sensors. No coordinator found for device '%s'", mac, ) + continue descriptions: list[GreeSensorDescription] = [] if coordinator.device.supports_property(GreeProp.SENSOR_TEMPERATURE): @@ -60,10 +62,6 @@ async def async_setup_entry( native_unit_of_measurement=UnitOfTemperature.CELSIUS, suggested_display_precision=0, value_func=lambda device: device.indoors_temperature_c, - available_func=lambda device: ( - device.available - and device.supports_property(GreeProp.SENSOR_TEMPERATURE) - ), ) ) if coordinator.device.supports_property(GreeProp.SENSOR_OUTSIDE_TEMPERATURE): @@ -76,12 +74,6 @@ async def async_setup_entry( native_unit_of_measurement=UnitOfTemperature.CELSIUS, suggested_display_precision=0, value_func=lambda device: device.outdoors_temperature_c, - available_func=lambda device: ( - device.available - and device.supports_property( - GreeProp.SENSOR_OUTSIDE_TEMPERATURE - ) - ), ) ) if coordinator.device.supports_property(GreeProp.SENSOR_HUMIDITY): @@ -94,10 +86,6 @@ async def async_setup_entry( native_unit_of_measurement=PERCENTAGE, suggested_display_precision=0, value_func=lambda device: device.humidity, - available_func=lambda device: ( - device.available - and device.supports_property(GreeProp.SENSOR_HUMIDITY) - ), ) ) @@ -113,7 +101,9 @@ async def async_setup_entry( coordinator, restore_state=False, check_availability=( - entry.data[CONF_ADVANCED].get(CONF_DISABLE_AVAILABLE_CHECK, False) + not entry.data[CONF_ADVANCED].get( + CONF_DISABLE_AVAILABLE_CHECK, DEFAULT_DISABLE_AVAILABLE_CHECK + ) ), ) for description in descriptions diff --git a/custom_components/gree_custom/switch.py b/custom_components/gree_custom/switch.py index 87e93c1..d6ed522 100644 --- a/custom_components/gree_custom/switch.py +++ b/custom_components/gree_custom/switch.py @@ -27,6 +27,8 @@ 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, @@ -59,19 +61,14 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): GreeSwitchDescription( key=GATTR_FEAT_FRESH_AIR, translation_key=GATTR_FEAT_FRESH_AIR, - available_func=lambda device: ( - device.available and device.supports_property(GreeProp.FEAT_FRESH_AIR) - ), value_func=lambda device, _: device.feature_fresh_air, set_func=lambda device, _, value: device.set_feature_fresh_air(value), ), GreeSwitchDescription( key=GATTR_FEAT_XFAN, translation_key=GATTR_FEAT_XFAN, - available_func=lambda device: ( - device.available - and device.supports_property(GreeProp.FEAT_XFAN) - and device.operation_mode in [OperationMode.cool, OperationMode.dry] + additional_available_func=lambda device: ( + device.operation_mode in [OperationMode.cool, OperationMode.dry] ), value_func=lambda device, _: device.feature_x_fan, set_func=lambda device, _, value: device.set_feature_xfan(value), @@ -79,11 +76,9 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): GreeSwitchDescription( key=GATTR_FEAT_SLEEP_MODE, translation_key=GATTR_FEAT_SLEEP_MODE, - available_func=( + additional_available_func=( lambda device: ( - device.available - and device.supports_property(GreeProp.FEAT_SLEEP_MODE) - and device.operation_mode + device.operation_mode in [OperationMode.cool, OperationMode.dry, OperationMode.heat] ) ), @@ -93,46 +88,30 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): GreeSwitchDescription( key=GATTR_FEAT_SMART_HEAT_8C, translation_key=GATTR_FEAT_SMART_HEAT_8C, - available_func=lambda device: ( - device.available and device.supports_property(GreeProp.FEAT_SMART_HEAT_8C) - ), value_func=lambda device, _: device.feature_smart_heat, set_func=lambda device, _, value: device.set_feature_smart_heat(value), ), GreeSwitchDescription( key=GATTR_FEAT_HEALTH, translation_key=GATTR_FEAT_HEALTH, - available_func=lambda device: ( - device.available and device.supports_property(GreeProp.FEAT_HEALTH) - ), value_func=lambda device, _: device.feature_health, set_func=lambda device, _, value: device.set_feature_health(value), ), GreeSwitchDescription( key=GATTR_ANTI_DIRECT_BLOW, translation_key=GATTR_ANTI_DIRECT_BLOW, - available_func=lambda device: ( - device.available - and device.supports_property(GreeProp.FEAT_ANTI_DIRECT_BLOW) - ), value_func=lambda device, _: device.feature_anti_direct_blow, set_func=lambda device, _, value: device.set_feature_anti_direct_blow(value), ), GreeSwitchDescription( key=GATTR_FEAT_ENERGY_SAVING, translation_key=GATTR_FEAT_ENERGY_SAVING, - available_func=lambda device: ( - device.available and device.supports_property(GreeProp.FEAT_ENERGY_SAVING) - ), value_func=lambda device, _: device.feature_energy_saving, set_func=lambda device, _, value: device.set_feature_energy_saving(value), ), GreeSwitchDescription( key=GATTR_FEAT_LIGHT, translation_key=GATTR_FEAT_LIGHT, - available_func=lambda device: ( - device.available and device.supports_property(GreeProp.FEAT_LIGHT) - ), value_func=lambda device, _: device.feature_light, set_func=lambda device, _, value: device.set_feature_light(value), entity_category=EntityCategory.CONFIG, @@ -140,12 +119,7 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): GreeSwitchDescription( key=GATTR_FEAT_SENSOR_LIGHT, translation_key=GATTR_FEAT_SENSOR_LIGHT, - available_func=lambda device: ( - device.available - and device.supports_property(GreeProp.FEAT_SENSOR_LIGHT) - and device.supports_property(GreeProp.FEAT_LIGHT) - and device.feature_light - ), + additional_available_func=lambda device: device.feature_light, value_func=lambda device, _: device.feature_light_sensor, set_func=lambda device, _, value: device.set_feature_light_sensor(value), entity_category=EntityCategory.CONFIG, @@ -153,7 +127,6 @@ class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription): GreeSwitchDescription( key=GATTR_BEEPER, translation_key=GATTR_BEEPER, - available_func=lambda device: device.available, value_func=lambda device, _: device.beeper, set_func=lambda device, _, value: device.set_beeper(value), entity_category=EntityCategory.CONFIG, @@ -161,6 +134,24 @@ 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, @@ -179,19 +170,23 @@ async def async_setup_entry( "Cannot create Gree Switches. No coordinator found for device '%s'", mac, ) + continue descriptions: list[GreeSwitchDescription] = [] - conf_supported_features: list[str] = [] + 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 + ) + supported_features: list[str] = [] - if d.get(CONF_FEATURES, None) is None: + if not d.get(CONF_FEATURES): _LOGGER.warning("Undefined supported features") - conf_supported_features = DEFAULT_SUPPORTED_FEATURES - else: - conf_supported_features = d.get(CONF_FEATURES, []) - # Double check features with device support, just in case + 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( @@ -225,14 +220,12 @@ async def async_setup_entry( description, coordinator, restore_state=( - d.get(CONF_RESTORE_STATES, True) + conf_restore_states if description.key != GATTR_BEEPER # Always restore beeper else True ), check_availability=( - entry.data[CONF_ADVANCED].get( - CONF_DISABLE_AVAILABLE_CHECK, False - ) + conf_check_availability if description.key != GATTR_BEEPER # Beeper is always available else False ), @@ -241,55 +234,25 @@ async def async_setup_entry( ] ) + # Add Auto Light if device supports Light if GATTR_FEAT_LIGHT in supported_features: entities.append( GreeSwitch( - GreeSwitchDescription( - key=ATTR_AUTO_LIGHT, - translation_key=ATTR_AUTO_LIGHT, - available_func=( - lambda device: ( - device.available - and device.supports_property(GreeProp.FEAT_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_LIGHT, coordinator, - restore_state=True, - check_availability=True, + 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( - GreeSwitchDescription( - key=ATTR_AUTO_XFAN, - translation_key=ATTR_AUTO_XFAN, - available_func=lambda device: ( - device.available - and device.supports_property(GreeProp.FEAT_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, - ), + SWITCH_TYPE_AUTO_XFAN, coordinator, - restore_state=True, - check_availability=True, + restore_state=True, # Always restore Auto XFan + check_availability=conf_check_availability, ) ) From a5722f629c3520286e372abb10a534942dfc007b Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Fri, 3 Apr 2026 15:25:38 +0100 Subject: [PATCH 095/113] Add device polling rate to the config --- custom_components/gree_custom/__init__.py | 20 +++++++++++++++---- custom_components/gree_custom/config_flow.py | 18 +++++++++++++++-- custom_components/gree_custom/const.py | 2 ++ custom_components/gree_custom/coordinator.py | 2 +- custom_components/gree_custom/manifest.json | 2 +- .../gree_custom/translations/en.json | 5 +++-- .../gree_custom/translations/pt.json | 6 ++++-- 7 files changed, 43 insertions(+), 12 deletions(-) diff --git a/custom_components/gree_custom/__init__.py b/custom_components/gree_custom/__init__.py index 911ed83..80b6fd0 100755 --- a/custom_components/gree_custom/__init__.py +++ b/custom_components/gree_custom/__init__.py @@ -3,12 +3,18 @@ from __future__ import annotations # Standard library imports -import asyncio import logging from homeassistant.components.diagnostics.util import async_redact_data from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT, CONF_TIMEOUT, Platform +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PORT, + CONF_SCAN_INTERVAL, + CONF_TIMEOUT, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr @@ -33,6 +39,7 @@ CONF_MAX_ONLINE_ATTEMPTS, CONF_UID, DEFAULT_ENCRYPTION_VERSION, + DEFAULT_SCAN_INTERVAL, DOMAIN, ) @@ -118,15 +125,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool conf.get(CONF_HOST), ) await device.bind_device() - # TODO: Add scan interval to config - coordinators[device.mac_address] = GreeCoordinator(hass, entry, device) + + coordinators[device.mac_address] = GreeCoordinator( + hass, entry, device, d.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + ) await coordinators[device.mac_address].async_config_entry_first_refresh() + _LOGGER.debug("Setup entry '%s': Bound to device %s", entry.entry_id, mac) + except TimeoutError as err: _LOGGER.exception( "Setup entry '%s': Conection to %s timed out", entry.entry_id, mac ) raise ConfigEntryNotReady from err + except GreeBindingError as err: _LOGGER.exception( "Setup entry '%s': Failed to bind to device %s", entry.entry_id, mac diff --git a/custom_components/gree_custom/config_flow.py b/custom_components/gree_custom/config_flow.py index ddc3c79..c057ac9 100644 --- a/custom_components/gree_custom/config_flow.py +++ b/custom_components/gree_custom/config_flow.py @@ -11,7 +11,13 @@ from homeassistant import config_entries from homeassistant.components import network from homeassistant.components.diagnostics.util import async_redact_data -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT, CONF_TIMEOUT +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PORT, + CONF_SCAN_INTERVAL, + CONF_TIMEOUT, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import section from homeassistant.helpers import config_validation as cv @@ -60,6 +66,7 @@ DEFAULT_FAN_MODES, DEFAULT_HVAC_MODES, DEFAULT_RESTORE_STATES, + DEFAULT_SCAN_INTERVAL, DEFAULT_SWING_HORIZONTAL_MODES, DEFAULT_SWING_MODES, DEFAULT_TARGET_TEMP_STEP, @@ -77,6 +84,7 @@ GATTR_FEAT_SMART_HEAT_8C, GATTR_FEAT_TURBO, GATTR_FEAT_XFAN, + MIN_SCAN_INTERVAL, ) from .coordinator import GreeConfigEntry @@ -108,7 +116,7 @@ def build_main_schema(data: Mapping | None) -> vol.Schema: else data[CONF_ADVANCED].get( CONF_PORT, DEFAULT_DEVICE_PORT ), - ): int, + ): cv.port, vol.Required( CONF_ENCRYPTION_VERSION, default="Auto-Detect" @@ -365,6 +373,12 @@ def build_options_schema( if data is None else data.get(CONF_RESTORE_STATES, DEFAULT_RESTORE_STATES), ): cv.boolean, + vol.Required( + CONF_SCAN_INTERVAL, + default=DEFAULT_SCAN_INTERVAL + if data is None + else data.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL), + ): vol.All(vol.Coerce(int), vol.Range(min=MIN_SCAN_INTERVAL)), } ) return vol.Schema(schema) diff --git a/custom_components/gree_custom/const.py b/custom_components/gree_custom/const.py index 63f94fd..9136064 100755 --- a/custom_components/gree_custom/const.py +++ b/custom_components/gree_custom/const.py @@ -34,6 +34,8 @@ DEFAULT_ENCRYPTION_VERSION = None DEFAULT_DISABLE_AVAILABLE_CHECK = False DEFAULT_RESTORE_STATES = True +DEFAULT_SCAN_INTERVAL = 30 +MIN_SCAN_INTERVAL = 5 # OPTIONAL FEATURES/MODES # use the device beeper on commands diff --git a/custom_components/gree_custom/coordinator.py b/custom_components/gree_custom/coordinator.py index d3e3030..1090451 100644 --- a/custom_components/gree_custom/coordinator.py +++ b/custom_components/gree_custom/coordinator.py @@ -28,7 +28,7 @@ def __init__( hass: HomeAssistant, config_entry: GreeConfigEntry, device: GreeDevice, - scan_interval: int = 30, + scan_interval: int, ) -> None: """Initialize coordinator.""" super().__init__( diff --git a/custom_components/gree_custom/manifest.json b/custom_components/gree_custom/manifest.json index 8dabf97..4c4a938 100755 --- a/custom_components/gree_custom/manifest.json +++ b/custom_components/gree_custom/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "local_polling", "requirements": ["pycryptodome", "asyncio_dgram"], - "version": "4.0.0-alpha.89" + "version": "4.0.0-alpha.90" } diff --git a/custom_components/gree_custom/translations/en.json b/custom_components/gree_custom/translations/en.json index 2edae66..6eece08 100755 --- a/custom_components/gree_custom/translations/en.json +++ b/custom_components/gree_custom/translations/en.json @@ -64,13 +64,14 @@ "external_humidity_sensor": "External Humidity Sensor", "restore_states": "Restore Entities", "target_temp_step": "Temperature Step", - "faults": "Fault Detection" + "scan_interval": "Scan Interval" }, "data_description": { "external_temperature_sensor": "If set will replace the built-in HVAC temperature sensor", "external_humidity_sensor": "If set will replace the built-in HVAC humidity sensor", "restore_states": "If set to true, when the integration is started the device will be overriden by previous states of the integration instead of using the current device state.", - "target_temp_step": "Sets the increment step for adjusting the target temperature. Fahrenheit degrees are clamped to the nearest integer." + "target_temp_step": "Sets the increment step for adjusting the target temperature. Fahrenheit degrees are clamped to the nearest integer.", + "scan_interval": "Frequency of the device data polling (in seconds)" } }, "reconfigure": { diff --git a/custom_components/gree_custom/translations/pt.json b/custom_components/gree_custom/translations/pt.json index fe77106..88b1b49 100755 --- a/custom_components/gree_custom/translations/pt.json +++ b/custom_components/gree_custom/translations/pt.json @@ -63,13 +63,15 @@ "external_temperature_sensor": "Sensor de Temperatura", "external_humidity_sensor": "Sensor de Humidade", "restore_states": "Restaurar Entidades", - "target_temp_step": "Incremento de Temperatura" + "target_temp_step": "Incremento de Temperatura", + "scan_interval": "Taxa de Atualização" }, "data_description": { "external_temperature_sensor": "Se definido, substitui o sensor integrado de temperatura interior do dispositivo", "external_humidity_sensor": "Se definido, substitui o sensor integrado de humidade interior do dispositivo", "restore_states": "Se ativo, quando a integração é iniciada, o estado do dispositivo será reposto para o último estado observado na integração.", - "target_temp_step": "Define o incremento da temperatura quando esta é ajustada. Graus Fahrenheit são arredondados para às unidades." + "target_temp_step": "Define o incremento da temperatura quando esta é ajustada. Graus Fahrenheit são arredondados para às unidades.", + "scan_interval": "Frequência de atualização dos dados do dispositivo (em segundos)" } }, "reconfigure": { From bd11d65c0e55f37875ba70aa7b4995635ae75f48 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Fri, 3 Apr 2026 15:37:21 +0100 Subject: [PATCH 096/113] Align with recommended HACS actions --- .github/workflows/hassfest.yaml | 14 -------------- .github/workflows/validate.yaml | 28 ++++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 14 deletions(-) delete mode 100644 .github/workflows/hassfest.yaml create mode 100644 .github/workflows/validate.yaml diff --git a/.github/workflows/hassfest.yaml b/.github/workflows/hassfest.yaml deleted file mode 100644 index ec7fcd6..0000000 --- a/.github/workflows/hassfest.yaml +++ /dev/null @@ -1,14 +0,0 @@ -name: Validate with hassfest - -on: - push: - pull_request: - schedule: - - cron: "0 0 * * *" - -jobs: - validate: - runs-on: "ubuntu-latest" - steps: - - uses: "actions/checkout@v6" - - uses: home-assistant/actions/hassfest@master diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml new file mode 100644 index 0000000..4138f84 --- /dev/null +++ b/.github/workflows/validate.yaml @@ -0,0 +1,28 @@ +name: Validate + +on: + workflow_dispatch: + push: + pull_request: + schedule: + - cron: "0 0 * * *" + +jobs: + hassfest: # https://developers.home-assistant.io/blog/2020/04/16/hassfest/ + name: Hassfest validation + runs-on: ubuntu-latest + steps: + - name: Checkout the repository + uses: "actions/checkout@v6" + + - name: Run hassfest validation + uses: home-assistant/actions/hassfest@master + + hacs: # https://github.com/hacs/action + name: HACS validation + runs-on: ubuntu-latest + steps: + - name: Run HACS validation + uses: hacs/action@22.5.0 + with: + category: integration \ No newline at end of file From dc3f7c39dc5cd3c3f413615dbe13ebdd5ad7ecf1 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Fri, 3 Apr 2026 15:55:01 +0100 Subject: [PATCH 097/113] Add hacs.json and required manifest fields --- README.md | 2 +- custom_components/gree_custom/manifest.json | 1 + hacs.json | 4 ++++ 3 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 hacs.json diff --git a/README.md b/README.md index 2b713f5..fb9ba7f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ [![HACS](https://img.shields.io/badge/HACS-Default-orange.svg)](https://hacs.xyz) -[![Home Assistant](https://img.shields.io/badge/Compatible-Home_Assistant_2026.3.1-blue.svg)](https://www.home-assistant.io) +[![Home Assistant](https://img.shields.io/badge/Compatible-Home_Assistant_2026.4-blue.svg)](https://www.home-assistant.io) # HomeAssistant-GreeClimateComponent diff --git a/custom_components/gree_custom/manifest.json b/custom_components/gree_custom/manifest.json index 4c4a938..d80890e 100755 --- a/custom_components/gree_custom/manifest.json +++ b/custom_components/gree_custom/manifest.json @@ -7,6 +7,7 @@ "documentation": "https://github.com/RobHofmann/HomeAssistant-GreeClimateComponent", "integration_type": "hub", "iot_class": "local_polling", + "issue_tracker": "https://github.com/RobHofmann/HomeAssistant-GreeClimateComponent/issues", "requirements": ["pycryptodome", "asyncio_dgram"], "version": "4.0.0-alpha.90" } diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..76ed70f --- /dev/null +++ b/hacs.json @@ -0,0 +1,4 @@ +{ + "name": "Gree A/C", + "homeassistant": "2026.1" +} \ No newline at end of file From 0f37e8f6265f99fe8131948c47bfdc4161e025ff Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Fri, 3 Apr 2026 15:57:47 +0100 Subject: [PATCH 098/113] Only validate master branch --- .github/workflows/validate.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml index 4138f84..07c86f6 100644 --- a/.github/workflows/validate.yaml +++ b/.github/workflows/validate.yaml @@ -3,7 +3,11 @@ name: Validate on: workflow_dispatch: push: + branches: + - master pull_request: + branches: + - master schedule: - cron: "0 0 * * *" From d66e08d20b1d9c688651b892148b7f457885d9f9 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Fri, 3 Apr 2026 16:58:01 +0100 Subject: [PATCH 099/113] Update Readme --- README.md | 32 +++++++++++++++++--------------- manual-configuration.yaml | 6 ++++-- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index fb9ba7f..66d4cd3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ [![HACS](https://img.shields.io/badge/HACS-Default-orange.svg)](https://hacs.xyz) -[![Home Assistant](https://img.shields.io/badge/Compatible-Home_Assistant_2026.4-blue.svg)](https://www.home-assistant.io) +[![Home Assistant](https://img.shields.io/badge/Compatible-Home_Assistant_2026.4+-blue.svg)](https://www.home-assistant.io) # HomeAssistant-GreeClimateComponent @@ -129,29 +129,31 @@ If your AC unit has a built-in room humidity sensor, it will be automatically de ## Available Switches and Controls -The integration exposes various entities to configure additional features of your Gree AC unit. All entities are created by default when the integration is set up, but their availability depends on the current HVAC mode and status. Entity availability may also vary depending on your specific Gree AC model and firmware version. These controls allow you to toggle special modes and adjust settings: +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: -### Basic Control Switches -- **X-Fan**: Enables or disables the X-Fan mode for extra drying when turning off -- **Lights**: Controls the display lights on the air conditioner unit -- **Health**: Enables or disables the Health mode for air ionization and purification -- **Beeper**: Controls the beeper sounds from the air conditioner unit. When enabled, the unit will make sounds for button presses and status changes +### Feature Switches -### Energy and Comfort Switches +- **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 -- **8°C Heat**: Enables or disables the 8°C heating mode for frost protection. Only available in heating mode +- **Smart 8°C Heat**: Enables or disables the 8°C heating mode for frost protection. Only available in heating mode - **Sleep**: Enables or disables the sleep mode for comfortable overnight operation. Only available in cooling or heating mode -- **Air**: Enables or disables the fresh air circulation mode - -### Advanced Control Switches +- **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 -- **Light Sensor**: Enables or disables light sensor for automatic brightness. Requires lights to be enabled + ### Configuration Controls -- **Auto X-Fan**: Automatically controls the X-Fan mode based on HVAC operations. When enabled, X-Fan will automatically turn on in cooling and dry modes. *Note: This is an integration feature, not an actual AC unit state* + +- **Beeper**: Controls the beeper sounds from the air conditioner unit. When enabled, the unit will make sounds for button presses and status changes +- **Lights**: Controls the display lights on the air conditioner unit - **Auto Light**: Automatically controls the display lights based on HVAC operations. When enabled, lights will turn on/off with the AC unit. *Note: This is an integration feature, not an actual AC unit state* +- **Light Sensor**: Enables or disables light sensor for automatic brightness. Requires lights to be enabled +- **Auto X-Fan**: Automatically controls the X-Fan mode based on HVAC operations. When enabled, X-Fan will automatically turn on in cooling and dry modes. *Note: This is an integration feature, not an actual AC unit state* - **Temperature Step**: Sets the increment step for adjusting the target temperature. This allows you to configure how much the temperature changes when using the up/down controls in Home Assistant -- **External Temperature Sensor**: Select a temperature sensor entity to use instead of the built-in AC sensor. Choose 'None' to use the built-in sensor. This is useful if you have a more accurate room temperature sensor that you want the AC to use for temperature readings + +## Diagnostics + +- **Fault Detection**: Sensor that shows if there is a problem with the device operation ## Credits diff --git a/manual-configuration.yaml b/manual-configuration.yaml index 3adc0f3..3d5ca90 100644 --- a/manual-configuration.yaml +++ b/manual-configuration.yaml @@ -11,7 +11,7 @@ # MAC address Format can be XX:XX:XX:XX:XX:XX, XX-XX-XX-XX-XX-XX, xxxxxxxxxxxx # # For VRF units the MAC is usually xxxxxxxxxxxx@yyyyyyyyyyyy where -# the first part (x) is the sub device MAC and the second (y) the main device MAC +# the first part (x) is the device MAC and the second (y) the main device MAC which controls the device # In this case it is preferred to place the main device MAC on the top config and # the sub device MAC on the device list config @@ -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"] | default = all options + features: # Supported device features | list | options = ["beeper", "air", "xfan", "sleep", "eightdegheat", "lights", "health", "anti_direct_blow", "powersave", "light_sensor", "faults"] | default = all options - "beeper" - "air" - "xfan" @@ -78,10 +78,12 @@ gree_custom: - "anti_direct_blow" - "powersave" - "light_sensor" + - "faults" 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" restore_states: true # Wether to restore the last HA state to device when HA starts | bool | default = true + scan_interval: 30 # Device polling rate | int > 5 | default = 30 # Example for multiple AC units: From f527344bbd79448e0f35f3e2ef1880435d9020dd Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Fri, 3 Apr 2026 17:32:37 +0100 Subject: [PATCH 100/113] Add a release workflow This workflow uses the version from the manifest to automatically create a tag and a release. Also prevents two consecutive releases with the same version --- .github/workflows/manual_release.yaml | 77 +++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 .github/workflows/manual_release.yaml diff --git a/.github/workflows/manual_release.yaml b/.github/workflows/manual_release.yaml new file mode 100644 index 0000000..32ab4df --- /dev/null +++ b/.github/workflows/manual_release.yaml @@ -0,0 +1,77 @@ +name: Manual Release + +on: + workflow_dispatch: + inputs: + summary: + description: "Optional summary (markdown supported)" + required: false + default: "" + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Get version from manifest + id: get_version + run: | + VERSION=$(jq -r .version custom_components/gree_custom/manifest.json) + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Get latest tag + id: latest_tag + run: | + git fetch --tags + TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + echo "tag=$TAG" >> $GITHUB_OUTPUT + + - name: Check if version already released + run: | + VERSION="v${{ steps.get_version.outputs.version }}" + TAG="${{ steps.latest_tag.outputs.tag }}" + + if [ "$VERSION" = "$TAG" ]; then + echo "[ERROR] Version $VERSION already released" + exit 1 + else + echo "New version: $VERSION (latest was: $TAG)" + fi + + - name: Determine if prerelease + id: prerelease + run: | + VERSION="${{ steps.get_version.outputs.version }}" + if [[ "$VERSION" == *"alpha"* || "$VERSION" == *"beta"* || "$VERSION" == *"rc"* ]]; then + echo "prerelease=true" >> $GITHUB_OUTPUT + else + echo "prerelease=false" >> $GITHUB_OUTPUT + fi + + - name: Prepare body + id: body + run: | + if [ -z "${{ github.event.inputs.summary }}" ]; then + echo "body=" >> $GITHUB_OUTPUT + else + echo "body=${{ github.event.inputs.summary }}\n\n---" >> $GITHUB_OUTPUT + fi + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.get_version.outputs.version }} + name: v${{ steps.get_version.outputs.version }} + + body: ${{ steps.body.outputs.body }} + generate_release_notes: true + + prerelease: ${{ steps.prerelease.outputs.prerelease }} + + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file From 51b221e624e29c01b628837e016fce784a191c0f Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Fri, 3 Apr 2026 17:32:56 +0100 Subject: [PATCH 101/113] Readme typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 66d4cd3..da6b030 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ Depending on the device configuration, specific Gree AC model and firmware versi - **Auto X-Fan**: Automatically controls the X-Fan mode based on HVAC operations. When enabled, X-Fan will automatically turn on in cooling and dry modes. *Note: This is an integration feature, not an actual AC unit state* - **Temperature Step**: Sets the increment step for adjusting the target temperature. This allows you to configure how much the temperature changes when using the up/down controls in Home Assistant -## Diagnostics +### Diagnostics - **Fault Detection**: Sensor that shows if there is a problem with the device operation From 39de394608e00943a62272d887b0f62428ff9097 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Fri, 3 Apr 2026 17:57:03 +0100 Subject: [PATCH 102/113] Improve issue templates --- .github/ISSUE_TEMPLATE/bug_report.md | 38 +++++++++++++++++------ .github/ISSUE_TEMPLATE/feature_request.md | 2 +- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index e4cdd24..a412d85 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,38 +1,56 @@ --- name: Bug report about: Create a report to help us improve -title: '' +title: 'Bug: ' labels: '' assignees: '' --- **Describe the bug** + A clear and concise description of what the bug is. **To Reproduce** -Steps to reproduce the behavior: + 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error -**Configuration** -Share your YAML here - **Expected behavior** + A clear and concise description of what you expected to happen. **Screenshots** + If applicable, add screenshots to help explain your problem. -**Platform:** - - OS: [e.g. HASSIO, Hassbian] - - Browser [e.g. chrome, safari] - - Version [e.g. 0.92.1] +**Runtime Information:** + - OS: [e.g. HASSIO, Hassbian, Docker] + - Home Assistant Version: [e.g. 2026.4] + - Integration Version: [e.g. 0.92.1] + - Device Firmware Version: [e.g. 1.44] + - Device model: [e.g., Gree GWH12ACC-K6DNA1D] + - Does the device respond to pings? Yes/No + **Additional context** + Add any other context about the problem here. +**Configuration** + +Share your configuration entry diagnostics download or YAML here + +```json +Paste the diagnostics json here +``` + +```yaml +If applicable, paste the config here +``` + **Logs** -Please share your Home Assistant logs here. Make sure to remove any personal/secret information. + +Please share your Home Assistant logs here. Make sure to remove any personal/secret information. See [here](https://github.com/p-monteiro/HomeAssistant-GreeClimateComponent-Rewrite/tree/gree-rewrite?tab=readme-ov-file#debugging). diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index bbcbbe7..6941d21 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,7 +1,7 @@ --- name: Feature request about: Suggest an idea for this project -title: '' +title: 'Feature Request: ' labels: '' assignees: '' From 73c86fc3e11f68ec18c6e7508185133726d26a1d Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Fri, 3 Apr 2026 22:29:22 +0100 Subject: [PATCH 103/113] Improve cipher code Use proper types V2 decrypt and verify combined Debug trimmed garbage (may be useful) --- .../gree_custom/aiogree/cipher.py | 47 +++++++++++-------- custom_components/gree_custom/manifest.json | 2 +- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/custom_components/gree_custom/aiogree/cipher.py b/custom_components/gree_custom/aiogree/cipher.py index 09102f2..588b8ce 100644 --- a/custom_components/gree_custom/aiogree/cipher.py +++ b/custom_components/gree_custom/aiogree/cipher.py @@ -1,10 +1,13 @@ """Encapsulates device encryption.""" +from abc import ABC, abstractmethod import base64 from enum import IntEnum, unique import logging from Crypto.Cipher import AES +from Crypto.Cipher._mode_ecb import EcbMode +from Crypto.Cipher._mode_gcm import GcmMode from Crypto.Util.Padding import pad, unpad from .errors import GreeError @@ -14,7 +17,7 @@ GCM_IV = b"\x54\x40\x78\x44\x49\x67\x5a\x51\x6c\x5e\x63\x13" GCM_ADD = b"qualcomm-test" -GREE_GENERIC_DEVICE_KEY = "a3K8Bx%2r8Y7#xDh" +GREE_GENERIC_DEVICE_KEY_ECB = "a3K8Bx%2r8Y7#xDh" GREE_GENERIC_DEVICE_KEY_GCM = "{yxAHAY_Lm6pbC/<" AES_BLOCK_SIZE = 16 @@ -28,7 +31,7 @@ class EncryptionVersion(IntEnum): V2 = 2 -class CipherBase: +class CipherBase(ABC): """Base class for the encryprion module.""" def __init__(self, key: str) -> None: @@ -36,6 +39,7 @@ def __init__(self, key: str) -> None: self.key = key @property + @abstractmethod def version(self) -> EncryptionVersion: """The encryption version of this cypher.""" raise NotImplementedError @@ -49,10 +53,12 @@ def key(self) -> str: def key(self, value: str) -> None: self._key = value.encode() + @abstractmethod def encrypt(self, data: str) -> tuple[str, str | None]: """Encrypts the data. Returns the encrypted data and an optional tag.""" raise NotImplementedError + @abstractmethod def decrypt(self, data: str, tag: str | None) -> str: """Decrypts the data. Optionally checks integrity if tag is provided.""" raise NotImplementedError @@ -63,9 +69,9 @@ class CipherV1(CipherBase): def __init__(self, key: str | None) -> None: """Initialize V1 Encryption.""" - super().__init__(key or GREE_GENERIC_DEVICE_KEY) + super().__init__(key or GREE_GENERIC_DEVICE_KEY_ECB) - def _create_cipher(self): + def _create_cipher(self) -> EcbMode: return AES.new(self._key, AES.MODE_ECB) @property @@ -92,14 +98,14 @@ def decrypt(self, data: str, tag: None) -> str: _LOGGER.debug("Decrypting data (V1): %s", data) cipher = self._create_cipher() - decoded = base64.b64decode(data) + decoded = base64.b64decode(data) decrypted = cipher.decrypt(decoded) try: plaintext = unpad(decrypted, AES_BLOCK_SIZE).decode() except ValueError: - # Fallback for some devices sending malformed padding + # GREE PROTOCOL: Fallback for some devices sending malformed padding plaintext = decrypted.decode(errors="ignore") _LOGGER.debug("Decrypted data successfully (V1)") @@ -108,13 +114,13 @@ def decrypt(self, data: str, tag: None) -> str: class CipherV2(CipherBase): - """Implements the V2 type encryption used by Gree.""" + """Implements the V2 (AES-GCM) type encryption used by Gree.""" def __init__(self, key: str | None) -> None: """Initialize V2 Encryption.""" super().__init__(key or GREE_GENERIC_DEVICE_KEY_GCM) - def _create_cipher(self) -> AES: + def _create_cipher(self) -> GcmMode: cipher = AES.new(self._key, AES.MODE_GCM, nonce=GCM_IV) cipher.update(GCM_ADD) return cipher @@ -140,19 +146,17 @@ def encrypt(self, data: str) -> tuple[str, str]: def decrypt(self, data: str, tag: str) -> str: """Decrypt data with V2 and verify the data with the tag.""" - _LOGGER.debug("Decrypting data (V2): %s", data) - - cipher = self._create_cipher() - - decoded = base64.b64decode(data) - decrypted = cipher.decrypt(decoded) + _LOGGER.debug("Decrypting data (V2): %s, tag=%s", data, tag) if not tag: raise GreeError("Decrypting data (V2) failed: tag is needed") - _LOGGER.debug("Verifying tag: %s", tag) - cipher.verify(base64.b64decode(tag)) + cipher = self._create_cipher() + + decoded = base64.b64decode(data) + decoded_tag = base64.b64decode(tag) + decrypted = cipher.decrypt_and_verify(decoded, decoded_tag) plaintext = decrypted.decode("utf-8") _LOGGER.debug("Decrypted data successfully (V2)") @@ -167,9 +171,14 @@ def _trim_json_payload(data: str) -> str: """ end = data.rfind("}") - if end != -1: - return data[: end + 1] - return data + + if end == -1: + raise GreeError("Malformed JSON payload without closing character") + + if end + 1 < len(data): + _LOGGER.debug("Trimmed JSON payload garbage: %s", data[end + 1 :]) + + return data[: end + 1] def get_cipher( diff --git a/custom_components/gree_custom/manifest.json b/custom_components/gree_custom/manifest.json index d80890e..0464385 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.90" + "version": "4.0.0-alpha.91" } From be338c8444455d567f355cbf6c5a2b531aefe5ae Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Fri, 3 Apr 2026 22:44:19 +0100 Subject: [PATCH 104/113] More cipher cleanups --- .../gree_custom/aiogree/cipher.py | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/custom_components/gree_custom/aiogree/cipher.py b/custom_components/gree_custom/aiogree/cipher.py index 588b8ce..2b3ca0e 100644 --- a/custom_components/gree_custom/aiogree/cipher.py +++ b/custom_components/gree_custom/aiogree/cipher.py @@ -14,9 +14,9 @@ _LOGGER = logging.getLogger(__name__) +# GREE PROTOCOL: Fixed parameters obtained by reverse-engineering the Gree protocol spec GCM_IV = b"\x54\x40\x78\x44\x49\x67\x5a\x51\x6c\x5e\x63\x13" GCM_ADD = b"qualcomm-test" - GREE_GENERIC_DEVICE_KEY_ECB = "a3K8Bx%2r8Y7#xDh" GREE_GENERIC_DEVICE_KEY_GCM = "{yxAHAY_Lm6pbC/<" @@ -32,7 +32,7 @@ class EncryptionVersion(IntEnum): class CipherBase(ABC): - """Base class for the encryprion module.""" + """Base class for the encryption module.""" def __init__(self, key: str) -> None: """Initialize the class.""" @@ -42,7 +42,6 @@ def __init__(self, key: str) -> None: @abstractmethod def version(self) -> EncryptionVersion: """The encryption version of this cypher.""" - raise NotImplementedError @property def key(self) -> str: @@ -56,12 +55,10 @@ def key(self, value: str) -> None: @abstractmethod def encrypt(self, data: str) -> tuple[str, str | None]: """Encrypts the data. Returns the encrypted data and an optional tag.""" - raise NotImplementedError @abstractmethod def decrypt(self, data: str, tag: str | None) -> str: """Decrypts the data. Optionally checks integrity if tag is provided.""" - raise NotImplementedError class CipherV1(CipherBase): @@ -93,7 +90,7 @@ def encrypt(self, data: str) -> tuple[str, str | None]: return encoded, None - def decrypt(self, data: str, tag: None) -> str: + def decrypt(self, data: str, tag: str | None = None) -> str: """Decrypt data with V1.""" _LOGGER.debug("Decrypting data (V1): %s", data) @@ -186,10 +183,10 @@ def get_cipher( ) -> CipherBase: """Get AES cipher object based on encryption version using default keys.""" - if encryption_version == EncryptionVersion.V1: - return CipherV1(key) - - if encryption_version == EncryptionVersion.V2: - return CipherV2(key) - - raise ValueError(f"Unsupported encryption version: {encryption_version}") + match encryption_version: + case EncryptionVersion.V1: + return CipherV1(key) + case EncryptionVersion.V2: + return CipherV2(key) + case _: + raise ValueError(f"Unsupported encryption version: {encryption_version}") From 7f8a562d977b8ab348932992750a7fc267e7698b Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Sat, 11 Apr 2026 12:12:52 +0100 Subject: [PATCH 105/113] Correct info --- README.md | 62 +++++++++++-------- .../gree_custom/aiogree/device.py | 2 +- 2 files changed, 37 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index da6b030..0c106cf 100644 --- a/README.md +++ b/README.md @@ -3,24 +3,22 @@ # HomeAssistant-GreeClimateComponent -Custom Gree integration for Home Assistant written in Python3. Controls ACs supporting the Gree protocol. +Custom Gree integration for Home Assistant written in Python 3. Controls ACs supporting the Gree UDP protocol. -This integration connects directly to your HVAC devices via their IP address on the local network, unlike the official mobile app, which establish a direct connection only during initial setup and subsequently operate through Gree’s servers. +This integration connects directly to your HVAC devices via their IP address on the local network, unlike the official mobile app, which establishes a direct connection only during initial setup and subsequently operates through Gree’s servers. > [!NOTE] > This integration only supports the Gree UDP protocol. If you have a newer firmware/device that only communicates using the new MQTT protocol, this integration will not work. For a comprehensive list of tested devices, see [Supported Devices](supported-devices.md). -The integration attempts to obtain the encryption key by the initial setup protocol, which has been reverse-engineered. +The integration attempts to obtain the encryption key through the initial setup protocol, which has been reverse-engineered. > [!WARNING] -> If your HVAC device was previously set up for remote access using a mobile app, the integration may fail to retrieve the encryption key automatically. Find out more on methods of obtaining your device key bellow. +> If your HVAC device was previously set up for remote access using a mobile app, the integration may fail to retrieve the encryption key automatically. Find out more about methods of obtaining your device key below. - - -**If you are experiencing issues please read the [Debugging](#debugging) section** +**If you are experiencing issues, please read the [Debugging](#debugging) section.** Official mobile applications: @@ -36,7 +34,7 @@ To configure HVAC wifi (without the mobile app): https://github.com/arthurkrupa/ ### HACS (recommended) -This integration is added to HACS default repository list. Search for 'Gree' in the HACS dashboard to find and install it. +This integration is added to the HACS default repository list. Search for 'Gree' in the HACS dashboard to find and install it. ### Manual @@ -51,10 +49,10 @@ The integration can be added from the Home Assistant UI. 1. Navigate to **Settings** > **Devices & Services** and click **Add Integration**. 2. Search for **Gree Climate** -3. Choose automatic discovery or manual setup and fill in the desired `name`, `host` and `MAC address`. -4. After a successfull connection with the device, you will be asked to configure the device options. +3. Choose automatic discovery or manual setup and fill in the desired `name`, `host`, and `MAC address`. +4. After a successful connection with the device, you will be asked to configure the device options. -Your can also **Reconfigure** a device by changing its options. Saving any changes in the options dialog automatically reloads the integration, so new settings take effect immediately without restarting Home Assistant. +You can also **Reconfigure** a device by changing its options. Saving any changes in the options dialog automatically reloads the integration, so new settings take effect immediately without restarting Home Assistant. ### Manual - YAML Configuration @@ -71,7 +69,7 @@ See [`manual-configuration.yaml`](manual-configuration.yaml) for a complete conf ### Obtaining the Encryption Key -The integration has the capability of automatically retrieve the encryption version and key of a device using the gree protocol which has been reverse-engineered. +The integration has the capability of automatically retrieve the encryption version and key of a device using the gree protocol, which has been reverse-engineered. However, if your HVAC device was previously set up for remote access using a mobile app, the integration may fail to retrieve the encryption key automatically. @@ -81,7 +79,7 @@ To extract encryption keys from an account on Gree’s cloud server, follow the #### Method 2: From the Android app -One way is to pull the sqlite db from android device like described here: +One way is to pull the sqlite db from an Android device, as described here: https://stackoverflow.com/questions/9997976/android-pulling-sqlite-database-android-device @@ -92,19 +90,19 @@ sqlite3 data.ab 'select privateKey from db_device_20170503;' # but table name ca ``` > [!TIP] -> If you are getting an UTF-8 error (like: "UnicodeDecodeError: 'utf-8' codec can't decode byte 0xda in position 1: invalid continuation byte"), see https://github.com/RobHofmann/HomeAssistant-GreeClimateComponent/issues/318. +> If you are getting a UTF-8 error (like: "UnicodeDecodeError: 'utf-8' codec can't decode byte 0xda in position 1: invalid continuation byte"), see https://github.com/RobHofmann/HomeAssistant-GreeClimateComponent/issues/318. Optionally, you can also sniff the `uid` parameter. This is not needed for all devices. ### Icon configuration -You can set custom icons for the climate enity by modifying the icon translation file `icons.json`. Refer to this documentation: https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/icon-translations/ +You can set custom icons for the climate entity by modifying the icon translation file `icons.json`. Refer to this documentation: https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/icon-translations/ ## Debugging -If you are having problems with your device, whenever you write a bug report, be sure to provide details about your device, Home Assistant version and what exactly went wrong. +If you are having problems with your device, whenever you write a bug report, be sure to provide details about your device, Home Assistant version, and what exactly went wrong. -It also helps tremendously if you include debug logs directly in your issue (otherwise we will just ask for them and it will take longer). So please enable debug logs in the integration UI or like this: +It also helps tremendously if you include debug logs directly in your issue (otherwise, we will just ask for them, and it will take longer). So please enable debug logs in the integration UI, or like this: ```yaml logger: @@ -113,23 +111,35 @@ logger: custom_components.gree_custom: debug ``` -## Additional Sensors +## Device Sensors + +The integration supports sensors if your Gree device has them: + +### Indoor Temperature -The integration supports additional sensors if your Gree device has them: +If your AC unit has a built-in room temperature sensor, it will be automatically detected and exposed as: +- **Separate sensor entity**: `sensor.your_ac_indoor_temperature` +- **Climate entity attribute**: `current_temperature` (accessible via `{{ state_attr('climate.your_ac', 'current_temperature') }}`). -### Outside Temperature Sensor -If your AC unit has an outside temperature sensor, it will be automatically detected and exposed as: +### Outdoor Temperature + +If your AC unit has an outdoor temperature sensor, it will be automatically detected and exposed as: +- **Separate sensor entity**: `sensor.your_ac_outdoor_temperature` - **Climate entity attribute**: `outside_temperature` (accessible via `{{ state_attr('climate.your_ac', 'outside_temperature') }}`) -- **Separate sensor entity**: `sensor.your_ac_outside_temperature` -### Humidity Sensor +### Indoor Humidity + If your AC unit has a built-in room humidity sensor, it will be automatically detected and exposed as: -- **Climate entity attribute**: `room_humidity` (accessible via `{{ state_attr('climate.your_ac', 'room_humidity') }}`) - **Separate sensor entity**: `sensor.your_ac_room_humidity` +- **Climate entity attribute**: `current_humidity` (accessible via `{{ state_attr('climate.your_ac', 'current_humidity') }}`). + +### Sensor Overrides + +The indoor _temperature_ (`current_temperature`) and _humidity_ (`current_humidity`) values exposed by the Climate entity (`climate.your_ac`) can be overridden during configuration by another HA entity. This is helpful for obtaining a more comprehensive climate entity when the AC does not provide the respective sensors. However, please note that the AC operation is not driven by these values, as they are only exposed for information purposes. ## Available Switches and Controls -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: +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 @@ -153,7 +163,7 @@ Depending on the device configuration, specific Gree AC model and firmware versi ### Diagnostics -- **Fault Detection**: Sensor that shows if there is a problem with the device operation +- **Fault Detection**: Sensor that shows if there is a problem with the device's operation ## Credits diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index 54d707b..15ce799 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -113,7 +113,7 @@ async def bind_device(self) -> bool: if self._is_bound: return True - # Use a targeted fetch_device_info (scan) to the device + # Use fetch_device_info (targeted scan) to the device # since binding only succeeds after a scan try: await self.fetch_device_info() From 10cf27bcaa28bb502470d601fddff109e746a9af Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Sat, 11 Apr 2026 12:26:01 +0100 Subject: [PATCH 106/113] Proxy update_state through coordinator --- custom_components/gree_custom/aiogree/device.py | 2 +- custom_components/gree_custom/climate.py | 12 ++++++------ custom_components/gree_custom/coordinator.py | 4 ++++ custom_components/gree_custom/select.py | 4 ++-- custom_components/gree_custom/switch.py | 6 +++--- 5 files changed, 16 insertions(+), 12 deletions(-) diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index 15ce799..5aaba3a 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -274,7 +274,7 @@ async def fetch_device_status(self): self._remove_unsupported_props() - async def update_device_status(self): + async def push_device_status(self): """Send the new local device state to the device and updates local state if successfull.""" if not self._is_bound: await self.bind_device() diff --git a/custom_components/gree_custom/climate.py b/custom_components/gree_custom/climate.py index 8a1cb50..835b6a7 100644 --- a/custom_components/gree_custom/climate.py +++ b/custom_components/gree_custom/climate.py @@ -569,7 +569,7 @@ async def async_turn_on(self): if self.coordinator.feature_auto_light: self.device.set_feature_light(True) - await self.device.update_device_status() + await self.coordinator.push_device_status() self.async_write_ha_state() @@ -600,7 +600,7 @@ async def async_turn_off(self): if self.coordinator.feature_auto_light: self.device.set_feature_light(False) - await self.device.update_device_status() + await self.coordinator.push_device_status() self.async_write_ha_state() @@ -711,7 +711,7 @@ async def async_set_fan_mode(self, fan_mode: str): if fan_mode not in (GATTR_FEAT_QUIET_MODE, GATTR_FEAT_TURBO): self.device.set_fan_speed(FanSpeed[fan_mode]) - await self.device.update_device_status() + await self.coordinator.push_device_status() # notify coordinator listeners of state change so that dependent entities are updated immediately self.coordinator.async_update_listeners() @@ -740,7 +740,7 @@ async def async_set_swing_mode(self, swing_mode): try: self.device.set_vertical_swing_mode(VerticalSwingMode[swing_mode]) - await self.device.update_device_status() + await self.coordinator.push_device_status() # notify coordinator listeners of state change so that dependent entities are updated immediately self.coordinator.async_update_listeners() @@ -775,7 +775,7 @@ async def async_set_swing_horizontal_mode(self, swing_horizontal_mode): self.device.set_horizontal_swing_mode( HorizontalSwingMode[swing_horizontal_mode] ) - await self.device.update_device_status() + await self.coordinator.push_device_status() # notify coordinator listeners of state change so that dependent entities are updated immediately self.coordinator.async_update_listeners() @@ -864,7 +864,7 @@ async def async_set_temperature(self, **kwargs): # This will call the set_hvac_mode which internally will send to device await self.async_set_hvac_mode(hvac_mode) else: - await self.device.update_device_status() + await self.coordinator.push_device_status() # notify coordinator listeners of state change so that dependent entities are updated immediately self.coordinator.async_update_listeners() diff --git a/custom_components/gree_custom/coordinator.py b/custom_components/gree_custom/coordinator.py index 1090451..ac2d1fc 100644 --- a/custom_components/gree_custom/coordinator.py +++ b/custom_components/gree_custom/coordinator.py @@ -69,6 +69,10 @@ async def _async_update_data(self): _LOGGER.exception("Error getting state from device") raise UpdateFailed("Error getting state from device") from err + async def push_device_status(self): + """Pushes the transient state to the device.""" + await self.device.push_device_status() + def get_coordinator_diagnostics(self) -> dict[str, Any]: """Returns diagnostic data for the coordinator.""" data = self.device.gather_diagnostics() diff --git a/custom_components/gree_custom/select.py b/custom_components/gree_custom/select.py index 0c6f3fb..540f8e7 100644 --- a/custom_components/gree_custom/select.py +++ b/custom_components/gree_custom/select.py @@ -168,7 +168,7 @@ async def async_select_option(self, option: str) -> None: self.entity_description.set_func(self.device, option) if self.entity_description.updates_device: - await self.device.update_device_status() + await self.coordinator.push_device_status() # notify coordinator listeners of state change so that dependent entities are updated immediately self.coordinator.async_update_listeners() @@ -204,7 +204,7 @@ async def async_added_to_hass(self): self.entity_description.set_func(self.device, last_state.state) if self.entity_description.updates_device: - await self.device.update_device_status() + await self.coordinator.push_device_status() self._attr_current_option = last_state.state except Exception as err: # noqa: BLE001 diff --git a/custom_components/gree_custom/switch.py b/custom_components/gree_custom/switch.py index d6ed522..4b53bc8 100644 --- a/custom_components/gree_custom/switch.py +++ b/custom_components/gree_custom/switch.py @@ -304,7 +304,7 @@ async def async_added_to_hass(self): ) if self.entity_description.updates_device: - await self.device.update_device_status() + await self.coordinator.push_device_status() self._attr_is_on = value except Exception as err: # noqa: BLE001 @@ -323,7 +323,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: self.entity_description.set_func(self.device, self.coordinator, True) if self.entity_description.updates_device: - await self.device.update_device_status() + await self.coordinator.push_device_status() # notify coordinator listeners of state change so that dependent entities are updated immediately self.coordinator.async_update_listeners() @@ -346,7 +346,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: self.entity_description.set_func(self.device, self.coordinator, False) if self.entity_description.updates_device: - await self.device.update_device_status() + await self.coordinator.push_device_status() # notify coordinator listeners of state change so that dependent entities are updated immediately self.coordinator.async_update_listeners() From fdcd2da39e38265b02b4f6122be2845fa6499a7c Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Sat, 11 Apr 2026 14:29:43 +0100 Subject: [PATCH 107/113] Add capability of self correcting IP --- custom_components/gree_custom/__init__.py | 16 ++- custom_components/gree_custom/aiogree/api.py | 8 +- .../gree_custom/aiogree/device.py | 31 +++++- .../gree_custom/aiogree/helpers.py | 2 +- .../gree_custom/binary_sensor.py | 3 +- custom_components/gree_custom/climate.py | 2 +- custom_components/gree_custom/config_flow.py | 37 ++----- custom_components/gree_custom/const.py | 1 + custom_components/gree_custom/coordinator.py | 24 ++++- custom_components/gree_custom/diagnostics.py | 4 +- custom_components/gree_custom/helpers.py | 98 +++++++++++++++++++ custom_components/gree_custom/select.py | 2 +- 12 files changed, 178 insertions(+), 50 deletions(-) create mode 100644 custom_components/gree_custom/helpers.py diff --git a/custom_components/gree_custom/__init__.py b/custom_components/gree_custom/__init__.py index 80b6fd0..957f629 100755 --- a/custom_components/gree_custom/__init__.py +++ b/custom_components/gree_custom/__init__.py @@ -5,7 +5,7 @@ # Standard library imports import logging -from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, @@ -27,7 +27,7 @@ DEFAULT_DEVICE_UID, ) from .aiogree.device import GreeDevice -from .aiogree.errors import GreeBindingError +from .aiogree.errors import GreeBindingError, GreeConnectionError # Local imports from .const import ( @@ -45,6 +45,7 @@ # Home Assistant imports from .coordinator import GreeConfigEntry, GreeCoordinator +from .helpers import try_find_new_ip PLATFORMS = [ Platform.BINARY_SENSOR, @@ -124,7 +125,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool mac, conf.get(CONF_HOST), ) - await device.bind_device() + + try: + await device.bind_device() + except GreeConnectionError: + # TODO: Ensure this is not a problem since it updates the config_entry inside on success. + if not await try_find_new_ip(hass, device, entry): + raise + await device.bind_device() coordinators[device.mac_address] = GreeCoordinator( hass, entry, device, d.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) @@ -135,7 +143,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool except TimeoutError as err: _LOGGER.exception( - "Setup entry '%s': Conection to %s timed out", entry.entry_id, mac + "Setup entry '%s': Connection to %s timed out", entry.entry_id, mac ) raise ConfigEntryNotReady from err diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index 191e183..04c6de8 100644 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -10,7 +10,7 @@ from .cipher import CipherBase, EncryptionVersion, get_cipher from .const import DEFAULT_DEVICE_PORT -from .errors import GreeBindingError, GreeError, GreeProtocolError +from .errors import GreeBindingError, GreeConnectionError, GreeError, GreeProtocolError from .transport import GreeTransport, async_udp_broadcast_request _LOGGER = logging.getLogger(__name__) @@ -177,6 +177,8 @@ async def get_result_pack( try: recv_json = await transport.request_json(json_data) data = get_gree_response_data(recv_json, cipher) + except GreeConnectionError: + raise except json.JSONDecodeError as err: raise GreeProtocolError("Invalid JSON response from device") from err except Exception as err: @@ -430,7 +432,7 @@ async def gree_get_status( try: result = await get_result_pack(json_payload, cipher, transport) - except GreeProtocolError: + except GreeConnectionError, GreeProtocolError: raise except Exception as err: @@ -470,7 +472,7 @@ async def gree_set_status( try: result = await get_result_pack(json_payload, cipher, transport) - except GreeProtocolError: + except GreeConnectionError, GreeProtocolError: raise except Exception as err: diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index 5aaba3a..690702c 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -24,7 +24,7 @@ DEFAULT_CONNECTION_TIMEOUT, DEFAULT_DEVICE_UID, ) -from .errors import GreeBindingError, GreeError, GreeProtocolError +from .errors import GreeBindingError, GreeConnectionError, GreeError, GreeProtocolError from .helpers import ( TempOffsetResolver, gree_get_target_temp_props_from_c, @@ -117,6 +117,10 @@ async def bind_device(self) -> bool: # since binding only succeeds after a scan try: await self.fetch_device_info() + + except GreeConnectionError: + raise + except Exception as err: raise GreeBindingError( "Could not fetch device info before binding" @@ -157,6 +161,8 @@ async def fetch_device_info(self, cipher: CipherBase = None): self._raw_info = await gree_get_device_info( self._transport, cipher or self._cipher ) + except GreeConnectionError: + raise except Exception as e: raise GreeProtocolError( @@ -265,7 +271,8 @@ async def fetch_device_status(self): self._is_available = True - except GreeProtocolError: + except GreeConnectionError, GreeProtocolError: + self._is_available = False raise except Exception as err: @@ -306,7 +313,8 @@ async def push_device_status(self): self._new_raw_state.clear() self._is_available = True - except GreeProtocolError: + except GreeConnectionError, GreeProtocolError: + self._is_available = False raise except Exception as err: @@ -444,6 +452,16 @@ def supports_property(self, property: GreeProp) -> bool: # 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 + @property + def ip(self) -> str: + """The IP address assigned to the device.""" + return self._ip_addr + + def set_ip(self, ip_addr: str): + """Updates the IP the device uses for communication.""" + self._ip_addr = ip_addr + self._transport.ip_addr = ip_addr + @property def name(self) -> str: """Returns the friendly name of the device.""" @@ -487,9 +505,14 @@ def firmware_version(self) -> str | None: @property def available(self) -> bool: - """Return True if the device is bouund and last connection was successful.""" + """Return True if the device is bound and last connection was successful.""" return self._is_bound and self._is_available + @property + def is_bound(self) -> bool: + """Return True if the device is bound.""" + return self._is_bound + @property def has_hvac_error(self) -> bool | None: """Return if there is an error with the device.""" diff --git a/custom_components/gree_custom/aiogree/helpers.py b/custom_components/gree_custom/aiogree/helpers.py index 8854014..47695c8 100644 --- a/custom_components/gree_custom/aiogree/helpers.py +++ b/custom_components/gree_custom/aiogree/helpers.py @@ -1,4 +1,4 @@ -"""Helpers for the Gree integration.""" +"""Helpers for the Gree device API.""" TEMSEN_OFFSET = 40 diff --git a/custom_components/gree_custom/binary_sensor.py b/custom_components/gree_custom/binary_sensor.py index 2f4278d..3a2f3ab 100644 --- a/custom_components/gree_custom/binary_sensor.py +++ b/custom_components/gree_custom/binary_sensor.py @@ -14,7 +14,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .aiogree.api import GreeProp from .aiogree.device import GreeDevice from .const import ( CONF_ADVANCED, @@ -36,7 +35,7 @@ class GreeBinarySensorDescription(GreeEntityDescription, BinarySensorEntityDescription): """Description of a Gree binary sensor.""" - additional_available_func = lambda _: True + 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 835b6a7..c0b4259 100644 --- a/custom_components/gree_custom/climate.py +++ b/custom_components/gree_custom/climate.py @@ -77,7 +77,7 @@ class GreeClimateDescription(GreeEntityDescription, ClimateEntityDescription): """Description of a Gree Climate entity.""" - additional_available_func = lambda _: True + additional_available_func = lambda _: True # noqa: E731 device_class = None entity_category = None entity_registry_enabled_default = True diff --git a/custom_components/gree_custom/config_flow.py b/custom_components/gree_custom/config_flow.py index c057ac9..12d5870 100644 --- a/custom_components/gree_custom/config_flow.py +++ b/custom_components/gree_custom/config_flow.py @@ -9,8 +9,7 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.components import network -from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import ( CONF_HOST, CONF_MAC, @@ -63,6 +62,7 @@ CONF_TEMPERATURE_STEP, CONF_UID, DEFAULT_DISABLE_AVAILABLE_CHECK, + DEFAULT_DISCOVERY_TIMEOUT, DEFAULT_FAN_MODES, DEFAULT_HVAC_MODES, DEFAULT_RESTORE_STATES, @@ -87,6 +87,7 @@ MIN_SCAN_INTERVAL, ) from .coordinator import GreeConfigEntry +from .helpers import get_hass_broadcast_addr _LOGGER = logging.getLogger(__name__) @@ -887,33 +888,11 @@ async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None) async def _discover_devices( self, hass: HomeAssistant ) -> list[GreeDiscoveredDevice]: - """Debug for discovering devices.""" - # Get broadcast addresses from Home Assistant's network helper - broadcast_addresses: list[str] = [] - try: - ha_broadcast_addresses: set[ - network.IPv4Address - ] = await network.async_get_ipv4_broadcast_addresses(hass) - ha_broadcast_strings: list[str] = [ - str(addr) for addr in ha_broadcast_addresses - ] - broadcast_addresses.extend(ha_broadcast_strings) - _LOGGER.debug("Found broadcast addresses from HA: %s", ha_broadcast_strings) - - except Exception: - _LOGGER.exception("Could not get HA broadcast addresses") - - # Default broadcast addresses to try - # default_broadcast_addresses = [ - # "255.255.255.255", # Limited broadcast - # "192.168.255.255", # /16 broadcast for 192.168.x.x networks - # "10.255.255.255", # /8 broadcast for 10.x.x.x networks - # "172.31.255.255", # /12 broadcast for 172.16-31.x.x networks - # ] - # broadcast_addresses.extend(default_broadcast_addresses) - # NOTE: Try to use the ones from HA only. Uncomment if people report bugs. - - return await discover_gree_devices(broadcast_addresses, 5) + """Discover devices in the network.""" + + return await discover_gree_devices( + await get_hass_broadcast_addr(hass), DEFAULT_DISCOVERY_TIMEOUT + ) def _create_final_entry(self): """Build final entry data.""" diff --git a/custom_components/gree_custom/const.py b/custom_components/gree_custom/const.py index 9136064..355c002 100755 --- a/custom_components/gree_custom/const.py +++ b/custom_components/gree_custom/const.py @@ -30,6 +30,7 @@ CONF_FEATURES = "features" CONF_TEMPERATURE_STEP = "target_temp_step" +DEFAULT_DISCOVERY_TIMEOUT = 5 DEFAULT_TARGET_TEMP_STEP = 1 DEFAULT_ENCRYPTION_VERSION = None DEFAULT_DISABLE_AVAILABLE_CHECK = False diff --git a/custom_components/gree_custom/coordinator.py b/custom_components/gree_custom/coordinator.py index ac2d1fc..251619d 100644 --- a/custom_components/gree_custom/coordinator.py +++ b/custom_components/gree_custom/coordinator.py @@ -13,7 +13,8 @@ ) from .aiogree.device import GreeDevice -from .aiogree.errors import GreeBindingError +from .aiogree.errors import GreeBindingError, GreeConnectionError +from .helpers import try_find_new_ip _LOGGER = logging.getLogger(__name__) @@ -62,16 +63,35 @@ async def _async_update_data(self): """ try: await self.device.fetch_device_status() + + except GreeConnectionError as err: + if not await try_find_new_ip(self.hass, self.device, self.config_entry): + raise UpdateFailed("Error getting state from device") from err + + # retry once after IP recovery + try: + await self.device.fetch_device_status() + except Exception as err_inner: + raise UpdateFailed("Error getting state from device") from err_inner + except GreeBindingError as err: _LOGGER.exception("Failed to initiate Gree device") raise ConfigEntryAuthFailed("Failed to initiate Gree device") from err + except Exception as err: _LOGGER.exception("Error getting state from device") raise UpdateFailed("Error getting state from device") from err async def push_device_status(self): """Pushes the transient state to the device.""" - await self.device.push_device_status() + try: + await self.device.push_device_status() + except GreeConnectionError: + if not await try_find_new_ip(self.hass, self.device, self.config_entry): + raise # propagate original error if recovery fails + + # retry once after recovering IP + await self.device.push_device_status() def get_coordinator_diagnostics(self) -> dict[str, Any]: """Returns diagnostic data for the coordinator.""" diff --git a/custom_components/gree_custom/diagnostics.py b/custom_components/gree_custom/diagnostics.py index bf53309..e85b6ec 100644 --- a/custom_components/gree_custom/diagnostics.py +++ b/custom_components/gree_custom/diagnostics.py @@ -49,9 +49,7 @@ async def async_get_device_diagnostics( coordinator = entry.runtime_data.get(mac, None) - diagnostics = { + return { "device": device.dict_repr, "data": coordinator.get_coordinator_diagnostics() if coordinator else "", } - - return diagnostics diff --git a/custom_components/gree_custom/helpers.py b/custom_components/gree_custom/helpers.py new file mode 100644 index 0000000..2746b42 --- /dev/null +++ b/custom_components/gree_custom/helpers.py @@ -0,0 +1,98 @@ +"""Helpers for the Gree integration.""" + +import logging + +from homeassistant.components import network +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from .aiogree.api import GreeDiscoveredDevice, discover_gree_devices +from .aiogree.device import GreeDevice +from .const import DEFAULT_DISCOVERY_TIMEOUT + +_LOGGER = logging.getLogger(__name__) + + +async def get_hass_broadcast_addr(hass: HomeAssistant) -> list[str]: + """Returns the broadcast adresses from HA.""" + broadcast_addresses: list[str] = [] + + try: + ha_broadcast_addresses: set[ + network.IPv4Address + ] = await network.async_get_ipv4_broadcast_addresses(hass) + + ha_broadcast_strings: list[str] = [str(addr) for addr in ha_broadcast_addresses] + broadcast_addresses.extend(ha_broadcast_strings) + _LOGGER.debug("Found broadcast addresses from HA: %s", ha_broadcast_strings) + + except Exception: + _LOGGER.exception("Could not get HA broadcast addresses") + + # Default broadcast addresses to try + # default_broadcast_addresses = [ + # "255.255.255.255", # Limited broadcast + # "192.168.255.255", # /16 broadcast for 192.168.x.x networks + # "10.255.255.255", # /8 broadcast for 10.x.x.x networks + # "172.31.255.255", # /12 broadcast for 172.16-31.x.x networks + # ] + # broadcast_addresses.extend(default_broadcast_addresses) + # NOTE: Try to use the ones from HA only. Uncomment if people report bugs. + + return broadcast_addresses + + +async def try_find_new_ip( + hass: HomeAssistant, + device: GreeDevice, + config_entry: ConfigEntry, +) -> bool: + """This will try find the IP of this device controller MAC address and update it.""" + + _LOGGER.debug( + "Trying to find a new IP address for %s", device.mac_address_controller + ) + + previous_ip = device.ip + + # Perform device discovery + discovered_devices: list[GreeDiscoveredDevice] = await discover_gree_devices( + get_hass_broadcast_addr(hass), DEFAULT_DISCOVERY_TIMEOUT + ) + + # Search for a match device + match_device: GreeDiscoveredDevice | None = next( + (d for d in discovered_devices if d.mac == device.mac_address_controller), + None, + ) + + if not match_device: + _LOGGER.debug( + "No device with mac '%s' found in the discovered devices", + device.mac_address_controller, + ) + return False + + if previous_ip == match_device.host: + _LOGGER.debug( + "IP for device with mac '%s' is already correct", + device.mac_address_controller, + ) + return False + + # Update config entry to save the new IP + new_data = {**config_entry.data, CONF_HOST: device.ip} + await hass.config_entries.async_update_entry(config_entry, data=new_data) + + # Update the device IP + device.set_ip(match_device.host) + + _LOGGER.info( + "IP for device with mac '%s' updated: %s -> %s", + device.mac_address_controller, + previous_ip, + device.ip, + ) + + return True diff --git a/custom_components/gree_custom/select.py b/custom_components/gree_custom/select.py index 540f8e7..c2b2fff 100644 --- a/custom_components/gree_custom/select.py +++ b/custom_components/gree_custom/select.py @@ -95,7 +95,7 @@ async def async_setup_entry( class GreeSelectDescription(GreeEntityDescription, SelectEntityDescription, Generic[T]): """Description of a Gree switch.""" - additional_available_func = lambda _: True + additional_available_func = lambda _: True # noqa: E731 device_class = None entity_category = None entity_registry_enabled_default = True From 657882a632c42576f5ea3e524cd8f7a9692e6422 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Sat, 11 Apr 2026 14:30:41 +0100 Subject: [PATCH 108/113] Bump version --- custom_components/gree_custom/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/gree_custom/manifest.json b/custom_components/gree_custom/manifest.json index 0464385..b681c72 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.91" + "version": "4.0.0-alpha.92" } From 90cbb0a1ee0578ee306bc55decf1f64316147ad2 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Sun, 12 Apr 2026 20:31:29 +0100 Subject: [PATCH 109/113] Fix auto IP recovery config save and lower timeouts --- custom_components/gree_custom/__init__.py | 15 ++++++--------- custom_components/gree_custom/aiogree/const.py | 2 -- custom_components/gree_custom/aiogree/device.py | 10 +++------- .../gree_custom/aiogree/transport.py | 3 ++- custom_components/gree_custom/config_flow.py | 10 ++++------ custom_components/gree_custom/const.py | 9 +++++++-- custom_components/gree_custom/helpers.py | 11 ++++++----- custom_components/gree_custom/manifest.json | 2 +- 8 files changed, 29 insertions(+), 33 deletions(-) diff --git a/custom_components/gree_custom/__init__.py b/custom_components/gree_custom/__init__.py index 957f629..9e8edd1 100755 --- a/custom_components/gree_custom/__init__.py +++ b/custom_components/gree_custom/__init__.py @@ -20,12 +20,6 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.typing import Any, ConfigType -from .aiogree.const import ( - DEFAULT_CONNECTION_MAX_ATTEMPTS, - DEFAULT_CONNECTION_TIMEOUT, - DEFAULT_DEVICE_PORT, - DEFAULT_DEVICE_UID, -) from .aiogree.device import GreeDevice from .aiogree.errors import GreeBindingError, GreeConnectionError @@ -38,6 +32,10 @@ CONF_ENCRYPTION_VERSION, CONF_MAX_ONLINE_ATTEMPTS, CONF_UID, + DEFAULT_CONNECTION_MAX_ATTEMPTS, + DEFAULT_CONNECTION_TIMEOUT, + DEFAULT_DEVICE_PORT, + DEFAULT_DEVICE_UID, DEFAULT_ENCRYPTION_VERSION, DEFAULT_SCAN_INTERVAL, DOMAIN, @@ -128,10 +126,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool try: await device.bind_device() - except GreeConnectionError: - # TODO: Ensure this is not a problem since it updates the config_entry inside on success. + except GreeConnectionError as err_inner: if not await try_find_new_ip(hass, device, entry): - raise + raise ConfigEntryNotReady from err_inner await device.bind_device() coordinators[device.mac_address] = GreeCoordinator( diff --git a/custom_components/gree_custom/aiogree/const.py b/custom_components/gree_custom/aiogree/const.py index 71d3127..9114aa2 100644 --- a/custom_components/gree_custom/aiogree/const.py +++ b/custom_components/gree_custom/aiogree/const.py @@ -8,5 +8,3 @@ DEFAULT_DEVICE_UID = 0 DEFAULT_DEVICE_PORT = 7000 -DEFAULT_CONNECTION_MAX_ATTEMPTS = 5 -DEFAULT_CONNECTION_TIMEOUT = 10 diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index 690702c..bb97ddc 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -19,11 +19,7 @@ gree_try_bind, ) from .cipher import CipherBase, get_cipher -from .const import ( - DEFAULT_CONNECTION_MAX_ATTEMPTS, - DEFAULT_CONNECTION_TIMEOUT, - DEFAULT_DEVICE_UID, -) +from .const import DEFAULT_DEVICE_UID from .errors import GreeBindingError, GreeConnectionError, GreeError, GreeProtocolError from .helpers import ( TempOffsetResolver, @@ -49,8 +45,8 @@ def __init__( encryption_key: str, encryption_version: EncryptionVersion | None = None, uid: int = DEFAULT_DEVICE_UID, - max_connection_attempts: int = DEFAULT_CONNECTION_MAX_ATTEMPTS, - timeout: int = DEFAULT_CONNECTION_TIMEOUT, + max_connection_attempts: int = 5, + timeout: int = 10, ) -> None: """Initialize the Gree device.""" diff --git a/custom_components/gree_custom/aiogree/transport.py b/custom_components/gree_custom/aiogree/transport.py index e5a7fbb..9fadd62 100644 --- a/custom_components/gree_custom/aiogree/transport.py +++ b/custom_components/gree_custom/aiogree/transport.py @@ -72,7 +72,8 @@ async def udp_request( last_error = err2 # Apply backoff before retrying - await asyncio.sleep(0.5 + attempt * 0.3) # 0.5s, 0.8s, 1.1s, ... + if attempt < self.max_retries - 1: + await asyncio.sleep(0.5 + attempt * 0.3) # 0.5s, 0.8s, 1.1s, ... raise GreeConnectionError( f"Failed to communicate with device '{self.ip_addr}:{self.port}' after {self.max_retries} attempts" diff --git a/custom_components/gree_custom/config_flow.py b/custom_components/gree_custom/config_flow.py index 12d5870..ab2a717 100644 --- a/custom_components/gree_custom/config_flow.py +++ b/custom_components/gree_custom/config_flow.py @@ -35,12 +35,6 @@ from .aiogree.api import GreeDiscoveredDevice, GreeProp, discover_gree_devices from .aiogree.cipher import EncryptionVersion -from .aiogree.const import ( - DEFAULT_CONNECTION_MAX_ATTEMPTS, - DEFAULT_CONNECTION_TIMEOUT, - DEFAULT_DEVICE_PORT, - DEFAULT_DEVICE_UID, -) from .aiogree.device import GreeDevice from .aiogree.errors import GreeBindingError, GreeConnectionError from .const import ( @@ -61,6 +55,10 @@ CONF_SWING_MODES, CONF_TEMPERATURE_STEP, CONF_UID, + DEFAULT_CONNECTION_MAX_ATTEMPTS, + DEFAULT_CONNECTION_TIMEOUT, + DEFAULT_DEVICE_PORT, + DEFAULT_DEVICE_UID, DEFAULT_DISABLE_AVAILABLE_CHECK, DEFAULT_DISCOVERY_TIMEOUT, DEFAULT_FAN_MODES, diff --git a/custom_components/gree_custom/const.py b/custom_components/gree_custom/const.py index 355c002..92a2e00 100755 --- a/custom_components/gree_custom/const.py +++ b/custom_components/gree_custom/const.py @@ -30,13 +30,18 @@ CONF_FEATURES = "features" CONF_TEMPERATURE_STEP = "target_temp_step" -DEFAULT_DISCOVERY_TIMEOUT = 5 DEFAULT_TARGET_TEMP_STEP = 1 DEFAULT_ENCRYPTION_VERSION = None DEFAULT_DISABLE_AVAILABLE_CHECK = False DEFAULT_RESTORE_STATES = True -DEFAULT_SCAN_INTERVAL = 30 MIN_SCAN_INTERVAL = 5 +DEFAULT_SCAN_INTERVAL = 30 + +DEFAULT_DEVICE_UID = 0 +DEFAULT_DEVICE_PORT = 7000 +DEFAULT_CONNECTION_MAX_ATTEMPTS = 3 +DEFAULT_CONNECTION_TIMEOUT = 5 +DEFAULT_DISCOVERY_TIMEOUT = 5 # OPTIONAL FEATURES/MODES # use the device beeper on commands diff --git a/custom_components/gree_custom/helpers.py b/custom_components/gree_custom/helpers.py index 2746b42..7c1935b 100644 --- a/custom_components/gree_custom/helpers.py +++ b/custom_components/gree_custom/helpers.py @@ -58,7 +58,7 @@ async def try_find_new_ip( # Perform device discovery discovered_devices: list[GreeDiscoveredDevice] = await discover_gree_devices( - get_hass_broadcast_addr(hass), DEFAULT_DISCOVERY_TIMEOUT + await get_hass_broadcast_addr(hass), DEFAULT_DISCOVERY_TIMEOUT ) # Search for a match device @@ -81,13 +81,14 @@ async def try_find_new_ip( ) return False - # Update config entry to save the new IP - new_data = {**config_entry.data, CONF_HOST: device.ip} - await hass.config_entries.async_update_entry(config_entry, data=new_data) - # Update the device IP device.set_ip(match_device.host) + # Update config entry to save the new IP + new_data = {**config_entry.data, CONF_HOST: device.ip} + if not hass.config_entries.async_update_entry(config_entry, data=new_data): + _LOGGER.debug("Failed to save new IP in config entry data") + _LOGGER.info( "IP for device with mac '%s' updated: %s -> %s", device.mac_address_controller, diff --git a/custom_components/gree_custom/manifest.json b/custom_components/gree_custom/manifest.json index b681c72..d96df5b 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.92" + "version": "4.0.0-alpha.93" } From 08402760b13fbba1f1474e79695846f00388f9e8 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Sun, 12 Apr 2026 20:31:57 +0100 Subject: [PATCH 110/113] Add clamping to temperature helpers --- .../gree_custom/aiogree/helpers.py | 38 +++++++++++++++++++ custom_components/gree_custom/manifest.json | 2 +- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/custom_components/gree_custom/aiogree/helpers.py b/custom_components/gree_custom/aiogree/helpers.py index 47695c8..1142f41 100644 --- a/custom_components/gree_custom/aiogree/helpers.py +++ b/custom_components/gree_custom/aiogree/helpers.py @@ -1,7 +1,13 @@ """Helpers for the Gree device API.""" +import logging + +from .const import MAX_TEMP_C, MAX_TEMP_F, MIN_TEMP_C, MIN_TEMP_F + TEMSEN_OFFSET = 40 +_LOGGER = logging.getLogger(__name__) + class TempOffsetResolver: """Detect whether this sensor reports temperatures in °C or in (°C + 40).""" @@ -70,6 +76,22 @@ def gree_get_target_temp_props_from_f(desired_temp_f: int) -> tuple[int, int]: """Get SetTem and TemRec for a given Fahrenheit temperature. Only integer values supported.""" # See: https://github.com/tomikaa87/gree-remote + if desired_temp_f > MAX_TEMP_F: + _LOGGER.warning( + "The desired temperature is greater than allowed. Clamping to highest value: %d > %d", + desired_temp_f, + MAX_TEMP_F, + ) + desired_temp_f = MAX_TEMP_F + + if desired_temp_f < MIN_TEMP_F: + _LOGGER.warning( + "The desired temperature is lower than allowed. Clamping to lowest value: %d < %d", + desired_temp_f, + MIN_TEMP_F, + ) + 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) @@ -80,6 +102,22 @@ def gree_get_target_temp_props_from_f(desired_temp_f: int) -> tuple[int, int]: def gree_get_target_temp_props_from_c(desired_temp_c: float) -> tuple[int, int]: """Get SetTem and TemRec for a given 1/2 degree Celsius temperature.""" + if desired_temp_c > MAX_TEMP_C: + _LOGGER.warning( + "The desired temperature is greater than allowed. Clamping to highest value: %d > %d", + desired_temp_c, + MAX_TEMP_C, + ) + desired_temp_c = MAX_TEMP_C + + if desired_temp_c < MIN_TEMP_C: + _LOGGER.warning( + "The desired temperature is lower than allowed. Clamping to lowest value: %d < %d", + desired_temp_c, + MIN_TEMP_C, + ) + desired_temp_c = MIN_TEMP_C + # 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. diff --git a/custom_components/gree_custom/manifest.json b/custom_components/gree_custom/manifest.json index d96df5b..ef3cd04 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.93" + "version": "4.0.0-alpha.94" } From 5bae2ead73c521ab358bdc095d15c3df297836d0 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Tue, 28 Apr 2026 12:17:37 +0100 Subject: [PATCH 111/113] Revert manual_release Not needed anymore since it has been implemented in master --- .github/workflows/manual_release.yaml | 77 --------------------------- 1 file changed, 77 deletions(-) delete mode 100644 .github/workflows/manual_release.yaml diff --git a/.github/workflows/manual_release.yaml b/.github/workflows/manual_release.yaml deleted file mode 100644 index 32ab4df..0000000 --- a/.github/workflows/manual_release.yaml +++ /dev/null @@ -1,77 +0,0 @@ -name: Manual Release - -on: - workflow_dispatch: - inputs: - summary: - description: "Optional summary (markdown supported)" - required: false - default: "" - -jobs: - release: - runs-on: ubuntu-latest - - steps: - - name: Checkout repo - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Get version from manifest - id: get_version - run: | - VERSION=$(jq -r .version custom_components/gree_custom/manifest.json) - echo "version=$VERSION" >> $GITHUB_OUTPUT - - - name: Get latest tag - id: latest_tag - run: | - git fetch --tags - TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") - echo "tag=$TAG" >> $GITHUB_OUTPUT - - - name: Check if version already released - run: | - VERSION="v${{ steps.get_version.outputs.version }}" - TAG="${{ steps.latest_tag.outputs.tag }}" - - if [ "$VERSION" = "$TAG" ]; then - echo "[ERROR] Version $VERSION already released" - exit 1 - else - echo "New version: $VERSION (latest was: $TAG)" - fi - - - name: Determine if prerelease - id: prerelease - run: | - VERSION="${{ steps.get_version.outputs.version }}" - if [[ "$VERSION" == *"alpha"* || "$VERSION" == *"beta"* || "$VERSION" == *"rc"* ]]; then - echo "prerelease=true" >> $GITHUB_OUTPUT - else - echo "prerelease=false" >> $GITHUB_OUTPUT - fi - - - name: Prepare body - id: body - run: | - if [ -z "${{ github.event.inputs.summary }}" ]; then - echo "body=" >> $GITHUB_OUTPUT - else - echo "body=${{ github.event.inputs.summary }}\n\n---" >> $GITHUB_OUTPUT - fi - - - name: Create Release - uses: softprops/action-gh-release@v2 - with: - tag_name: v${{ steps.get_version.outputs.version }} - name: v${{ steps.get_version.outputs.version }} - - body: ${{ steps.body.outputs.body }} - generate_release_notes: true - - prerelease: ${{ steps.prerelease.outputs.prerelease }} - - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file From 05416f43720e62fc7ff090ce772853fa1bddca18 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Wed, 29 Apr 2026 15:30:33 +0100 Subject: [PATCH 112/113] Bring up Cross-VLAN discovery Adopt Cross-VLAN discovery from master. It uses persistent storange instead of ephemeral storage so that automatic IP recovery works --- custom_components/gree_custom/config_flow.py | 119 +++++++++++++++++- custom_components/gree_custom/const.py | 7 ++ custom_components/gree_custom/helpers.py | 91 +++++++++++++- custom_components/gree_custom/manifest.json | 2 +- .../gree_custom/translations/en.json | 17 ++- .../gree_custom/translations/pt.json | 15 ++- 6 files changed, 240 insertions(+), 11 deletions(-) diff --git a/custom_components/gree_custom/config_flow.py b/custom_components/gree_custom/config_flow.py index ab2a717..aee846f 100644 --- a/custom_components/gree_custom/config_flow.py +++ b/custom_components/gree_custom/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +from ipaddress import IPv4Address, IPv4Network, ip_address, ip_network import logging from typing import Any @@ -32,6 +33,7 @@ TextSelectorConfig, TextSelectorType, ) +from homeassistant.helpers.storage import Store from .aiogree.api import GreeDiscoveredDevice, GreeProp, discover_gree_devices from .aiogree.cipher import EncryptionVersion @@ -44,8 +46,12 @@ CONF_DEV_NAME, CONF_DEVICES, CONF_DISABLE_AVAILABLE_CHECK, + CONF_DISCOVERY_PREFS_KEY, + CONF_DISCOVERY_PREFS_VERSION, CONF_ENCRYPTION_KEY, CONF_ENCRYPTION_VERSION, + CONF_EXTRA_SCAN_HOSTS, + CONF_EXTRA_SCAN_NETWORKS, CONF_FAN_MODES, CONF_FEATURES, CONF_HVAC_MODES, @@ -82,10 +88,11 @@ GATTR_FEAT_SMART_HEAT_8C, GATTR_FEAT_TURBO, GATTR_FEAT_XFAN, + MAX_UNICAST_SCAN_HOSTS, MIN_SCAN_INTERVAL, ) from .coordinator import GreeConfigEntry -from .helpers import get_hass_broadcast_addr +from .helpers import get_discovery_addresses _LOGGER = logging.getLogger(__name__) @@ -486,6 +493,8 @@ def __init__(self) -> None: self._devices: dict[str, GreeDevice] = {} self._is_reconfigure: bool = False + self.pref_storage = None + async def async_step_import( self, import_config: dict ) -> config_entries.ConfigFlowResult: @@ -588,8 +597,11 @@ async def async_step_user( ) -> config_entries.ConfigFlowResult: """Handle the initial step - show discovery or manual entry.""" if user_input is not None: - if user_input.get("discovery") == "discover": + choice = user_input.get("discovery") + if choice == "discover": return await self.async_step_manual_discovery() + if choice == "discover_extended": + return await self.async_step_discovery_options() return await self.async_step_manual_add() # Show discovery vs manual choice @@ -597,7 +609,7 @@ async def async_step_user( { vol.Required("discovery", default="discover"): SelectSelector( SelectSelectorConfig( - options=["discover", "manual"], + options=["discover", "discover_extended", "manual"], translation_key="discovery_method", ) ) @@ -659,6 +671,105 @@ async def async_step_manual_discovery( }, ) + async def async_step_discovery_options( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Collect optional cross-VLAN scan ranges before running discovery.""" + errors: dict[str, str] = {} + networks_raw = "" + hosts_raw = "" + self.pref_storage = self.pref_storage or Store( + self.hass, CONF_DISCOVERY_PREFS_VERSION, CONF_DISCOVERY_PREFS_KEY + ) + + # BUG: HA persists the old value if a field is empty. Workaround is to send a [space]. + if user_input is not None: + networks_raw: str = (user_input.get(CONF_EXTRA_SCAN_NETWORKS, "")).strip() + hosts_raw: str = (user_input.get(CONF_EXTRA_SCAN_HOSTS, "")).strip() + + extra_networks: list[str] = ( + [s.strip() for s in networks_raw.split(",") if s.strip()] + if networks_raw + else [] + ) + extra_hosts: list[str] = ( + [s.strip() for s in hosts_raw.split(",") if s.strip()] + if hosts_raw + else [] + ) + + num_hosts = 0 + for cidr in extra_networks: + try: + net = ip_network(cidr, strict=False) + except ValueError: + errors[CONF_EXTRA_SCAN_NETWORKS] = "invalid_network" + break + + if not isinstance(net, IPv4Network): + errors[CONF_EXTRA_SCAN_NETWORKS] = "invalid_network" + break + + # /31 => 2 usable, /32 => 1 usable, otherwise subtract net+broadcast + usable = ( + net.num_addresses if net.prefixlen >= 31 else net.num_addresses - 2 + ) + if usable > MAX_UNICAST_SCAN_HOSTS: + errors[CONF_EXTRA_SCAN_NETWORKS] = "network_too_large" + break + num_hosts += usable + + for ip in extra_hosts: + try: + addr = ip_address(ip) + except ValueError: + errors[CONF_EXTRA_SCAN_HOSTS] = "invalid_host" + break + + if not isinstance(addr, IPv4Address): + errors[CONF_EXTRA_SCAN_HOSTS] = "invalid_host" + break + num_hosts += 1 + + if num_hosts > MAX_UNICAST_SCAN_HOSTS: + errors["base"] = "too_many_targets" + + if not errors: + # Persist last-used values for this HA session + await self.pref_storage.async_save( + { + CONF_EXTRA_SCAN_NETWORKS: extra_networks, + CONF_EXTRA_SCAN_HOSTS: extra_hosts, + } + ) + + # self._extra_networks = extra_networks or None + # self._extra_hosts = extra_hosts or None + return await self.async_step_manual_discovery() + + # Prefill from previous run (if any) or from current submission + + prefs = await self.pref_storage.async_load() or {} + default_networks: str = networks_raw or ", ".join( + prefs.get(CONF_EXTRA_SCAN_NETWORKS, []) + ) + default_hosts: str = hosts_raw or ", ".join( + prefs.get(CONF_EXTRA_SCAN_HOSTS, []) + ) + + # TODO: Use a TextSelector with multiple set to True. Unfortunately, as of now, HA UI has a bug where the focus on the textfield exits at every character + data_schema = vol.Schema( + { + vol.Optional(CONF_EXTRA_SCAN_NETWORKS, default=default_networks): str, + vol.Optional(CONF_EXTRA_SCAN_HOSTS, default=default_hosts): str, + } + ) + return self.async_show_form( + step_id="discovery_options", + data_schema=data_schema, + errors=errors, + ) + async def async_step_manual_add( self, user_input: dict | None = None, reconfigure_input: dict | None = None ) -> config_entries.ConfigFlowResult: @@ -889,7 +1000,7 @@ async def _discover_devices( """Discover devices in the network.""" return await discover_gree_devices( - await get_hass_broadcast_addr(hass), DEFAULT_DISCOVERY_TIMEOUT + await get_discovery_addresses(hass), DEFAULT_DISCOVERY_TIMEOUT ) def _create_final_entry(self): diff --git a/custom_components/gree_custom/const.py b/custom_components/gree_custom/const.py index 92a2e00..a2e3661 100755 --- a/custom_components/gree_custom/const.py +++ b/custom_components/gree_custom/const.py @@ -14,6 +14,11 @@ DOMAIN = "gree_custom" +CONF_EXTRA_SCAN_NETWORKS = "extra_scan_networks" +CONF_EXTRA_SCAN_HOSTS = "extra_scan_hosts" +CONF_DISCOVERY_PREFS_KEY = DOMAIN + "_discovery_prefs" +CONF_DISCOVERY_PREFS_VERSION = 1 + CONF_ADVANCED = "advanced" CONF_UID = "uid" CONF_ENCRYPTION_KEY = "encryption_key" @@ -43,6 +48,8 @@ DEFAULT_CONNECTION_TIMEOUT = 5 DEFAULT_DISCOVERY_TIMEOUT = 5 +MAX_UNICAST_SCAN_HOSTS = 65536 + # OPTIONAL FEATURES/MODES # use the device beeper on commands GATTR_BEEPER = "beeper" diff --git a/custom_components/gree_custom/helpers.py b/custom_components/gree_custom/helpers.py index 7c1935b..4c11484 100644 --- a/custom_components/gree_custom/helpers.py +++ b/custom_components/gree_custom/helpers.py @@ -1,24 +1,35 @@ """Helpers for the Gree integration.""" +from ipaddress import IPv4Address, IPv4Network, ip_address, ip_network import logging from homeassistant.components import network from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import Store from .aiogree.api import GreeDiscoveredDevice, discover_gree_devices from .aiogree.device import GreeDevice -from .const import DEFAULT_DISCOVERY_TIMEOUT +from .const import ( + CONF_DISCOVERY_PREFS_KEY, + CONF_DISCOVERY_PREFS_VERSION, + CONF_EXTRA_SCAN_HOSTS, + CONF_EXTRA_SCAN_NETWORKS, + DEFAULT_DISCOVERY_TIMEOUT, + MAX_UNICAST_SCAN_HOSTS, +) _LOGGER = logging.getLogger(__name__) -async def get_hass_broadcast_addr(hass: HomeAssistant) -> list[str]: +async def _get_hass_broadcast_addr(hass: HomeAssistant) -> list[str]: """Returns the broadcast adresses from HA.""" broadcast_addresses: list[str] = [] try: + # This returns every broadcast address for every enabled network adapter in HA + # If only the default adapter is enabled, HA only returns 255.255.255.255 ha_broadcast_addresses: set[ network.IPv4Address ] = await network.async_get_ipv4_broadcast_addresses(hass) @@ -43,6 +54,79 @@ async def get_hass_broadcast_addr(hass: HomeAssistant) -> list[str]: return broadcast_addresses +def _expand_unicast_targets( + networks: list[str] | None = None, + hosts: list[str] | None = None, +) -> list[str]: + """Expand IPv4 CIDRs + individual IPv4s into ordered deduplicated hosts. + + Raises ValueError if any network or total exceeds max_hosts. + """ + targets: dict[str, None] = {} + add = targets.setdefault + + for cidr in networks or (): + net = ip_network(cidr, strict=False) + + if not isinstance(net, IPv4Network): + raise TypeError(f"IPv6 not supported: {cidr}") + + # /31 => 2 usable, /32 => 1 usable, otherwise subtract net+broadcast + usable = net.num_addresses if net.prefixlen >= 31 else net.num_addresses - 2 + + if usable > MAX_UNICAST_SCAN_HOSTS: + raise ValueError( + f"Network {cidr} has {usable} hosts, exceeds limit of {MAX_UNICAST_SCAN_HOSTS}" + ) + + for host in net.hosts(): + add(str(host), None) + + if len(targets) > MAX_UNICAST_SCAN_HOSTS: + raise ValueError( + f"Total unicast targets ({len(targets)}) exceed limit of {MAX_UNICAST_SCAN_HOSTS}" + ) + + for raw_ip in hosts or (): + addr = ip_address(raw_ip) + + if not isinstance(addr, IPv4Address): + raise TypeError(f"IPv6 not supported: {raw_ip}") + + add(str(addr), None) + + if len(targets) > MAX_UNICAST_SCAN_HOSTS: + raise ValueError( + f"Total unicast targets ({len(targets)}) exceed limit of {MAX_UNICAST_SCAN_HOSTS}" + ) + + _LOGGER.debug("Expanded unicast addresses: %s found", len(targets)) + return list(targets) + + +async def get_discovery_addresses( + hass: HomeAssistant, +) -> list[str]: + """Gathers a list of broadcast and unicast addresses.""" + + addresses: list[str] = [] + + # Collect HA broadcast addresses + broadcast_addresses = await _get_hass_broadcast_addr(hass) + addresses.extend(broadcast_addresses) + + # Collect unicast addresses from HASS prefs + pref_storage = Store(hass, CONF_DISCOVERY_PREFS_VERSION, CONF_DISCOVERY_PREFS_KEY) + prefs = await pref_storage.async_load() or {} + + extra_networks: list[str] = prefs.get(CONF_EXTRA_SCAN_NETWORKS, []) + extra_hosts: list[str] = prefs.get(CONF_EXTRA_SCAN_HOSTS, []) + unicast_addresses = _expand_unicast_targets(extra_networks, extra_hosts) + addresses.extend(unicast_addresses) + + return addresses + + async def try_find_new_ip( hass: HomeAssistant, device: GreeDevice, @@ -57,8 +141,9 @@ async def try_find_new_ip( previous_ip = device.ip # Perform device discovery + discovery_addresses = await get_discovery_addresses(hass) discovered_devices: list[GreeDiscoveredDevice] = await discover_gree_devices( - await get_hass_broadcast_addr(hass), DEFAULT_DISCOVERY_TIMEOUT + discovery_addresses, DEFAULT_DISCOVERY_TIMEOUT ) # Search for a match device diff --git a/custom_components/gree_custom/manifest.json b/custom_components/gree_custom/manifest.json index ef3cd04..33d8884 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.94" + "version": "4.0.0-alpha.95" } diff --git a/custom_components/gree_custom/translations/en.json b/custom_components/gree_custom/translations/en.json index 6eece08..c6cbadc 100755 --- a/custom_components/gree_custom/translations/en.json +++ b/custom_components/gree_custom/translations/en.json @@ -4,7 +4,11 @@ "unknown": "Something went wrong, please try again. If the issue persists, please check the logs.", "cannot_connect": "Unable to connect to the device. Please check the device configuration and network connection and try again. If the issue persists, please check the logs.", "cannot_bind": "Unable to bind the device. It was not possible to find the device encryption version or key. If the issue persists, please check the logs.", - "no_devices_found": "Couldn't find any Gree device in the network. Please add your device manually." + "no_devices_found": "Couldn't find any Gree device in the network. Please add your device manually.", + "invalid_network": "Invalid CIDR. Example: 192.168.30.0/24", + "invalid_host": "Invalid IP address. Example: 192.168.30.50", + "network_too_large": "Network exceeds the maximum of 65536 hosts (a /16). Split into multiple CIDRs or list specific hosts.", + "too_many_targets": "The specified networks and hosts exceed the maximum of 65536 hosts (a /16). Split into multiple CIDRs or list specific hosts." }, "abort": { "already_configured": "A device with this MAC address is already configured." @@ -24,6 +28,14 @@ "device": "Device" } }, + "discovery_options": { + "title": "Extended Discovery", + "description": "Devices on a different subnet or VLAN? Enter one or more networks and/or specific IP addresses to probe via unicast. Inter-VLAN routing and firewall rules must allow UDP port 7000 from Home Assistant to the target subnet.", + "data": { + "extra_scan_networks": "Networks (comma-separated CIDRs, e.g. 192.168.20.0/24,192.168.30.0/24)", + "extra_scan_hosts": "Hosts (comma-separated IPs, e.g. 192.168.30.50,192.168.30.51)" + } + }, "manual_add": { "title": "Device configuration", "data": { @@ -106,7 +118,8 @@ "selector": { "discovery_method": { "options": { - "discover": "Discover devices automatically", + "discover": "Discover devices on the local network", + "discover_extended": "Discover devices on the local network and other VLANs/subnets", "manual": "Add device manually" } }, diff --git a/custom_components/gree_custom/translations/pt.json b/custom_components/gree_custom/translations/pt.json index 88b1b49..10aeb94 100755 --- a/custom_components/gree_custom/translations/pt.json +++ b/custom_components/gree_custom/translations/pt.json @@ -4,7 +4,11 @@ "unknown": "Ocorreu algo de errado, tente de novo. Se o problema persistir, verifique os registos.", "cannot_connect": "Não foi possível ligar ao dispositivo. Verifique as configurações do dispositivo e de rede e tente de novo.ain. Se o problema persistir, verifique os registos.", "cannot_bind": "Não foi possível encontrar uma combinação da versão de encriptação e chave do dispositivo válida. Se o problema persistir, verifique os registos.", - "no_devices_found": "Não foram encontrados novos dispositivos Gree compatíveis na rede. Por favor, adicione o dispositivo manualmente." + "no_devices_found": "Não foram encontrados novos dispositivos Gree compatíveis na rede. Por favor, adicione o dispositivo manualmente.", + "invalid_network": "CIDR Inválido. Exemplo: 192.168.30.0/24", + "invalid_host": "Endereço IP Inválido. Exemplo: 192.168.30.50", + "network_too_large": "Uma das redes introduzidas excede o número máximo de dispositivos (65536, rede /16). Divida a rede em múltiplos CIDRs ou especifique os dispositivos.", + "too_many_targets": "O conjunto de redes e dispositivos introduzidos excede o número máximo de dispositivos (65536, rede /16). Divida a rede em múltiplos CIDRs ou especifique os dispositivos." }, "abort": { "already_configured": "Um dispositivo com este endereço MAC já foi configurado previamente." @@ -24,6 +28,14 @@ "device": "Dispositivo" } }, + "discovery_options": { + "title": "Procura Expandida", + "description": "Tem dispositivos numa VLAN? Introduza uma ou mais redes e/ou IP de dispositivos para expandir a procura automática. Rotas Inter-VLAN e regras de firewall devem existir para permitir tráfego UDP na porta 7000 desde o Home Assistant até às redes e dispositivos especificados.", + "data": { + "extra_scan_networks": "Redes (CIDRs separados por vírgula, e.g. 192.168.20.0/24,192.168.30.0/24)", + "extra_scan_hosts": "Dispositivos (IPs separados por vírgula, e.g. 192.168.30.50,192.168.30.51)" + } + }, "manual_add": { "title": "Configuração do dispositivo", "data": { @@ -107,6 +119,7 @@ "discovery_method": { "options": { "discover": "Procura automática de dispositivos", + "discover_extended": "Procura automática de dispositivos expandida", "manual": "Adicionar manualmente" } }, From 853d9390e5c507aa49af8d9828dd7c161c31d40b Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Wed, 29 Apr 2026 15:33:12 +0100 Subject: [PATCH 113/113] Update entry name on IP recovery --- custom_components/gree_custom/helpers.py | 4 +++- custom_components/gree_custom/manifest.json | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/custom_components/gree_custom/helpers.py b/custom_components/gree_custom/helpers.py index 4c11484..bde4cb2 100644 --- a/custom_components/gree_custom/helpers.py +++ b/custom_components/gree_custom/helpers.py @@ -171,7 +171,9 @@ async def try_find_new_ip( # Update config entry to save the new IP new_data = {**config_entry.data, CONF_HOST: device.ip} - if not hass.config_entries.async_update_entry(config_entry, data=new_data): + if not hass.config_entries.async_update_entry( + config_entry, title=f"Gree System at {device.ip}", data=new_data + ): _LOGGER.debug("Failed to save new IP in config entry data") _LOGGER.info( diff --git a/custom_components/gree_custom/manifest.json b/custom_components/gree_custom/manifest.json index 33d8884..b1d3c4b 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.95" + "version": "4.0.0-alpha.96" }