From 2a72f33e06e59a53676609b29b2cd29e70475324 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Fri, 26 Jun 2026 16:33:15 +0100 Subject: [PATCH 01/10] Add gather_prop_dump() This allows to debug the value of all unknown props found in firmware disassembly. Might be useful to debug and find new features. TODO: Hook it to HA --- custom_components/gree_custom/aiogree/api.py | 384 ++++++++++++++++-- .../gree_custom/aiogree/device.py | 50 ++- custom_components/gree_custom/config_flow.py | 2 +- custom_components/gree_custom/const.py | 2 +- 4 files changed, 401 insertions(+), 37 deletions(-) diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index 04c6de83..89ca3485 100644 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -1,12 +1,12 @@ """Contains the API to interface with the Gree device.""" -from enum import Enum, IntEnum, unique +from collections.abc import Callable, Mapping +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 typing import Any, TypeVar from .cipher import CipherBase, EncryptionVersion, get_cipher from .const import DEFAULT_DEVICE_PORT @@ -15,8 +15,11 @@ _LOGGER = logging.getLogger(__name__) +PropT = TypeVar("PropT", bound=StrEnum) +PropValueT = TypeVar("PropValueT") + -class GreeProp(Enum): +class GreeProp(StrEnum): """Enumeration of Gree device properties.""" # HVAC CONTROLS @@ -24,23 +27,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 +74,299 @@ 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 AllProps(StrEnum): + """Enumeration of Gree device properties.""" + + POWER = "Pow" + OP_MODE = "Mod" + BEEPER = "Buzzer_ON_OFF" + BEEPER_NEW = "BuzzerCtrl" + + FAN_SPEED = "WdSpd" + SWING_HORIZONTAL = "SwingLfRig" + SWING_VERTICAL = "SwUpDn" + + TARGET_TEMPERATURE = "SetTem" + TARGET_TEMPERATURE_BIT = "TemRec" + TARGET_TEMPERATURE_UNIT = "TemUn" + + FEAT_QUIET_MODE = "Quiet" + FEAT_TURBO_MODE = "Tur" + FEAT_FRESH_AIR = "Air" + FEAT_XFAN = "Blo" + FEAT_HEALTH = "Health" + FEAT_SLEEP_MODE_SWING = "SwhSlp" + FEAT_SLEEP_MODE = "SlpMod" + FEAT_LIGHT = "Lig" + FEAT_SMART_HEAT_8C = "StHt" + FEAT_ENERGY_SAVING = "SvSt" + FEAT_ANTI_DIRECT_BLOW = "AntiDirectBlow" + FEAT_SENSOR_LIGHT = "LigSen" + + SENSOR_TEMPERATURE = "TemSen" + SENSOR_OUTSIDE_TEMPERATURE = "OutEnvTem" + SENSOR_HUMIDITY = "DwatSen" + SENSOR_FAULT = "FaultDisplay" + + _UNKN_HEAT_COOL_TYPE = "HeatCoolType" + _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" + + +ALLPROP_KEY_TO_ENUM = {prop.value: prop for prop in AllProps} @unique @@ -166,9 +452,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: @@ -413,18 +696,34 @@ async def gree_get_status( mac_addr_controller: str, mac_addr: str, uid: int, - props: list[GreeProp], + props: list[PropT], + propkey_to_enum: Mapping[str, PropT], 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.""" + value_parser: Callable[[str], PropValueT] = int, +) -> tuple[dict[PropT, PropValueT], list[PropT]]: + """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") + 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 - status_values_raw: dict[GreeProp, int | None] = {} + 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") pack = gree_create_status_pack(mac_addr, [prop.value for prop in props]) encrypted_pack, tag = gree_encrypt_pack(pack, cipher) + # WARNING: My device does not respond if the encrypted_pack is more that 1024 bytes json_payload = gree_create_payload( encrypted_pack, "pack", GreeCommand.STATUS, mac_addr_controller, uid, tag ) @@ -438,17 +737,40 @@ 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 + return {}, [cols] + + raise GreeProtocolError(f"Malformed response: cols={len(cols)} dat={len(dat)}") + + status_values: dict[PropT, PropValueT] = {} + returned_props: set[PropT] = set() + + for key, raw in zip(cols, dat, strict=True): + if key not in propkey_to_enum: + _LOGGER.debug("Ignoring unknown property key: %s", key) + continue + + prop = propkey_to_enum[key] + returned_props.add(prop) + + try: + status_values[prop] = value_parser(raw) + except Exception: + _LOGGER.exception("Failed to parse %s=%r. Skipping", prop, raw) + + invalid_props = [p for p in props if p not in returned_props] - 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 status_values, [p for p in props if p not in status_values] + return status_values, invalid_props async def gree_set_status( @@ -483,7 +805,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 +837,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 +848,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 f0fdfdc4..8a69b83b 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -1,9 +1,13 @@ """Contains the API to interface with the Gree device.""" +from itertools import islice import logging from typing import Any from .api import ( + ALLPROP_KEY_TO_ENUM, + PROP_KEY_TO_ENUM, + AllProps, EncryptionVersion, FanSpeed, GreeDiscoveredDevice, @@ -33,6 +37,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.""" @@ -151,7 +162,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( @@ -246,6 +257,7 @@ async def fetch_device_status(self): self._mac_addr, self._uid, self._props_to_update, + PROP_KEY_TO_ENUM, self._cipher, self._transport, ) @@ -324,7 +336,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 +454,36 @@ def gather_diagnostics(self) -> dict[str, Any]: return data + async def gather_prop_dump( + self, request_batch: int = 50 + ) -> tuple[dict[AllProps, str], list[AllProps]]: + """Dumps all possible props to the log.""" + props_to_update: list[AllProps] = list(AllProps) + combined_state: dict[AllProps, str] = {} + combined_missing: list[AllProps] = [] + + assert self._cipher is not None + + for props_chunk in chunked(props_to_update, request_batch): + state, missing = await gree_get_status( + self._mac_addr_controller, + self._mac_addr, + self._uid, + props_chunk, + ALLPROP_KEY_TO_ENUM, + self._cipher, + self._transport, + str, + ) + combined_state.update(state) + if len(missing) != 0: + combined_missing.extend(missing) + + _LOGGER.info("%s STATE: %s", self._mac_addr, combined_state) + _LOGGER.info("%s MISSING_PROPS: %s", self._mac_addr, combined_missing) + + return combined_state, combined_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 +552,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/config_flow.py b/custom_components/gree_custom/config_flow.py index aee846f3..8501fe75 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 dcd45937..b3293e57 100755 --- a/custom_components/gree_custom/const.py +++ b/custom_components/gree_custom/const.py @@ -99,7 +99,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 From 16153ed4f73668bacd6e549e811a6334cb8abadd Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Mon, 29 Jun 2026 11:33:25 +0100 Subject: [PATCH 02/10] Revert "Add gather_prop_dump()" This reverts commit 122edc3bb1a5dbaf16c64818ffa0c468eb8e6177. --- custom_components/gree_custom/aiogree/api.py | 384 ++---------------- .../gree_custom/aiogree/device.py | 50 +-- custom_components/gree_custom/config_flow.py | 2 +- custom_components/gree_custom/const.py | 2 +- custom_components/gree_custom/manifest.json | 2 +- 5 files changed, 38 insertions(+), 402 deletions(-) diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index 89ca3485..04c6de83 100644 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -1,12 +1,12 @@ """Contains the API to interface with the Gree device.""" -from collections.abc import Callable, Mapping -from dataclasses import dataclass -from enum import IntEnum, StrEnum, unique +from enum import Enum, IntEnum, unique import json import logging import re -from typing import Any, TypeVar +from typing import Any + +from attr import dataclass from .cipher import CipherBase, EncryptionVersion, get_cipher from .const import DEFAULT_DEVICE_PORT @@ -15,11 +15,8 @@ _LOGGER = logging.getLogger(__name__) -PropT = TypeVar("PropT", bound=StrEnum) -PropValueT = TypeVar("PropValueT") - -class GreeProp(StrEnum): +class GreeProp(Enum): """Enumeration of Gree device properties.""" # HVAC CONTROLS @@ -27,25 +24,23 @@ class GreeProp(StrEnum): 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" @@ -74,299 +69,18 @@ class GreeProp(StrEnum): 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" - -PROP_KEY_TO_ENUM = {prop.value: prop for prop in GreeProp} - - -class AllProps(StrEnum): - """Enumeration of Gree device properties.""" - - POWER = "Pow" - OP_MODE = "Mod" - BEEPER = "Buzzer_ON_OFF" - BEEPER_NEW = "BuzzerCtrl" - - FAN_SPEED = "WdSpd" - SWING_HORIZONTAL = "SwingLfRig" - SWING_VERTICAL = "SwUpDn" - - TARGET_TEMPERATURE = "SetTem" - TARGET_TEMPERATURE_BIT = "TemRec" - TARGET_TEMPERATURE_UNIT = "TemUn" - - FEAT_QUIET_MODE = "Quiet" - FEAT_TURBO_MODE = "Tur" - FEAT_FRESH_AIR = "Air" - FEAT_XFAN = "Blo" - FEAT_HEALTH = "Health" - FEAT_SLEEP_MODE_SWING = "SwhSlp" - FEAT_SLEEP_MODE = "SlpMod" - FEAT_LIGHT = "Lig" - FEAT_SMART_HEAT_8C = "StHt" - FEAT_ENERGY_SAVING = "SvSt" - FEAT_ANTI_DIRECT_BLOW = "AntiDirectBlow" - FEAT_SENSOR_LIGHT = "LigSen" - - SENSOR_TEMPERATURE = "TemSen" - SENSOR_OUTSIDE_TEMPERATURE = "OutEnvTem" - SENSOR_HUMIDITY = "DwatSen" - SENSOR_FAULT = "FaultDisplay" - - _UNKN_HEAT_COOL_TYPE = "HeatCoolType" - _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" - - -ALLPROP_KEY_TO_ENUM = {prop.value: prop for prop in AllProps} + # EXPERIMENTAL + # error display. 0 if no error, otherwise error + FAULT = "FaultDisplay" + # MODEL = "ModelType" @unique @@ -452,6 +166,9 @@ 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: @@ -696,34 +413,18 @@ async def gree_get_status( mac_addr_controller: str, mac_addr: str, uid: int, - props: list[PropT], - propkey_to_enum: Mapping[str, PropT], + props: list[GreeProp], cipher: CipherBase, transport: GreeTransport, - value_parser: Callable[[str], PropValueT] = int, -) -> tuple[dict[PropT, PropValueT], list[PropT]]: - """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. - """ +) -> 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_raw: dict[GreeProp, int | None] = {} + pack = gree_create_status_pack(mac_addr, [prop.value for prop in props]) encrypted_pack, tag = gree_encrypt_pack(pack, cipher) - # WARNING: My device does not respond if the encrypted_pack is more that 1024 bytes json_payload = gree_create_payload( encrypted_pack, "pack", GreeCommand.STATUS, mac_addr_controller, uid, tag ) @@ -737,40 +438,17 @@ async def gree_get_status( except Exception as err: raise GreeProtocolError("Error getting device status") from err - cols = result.get("cols") - dat = result.get("dat") - - if cols is None or dat is None: + if result["cols"] is None or result["dat"] is None: raise GreeProtocolError("No data received while getting device status") - if len(cols) != len(dat): - if len(cols) == 1: - # if there is a single prop without value, add to missing - return {}, [cols] - - raise GreeProtocolError(f"Malformed response: cols={len(cols)} dat={len(dat)}") - - status_values: dict[PropT, PropValueT] = {} - returned_props: set[PropT] = set() - - for key, raw in zip(cols, dat, strict=True): - if key not in propkey_to_enum: - _LOGGER.debug("Ignoring unknown property key: %s", key) - continue - - prop = propkey_to_enum[key] - returned_props.add(prop) - - try: - status_values[prop] = value_parser(raw) - except Exception: - _LOGGER.exception("Failed to parse %s=%r. Skipping", prop, raw) - - invalid_props = [p for p in props if p not in returned_props] + 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)) + 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 status_values, invalid_props + return status_values, [p for p in props if p not in status_values] async def gree_set_status( @@ -805,7 +483,7 @@ async def gree_set_status( f"Error setting device status, response code: {result['r']}" ) - options_set = [PROP_KEY_TO_ENUM[c] for c in result["opt"] if c in PROP_KEY_TO_ENUM] + 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 GreeProtocolError("No options were set, something went wrong") @@ -837,7 +515,7 @@ async def gree_set_status( async def gree_get_device_info( transport: GreeTransport, cipher: CipherBase | None = None -) -> dict[str, str | dict | None]: +) -> dict[str, str | None]: """Tries to retrive the device info.""" data: dict = await get_result_pack( @@ -848,7 +526,7 @@ async def gree_get_device_info( _LOGGER.debug("Got device info: %s", data) - info: dict[str, str | dict | None] = {} + info: dict[str, str | 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 8a69b83b..f0fdfdc4 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -1,13 +1,9 @@ """Contains the API to interface with the Gree device.""" -from itertools import islice import logging from typing import Any from .api import ( - ALLPROP_KEY_TO_ENUM, - PROP_KEY_TO_ENUM, - AllProps, EncryptionVersion, FanSpeed, GreeDiscoveredDevice, @@ -37,13 +33,6 @@ _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.""" @@ -162,7 +151,7 @@ async def bind_device(self) -> bool: return True - async def fetch_device_info(self, cipher: CipherBase | None = None): + async def fetch_device_info(self, cipher: CipherBase = None): """Updates the device info fields.""" try: self._raw_info = await gree_get_device_info( @@ -257,7 +246,6 @@ async def fetch_device_status(self): self._mac_addr, self._uid, self._props_to_update, - PROP_KEY_TO_ENUM, self._cipher, self._transport, ) @@ -336,7 +324,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 bool(prop_value) + return None if prop_value is None else bool(prop_value) def _remove_unsupported_props(self): """Remove unsupported properties from the list to update.""" @@ -454,36 +442,6 @@ def gather_diagnostics(self) -> dict[str, Any]: return data - async def gather_prop_dump( - self, request_batch: int = 50 - ) -> tuple[dict[AllProps, str], list[AllProps]]: - """Dumps all possible props to the log.""" - props_to_update: list[AllProps] = list(AllProps) - combined_state: dict[AllProps, str] = {} - combined_missing: list[AllProps] = [] - - assert self._cipher is not None - - for props_chunk in chunked(props_to_update, request_batch): - state, missing = await gree_get_status( - self._mac_addr_controller, - self._mac_addr, - self._uid, - props_chunk, - ALLPROP_KEY_TO_ENUM, - self._cipher, - self._transport, - str, - ) - combined_state.update(state) - if len(missing) != 0: - combined_missing.extend(missing) - - _LOGGER.info("%s STATE: %s", self._mac_addr, combined_state) - _LOGGER.info("%s MISSING_PROPS: %s", self._mac_addr, combined_missing) - - return combined_state, combined_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 @@ -552,9 +510,9 @@ def is_bound(self) -> bool: return self._is_bound @property - def has_hvac_error(self) -> bool: + def has_hvac_error(self) -> bool | None: """Return if there is an error with the device.""" - return self._bool_from_raw_state(GreeProp.SENSOR_FAULT) + return self._bool_from_raw_state(GreeProp.FAULT, None) @property def beeper(self) -> bool: diff --git a/custom_components/gree_custom/config_flow.py b/custom_components/gree_custom/config_flow.py index 8501fe75..aee846f3 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.SENSOR_FAULT): + 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 b3293e57..dcd45937 100755 --- a/custom_components/gree_custom/const.py +++ b/custom_components/gree_custom/const.py @@ -99,7 +99,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.SENSOR_FAULT, + GATTR_FAULTS: GreeProp.FAULT, } # HVAC modes - these come from Home Assistant and are standard diff --git a/custom_components/gree_custom/manifest.json b/custom_components/gree_custom/manifest.json index 3d5c0acc..3a019259 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.99" } From 7a2a3da5dcf1a15ae152370531f6eebc7b4eaa85 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Mon, 29 Jun 2026 11:34:45 +0100 Subject: [PATCH 03/10] 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 3a019259..3d5c0acc 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.99" + "version": "4.0.0-alpha.100" } From 3f460ec5530828eeb3bfd38a226cb83b2dd4319b Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Mon, 29 Jun 2026 11:49:45 +0100 Subject: [PATCH 04/10] Reapply "Add gather_prop_dump()" This reverts commit 488dba71b2c4bdc6090d8026a6eb407658479ca1. --- custom_components/gree_custom/aiogree/api.py | 384 ++++++++++++++++-- .../gree_custom/aiogree/device.py | 50 ++- custom_components/gree_custom/config_flow.py | 2 +- custom_components/gree_custom/const.py | 2 +- 4 files changed, 401 insertions(+), 37 deletions(-) diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index 04c6de83..89ca3485 100644 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -1,12 +1,12 @@ """Contains the API to interface with the Gree device.""" -from enum import Enum, IntEnum, unique +from collections.abc import Callable, Mapping +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 typing import Any, TypeVar from .cipher import CipherBase, EncryptionVersion, get_cipher from .const import DEFAULT_DEVICE_PORT @@ -15,8 +15,11 @@ _LOGGER = logging.getLogger(__name__) +PropT = TypeVar("PropT", bound=StrEnum) +PropValueT = TypeVar("PropValueT") + -class GreeProp(Enum): +class GreeProp(StrEnum): """Enumeration of Gree device properties.""" # HVAC CONTROLS @@ -24,23 +27,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 +74,299 @@ 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 AllProps(StrEnum): + """Enumeration of Gree device properties.""" + + POWER = "Pow" + OP_MODE = "Mod" + BEEPER = "Buzzer_ON_OFF" + BEEPER_NEW = "BuzzerCtrl" + + FAN_SPEED = "WdSpd" + SWING_HORIZONTAL = "SwingLfRig" + SWING_VERTICAL = "SwUpDn" + + TARGET_TEMPERATURE = "SetTem" + TARGET_TEMPERATURE_BIT = "TemRec" + TARGET_TEMPERATURE_UNIT = "TemUn" + + FEAT_QUIET_MODE = "Quiet" + FEAT_TURBO_MODE = "Tur" + FEAT_FRESH_AIR = "Air" + FEAT_XFAN = "Blo" + FEAT_HEALTH = "Health" + FEAT_SLEEP_MODE_SWING = "SwhSlp" + FEAT_SLEEP_MODE = "SlpMod" + FEAT_LIGHT = "Lig" + FEAT_SMART_HEAT_8C = "StHt" + FEAT_ENERGY_SAVING = "SvSt" + FEAT_ANTI_DIRECT_BLOW = "AntiDirectBlow" + FEAT_SENSOR_LIGHT = "LigSen" + + SENSOR_TEMPERATURE = "TemSen" + SENSOR_OUTSIDE_TEMPERATURE = "OutEnvTem" + SENSOR_HUMIDITY = "DwatSen" + SENSOR_FAULT = "FaultDisplay" + + _UNKN_HEAT_COOL_TYPE = "HeatCoolType" + _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" + + +ALLPROP_KEY_TO_ENUM = {prop.value: prop for prop in AllProps} @unique @@ -166,9 +452,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: @@ -413,18 +696,34 @@ async def gree_get_status( mac_addr_controller: str, mac_addr: str, uid: int, - props: list[GreeProp], + props: list[PropT], + propkey_to_enum: Mapping[str, PropT], 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.""" + value_parser: Callable[[str], PropValueT] = int, +) -> tuple[dict[PropT, PropValueT], list[PropT]]: + """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") + 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 - status_values_raw: dict[GreeProp, int | None] = {} + 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") pack = gree_create_status_pack(mac_addr, [prop.value for prop in props]) encrypted_pack, tag = gree_encrypt_pack(pack, cipher) + # WARNING: My device does not respond if the encrypted_pack is more that 1024 bytes json_payload = gree_create_payload( encrypted_pack, "pack", GreeCommand.STATUS, mac_addr_controller, uid, tag ) @@ -438,17 +737,40 @@ 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 + return {}, [cols] + + raise GreeProtocolError(f"Malformed response: cols={len(cols)} dat={len(dat)}") + + status_values: dict[PropT, PropValueT] = {} + returned_props: set[PropT] = set() + + for key, raw in zip(cols, dat, strict=True): + if key not in propkey_to_enum: + _LOGGER.debug("Ignoring unknown property key: %s", key) + continue + + prop = propkey_to_enum[key] + returned_props.add(prop) + + try: + status_values[prop] = value_parser(raw) + except Exception: + _LOGGER.exception("Failed to parse %s=%r. Skipping", prop, raw) + + invalid_props = [p for p in props if p not in returned_props] - 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 status_values, [p for p in props if p not in status_values] + return status_values, invalid_props async def gree_set_status( @@ -483,7 +805,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 +837,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 +848,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 f0fdfdc4..8a69b83b 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -1,9 +1,13 @@ """Contains the API to interface with the Gree device.""" +from itertools import islice import logging from typing import Any from .api import ( + ALLPROP_KEY_TO_ENUM, + PROP_KEY_TO_ENUM, + AllProps, EncryptionVersion, FanSpeed, GreeDiscoveredDevice, @@ -33,6 +37,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.""" @@ -151,7 +162,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( @@ -246,6 +257,7 @@ async def fetch_device_status(self): self._mac_addr, self._uid, self._props_to_update, + PROP_KEY_TO_ENUM, self._cipher, self._transport, ) @@ -324,7 +336,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 +454,36 @@ def gather_diagnostics(self) -> dict[str, Any]: return data + async def gather_prop_dump( + self, request_batch: int = 50 + ) -> tuple[dict[AllProps, str], list[AllProps]]: + """Dumps all possible props to the log.""" + props_to_update: list[AllProps] = list(AllProps) + combined_state: dict[AllProps, str] = {} + combined_missing: list[AllProps] = [] + + assert self._cipher is not None + + for props_chunk in chunked(props_to_update, request_batch): + state, missing = await gree_get_status( + self._mac_addr_controller, + self._mac_addr, + self._uid, + props_chunk, + ALLPROP_KEY_TO_ENUM, + self._cipher, + self._transport, + str, + ) + combined_state.update(state) + if len(missing) != 0: + combined_missing.extend(missing) + + _LOGGER.info("%s STATE: %s", self._mac_addr, combined_state) + _LOGGER.info("%s MISSING_PROPS: %s", self._mac_addr, combined_missing) + + return combined_state, combined_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 +552,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/config_flow.py b/custom_components/gree_custom/config_flow.py index aee846f3..8501fe75 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 dcd45937..b3293e57 100755 --- a/custom_components/gree_custom/const.py +++ b/custom_components/gree_custom/const.py @@ -99,7 +99,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 From e00d673d7a81ae74ae686e6153b57a8b6b3b569d Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Mon, 29 Jun 2026 16:19:48 +0100 Subject: [PATCH 05/10] Generalize gree_get_status() to accept str instead of a fixed enum Move the logic and reuse it in the device. Exposes query_props and query_props_all on the device level --- custom_components/gree_custom/aiogree/api.py | 85 +++++-------------- .../gree_custom/aiogree/device.py | 85 +++++++++---------- 2 files changed, 63 insertions(+), 107 deletions(-) diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index 89ca3485..3a6cf196 100644 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -1,12 +1,11 @@ """Contains the API to interface with the Gree device.""" -from collections.abc import Callable, Mapping from dataclasses import dataclass from enum import IntEnum, StrEnum, unique import json import logging import re -from typing import Any, TypeVar +from typing import Any from .cipher import CipherBase, EncryptionVersion, get_cipher from .const import DEFAULT_DEVICE_PORT @@ -15,9 +14,6 @@ _LOGGER = logging.getLogger(__name__) -PropT = TypeVar("PropT", bound=StrEnum) -PropValueT = TypeVar("PropValueT") - class GreeProp(StrEnum): """Enumeration of Gree device properties.""" @@ -89,41 +85,9 @@ class GreeProp(StrEnum): PROP_KEY_TO_ENUM = {prop.value: prop for prop in GreeProp} -class AllProps(StrEnum): - """Enumeration of Gree device properties.""" - - POWER = "Pow" - OP_MODE = "Mod" - BEEPER = "Buzzer_ON_OFF" - BEEPER_NEW = "BuzzerCtrl" - - FAN_SPEED = "WdSpd" - SWING_HORIZONTAL = "SwingLfRig" - SWING_VERTICAL = "SwUpDn" - - TARGET_TEMPERATURE = "SetTem" - TARGET_TEMPERATURE_BIT = "TemRec" - TARGET_TEMPERATURE_UNIT = "TemUn" +class OtherProps(StrEnum): + """Enumeration of other Gree device properties.""" - FEAT_QUIET_MODE = "Quiet" - FEAT_TURBO_MODE = "Tur" - FEAT_FRESH_AIR = "Air" - FEAT_XFAN = "Blo" - FEAT_HEALTH = "Health" - FEAT_SLEEP_MODE_SWING = "SwhSlp" - FEAT_SLEEP_MODE = "SlpMod" - FEAT_LIGHT = "Lig" - FEAT_SMART_HEAT_8C = "StHt" - FEAT_ENERGY_SAVING = "SvSt" - FEAT_ANTI_DIRECT_BLOW = "AntiDirectBlow" - FEAT_SENSOR_LIGHT = "LigSen" - - SENSOR_TEMPERATURE = "TemSen" - SENSOR_OUTSIDE_TEMPERATURE = "OutEnvTem" - SENSOR_HUMIDITY = "DwatSen" - SENSOR_FAULT = "FaultDisplay" - - _UNKN_HEAT_COOL_TYPE = "HeatCoolType" _UNKN_MODEL = "ModelType" _UNKN_ACStupPos = "ACStupPos" _UNKN_ActiveTime = "ActiveTime" @@ -366,9 +330,6 @@ class AllProps(StrEnum): # _INV_DevId = "DevID" -ALLPROP_KEY_TO_ENUM = {prop.value: prop for prop in AllProps} - - @unique class TemperatureUnits(IntEnum): """Enumeration of temperature units.""" @@ -696,12 +657,10 @@ async def gree_get_status( mac_addr_controller: str, mac_addr: str, uid: int, - props: list[PropT], - propkey_to_enum: Mapping[str, PropT], + props: list[str], cipher: CipherBase, transport: GreeTransport, - value_parser: Callable[[str], PropValueT] = int, -) -> tuple[dict[PropT, PropValueT], list[PropT]]: +) -> 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 @@ -719,11 +678,15 @@ async def gree_get_status( 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) - 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) + # WARNING: My device does not respond if the encrypted_pack is more that 1024 bytes + if len(encrypted_pack.encode("utf-8")) > 1024: + _LOGGER.warning("Pack length is over 1024 bytes") + json_payload = gree_create_payload( encrypted_pack, "pack", GreeCommand.STATUS, mac_addr_controller, uid, tag ) @@ -746,30 +709,28 @@ async def gree_get_status( 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: dict[PropT, PropValueT] = {} - returned_props: set[PropT] = set() + status_values: dict[str, str] = {} + returned_props: set[str] = set() - for key, raw in zip(cols, dat, strict=True): - if key not in propkey_to_enum: - _LOGGER.debug("Ignoring unknown property key: %s", key) - continue - - prop = propkey_to_enum[key] + for prop, value in zip(cols, dat, strict=True): returned_props.add(prop) - - try: - status_values[prop] = value_parser(raw) - except Exception: - _LOGGER.exception("Failed to parse %s=%r. Skipping", prop, raw) + status_values[prop] = value invalid_props = [p for p in props if p not in returned_props] - _LOGGER.debug("Device status values: %s", status_values) + _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 diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index 8a69b83b..b7cd48c4 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -5,15 +5,14 @@ from typing import Any from .api import ( - ALLPROP_KEY_TO_ENUM, PROP_KEY_TO_ENUM, - AllProps, EncryptionVersion, FanSpeed, GreeDiscoveredDevice, GreeProp, HorizontalSwingMode, OperationMode, + OtherProps, TemperatureUnits, VerticalSwingMode, gree_get_device_info, @@ -109,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 @@ -244,38 +243,24 @@ 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, - PROP_KEY_TO_ENUM, - 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) + + status_values: dict[GreeProp, int] = {} + + for key, val in status.items(): + try: + prop = PROP_KEY_TO_ENUM[key] + status_values[prop] = int(val) + except Exception: + _LOGGER.exception("Failed to parse %s=%r. Skipping", key, val) + + self._raw_state.update(status_values) self._is_available = True @@ -454,36 +439,46 @@ def gather_diagnostics(self) -> dict[str, Any]: return data - async def gather_prop_dump( - self, request_batch: int = 50 - ) -> tuple[dict[AllProps, str], list[AllProps]]: - """Dumps all possible props to the log.""" - props_to_update: list[AllProps] = list(AllProps) - combined_state: dict[AllProps, str] = {} - combined_missing: list[AllProps] = [] + async def query_props( + self, props: list[str], request_batch: int = 1 + ) -> 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_to_update, request_batch): + for props_chunk in chunked(props, request_batch): state, missing = await gree_get_status( self._mac_addr_controller, self._mac_addr, self._uid, props_chunk, - ALLPROP_KEY_TO_ENUM, self._cipher, self._transport, - str, ) combined_state.update(state) if len(missing) != 0: combined_missing.extend(missing) - _LOGGER.info("%s STATE: %s", self._mac_addr, combined_state) - _LOGGER.info("%s MISSING_PROPS: %s", self._mac_addr, combined_missing) - return combined_state, combined_missing + async def query_props_all( + self, request_batch: int = 1 + ) -> 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) + 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 From fa06afbb8e6a7e5b6cfe5620728baaa37a908e94 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Mon, 29 Jun 2026 19:25:12 +0100 Subject: [PATCH 06/10] Expose services to query device properties Adds 2 services to query specific or all device properties --- custom_components/gree_custom/__init__.py | 15 +- custom_components/gree_custom/aiogree/api.py | 3 + .../gree_custom/aiogree/device.py | 35 ++-- custom_components/gree_custom/const.py | 2 + custom_components/gree_custom/icons.json | 10 +- custom_components/gree_custom/manifest.json | 2 +- custom_components/gree_custom/services.py | 161 ++++++++++++++++++ custom_components/gree_custom/services.yaml | 23 +++ .../gree_custom/translations/en.json | 37 +++- .../gree_custom/translations/pt.json | 35 ++++ 10 files changed, 299 insertions(+), 24 deletions(-) create mode 100644 custom_components/gree_custom/services.py create mode 100644 custom_components/gree_custom/services.yaml diff --git a/custom_components/gree_custom/__init__.py b/custom_components/gree_custom/__init__.py index 9e8edd1c..70564f70 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 3a6cf196..36edf41b 100644 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -680,6 +680,9 @@ async def gree_get_status( _LOGGER.debug("Getting status for device '%s'", mac_addr) + # 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, props) encrypted_pack, tag = gree_encrypt_pack(pack, cipher) diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index b7cd48c4..fc47cb15 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -440,7 +440,7 @@ def gather_diagnostics(self) -> dict[str, Any]: return data async def query_props( - self, props: list[str], request_batch: int = 1 + 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.""" @@ -453,22 +453,29 @@ async def query_props( assert self._cipher is not None for props_chunk in chunked(props, request_batch): - 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) + 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 + self, request_batch: int = 1, error_as_missing: bool = False ) -> tuple[dict[str, str], list[str]]: """Query all possible props to the log.""" @@ -477,7 +484,7 @@ async def query_props_all( *[prop.value for prop in OtherProps], ] - return await self.query_props(all_props, request_batch) + 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.""" diff --git a/custom_components/gree_custom/const.py b/custom_components/gree_custom/const.py index b3293e57..f9dcbc53 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, diff --git a/custom_components/gree_custom/icons.json b/custom_components/gree_custom/icons.json index d8da3a7b..1d519545 100755 --- a/custom_components/gree_custom/icons.json +++ b/custom_components/gree_custom/icons.json @@ -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 3d5c0acc..0e2aece3 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 00000000..58f9a8cc --- /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 Shelly 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 00000000..b56c919f --- /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 c6cbadcc..17bf465d 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 10aeb941..19004507 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 From 60d6e4bcbdab9a41797ff1a1adc81811ede30126 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Wed, 1 Jul 2026 21:11:12 +0100 Subject: [PATCH 07/10] Move pack size warning to common function --- custom_components/gree_custom/aiogree/api.py | 8 ++++---- custom_components/gree_custom/services.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index 36edf41b..613f415c 100644 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -472,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) @@ -686,10 +690,6 @@ async def gree_get_status( pack = gree_create_status_pack(mac_addr, props) encrypted_pack, tag = gree_encrypt_pack(pack, cipher) - # WARNING: My device does not respond if the encrypted_pack is more that 1024 bytes - if len(encrypted_pack.encode("utf-8")) > 1024: - _LOGGER.warning("Pack length is over 1024 bytes") - json_payload = gree_create_payload( encrypted_pack, "pack", GreeCommand.STATUS, mac_addr_controller, uid, tag ) diff --git a/custom_components/gree_custom/services.py b/custom_components/gree_custom/services.py index 58f9a8cc..e505aec7 100644 --- a/custom_components/gree_custom/services.py +++ b/custom_components/gree_custom/services.py @@ -137,7 +137,7 @@ async def async_get_prop_values(call: ServiceCall) -> ServiceResponse: @callback def async_setup_services(hass: HomeAssistant) -> None: - """Set up the services for Shelly integration.""" + """Set up the services for Gree integration.""" for service, method, schema, response in ( ( SVC_GET_PROPS_ALL, From c5e49e469167b637a9ff9401057a3ce3cabea569 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Wed, 1 Jul 2026 21:16:21 +0100 Subject: [PATCH 08/10] Remove unecessary temporary dictionary --- custom_components/gree_custom/aiogree/device.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index fc47cb15..3fc12a1f 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -251,17 +251,13 @@ async def fetch_device_status(self): len(self._props_to_update), ) - status_values: dict[GreeProp, int] = {} - for key, val in status.items(): try: prop = PROP_KEY_TO_ENUM[key] - status_values[prop] = int(val) + self._raw_state[prop] = int(val) except Exception: _LOGGER.exception("Failed to parse %s=%r. Skipping", key, val) - self._raw_state.update(status_values) - self._is_available = True except GreeConnectionError, GreeProtocolError: From 5620b0e3e0fde88166cb470a295bcdcb99eb6678 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Thu, 2 Jul 2026 21:56:12 +0100 Subject: [PATCH 09/10] Fix wrong lookup for fan mode in climate entity --- custom_components/gree_custom/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/gree_custom/climate.py b/custom_components/gree_custom/climate.py index 576da748..6ddb2808 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 From 4246a66d5852bf7ae900ad7c43e2344db5140188 Mon Sep 17 00:00:00 2001 From: Pedro Monteiro Date: Thu, 9 Jul 2026 09:38:26 +0100 Subject: [PATCH 10/10] Use different icon for sleep and quiet fan --- custom_components/gree_custom/icons.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/gree_custom/icons.json b/custom_components/gree_custom/icons.json index 1d519545..fdc9bed6 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"