Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion custom_components/gree/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
OPTION_KEYS,
)

PLATFORMS = [Platform.CLIMATE, Platform.SWITCH, Platform.NUMBER, Platform.SELECT, Platform.SENSOR]
PLATFORMS = [Platform.CLIMATE, Platform.SWITCH, Platform.NUMBER, Platform.SELECT, Platform.SENSOR, Platform.BINARY_SENSOR]
_LOGGER = logging.getLogger(__name__)

# YAML configuration schema
Expand Down
81 changes: 81 additions & 0 deletions custom_components/gree/binary_sensor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Support for Gree binary sensors."""

from __future__ import annotations

# Standard library imports
import logging
from dataclasses import dataclass

# Home Assistant imports
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.helpers.entity import EntityCategory

# Local imports
from .const import DOMAIN
from .entity import GreeEntity, GreeEntityDescription

_LOGGER = logging.getLogger(__name__)


@dataclass
class GreeBinarySensorEntityDescription(GreeEntityDescription, BinarySensorEntityDescription):
"""Describes Gree binary sensor entity."""

pass


BINARY_SENSORS: tuple[GreeBinarySensorEntityDescription, ...] = (
GreeBinarySensorEntityDescription(
property_key="filter_alarm",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
icon="mdi:air-filter",
value_fn=lambda device: device.filter_alarm,
available_fn=lambda device: device.available and bool(device._has_filter_alarm),
),
GreeBinarySensorEntityDescription(
property_key="hepa_alarm",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
icon="mdi:air-purifier",
value_fn=lambda device: device.hepa_alarm,
available_fn=lambda device: device.available and bool(device._has_hepa_alarm),
),
)


async def async_setup_entry(hass, entry, async_add_entities):
"""Set up Gree binary sensors from a config entry."""
entry_data = hass.data[DOMAIN][entry.entry_id]
device = entry_data["device"]

sensors = [
GreeBinarySensor(hass, entry, description)
for description in BINARY_SENSORS
if description.exists_fn(description, device)
]

if sensors:
async_add_entities(sensors)
_LOGGER.info(f"Added {len(sensors)} Gree binary sensors")


class GreeBinarySensor(GreeEntity, BinarySensorEntity):
"""Gree binary sensor entity."""

entity_description: GreeBinarySensorEntityDescription

@property
def is_on(self) -> bool | None:
"""Return True when the device reports a problem."""
return self.entity_description.value_fn(self._device)

@property
def available(self) -> bool:
"""Return True if entity is available."""
return self.entity_description.available_fn(self._device)
229 changes: 139 additions & 90 deletions custom_components/gree/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@
MAX_TEMP_F,
MODES_MAPPING,
TEMSEN_OFFSET,
DIAGNOSTIC_TEMP_OFFSET,
PROBED_PROPS,
CONTROLLABLE_OPTIONAL_PROPS,
CONF_HVAC_MODES,
CONF_FAN_MODES,
CONF_SWING_MODES,
Expand Down Expand Up @@ -183,11 +186,9 @@ def __init__(
# Keep unsub callbacks for deregistering listeners
self._listeners: list = []

self._has_temp_sensor = None
self._has_anti_direct_blow = None
self._has_light_sensor = None
self._has_outside_temp_sensor = None
self._has_room_humidity_sensor = None
# Optional-feature flags: None = not probed yet, True/False = probe result.
for _prop, _flag in PROBED_PROPS:
setattr(self, _flag, None)

self._current_temperature = None
self._current_anti_direct_blow = None
Expand Down Expand Up @@ -251,7 +252,8 @@ def __init__(
# helper method to determine TemSen offset
self._process_temp_sensor = TempOffsetResolver()

async def GreeGetValues(self, propertyNames):
async def GreeFetchStatus(self, propertyNames):
"""Send a status request for propertyNames and return the decoded response."""
plaintext = '{"cols":' + simplejson.dumps(propertyNames) + ',"mac":"' + str(self._sub_mac_addr) + '","t":"status"}'
if self.encryption_version == 1:
cipher = self.CIPHER
Expand All @@ -260,9 +262,21 @@ async def GreeGetValues(self, propertyNames):
pack, tag = EncryptGCM(self._encryption_key, plaintext)
jsonPayloadToSend = '{"cid":"app","i":0,"pack":"' + pack + '","t":"pack","tcid":"' + str(self._mac_addr) + '","uid":{}'.format(self._uid) + ',"tag" : "' + tag + '"}'
cipher = GetGCMCipher(self._encryption_key)
result = await FetchResult(cipher, self._ip_addr, self._port, jsonPayloadToSend, encryption_version=self.encryption_version)
return await FetchResult(cipher, self._ip_addr, self._port, jsonPayloadToSend, encryption_version=self.encryption_version)

async def GreeGetValues(self, propertyNames):
result = await self.GreeFetchStatus(propertyNames)
return result["dat"][0] if len(result["dat"]) == 1 else result["dat"]

async def GreeGetSupportedValues(self, propertyNames):
"""Return {column: value} for the requested properties.

The device answers with only those columns it actually implements, so the keys of
the returned mapping are exactly the supported subset of propertyNames.
"""
result = await self.GreeFetchStatus(propertyNames)
return dict(zip(result.get("cols", []), result.get("dat", [])))

def SetAcOptions(self, acOptions, newOptionsToOverride, optionValuesToOverride=None):
if optionValuesToOverride is not None:
# Build a list of key-value pairs for a single log line
Expand All @@ -283,6 +297,9 @@ def SetAcOptions(self, acOptions, newOptionsToOverride, optionValuesToOverride=N

async def SendStateToAc(self):
opt_list = ["Pow", "Mod", "SetTem", "WdSpd", "Air", "Blo", "Health", "SwhSlp", "Lig", "SwingLfRig", "SwUpDn", "Quiet", "Tur", "StHt", "TemUn", "HeatCoolType", "TemRec", "SvSt", "SlpMod", "AntiDirectBlow", "LigSen"]
# Writable optional properties. Ones the device never reported stay None in
# _acOptions and are dropped by the filter below.
opt_list += [prop for prop in CONTROLLABLE_OPTIONAL_PROPS if prop not in opt_list]

# Collect values from _acOptions
p_values = [self._acOptions.get(k) for k in opt_list]
Expand Down Expand Up @@ -466,93 +483,55 @@ def UpdateHAStateToCurrentACState(self):
self.UpdateHAOutsideTemperature()
self.UpdateHARoomHumidity()

async def SyncState(self, acOptions={}):
# Fetch current settings from HVAC
_LOGGER.debug(f"{self._name}: Starting device state sync")

if self._has_temp_sensor is None:
_LOGGER.debug("Attempt to check whether device has an built-in temperature sensor")
try:
temp_sensor = await self.GreeGetValues(["TemSen"])
except Exception:
_LOGGER.debug("Could not determine whether device has an built-in temperature sensor. Retrying at next update()")
else:
if temp_sensor:
self._has_temp_sensor = True
self._acOptions.update({"TemSen": None})
self._optionsToFetch.append("TemSen")
_LOGGER.debug("Device has an built-in temperature sensor")
else:
self._has_temp_sensor = False
_LOGGER.debug("Device has no built-in temperature sensor")
async def _ProbeOptionalProps(self):
"""Detect which optional properties this device implements, once per property.

A property counts as supported when the device echoes its name back in the
response's column list. Testing the value instead would misclassify a legitimate
zero -- a stopped compressor, an unlocked child lock -- as an absent feature.

Every supported key is appended to the poll list. Unsupported keys must stay out
of it: the device omits unknown columns from its reply, and SetAcOptions() pairs
the reply positionally with the requested column list, so one missing column would
shift every value after it.
"""
pending = [(prop, flag) for prop, flag in PROBED_PROPS if getattr(self, flag, None) is None]
if not pending:
return

# One request covering every pending key: the reply already tells us which of them
# exist. Older firmware that rejects a long or unfamiliar column list falls back to
# probing each key on its own.
try:
supported = set(await self.GreeGetSupportedValues([prop for prop, _ in pending]))
except Exception as e:
_LOGGER.debug(f"{self._name}: Batch feature probe failed ({e}), falling back to individual probes")
supported = set()
for prop, _flag in pending:
try:
if prop in await self.GreeGetSupportedValues([prop]):
supported.add(prop)
except Exception:
_LOGGER.debug(f"{self._name}: Could not probe {prop}. Retrying at next update()")
return

# Check if device has anti direct blow feature
if self._has_anti_direct_blow is None:
_LOGGER.debug("Attempt to check whether device has an anti direct blow feature")
try:
anti_direct_blow = await self.GreeGetValues(["AntiDirectBlow"])
except Exception:
_LOGGER.debug("Could not determine whether device has an anti direct blow feature. Retrying at next update()")
else:
if anti_direct_blow:
self._has_anti_direct_blow = True
self._acOptions.update({"AntiDirectBlow": None})
self._optionsToFetch.append("AntiDirectBlow")
_LOGGER.debug("Device has an anti direct blow feature")
else:
self._has_anti_direct_blow = False
_LOGGER.debug("Device has no anti direct blow feature")
for prop, flag in pending:
is_supported = prop in supported
setattr(self, flag, is_supported)
if is_supported:
self._acOptions.update({prop: None})
self._optionsToFetch.append(prop)

# Check if device has light sensor
if self._has_light_sensor is None:
_LOGGER.debug("Attempt to check whether device has a built-in light sensor")
try:
light_sensor = await self.GreeGetValues(["LigSen"])
except Exception:
_LOGGER.debug("Could not determine whether device has a built-in light sensor. Retrying at next update()")
else:
if light_sensor:
self._has_light_sensor = True
self._acOptions.update({"LigSen": None})
self._optionsToFetch.append("LigSen")
_LOGGER.debug("Device has a built-in light sensor")
else:
self._has_light_sensor = False
_LOGGER.debug("Device has no built-in light sensor")
found = sorted(prop for prop, _ in pending if prop in supported)
missing = sorted(prop for prop, _ in pending if prop not in supported)
_LOGGER.debug(f"{self._name}: Optional properties supported: {found or 'none'}")
_LOGGER.debug(f"{self._name}: Optional properties not supported: {missing or 'none'}")

# Check if device has outside temperature sensor
if self._has_outside_temp_sensor is None:
_LOGGER.debug("Attempt to check whether device has an outside temperature sensor")
try:
outside_temp_sensor = await self.GreeGetValues(["OutEnvTem"])
except Exception:
_LOGGER.debug("Could not determine whether device has an outside temperature sensor. Retrying at next update()")
else:
if outside_temp_sensor:
self._has_outside_temp_sensor = True
self._acOptions.update({"OutEnvTem": None})
self._optionsToFetch.append("OutEnvTem")
_LOGGER.debug("Device has an outside temperature sensor")
else:
self._has_outside_temp_sensor = False
_LOGGER.debug("Device has no outside temperature sensor")
async def SyncState(self, acOptions={}):
# Fetch current settings from HVAC
_LOGGER.debug(f"{self._name}: Starting device state sync")

# Check if device has room humidity sensor
if self._has_room_humidity_sensor is None:
_LOGGER.debug("Attempt to check whether device has a room humidity sensor")
try:
humidity_sensor = await self.GreeGetValues(["DwatSen"])
except Exception:
_LOGGER.debug("Could not determine whether device has a room humidity sensor. Retrying at next update()")
else:
if humidity_sensor:
self._has_room_humidity_sensor = True
self._acOptions.update({"DwatSen": None})
self._optionsToFetch.append("DwatSen")
_LOGGER.debug("Device has a room humidity sensor")
else:
self._has_room_humidity_sensor = False
_LOGGER.debug("Device has no room humidity sensor")
await self._ProbeOptionalProps()

optionsToFetch = self._optionsToFetch

Expand Down Expand Up @@ -780,6 +759,76 @@ def room_humidity(self):
return self._current_room_humidity
return None

def _diagnostic_value(self, key, flag):
"""Return a raw optional property, or None when the device does not report it."""
if not getattr(self, flag, False):
return None
return self._acOptions.get(key)

def _diagnostic_temperature(self, key, flag):
"""Same as _diagnostic_value but decodes the +40 °C sensor offset."""
raw = self._diagnostic_value(key, flag)
if raw is None:
return None
return raw - DIAGNOSTIC_TEMP_OFFSET

@property
def compressor_frequency(self):
"""Return the outdoor compressor frequency in Hz.

On multi-split systems every indoor unit reports the frequency of the shared
outdoor unit, so this value is a property of the system, not of one room.
"""
return self._diagnostic_value("CompressorFqy", "_has_compressor_freq")

@property
def compressor_temperature(self):
"""Return the compressor temperature in °C."""
return self._diagnostic_temperature("CompressorTem", "_has_compressor_temp")

@property
def evaporator_temperature(self):
"""Return the indoor coil (evaporator) temperature in °C."""
return self._diagnostic_temperature("InEvaTem", "_has_evaporator_temp")

@property
def env_temperature(self):
"""Return the secondary indoor ambient sensor reading in °C."""
return self._diagnostic_temperature("EnvTem", "_has_env_temp")

@property
def outside_temperature_alt(self):
"""Return the secondary outdoor sensor reading in °C."""
return self._diagnostic_temperature("TemsSenOut", "_has_outside_temp_alt")

@property
def pm25(self):
"""Return the PM2.5 reading, or None when the device reports no measurement."""
value = self._diagnostic_value("PM2P5", "_has_pm25")
return value if value else None

@property
def error_code(self):
"""Return the aggregated fault code (0 means no fault)."""
return self._diagnostic_value("AllErr", "_has_all_err")

@property
def jf_error_code(self):
"""Return the secondary fault code register (0 means no fault)."""
return self._diagnostic_value("JFErrorCode", "_has_jf_error")

@property
def filter_alarm(self):
"""Return True when the device asks for filter cleaning."""
value = self._diagnostic_value("Dfltr", "_has_filter_alarm")
return None if value is None else bool(value)

@property
def hepa_alarm(self):
"""Return True when the device asks for HEPA replacement."""
value = self._diagnostic_value("ReplaceHEPA", "_has_hepa_alarm")
return None if value is None else bool(value)

@property
def extra_state_attributes(self):
"""Return additional state attributes."""
Expand Down
Loading