Skip to content
Open
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
3 changes: 2 additions & 1 deletion custom_components/gree_custom/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,8 @@ def cleanup_device_connection_issues(
registry = ir.async_get(hass)
prefix = f"{ISSUE_DEVICE_CONNECTION_FAILED}_{config_entry_id}_"

for domain, issue_id in registry.issues:
# Loop over a copy, the delete below changes the dict
for domain, issue_id in list(registry.issues):
if domain != DOMAIN or not issue_id.startswith(prefix):
continue

Expand Down
89 changes: 86 additions & 3 deletions custom_components/gree_custom/aiogree/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,20 @@ class GreeProp(StrEnum):
# If set to 1 the unit will beep on every command (available on newer firmwares)
BEEPER_NEW = "BuzzerCtrl"

# ZONE CONTROLLER
# target temperature of one zone, in whole degrees Celsius minus 16
ZONE_TARGET_TEMPERATURE = "StTem"
# used to distinguish between Fahrenheit values of a zone, like TemRec
ZONE_TARGET_TEMPERATURE_BIT = "StFahFlg"
# the unit of temperature of a zone controller and all its sub-units
ZONE_TEMPERATURE_UNIT = "TemUnit"


PROP_KEY_TO_ENUM = {prop.value: prop for prop in GreeProp}

# Props every device is polled for by default; the beeper is only written, never read
# Props every device is polled for by default. The beeper is only written,
# never read. The first poll drops every prop a device does not answer, so a
# unit without the zone controller columns asks for them only once.
POLLED_PROPS: tuple[GreeProp, ...] = tuple(
p for p in GreeProp if p not in (GreeProp.BEEPER, GreeProp.BEEPER_NEW)
)
Expand Down Expand Up @@ -471,6 +481,55 @@ class HumidityControlMode(IntEnum):
smart_dry = 2 # This is only available in cool operation mode


@unique
class DeviceType(StrEnum):
"""What kind of device a unit is. Known after the first bind.

A zone controller (seen: LE60-13/GH with a ME31-00/C13 WiFi module) is a
gateway like a VRF gateway. Its sub-unit `00` is the ducted unit, which has
the mode and the fan. Sub-units `01` to `08` are the zones. See
docs/protocol.md.
"""

# A standalone unit
AC_UNIT = "ac_unit"
# An indoor unit behind a VRF gateway
VRF_UNIT = "vrf_unit"
# Sub-unit 00 of a zone controller: the ducted unit
ZONE_CONTROLLER = "zone_controller"
# Sub-unit 01 to 08 of a zone controller
ZONE = "zone"


# The sub-units of a zone controller. They number the modes in their own way.
ZONE_CONTROLLER_TYPES = frozenset({DeviceType.ZONE_CONTROLLER, DeviceType.ZONE})


# A sub-unit of a zone controller answers the info request with the name of
# the controller, which starts with this. The MAC suffix tells the ducted unit
# from the zones. The WiFi module sets both, not the units: they are fixed in
# its firmware (v1.21, v1.27 and v1.28 checked).
ZONE_CONTROLLER_NAME_PREFIX = "GR-ZCntrlr"
ZONE_CONTROLLER_AC_UNIT_SUFFIX = "00"


# The ducted unit behind a zone controller numbers the modes in its own way.
ZONE_CONTROLLER_MODES: dict[int, OperationMode] = {
1: OperationMode.cool,
2: OperationMode.heat,
3: OperationMode.dry,
4: OperationMode.fan,
5: OperationMode.auto,
}
ZONE_CONTROLLER_MODES_TO_RAW: dict[OperationMode, int] = {
mode: raw for raw, mode in ZONE_CONTROLLER_MODES.items()
}
# It has no turbo column. Turbo is one more fan speed.
ZONE_CONTROLLER_FAN_TURBO = 6
# A zone stores its target temperature as degrees Celsius minus this.
ZONE_TEMPERATURE_OFFSET = 16


class GreeCommand(StrEnum):
"""Enumeration of Gree commands."""

Expand Down Expand Up @@ -1281,6 +1340,25 @@ def _parse_sub_devices_list(
return sub_devs


def _zone_controller_sub_unit_name(sub_mac: str, controller_name: str) -> str | None:
"""Name a sub-unit of a zone controller.

A zone controller calls every sub-unit "zone", so that name says nothing.
The name of the controller tells that it is a zone controller, and the
last two characters of the MAC tell the ducted unit from the zones and
give the zone number.

Returns:
The name, or None if the unit is not part of a zone controller

"""
if not controller_name.startswith(ZONE_CONTROLLER_NAME_PREFIX):
return None
if sub_mac.endswith(ZONE_CONTROLLER_AC_UNIT_SUFFIX):
return "AC unit"
return f"Zone {int(sub_mac[-2:], 16)}"


async def _get_sub_devices_list(
mac_addr_controller: str,
uid: int,
Expand Down Expand Up @@ -1383,11 +1461,15 @@ async def _get_sub_devices_list(
discovered_subdevices: list[GreeDiscoveredDevice] = []
for sub_mac, sub_dev in merged.items():
new_dev: GreeDiscoveredDevice
zone_name = _zone_controller_sub_unit_name(
sub_mac, parent_device.name if parent_device else ""
)
if parent_device:
# TODO: get real-data from VRF discovery to check the result list fields
new_dev = replace(
parent_device,
name=sub_dev.get(
name=zone_name
or sub_dev.get(
"name",
f"{sub_mac[-5:]} VRF at {parent_device.name}",
),
Expand All @@ -1397,7 +1479,8 @@ async def _get_sub_devices_list(
)
else:
new_dev = GreeDiscoveredDevice(
name=sub_dev.get(
name=zone_name
or sub_dev.get(
"name",
f"{sub_mac[-5:]} VRF at {mac_addr_controller[-5:]}",
),
Expand Down
139 changes: 132 additions & 7 deletions custom_components/gree_custom/aiogree/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@
from packaging.version import Version

from .api import (
ZONE_CONTROLLER_AC_UNIT_SUFFIX,
ZONE_CONTROLLER_FAN_TURBO,
ZONE_CONTROLLER_MODES,
ZONE_CONTROLLER_MODES_TO_RAW,
ZONE_CONTROLLER_NAME_PREFIX,
ZONE_CONTROLLER_TYPES,
ZONE_TEMPERATURE_OFFSET,
DeviceType,
EncryptionVersion,
FanSpeed,
GreeProp,
Expand Down Expand Up @@ -96,6 +104,8 @@ def __init__(
self._firmware_code: str | None = None
self._firmware_protocol_version: str = ""

self._device_type: DeviceType = DeviceType.AC_UNIT

self._state = DeviceState(
device_id=self.unique_id, capabilities=self._capabilities
)
Expand Down Expand Up @@ -175,6 +185,7 @@ async def bind_with_transport(

# Fetch initial information after sucessful bind
await self.fetch_device_info()
self._detect_device_type()
await self.fetch_device_status(first_fetch=True)
self._remove_unsupported_props()
return
Expand All @@ -185,6 +196,31 @@ def _device_pushed_status(self, status: dict[str, str]) -> None:
_LOGGER.debug("[%s] Got data pushed from the device", self.unique_id)
self._state.process_new_state(status)

def _detect_device_type(self) -> None:
"""Find out what kind of device this is.

A unit that is not a sub-unit is a standalone unit. A sub-unit of a
zone controller answers the info request with the name of the zone
controller, and its MAC suffix tells the ducted unit from the zones.
Every other sub-unit is a VRF indoor unit.

The columns a device uses come from what it answers. The type is only
used where the columns cannot tell: the mode numbers and turbo of a
zone controller, and which entities a sub-unit gets. The ducted unit
also answers StTem, for example, always with 0.
"""
name = self._state.info.get(InfoProp.DEVICE_NAME, "")
if not self.is_sub_unit:
self._device_type = DeviceType.AC_UNIT
elif not name.startswith(ZONE_CONTROLLER_NAME_PREFIX):
self._device_type = DeviceType.VRF_UNIT
elif self._mac_addr.endswith(ZONE_CONTROLLER_AC_UNIT_SUFFIX):
self._device_type = DeviceType.ZONE_CONTROLLER
else:
self._device_type = DeviceType.ZONE

_LOGGER.debug("[%s] Device type: %s", self.unique_id, self._device_type)

async def unbind_device(self) -> None:
"""Properly disconnect the device from transport."""
if not self._api.bound:
Expand Down Expand Up @@ -400,6 +436,7 @@ def gather_diagnostics(self) -> dict[str, Any]:
"transport": str(self._api.transport),
"mac": self.mac_address,
"mac_controller": self.mac_address_controller,
"device_type": self._device_type.value,
"name": self.name,
"fw": self.firmware_version,
"is_bound": self._api.bound,
Expand Down Expand Up @@ -450,6 +487,13 @@ async def set_props(self, values: Mapping[str, int]) -> None:

def supports_property(self, property: GreeProp) -> bool:
"""Return True if the device endpoint supports the property."""
# A zone controller has turbo as a fan speed, not as its own column.
if (
property is GreeProp.FEAT_TURBO_MODE
and self._device_type is DeviceType.ZONE_CONTROLLER
):
return self._state.supports(GreeProp.FAN_SPEED)

# We consider a property as unsupported if it is not present in the raw state list
# This assumes that the full state is fetched at least once before this method is called
return self._state.supports(property)
Expand Down Expand Up @@ -591,6 +635,11 @@ def is_sub_unit(self) -> bool:
"""Return True if the device is an indoor unit behind a VRF gateway."""
return self.mac_address != self.mac_address_controller

@property
def device_type(self) -> DeviceType:
"""Return what kind of device this is. Known after the first bind."""
return self._device_type

@property
def has_held_values(self) -> bool:
"""Return True if sent values still wait for the device to confirm them."""
Expand Down Expand Up @@ -682,6 +731,11 @@ def set_power_mode(self, value: bool) -> None:
@property
def operation_mode(self) -> OperationMode:
"""Return the current operation mode."""
if self._device_type in ZONE_CONTROLLER_TYPES:
return ZONE_CONTROLLER_MODES.get(
self._state.get(GreeProp.OP_MODE) or 0, OperationMode.auto
)

return OperationMode(
self._state.get(GreeProp.OP_MODE) or OperationMode.auto.value
)
Expand All @@ -706,12 +760,29 @@ def set_operation_mode(self, mode: OperationMode) -> None:
if mode != OperationMode.heat and self.feature_smart_heat:
self.set_feature_smart_heat(False)

if self._device_type in ZONE_CONTROLLER_TYPES:
# Turbo is a fan speed here, and only Cool and Heat have it.
if self.feature_turbo and mode not in (
OperationMode.cool,
OperationMode.heat,
):
self.set_feature_turbo(False)
self._state.set(GreeProp.OP_MODE, ZONE_CONTROLLER_MODES_TO_RAW[mode])
return

self._state.set(GreeProp.OP_MODE, mode)

@property
def fan_speed(self) -> FanSpeed:
"""Return the current fan speed."""
return FanSpeed(self._state.get(GreeProp.FAN_SPEED) or FanSpeed.auto.value)
raw = self._state.get(GreeProp.FAN_SPEED) or FanSpeed.auto.value
if (
self._device_type in ZONE_CONTROLLER_TYPES
and raw == ZONE_CONTROLLER_FAN_TURBO
):
# Turbo is reported through `feature_turbo`.
return FanSpeed.high
return FanSpeed(raw)

def set_fan_speed(self, speed: FanSpeed) -> None:
"""Set the device fan speed mode.
Expand Down Expand Up @@ -762,17 +833,43 @@ def set_horizontal_swing_mode(self, swing_mode: HorizontalSwingMode) -> None:
def target_temperature_unit(self) -> TemperatureUnits:
"""Return the units of the target temperature."""
return TemperatureUnits(
self._state.get(GreeProp.TARGET_TEMPERATURE_UNIT)
or TemperatureUnits.C.value
self._state.get(self._temperature_unit_prop) or TemperatureUnits.C.value
)

def set_target_temperature_unit(self, units: TemperatureUnits) -> None:
"""Set the units of the target temperature."""
self._state.set(GreeProp.TARGET_TEMPERATURE_UNIT, units)
self._state.set(self._temperature_unit_prop, units)

@property
def _temperature_unit_prop(self) -> GreeProp:
"""Return the column with the unit: TemUn, or TemUnit when only that one is answered.

The sub-units of a zone controller answer TemUnit and not TemUn.
"""
if not self._state.supports(
GreeProp.TARGET_TEMPERATURE_UNIT
) and self._state.supports(GreeProp.ZONE_TEMPERATURE_UNIT):
return GreeProp.ZONE_TEMPERATURE_UNIT
return GreeProp.TARGET_TEMPERATURE_UNIT

@property
def target_temperature(self) -> float:
"""Return the target temperature in target_temperature_unit."""
def target_temperature(self) -> float | None:
"""Return the target temperature in target_temperature_unit.

A zone has its own columns, see GreeProp.ZONE_TARGET_TEMPERATURE. They
work like SetTem and TemRec, with the bit as the half degree in Celsius
and the same table in Fahrenheit. It is None until the zone is read.
"""

if self._device_type is DeviceType.ZONE:
raw = self._state.get(GreeProp.ZONE_TARGET_TEMPERATURE)
if raw is None:
return None
zone_c = raw + ZONE_TEMPERATURE_OFFSET
bit = self._state.get(GreeProp.ZONE_TARGET_TEMPERATURE_BIT) or 0
if self.target_temperature_unit == TemperatureUnits.F:
return gree_get_target_temperature_f(zone_c, bit)
return gree_get_target_temperature_c(zone_c, bit)

raw_c = self._state.get(GreeProp.TARGET_TEMPERATURE, 0)
tem_rec = self._state.get(GreeProp.TARGET_TEMPERATURE_BIT, 0)
Expand All @@ -785,7 +882,24 @@ def target_temperature(self) -> float:
return 0.0

def set_target_temperature(self, value: float) -> None:
"""Set the target temperature in target_temperature_unit."""
"""Set the target temperature in target_temperature_unit.

Celsius goes to the nearest half degree, Fahrenheit to whole degrees.
A zone uses its own columns the same way. When the zone has the bit,
it is always sent along, because the controller keeps the old one when
only StTem is sent.
"""

if self._device_type is DeviceType.ZONE:
if self.target_temperature_unit == TemperatureUnits.F:
raw_c, bit = gree_get_target_temp_props_from_f(round(value))
else:
raw_c, bit = gree_get_target_temp_props_from_c(value)
props = {GreeProp.ZONE_TARGET_TEMPERATURE: raw_c - ZONE_TEMPERATURE_OFFSET}
if self._state.supports(GreeProp.ZONE_TARGET_TEMPERATURE_BIT):
props[GreeProp.ZONE_TARGET_TEMPERATURE_BIT] = bit
self._state.update(props)
return

if self.target_temperature_unit == TemperatureUnits.F:
if not value.is_integer():
Expand Down Expand Up @@ -928,6 +1042,8 @@ def set_feature_quiet(self, value: bool) -> None:
@property
def feature_turbo(self) -> bool:
"""Return the turbo mode state."""
if self._device_type in ZONE_CONTROLLER_TYPES:
return self._state.get(GreeProp.FAN_SPEED) == ZONE_CONTROLLER_FAN_TURBO
return self._state.get_bool(GreeProp.FEAT_TURBO_MODE)

def set_feature_turbo(self, value: bool) -> None:
Expand All @@ -945,6 +1061,15 @@ def set_feature_turbo(self, value: bool) -> None:
"Turbo mode is only available under Cool or Heat modes"
)

if self._device_type in ZONE_CONTROLLER_TYPES:
# A zone controller has no turbo column. Turbo is fan speed 6, and
# leaving it goes back to the highest normal speed.
if value:
self._state.set(GreeProp.FAN_SPEED, ZONE_CONTROLLER_FAN_TURBO)
elif self.feature_turbo:
self._state.set(GreeProp.FAN_SPEED, FanSpeed.high)
return

self._state.set_bool(GreeProp.FEAT_TURBO_MODE, value)

if value and (self.feature_energy_saving or self.feature_smart_heat):
Expand Down
Loading
Loading