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
98 changes: 84 additions & 14 deletions greeclimate/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import typing
from asyncio import AbstractEventLoop
from enum import IntEnum, unique
from typing import Union, Optional, Any
from typing import List, Union, Optional, Any

from greeclimate.cipher import CipherV1, CipherV2
from greeclimate.deviceinfo import DeviceInfo
Expand Down Expand Up @@ -184,6 +184,9 @@ def __init__(self, device_info: DeviceInfo, timeout: int = 120, bind_timeout: in
self._valid_state: asyncio.Event = asyncio.Event()
self._valid_state.clear()

self._sublist_event: asyncio.Event = asyncio.Event()
self._sub_devices_raw: list = []

async def bind(
self,
key: str = None,
Expand All @@ -208,13 +211,10 @@ async def bind(
DeviceTimeoutError: The device didn't respond
"""

if key:
if not cipher:
raise ValueError("cipher must be provided when key is provided")
else:
cipher.key = key
self.device_cipher = cipher
return
if key is None and self.device_info and self.device_info.gateway_key:
key = self.device_info.gateway_key
if cipher is None and self.device_info.gateway_cipher is not None:
cipher = self.device_info.gateway_cipher

if not self.device_info:
raise DeviceNotBoundError
Expand All @@ -224,6 +224,14 @@ async def bind(
lambda: self, remote_addr=(self.device_info.ip, self.device_info.port)
)

if key:
if not cipher:
raise ValueError("cipher must be provided when key is provided")
else:
cipher.key = key
self.device_cipher = cipher
return

self._logger.info("Starting device binding to %s", str(self.device_info))

try:
Expand Down Expand Up @@ -258,6 +266,62 @@ def handle_device_bound(self, key: str) -> None:
self.device_cipher.key = key
self._loop.create_task(self.update_state())

def handle_sublist_response(self, sub_devices: list) -> None:
"""Handle the sub-device list response from the gateway."""
self._sub_devices_raw = sub_devices
self._sublist_event.set()

async def get_sub_devices(self) -> List[DeviceInfo]:
"""Query the gateway for its sub-devices.

Returns:
List[DeviceInfo]: List of sub-device info objects with the gateway's IP/port

Raises:
DeviceNotBoundError: If the device is not bound
DeviceTimeoutError: If the gateway doesn't respond
"""
if not self.device_cipher:
await self.bind()

self._sublist_event.clear()
self._sub_devices_raw = []

self._logger.debug("Requesting sub-device list from (%s)", str(self.device_info))

# Wait briefly for any pending state update to finish so we don't
# collide with it on the wire, but don't block long.
if not self._valid_state.is_set():
try:
await asyncio.wait_for(self._valid_state.wait(), timeout=5)
except asyncio.TimeoutError:
self._logger.debug(
"State update not received, proceeding with subList request to (%s)",
str(self.device_info),
)

try:
await self.send(self.create_sublist_message(self.device_info))
await asyncio.wait_for(self._sublist_event.wait(), timeout=self._bind_timeout)
except asyncio.TimeoutError:
raise DeviceTimeoutError

sub_infos = []
for sub in self._sub_devices_raw:
sub_info = DeviceInfo(
self.device_info.ip,
self.device_info.port,
sub.get("mac"),
sub.get("name"),
sub.get("brand"),
sub.get("model"),
sub.get("ver"),
gateway_key=self.device_cipher.key,
gateway_cipher=self.device_cipher,
)
sub_infos.append(sub_info)
return sub_infos

async def request_version(self) -> None:
"""Request the firmware version from the device."""
if not self.device_cipher:
Expand All @@ -277,13 +341,13 @@ async def update_state(self):

self._logger.debug("Updating device properties for (%s)", str(self.device_info))

self._valid_state.clear()
props = [x.value for x in Props]
if not self.hid:
props.append("hid")

try:
await self.send(self.create_status_message(self.device_info, *props))

except asyncio.TimeoutError:
raise DeviceTimeoutError

Expand All @@ -303,7 +367,7 @@ def handle_state_update(self, **kwargs) -> None:
self.check_version = False
temp = self.get_property(Props.TEMP_SENSOR)
self._logger.debug(f"Checking for temperature offset, reported temp {temp}")
if temp and temp < TEMP_OFFSET:
if isinstance(temp, (int, float)) and temp and temp < TEMP_OFFSET:
self.version = "4.0"
self._logger.info(f"Device version changed to {self.version}, hid {self.hid}")
self._logger.debug(f"Using device temperature {self.current_temperature}")
Expand Down Expand Up @@ -417,8 +481,14 @@ def _convert_to_units(self, value, bit):
def target_temperature(self) -> Optional[int]:
temset = self.get_property(Props.TEMP_SET)
temrec = self.get_property(Props.TEMP_BIT)
if temset is None or temrec is None:
if temset is None or temset == "":
return None
# TEMP_BIT is only the 0.5-degree rounding bit. Some devices (notably
# gateway sub-devices) omit it or return it as None / "" in status and
# command acknowledgements; treat those as 0 rather than dropping the
# whole target temperature.
if not isinstance(temrec, int):
temrec = 0
return self._convert_to_units(temset, temrec)

@target_temperature.setter
Expand Down Expand Up @@ -448,15 +518,15 @@ def temperature_units(self, value: int):
def current_temperature(self) -> Optional[int]:
prop = self.get_property(Props.TEMP_SENSOR)
bit = self.get_property(Props.TEMP_BIT)
if prop is not None:
bit = bit if bit is not None else 0
if prop is not None and isinstance(prop, (int, float)):
bit = bit if isinstance(bit, (int, float)) else 0
v = self.version and int(self.version.split(".")[0])
try:
if v == 4:
return self._convert_to_units(prop, bit)
elif prop != 0:
return self._convert_to_units(prop - TEMP_OFFSET, bit)
except ValueError:
except (ValueError, TypeError):
logging.warning("Converting unexpected set temperature value %s", prop)

return self.target_temperature
Expand Down
9 changes: 8 additions & 1 deletion greeclimate/deviceinfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,22 @@ class DeviceInfo:
port: Usually this will always be 7000
mac: mac address, in the format 'aabbcc112233'
name: Name of unit, if available
sub_count: Number of sub-devices behind this device (>0 for a gateway)
gateway_key: For a sub-device, the bound key of its parent gateway
gateway_cipher: For a sub-device, the cipher instance of its parent gateway
"""

def __init__(self, ip, port, mac, name, brand=None, model=None, version=None):
def __init__(self, ip, port, mac, name, brand=None, model=None, version=None, sub_count=0, gateway_key=None, gateway_cipher=None):
self.ip = ip
self.port = port
self.mac = mac
self.name = name if name else mac.replace(":", "")
self.brand = brand
self.model = model
self.version = version
self.sub_count = sub_count
self.gateway_key = gateway_key
self.gateway_cipher = gateway_cipher

def __str__(self):
return f"Device: {self.name} @ {self.ip}:{self.port} (mac: {self.mac})"
Expand All @@ -29,6 +35,7 @@ def __eq__(self, other):
and self.brand == other.brand
and self.model == other.model
and self.version == other.version
and self.sub_count == other.sub_count
)
return False

Expand Down
57 changes: 48 additions & 9 deletions greeclimate/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
from ipaddress import IPv4Address

from greeclimate.cipher import CipherV1
from greeclimate.device import DeviceInfo
from greeclimate.device import Device
from greeclimate.deviceinfo import DeviceInfo
from greeclimate.exceptions import DeviceNotBoundError, DeviceTimeoutError
from greeclimate.network import BroadcastListenerProtocol, IPAddr
from greeclimate.taskable import Taskable

Expand Down Expand Up @@ -48,6 +50,7 @@ def __init__(
Taskable.__init__(self, loop)
self.device_cipher = CipherV1()
self._allow_loopback: bool = allow_loopback
self._include_gateways: bool = False
self._device_infos: list[DeviceInfo] = []
self._listeners: list[Listener] = []

Expand Down Expand Up @@ -114,6 +117,12 @@ async def device_found(self, device_info: DeviceInfo) -> None:

_LOGGER.info("Found gree device %s", str(device_info))

# If this is a gateway, query its sub-devices in the background
if device_info.sub_count > 0:
self._create_task(self._query_gateway(device_info))
if not self._include_gateways:
return

tasks = [l.device_found(device_info) for l in self._listeners]
await asyncio.gather(*tasks, return_exceptions=True)

Expand All @@ -132,31 +141,61 @@ def packet_received(self, obj, addr: IPAddr) -> None:
pack.get("brand"),
pack.get("model"),
pack.get("ver"),
pack.get("subCnt", 0),
)

self._create_task(self.device_found(DeviceInfo(*device)))

async def _query_gateway(self, gw_info: DeviceInfo) -> None:
"""Bind to a gateway device and discover its sub-devices."""
gw = Device(gw_info, timeout=10, bind_timeout=15, loop=self._loop)
try:
await gw.bind()
sub_infos = await gw.get_sub_devices()
for sub_info in sub_infos:
await self.device_found(sub_info)
except (DeviceNotBoundError, DeviceTimeoutError):
_LOGGER.warning(
"Failed to query sub-devices from gateway %s", gw_info.mac
)
finally:
try:
gw.close()
except (RuntimeError, AttributeError):
pass

# Discovery
async def scan(self, wait_for: int = 0, bcast_ifaces: list[IPv4Address] | None = None) -> list[DeviceInfo]:
async def scan(self, wait_for: int = 0, bcast_ifaces: list[IPv4Address] | None = None, include_gateways: bool = False) -> list[DeviceInfo]:
"""Sends a discovery broadcast packet on each network interface to
locate Gree units on the network
locate Gree units on the network.
When a gateway device is found, its sub-devices are automatically
queried and returned as regular devices.

Args:
wait_for (int): Optionally wait this many seconds for discovery
and return the devices found.
bcast_ifaces (list[IPv4Address]): List of broadcast addresses to scan
include_gateways (bool): If True, gateway devices are included in
the results alongside their sub-devices.
Default is False.

Returns:
List[DeviceInfo]: List of devices found during this scan
"""
_LOGGER.info("Scanning for Gree devices ...")

await self.search_devices(bcast_ifaces)
if wait_for:
await asyncio.sleep(wait_for)
await asyncio.gather(*self.tasks, return_exceptions=True)

return self._device_infos
self._include_gateways = include_gateways
try:
await self.search_devices(bcast_ifaces)
if wait_for:
await asyncio.sleep(wait_for)
await asyncio.gather(*self.tasks, return_exceptions=True)

if include_gateways:
return list(self._device_infos)
return [d for d in self._device_infos if d.sub_count == 0]
finally:
self._include_gateways = False

def _get_broadcast_addresses(self) -> list[IPv4Address]:
"""Return a list of broadcast addresses for each discovered interface"""
Expand Down
Loading