diff --git a/custom_components/gree_custom/__init__.py b/custom_components/gree_custom/__init__.py index 9e8edd1..70564f7 100755 --- a/custom_components/gree_custom/__init__.py +++ b/custom_components/gree_custom/__init__.py @@ -1,7 +1,5 @@ """Gree climate integration init.""" -from __future__ import annotations - # Standard library imports import logging @@ -44,6 +42,7 @@ # Home Assistant imports from .coordinator import GreeConfigEntry, GreeCoordinator from .helpers import try_find_new_ip +from .services import async_setup_services PLATFORMS = [ Platform.BINARY_SENSOR, @@ -52,20 +51,22 @@ Platform.SENSOR, Platform.SWITCH, ] + _LOGGER = logging.getLogger(__name__) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Gree component from yaml.""" - if DOMAIN not in config: - return True + """Set up the Gree component.""" + + async_setup_services(hass) - for climate_config in config[DOMAIN]: + # Setup YAML entries + for gree_config in config.get(DOMAIN, []): hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": "import"}, - data=climate_config, + data=gree_config, ) ) diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index 04c6de8..613f415 100644 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -1,13 +1,12 @@ """Contains the API to interface with the Gree device.""" -from enum import Enum, IntEnum, unique +from dataclasses import dataclass +from enum import IntEnum, StrEnum, unique import json import logging import re from typing import Any -from attr import dataclass - from .cipher import CipherBase, EncryptionVersion, get_cipher from .const import DEFAULT_DEVICE_PORT from .errors import GreeBindingError, GreeConnectionError, GreeError, GreeProtocolError @@ -16,7 +15,7 @@ _LOGGER = logging.getLogger(__name__) -class GreeProp(Enum): +class GreeProp(StrEnum): """Enumeration of Gree device properties.""" # HVAC CONTROLS @@ -24,23 +23,25 @@ class GreeProp(Enum): POWER = "Pow" # mode of operation OP_MODE = "Mod" + # fan speed mode FAN_SPEED = "WdSpd" + # 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" + # 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" @@ -69,18 +70,264 @@ class GreeProp(Enum): SENSOR_OUTSIDE_TEMPERATURE = "OutEnvTem" # indoor humidity sensor, used to read the current room humidity, if available SENSOR_HUMIDITY = "DwatSen" + # error display. 0 if no error, otherwise error + SENSOR_FAULT = "FaultDisplay" # OTHER _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" - # EXPERIMENTAL - # error display. 0 if no error, otherwise error - FAULT = "FaultDisplay" - # MODEL = "ModelType" + +PROP_KEY_TO_ENUM = {prop.value: prop for prop in GreeProp} + + +class OtherProps(StrEnum): + """Enumeration of other Gree device properties.""" + + _UNKN_MODEL = "ModelType" + _UNKN_ACStupPos = "ACStupPos" + _UNKN_ActiveTime = "ActiveTime" + _UNKN_Add0_1 = "Add0.1" + _UNKN_Add0_5 = "Add0.5" + _UNKN_AirQ = "AirQ" + _UNKN_AllErr = "AllErr" + _UNKN_Antifreeze = "Antifreeze" + _UNKN_AssHt = "AssHt" + _UNKN_AutoClean = "AutoClean" + _UNKN_AutoComnCloud = "AutoComnCloud" + _UNKN_AutoUpdate = "AutoUpdate" + _UNKN_BlkTemCom = "BlkTemCom" + _UNKN_ChildLock = "ChildLock" + _UNKN_CO2 = "CO2" + _UNKN_CO2Level = "CO2Level" + _UNKN_CommErr = "CommErr" + _UNKN_CompressorFqy = "CompressorFqy" + _UNKN_CompressorTem = "CompressorTem" + _UNKN_Coolmod = "Coolmod" + _UNKN_CoolNoise = "CoolNoise" + _UNKN_CoolSvStTemMin = "CoolSvStTemMin" + _UNKN_CpsTem = "CpsTem" + _UNKN_CurTmHor = "CurTmHor" + _UNKN_CurTmMin = "CurTmMin" + _UNKN_Dazzling = "Dazzling" + _UNKN_Defrost = "Defrost" + _UNKN_Dfltr = "Dfltr" + _UNKN_DFPoint = "DFPoint" + _UNKN_DIYGra1PoiAmo = "DIYGra1PoiAmo" + _UNKN_Dmod = "Dmod" + _UNKN_DnPLLRSwing = "DnPLLRSwing" + _UNKN_DnPRLRSwing = "DnPRLRSwing" + _UNKN_DnPUDSwing = "DnPUDSwing" + _UNKN_Dpump = "Dpump" + _UNKN_DsplySt = "DsplySt" + _UNKN_DwatFul = "DwatFul" + _UNKN_Dwet = "Dwet" + _UNKN_Elc1Kwh = "Elc1Kwh" + _UNKN_ElcAllKwhClr = "ElcAllKwhClr" + _UNKN_ElcAllKwhH = "ElcAllKwhH" + _UNKN_ElcAllKwhL = "ElcAllKwhL" + _UNKN_ElcDatDte = "ElcDatDte" + _UNKN_ElcDatHor = "ElcDatHor" + _UNKN_ElcDatMth = "ElcDatMth" + _UNKN_ElcErg = "ElcErg" + _UNKN_ElcGear = "ElcGear" + _UNKN_ElcOnKwh = "ElcOnKwh" + _UNKN_ElcP = "ElcP" + _UNKN_Emod = "Emod" + _UNKN_EnergyFlow = "EnergyFlow" + _UNKN_EnvArea1St = "EnvArea1St" + _UNKN_EnvArea2St = "EnvArea2St" + _UNKN_EnvArea3St = "EnvArea3St" + _UNKN_EnvArea4St = "EnvArea4St" + _UNKN_EnvArea5St = "EnvArea5St" + _UNKN_EnvArea6St = "EnvArea6St" + _UNKN_EnvArea7St = "EnvArea7St" + _UNKN_EnvArea8St = "EnvArea8St" + _UNKN_EnvArea9St = "EnvArea9St" + _UNKN_EnvFun = "EnvFun" + _UNKN_EvapClr = "EvapClr" + _UNKN_FanMod = "FanMod" + _UNKN_FavorMode = "FavorMode" + _UNKN_FbidBloPer = "FbidBloPer" + _UNKN_GasAvail = "GasAvail" + _UNKN_GasLED = "GasLED" + _UNKN_GasMas = "GasMas" + _UNKN_GasMod = "GasMod" + _UNKN_GasN = "GasN" + _UNKN_GetEr = "GetEr" + _UNKN_HabitLearn = "HabitLearn" + _UNKN_HandCtl = "HandCtl" + _UNKN_HasTmr = "HasTmr" + _UNKN_HeatCool = "HeatCool" + _UNKN_HeatNoise = "HeatNoise" + _UNKN_HeatSvStTemMax = "HeatSvStTemMax" + _UNKN_HumiSvStTemMin = "HumiSvStTemMin" + _UNKN_HumSen = "HumSen" + _UNKN_HumSor = "HumSor" + _UNKN_IDUAirQu = "IDUAirQu" + _UNKN_ImageRecovery = "ImageRecovery" + _UNKN_ImgUpdateCol = "ImgUpdateCol" + _UNKN_ImgUpdateFail = "ImgUpdateFail" + _UNKN_ImgUpdateSta = "ImgUpdateSta" + _UNKN_ImgUpdateSucs = "ImgUpdateSucs" + _UNKN_ImgVerSta = "ImgVerSta" + _UNKN_InEvaTem = "InEvaTem" + _UNKN_InHid = "InHid" + _UNKN_InHidDownPer = "InHidDownPer" + _UNKN_InHidSvrVer = "InHidSvrVer" + _UNKN_JFErrorCode = "JFErrorCode" + _UNKN_LedLig = "LedLig" + _UNKN_LTemDry = "LTemDry" + _UNKN_MaeS = "MaeS" + _UNKN_MakeWat = "MakeWat" + _UNKN_MasIDUMod = "MasIDUMod" + _UNKN_MasSub = "MasSub" + _UNKN_MicroSen = "MicroSen" + _UNKN_MidType = "MidType" + _UNKN_ModS = "ModS" + _UNKN_NewTimer = "NewTimer" + _UNKN_NewTimerSet = "NewTimerSet" + _UNKN_NobodySave = "NobodySave" + _UNKN_NoD = "NoD" + _UNKN_NoiseSet = "NoiseSet" + _UNKN_ODUViti = "ODUViti" + _UNKN_OEEPHid = "OEEPHid" + _UNKN_OEEPHidDownPer = "OEEPHidDownPer" + _UNKN_OEEPHidSvrVer = "OEEPHidSvrVer" + _UNKN_PctCle = "PctCle" + _UNKN_PctCleOnTm = "PctCleOnTm" + _UNKN_PctCleSetTm = "PctCleSetTm" + _UNKN_PctRe = "PctRe" + _UNKN_PM2P5 = "PM2P5" + _UNKN_PM2P5Sta = "PM2P5Sta" + _UNKN_PM2P5V = "PM2P5V" + _UNKN_PMVComfort = "PMVComfort" + _UNKN_Purify = "Purify" + _UNKN_RemWarnLig = "RemWarnLig" + _UNKN_ReplaceHEPA = "ReplaceHEPA" + _UNKN_ReportCtrl = "ReportCtrl" + _UNKN_ReportFreq = "ReportFreq" + _UNKN_ReportInterval = "ReportInterval" + _UNKN_RoomHigh = "RoomHigh" + _UNKN_RoomLen = "RoomLen" + _UNKN_RoomWid = "RoomWid" + _UNKN_SaveGuid = "SaveGuid" + _UNKN_Security = "Security" + _UNKN_SecurityMode = "SecurityMode" + _UNKN_Sfog = "Sfog" + _UNKN_Slp1H1 = "Slp1H1" + _UNKN_Slp1H2 = "Slp1H2" + _UNKN_Slp1H3 = "Slp1H3" + _UNKN_Slp1H4 = "Slp1H4" + _UNKN_Slp1H5 = "Slp1H5" + _UNKN_Slp1H6 = "Slp1H6" + _UNKN_Slp1H7 = "Slp1H7" + _UNKN_Slp1H8 = "Slp1H8" + _UNKN_Slp1L1 = "Slp1L1" + _UNKN_Slp1L2 = "Slp1L2" + _UNKN_Slp1L3 = "Slp1L3" + _UNKN_Slp1L4 = "Slp1L4" + _UNKN_Slp1L5 = "Slp1L5" + _UNKN_Slp1L6 = "Slp1L6" + _UNKN_Slp1L7 = "Slp1L7" + _UNKN_Slp1L8 = "Slp1L8" + _UNKN_SmartMod = "SmartMod" + _UNKN_SmartSlpMod = "SmartSlpMod" + _UNKN_SmartSlpModEx = "SmartSlpModEx" + _UNKN_SmartWind = "SmartWind" + _UNKN_Smod = "Smod" + _UNKN_SorErr = "SorErr" + _UNKN_Srst = "Srst" + _UNKN_SrstAF = "SrstAF" + _UNKN_SrstCF = "SrstCF" + _UNKN_SrstPF = "SrstPF" + _UNKN_SrstPP = "SrstPP" + _UNKN_SrstRF = "SrstRF" + _UNKN_StSlp1C = "StSlp1C" + _UNKN_StSlp1CInc = "StSlp1CInc" + _UNKN_StSlp1CSp = "StSlp1CSp" + _UNKN_StSlp1H = "StSlp1H" + _UNKN_StSlp1HInc = "StSlp1HInc" + _UNKN_StSlp1HSp = "StSlp1HSp" + _UNKN_StSlp2C = "StSlp2C" + _UNKN_StSlp2CInc = "StSlp2CInc" + _UNKN_StSlp2CSp = "StSlp2CSp" + _UNKN_StSlp2H = "StSlp2H" + _UNKN_StSlp2HInc = "StSlp2HInc" + _UNKN_StSlp2HSp = "StSlp2HSp" + _UNKN_StSlp3C = "StSlp3C" + _UNKN_StSlp3CInc = "StSlp3CInc" + _UNKN_StSlp3CSp = "StSlp3CSp" + _UNKN_StSlp3H = "StSlp3H" + _UNKN_StSlp3HInc = "StSlp3HInc" + _UNKN_StSlp3HSp = "StSlp3HSp" + _UNKN_StSlp4C = "StSlp4C" + _UNKN_StSlp4CInc = "StSlp4CInc" + _UNKN_StSlp4CSp = "StSlp4CSp" + _UNKN_StSlp4H = "StSlp4H" + _UNKN_StSlp4HInc = "StSlp4HInc" + _UNKN_StSlp4HSp = "StSlp4HSp" + _UNKN_StTmr = "StTmr" + _UNKN_Swash = "Swash" + _UNKN_Swat = "Swat" + _UNKN_SwhDIYGra1 = "SwhDIYGra1" + _UNKN_SwhFreAir = "SwhFreAir" + _UNKN_SwhSw = "SwhSw" + _UNKN_SwhWifi = "SwhWifi" + _UNKN_SwhWifiCo = "SwhWifiCo" + _UNKN_SwhWifiRe = "SwhWifiRe" + _UNKN_TemSor = "TemSor" + _UNKN_TemsSenOut = "TemsSenOut" + _UNKN_TmrLpTms = "TmrLpTms" + _UNKN_TmrOff = "TmrOff" + _UNKN_TmrOffHorLf = "TmrOffHorLf" + _UNKN_TmrOffMinLf = "TmrOffMinLf" + _UNKN_TmrOn = "TmrOn" + _UNKN_TmrOnHorLf = "TmrOnHorLf" + _UNKN_TmrOnMinLf = "TmrOnMinLf" + _UNKN_UDFanPort = "UDFanPort" + _UNKN_UnmanedOffTime = "UnmanedOffTime" + _UNKN_UnmanedShutDown = "UnmanedShutDown" + _UNKN_UvcControl = "UvcControl" + _UNKN_Video = "Video" + _UNKN_VitiGr = "VitiGr" + _UNKN_VOC = "VOC" + _UNKN_VocCtl = "VocCtl" + _UNKN_VocIdiom = "VocIdiom" + _UNKN_VocRole = "VocRole" + _UNKN_VocUpdateCol = "VocUpdateCol" + _UNKN_VocUpdateRes = "VocUpdateRes" + _UNKN_VocUpdateSta = "VocUpdateSta" + _UNKN_VocVerSta = "VocVerSta" + _UNKN_WatErr = "WatErr" + _UNKN_WatTmp = "WatTmp" + _UNKN_Werr = "Werr" + _UNKN_Wet = "Wet" + _UNKN_Wmod = "Wmod" + _UNKN_WschOff = "WschOff" + _UNKN_WschOffMin = "WschOffMin" + _UNKN_WschOn = "WschOn" + _UNKN_WschOnMin = "WschOnMin" + _UNKN_WsenNub = "WsenNub" + _UNKN_WsenTmpH = "WsenTmpH" + _UNKN_WsenTmpL = "WsenTmpL" + _UNKN_WsenTmpM = "WsenTmpM" + _UNKN_WsetTmp = "WsetTmp" + _UNKN_WstpH = "WstpH" + _UNKN_WstpSv = "WstpSv" + _UNKN_Wtmr1 = "Wtmr1" + _UNKN_Wtmr1Min = "Wtmr1Min" + _UNKN_Wtmr2 = "Wtmr2" + _UNKN_Wtmr2Min = "Wtmr2Min" + _UNKN_Wtmr3 = "Wtmr3" + _UNKN_Wtmr3Min = "Wtmr3Min" + # # INVALID + # _INV_MafIdf = "MafIdf" + # _INV_DevId = "DevID" @unique @@ -166,9 +413,6 @@ class GreeDiscoveredDevice: subdevices: int -propkey_to_enum = {prop.value: prop for prop in GreeProp} - - async def get_result_pack( json_data: dict, cipher: CipherBase, transport: GreeTransport ) -> dict: @@ -228,6 +472,10 @@ def gree_encrypt_pack( encrypted_data, tag = cipher.encrypt(json.dumps(pack)) + # WARNING: My device does not respond if the encrypted_pack is more that 1024 bytes + if len(encrypted_data.encode("utf-8")) > 1024: + _LOGGER.warning("Pack length is over 1024 bytes") + return (encrypted_data, tag) @@ -413,18 +661,35 @@ async def gree_get_status( mac_addr_controller: str, mac_addr: str, uid: int, - props: list[GreeProp], + props: list[str], 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.""" +) -> tuple[dict[str, str], list[str]]: + """Get the status of the device by sending a status request to the device (async). Also returns the props not present. + + Gree Protocol is a best-effort key/value response with no guaranteed completeness + + If a invalid prop is requested the response will not have it which is good + However, some "invalid" props are returned in the response with no data, making it impossible to know in a batch where they are + Note: Invalid != Unsupported + + Meaning: + + cols = what the device claims it is returning + dat = best-effort values, possibly incomplete + alignment between them is not guaranteed globally + + As such, it is only safe to batch props that are known to work. + """ - _LOGGER.debug("Trying to get device status") + _LOGGER.debug("Getting status for device '%s'", mac_addr) - status_values_raw: dict[GreeProp, int | None] = {} + # Filter empty, none and white spaces + props = [p for p in props if p is not None and p.strip()] - pack = gree_create_status_pack(mac_addr, [prop.value for prop in props]) + pack = gree_create_status_pack(mac_addr, props) encrypted_pack, tag = gree_encrypt_pack(pack, cipher) + json_payload = gree_create_payload( encrypted_pack, "pack", GreeCommand.STATUS, mac_addr_controller, uid, tag ) @@ -438,17 +703,38 @@ async def gree_get_status( except Exception as err: raise GreeProtocolError("Error getting device status") from err - if result["cols"] is None or result["dat"] is None: + cols = result.get("cols") + dat = result.get("dat") + + if cols is None or dat is None: 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"]] - status_values_raw = dict(zip(cols, values, strict=True)) + if len(cols) != len(dat): + if len(cols) == 1: + # if there is a single prop without value, add to missing + _LOGGER.error( + "Device '%s' was queried for invalid prop: %s", mac_addr, cols + ) + return {}, [cols] + + raise GreeProtocolError(f"Malformed response: cols={len(cols)} dat={len(dat)}") - 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) + status_values: dict[str, str] = {} + returned_props: set[str] = set() - return status_values, [p for p in props if p not in status_values] + for prop, value in zip(cols, dat, strict=True): + returned_props.add(prop) + status_values[prop] = value + + invalid_props = [p for p in props if p not in returned_props] + + _LOGGER.debug("Got status for device '%s': %s", mac_addr, status_values) + + if len(invalid_props) > 0: + _LOGGER.error( + "Device '%s' was queried for invalid props: %s", mac_addr, invalid_props + ) + return status_values, invalid_props async def gree_set_status( @@ -483,7 +769,7 @@ async def gree_set_status( 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] + options_set = [PROP_KEY_TO_ENUM[c] for c in result["opt"] if c in PROP_KEY_TO_ENUM] if options_set is None or len(options_set) == 0: raise GreeProtocolError("No options were set, something went wrong") @@ -515,7 +801,7 @@ async def gree_set_status( async def gree_get_device_info( transport: GreeTransport, cipher: CipherBase | None = None -) -> dict[str, str | None]: +) -> dict[str, str | dict | None]: """Tries to retrive the device info.""" data: dict = await get_result_pack( @@ -526,7 +812,7 @@ async def gree_get_device_info( _LOGGER.debug("Got device info: %s", data) - info: dict[str, str | None] = {} + info: dict[str, str | dict | None] = {} info["raw"] = data info["firmware_version"], info["firmware_code"] = extract_version(data) info["mac"] = data.get("mac", "") diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index f0fdfdc..3fc12a1 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -1,15 +1,18 @@ """Contains the API to interface with the Gree device.""" +from itertools import islice import logging from typing import Any from .api import ( + PROP_KEY_TO_ENUM, EncryptionVersion, FanSpeed, GreeDiscoveredDevice, GreeProp, HorizontalSwingMode, OperationMode, + OtherProps, TemperatureUnits, VerticalSwingMode, gree_get_device_info, @@ -33,6 +36,13 @@ _LOGGER = logging.getLogger(__name__) +def chunked(iterable, size): + """Creates chunks of data.""" + it = iter(iterable) + while chunk := list(islice(it, size)): + yield chunk + + class GreeDevice: """Representation of a Gree device.""" @@ -98,7 +108,7 @@ def __init__( self._temp_processor_outdoors: TempOffsetResolver | None = None self._beeper = False - self._raw_info: dict[str, str | None] = {} + self._raw_info: dict[str, Any] = {} self._firmware_version: str | None = None self._firmware_code: str | None = None self._subdevicesCount: int = 0 @@ -151,7 +161,7 @@ async def bind_device(self) -> bool: return True - async def fetch_device_info(self, cipher: CipherBase = None): + async def fetch_device_info(self, cipher: CipherBase | None = None): """Updates the device info fields.""" try: self._raw_info = await gree_get_device_info( @@ -233,37 +243,20 @@ async def fetch_sub_devices(self) -> list[GreeDiscoveredDevice]: async def fetch_device_status(self): """Get the device status (async).""" - _LOGGER.debug("Trying to get device status") - - if not self._is_bound: - await self.bind_device() - - assert self._cipher is not None + _LOGGER.debug("Trying to get device '%s' status", self.mac_address) try: - state, _ = await gree_get_status( - self._mac_addr_controller, - self._mac_addr, - self._uid, - self._props_to_update, - self._cipher, - self._transport, + status, _ = await self.query_props( + [prop.value for prop in self._props_to_update], + len(self._props_to_update), ) - 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) + + for key, val in status.items(): + try: + prop = PROP_KEY_TO_ENUM[key] + self._raw_state[prop] = int(val) + except Exception: + _LOGGER.exception("Failed to parse %s=%r. Skipping", key, val) self._is_available = True @@ -324,7 +317,7 @@ def _set_device_status(self, props: dict[GreeProp, int]) -> None: def _bool_from_raw_state(self, prop: GreeProp, default: int = 0) -> bool: prop_value: int | None = self._get_prop_raw(prop, default) - return None if prop_value is None else bool(prop_value) + return bool(prop_value) def _remove_unsupported_props(self): """Remove unsupported properties from the list to update.""" @@ -442,6 +435,53 @@ def gather_diagnostics(self) -> dict[str, Any]: return data + async def query_props( + self, props: list[str], request_batch: int = 1, error_as_missing: bool = False + ) -> tuple[dict[str, str], list[str]]: + """Query the value of the given props.""" + + if not self._is_bound: + await self.bind_device() + + combined_state: dict[str, str] = {} + combined_missing: list[str] = [] + + assert self._cipher is not None + + for props_chunk in chunked(props, request_batch): + try: + state, missing = await gree_get_status( + self._mac_addr_controller, + self._mac_addr, + self._uid, + props_chunk, + self._cipher, + self._transport, + ) + combined_state.update(state) + if len(missing) != 0: + combined_missing.extend(missing) + + except Exception: + if error_as_missing: + combined_missing.extend(props_chunk) + else: + raise + + return combined_state, combined_missing + + async def query_props_all( + self, request_batch: int = 1, error_as_missing: bool = False + ) -> tuple[dict[str, str], list[str]]: + """Query all possible props to the log.""" + + all_props = [ + *[prop.value for prop in GreeProp], + *[prop.value for prop in OtherProps], + ] + + return await self.query_props(all_props, request_batch, error_as_missing) + 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 @@ -510,9 +550,9 @@ def is_bound(self) -> bool: return self._is_bound @property - def has_hvac_error(self) -> bool | None: + def has_hvac_error(self) -> bool: """Return if there is an error with the device.""" - return self._bool_from_raw_state(GreeProp.FAULT, None) + return self._bool_from_raw_state(GreeProp.SENSOR_FAULT) @property def beeper(self) -> bool: diff --git a/custom_components/gree_custom/climate.py b/custom_components/gree_custom/climate.py index 576da74..6ddb280 100644 --- a/custom_components/gree_custom/climate.py +++ b/custom_components/gree_custom/climate.py @@ -695,10 +695,10 @@ 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 self._attr_fan_modes and GATTR_FEAT_QUIET_MODE in self._attr_fan_modes and self.device.feature_quiet: return GATTR_FEAT_QUIET_MODE - if GATTR_FEAT_TURBO in self._attr_hvac_modes and self.device.feature_turbo: + if self._attr_fan_modes and GATTR_FEAT_TURBO in self._attr_fan_modes and self.device.feature_turbo: return GATTR_FEAT_TURBO return self.device.fan_speed.name diff --git a/custom_components/gree_custom/config_flow.py b/custom_components/gree_custom/config_flow.py index aee846f..8501fe7 100644 --- a/custom_components/gree_custom/config_flow.py +++ b/custom_components/gree_custom/config_flow.py @@ -301,7 +301,7 @@ 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): + if device.supports_property(GreeProp.SENSOR_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 dcd4593..f9dcbc5 100755 --- a/custom_components/gree_custom/const.py +++ b/custom_components/gree_custom/const.py @@ -88,6 +88,8 @@ ATTR_AUTO_XFAN = "auto_xfan" ATTR_AUTO_LIGHT = "auto_light" +ATTR_SVC_PROPS = "prop_list" + # Map each feature constant to its corresponding GreeProp CONF_TO_PROP_FEATURE_MAP = { GATTR_BEEPER: GreeProp.BEEPER, @@ -99,7 +101,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, + GATTR_FAULTS: GreeProp.SENSOR_FAULT, } # HVAC modes - these come from Home Assistant and are standard diff --git a/custom_components/gree_custom/icons.json b/custom_components/gree_custom/icons.json index d8da3a7..fdc9bed 100755 --- a/custom_components/gree_custom/icons.json +++ b/custom_components/gree_custom/icons.json @@ -58,7 +58,7 @@ "default": "mdi:fan" }, "sleep": { - "default": "mdi:sleep" + "default": "mdi:weather-night" }, "eightdegheat": { "default": "mdi:thermometer-low" @@ -88,5 +88,13 @@ "default": "mdi:volume-high" } } + }, + "services": { + "get_prop_values_all": { + "service": "mdi:bug-outline" + }, + "get_prop_values": { + "service": "mdi:bug-outline" + } } -} +} \ No newline at end of file diff --git a/custom_components/gree_custom/manifest.json b/custom_components/gree_custom/manifest.json index 3d5c0ac..0e2aece 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.100" + "version": "4.0.0-alpha.101" } diff --git a/custom_components/gree_custom/services.py b/custom_components/gree_custom/services.py new file mode 100644 index 0000000..e505aec --- /dev/null +++ b/custom_components/gree_custom/services.py @@ -0,0 +1,161 @@ +"""Support for services.""" + +import logging +from typing import TYPE_CHECKING, Any + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv, device_registry as dr + +from .aiogree.device import GreeDevice +from .const import ATTR_SVC_PROPS, DOMAIN +from .coordinator import GreeConfigEntry, GreeCoordinator + +_LOGGER = logging.getLogger(__name__) + +SVC_BASE_SCHEMA = { + vol.Required(ATTR_DEVICE_ID): cv.string, +} + +SVC_GET_PROPS_ALL = "get_prop_values_all" +SVC_GET_PROPS_ALL_SCHEMA = vol.Schema(SVC_BASE_SCHEMA) + +SVC_GET_PROPS = "get_prop_values" +SVC_GET_PROPS_SCHEMA = vol.Schema( + SVC_GET_PROPS_ALL_SCHEMA.extend( + { + vol.Required(ATTR_SVC_PROPS): vol.All([cv.string]), + } + ) +) + + +@callback +def async_get_device_from_service_call( + call: ServiceCall, +) -> GreeDevice: + """Get the config entry related to a service call (by device ID).""" + device_registry = dr.async_get(call.hass) + device_id = call.data[ATTR_DEVICE_ID] + + if (device_entry := device_registry.async_get(device_id)) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_device_id", + ) + + # Find MAC address for this device (from identifiers) + mac: str | None = next( + ( + identifier + for domain, identifier in device_entry.identifiers + if domain == DOMAIN + ), + None, + ) + + if mac is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_device_id", + ) + + config_entry: GreeConfigEntry | None = None + for entry_id in device_entry.config_entries: + entry = call.hass.config_entries.async_get_entry(entry_id) + + if TYPE_CHECKING: + assert entry + + if entry.domain != DOMAIN: + continue + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="entry_not_loaded", + ) + + config_entry = entry + + if not config_entry: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="config_entry_not_found", + ) + + runtime_data: GreeCoordinator | None = config_entry.runtime_data.get(mac, None) + + if not runtime_data: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_config_data", + ) + + return runtime_data.device + + +async def async_get_prop_values_all(call: ServiceCall) -> ServiceResponse: + """Handle the get_prop_values_all service call.""" + + _LOGGER.debug("Service called: get_prop_values_all") + device: GreeDevice = async_get_device_from_service_call(call) + state, missing = await device.query_props_all(error_as_missing=True) + + result: dict[str, Any] = {} + result["states"] = state + result["missing"] = missing + + return result + + +async def async_get_prop_values(call: ServiceCall) -> ServiceResponse: + """Handle the get_prop_values service call.""" + + _LOGGER.debug("Service called: get_prop_values") + + props = call.data[ATTR_SVC_PROPS] + + device: GreeDevice = async_get_device_from_service_call(call) + state, missing = await device.query_props(props=props, error_as_missing=True) + + result: dict[str, Any] = {} + result["states"] = state + result["missing"] = missing + + return result + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up the services for Gree integration.""" + for service, method, schema, response in ( + ( + SVC_GET_PROPS_ALL, + async_get_prop_values_all, + SVC_GET_PROPS_ALL_SCHEMA, + SupportsResponse.ONLY, + ), + ( + SVC_GET_PROPS, + async_get_prop_values, + SVC_GET_PROPS_SCHEMA, + SupportsResponse.ONLY, + ), + ): + hass.services.async_register( + DOMAIN, + service, + method, + schema=schema, + supports_response=response, + ) diff --git a/custom_components/gree_custom/services.yaml b/custom_components/gree_custom/services.yaml new file mode 100644 index 0000000..b56c919 --- /dev/null +++ b/custom_components/gree_custom/services.yaml @@ -0,0 +1,23 @@ +get_prop_values_all: + fields: + device_id: + required: true + selector: + device: + integration: gree_custom + +get_prop_values: + fields: + device_id: + required: true + selector: + device: + integration: gree_custom + prop_list: + required: true + example: + - Pow + - Mod + selector: + text: + multiple: true \ No newline at end of file diff --git a/custom_components/gree_custom/translations/en.json b/custom_components/gree_custom/translations/en.json index c6cbadc..17bf465 100755 --- a/custom_components/gree_custom/translations/en.json +++ b/custom_components/gree_custom/translations/en.json @@ -311,6 +311,41 @@ }, "generic": { "message": "There was a problem performing the requested change, please consult the integration log." + }, + "invalid_device_id": { + "message": "There was a problem performing the action. An invalid device was selected." + }, + "entry_not_loaded": { + "message": "There was a problem performing the action. The configuration entry for the device is not loaded." + }, + "config_entry_not_found": { + "message": "There was a problem performing the action. The configuration entry for the device was not found." + }, + "invalid_config_data": { + "message": "There was a problem performing the action. The configuration entry has invalid data." + } + }, + "services": { + "get_prop_values_all": { + "name": "Query all properties", + "description": "Query all properties of a Gree device", + "fields": { + "device_id": { + "name": "Device ID" + } + } + }, + "get_prop_values": { + "name": "Query properties", + "description": "Query properties of a Gree device", + "fields": { + "device_id": { + "name": "Device ID" + }, + "prop_list": { + "name": "List of properties to query" + } + } } } -} +} \ No newline at end of file diff --git a/custom_components/gree_custom/translations/pt.json b/custom_components/gree_custom/translations/pt.json index 10aeb94..1900450 100755 --- a/custom_components/gree_custom/translations/pt.json +++ b/custom_components/gree_custom/translations/pt.json @@ -319,6 +319,41 @@ }, "generic": { "message": "Ocorreu um erro a realizar a ação pretendida, consulto os registos da integração." + }, + "invalid_device_id": { + "message": "Ocorreu um erro a executar a ação. O dispositivo selecionado é inválido." + }, + "entry_not_loaded": { + "message": "Ocorreu um erro a executar a ação. A configuração para o dispositivo não está ativa." + }, + "config_entry_not_found": { + "message": "Ocorreu um erro a executar a ação. A configuração para o dispositivo não foi encontrada." + }, + "invalid_config_data": { + "message": "Ocorreu um erro a executar a ação. A configuração para o dispositivo contém dados inválidos." + } + }, + "services": { + "get_prop_values_all": { + "name": "Consultar todas as propriedades", + "description": "Obtém todas as propriedades de um dispositivo Gree", + "fields": { + "device_id": { + "name": "ID do Dispositivo" + } + } + }, + "get_prop_values": { + "name": "Consultar propriedades", + "description": "Obtém o valor das propriedades de um dispositivo Gree", + "fields": { + "device_id": { + "name": "ID do Dispositivo" + }, + "prop_list": { + "name": "Lista de propriedades a obter" + } + } } } } \ No newline at end of file