diff --git a/greeclimate/device.py b/greeclimate/device.py index 58d7f02..89e295d 100644 --- a/greeclimate/device.py +++ b/greeclimate/device.py @@ -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 @@ -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, @@ -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 @@ -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: @@ -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: @@ -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 @@ -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}") @@ -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 @@ -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 diff --git a/greeclimate/deviceinfo.py b/greeclimate/deviceinfo.py index f2be209..7460f9e 100644 --- a/greeclimate/deviceinfo.py +++ b/greeclimate/deviceinfo.py @@ -6,9 +6,12 @@ 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 @@ -16,6 +19,9 @@ def __init__(self, ip, port, mac, name, brand=None, model=None, version=None): 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})" @@ -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 diff --git a/greeclimate/discovery.py b/greeclimate/discovery.py index 08aefa5..53b056f 100644 --- a/greeclimate/discovery.py +++ b/greeclimate/discovery.py @@ -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 @@ -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] = [] @@ -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) @@ -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""" diff --git a/greeclimate/network.py b/greeclimate/network.py index b31db98..47a0c4d 100644 --- a/greeclimate/network.py +++ b/greeclimate/network.py @@ -21,12 +21,14 @@ class Commands(Enum): PACK = "pack" SCAN = "scan" STATUS = "status" + SUBLIST = "subList" class Response(Enum): BIND_OK = "bindok" DATA = "dat" RESULT = "res" + SUBLIST = "sublist" @dataclass @@ -178,7 +180,6 @@ def connection_made(self, transport: asyncio.transports.DatagramTransport) -> No class DeviceProtocol2(DeviceProtocolBase2): """Protocol handler for direct device communication.""" - _handlers = {} def __init__(self, timeout: int = 10, drained: asyncio.Event = None) -> None: """Initialize the device protocol object. @@ -188,6 +189,11 @@ def __init__(self, timeout: int = 10, drained: asyncio.Event = None) -> None: drained (asyncio.Event): Packet send drain event signal """ DeviceProtocolBase2.__init__(self, timeout, drained) + # Per-instance handler registry. This MUST NOT be a class attribute: + # a gateway plus its sub-devices (and any standalone units) create + # multiple DeviceProtocol2 instances, and a shared dict would cross- + # dispatch one device's callbacks to another, breaking bind/state. + self._handlers: dict = {} self._ready = asyncio.Event() self._ready.clear() @@ -212,6 +218,19 @@ def remove_handler(self, event_name: Response, callback): if event_name in self._handlers: self._handlers[event_name].remove(callback) + def _resolve_response_type(self, obj) -> str: + """Determine the response type from a received packet. + + Checks pack.t first (standard responses), then falls back to + obj.t (e.g. subList responses with top-level type field). + """ + pack = obj.get("pack", {}) + if isinstance(pack, dict) and pack.get("t"): + return pack["t"].lower() + if obj.get("t"): + return obj["t"].lower() + return "" + def packet_received(self, obj, addr: IPAddr) -> None: """Event called when a packet is received and decoded. @@ -223,14 +242,16 @@ def packet_received(self, obj, addr: IPAddr) -> None: Response.BIND_OK.value: lambda o, a: [o["pack"]["key"]], Response.DATA.value: lambda o, a: [dict(zip(o["pack"]["cols"], o["pack"]["dat"]))], Response.RESULT.value: lambda o, a: [dict(zip(o["pack"]["opt"], o["pack"].get("val", []) or o["pack"].get("p", [])))], + Response.SUBLIST.value: lambda o, a: [o["pack"].get("list", []) if isinstance(o.get("pack"), dict) else o.get("list", [])], } handlers = { Response.BIND_OK.value: lambda *args: self.__handle_device_bound(*args), Response.DATA.value: lambda *args: self.__handle_state_update(*args), Response.RESULT.value: lambda *args: self.__handle_state_update(*args), + Response.SUBLIST.value: lambda *args: self.__handle_sublist_response(*args), } try: - resp = obj.get("pack", {}).get("t") + resp = self._resolve_response_type(obj) handler = handlers.get(resp, self.handle_unknown_packet) param = params.get(resp, lambda o, a: (o, a))(obj, addr) handler(*param) @@ -261,6 +282,13 @@ def handle_state_update(self, **kwargs) -> None: """ Implement this function to handle device state updates. """ pass + def __handle_sublist_response(self, sub_devices) -> None: + self.handle_sublist_response(sub_devices) + + def handle_sublist_response(self, sub_devices: list) -> None: + """ Implement this function to handle sub-device list responses. """ + pass + def _generate_payload(self, command: Commands, device_info: DeviceInfo, data: Dict[str, Any]) -> Dict[str, Any]: payload = { "cid": "app", @@ -286,3 +314,14 @@ def create_status_message(self, device_info: DeviceInfo, *args) -> Dict[str, Any def create_command_message(self, device_info: DeviceInfo, **kwargs) -> Dict[str, Any]: return self._generate_payload(Commands.CMD, device_info, {"opt": list(kwargs.keys()), "p": list(kwargs.values())}) + + def create_sublist_message(self, device_info: DeviceInfo) -> Dict[str, Any]: + # subList is a protocol-level query sent unencrypted (no pack field). + # The gateway handles it outside the encrypted pack channel. + return { + "cid": "app", + "i": 0, + "t": Commands.SUBLIST.value, + "uid": 0, + "tcid": device_info.mac, + } diff --git a/tests/test_device.py b/tests/test_device.py index b3339ad..66b344b 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -703,7 +703,7 @@ async def test_mismatch_temrec_farenheit(temperature, cipher, send): def fake_send(*args, **kwargs): device.handle_state_update(**state) - send.side_effect = None + send.side_effect = fake_send await device.update_state() @@ -813,3 +813,143 @@ async def request_version_timeout_error(cipher, send): with pytest.raises(DeviceTimeoutError): await device.request_version() + + +@pytest.mark.asyncio +async def test_get_sub_devices(cipher, send): + """Check that get_sub_devices sends subList and returns DeviceInfo list.""" + info = DeviceInfo("1.1.1.0", "7000", "aabbcc001122", "Gateway", sub_count=2) + device = Device(info, timeout=1) + await device.bind(key="fake_key", cipher=CipherV1()) + + sub_devices_raw = [ + {"mac": "sub111111", "name": "SubDevice1", "brand": "gree", "model": "model1", "ver": "1.0"}, + {"mac": "sub222222", "name": "SubDevice2"}, + ] + + def fake_send(*args, **kwargs): + device.handle_sublist_response(sub_devices_raw) + send.side_effect = fake_send + + result = await device.get_sub_devices() + + assert len(result) == 2 + assert result[0].mac == "sub111111" + assert result[0].name == "SubDevice1" + assert result[0].ip == info.ip + assert result[0].port == info.port + assert result[1].mac == "sub222222" + assert result[1].name == "SubDevice2" + assert result[0].gateway_key == "fake_key" + assert result[1].gateway_key == "fake_key" + + +@pytest.mark.asyncio +async def test_get_sub_devices_timeout(cipher, send): + """Check that get_sub_devices raises DeviceTimeoutError on no response.""" + info = DeviceInfo("1.1.1.0", "7000", "aabbcc001122", "Gateway", sub_count=2) + device = Device(info, timeout=1) + await device.bind(key="fake_key", cipher=CipherV1()) + + with pytest.raises(DeviceTimeoutError): + await device.get_sub_devices() + + +@pytest.mark.asyncio +async def test_sub_device_bind_with_key(cipher, send): + """Check that a sub-device can bind with an explicit key and cipher.""" + sub_info = DeviceInfo("1.1.1.0", "7000", "sub111111", "SubDevice1") + sub_device = Device(sub_info, timeout=1) + + await sub_device.bind(key="gateway_key", cipher=CipherV1()) + + assert sub_device.device_cipher is not None + assert sub_device.device_cipher.key == "gateway_key" + + +@pytest.mark.asyncio +async def test_sub_device_update_state(cipher, send): + """Check that a sub-device can update state through its own transport.""" + sub_info = DeviceInfo("1.1.1.0", "7000", "sub111111", "SubDevice1") + sub_device = Device(sub_info, timeout=1) + await sub_device.bind(key="gateway_key", cipher=CipherV1()) + + def fake_send(*args, **kwargs): + sub_device.handle_state_update(**get_mock_state()) + send.side_effect = fake_send + + await sub_device.update_state() + assert sub_device.power is True + assert sub_device.has_valid_state is True + + +@pytest.mark.asyncio +async def test_bind_with_gateway_key(cipher, send): + """Check that bind uses gateway_key from device_info when no key is provided.""" + sub_info = DeviceInfo("1.1.1.0", "7000", "sub111111", "SubDevice1", gateway_key="gateway_key_123", gateway_cipher=CipherV1()) + device = Device(sub_info, timeout=1, bind_timeout=1) + + def fake_send(*args, **kwargs): + device.handle_state_update(Pow=1) + send.side_effect = fake_send + + await device.bind() + + assert device.device_cipher is not None + assert device.device_cipher.key == "gateway_key_123" + + +@pytest.mark.asyncio +async def test_get_sub_devices_name_fallback(cipher, send): + """Sub-device name comes from the response, falling back to mac when absent.""" + info = DeviceInfo("1.1.1.0", "7000", "aabbcc001122", "Gateway", sub_count=2) + device = Device(info, timeout=1) + await device.bind(key="fake_key", cipher=CipherV1()) + + sub_devices_raw = [ + {"mac": "aabb11223344", "name": "Living Room"}, + {"mac": "ddee55667788"}, + ] + + def fake_send(*args, **kwargs): + device.handle_sublist_response(sub_devices_raw) + send.side_effect = fake_send + + result = await device.get_sub_devices() + + assert len(result) == 2 + # name present in response is used verbatim + assert result[0].name == "Living Room" + # no name in response → DeviceInfo falls back to mac + assert result[1].name == "ddee55667788" + + +def test_target_temperature_survives_missing_temrec(): + """Sub-devices omit / null out TemRec; target temperature must not vanish. + + A SetTem command acknowledgement echoes val=[25, null, null] for + ["SetTem", "TemRec", "TemUn"], which previously reset target_temperature + to None. TemRec is only the 0.5-degree bit and should default to 0. + """ + device = Device(DeviceInfo("1.1.1.1", "7000", "aabbcc001122", "Sub")) + device.version = "1.10" + + # Initial status from a gateway sub-device: TemUn / TemRec are empty strings + device._properties = { + Props.POWER.value: 1, + Props.MODE.value: 1, + Props.TEMP_SET.value: 25, + Props.TEMP_UNIT.value: "", + Props.TEMP_BIT.value: "", + Props.TEMP_SENSOR.value: "", + } + assert device.target_temperature == 25 + + # The res echo of a SetTem command writes None for TemRec / TemUn + device.handle_state_update(SetTem=25, TemRec=None, TemUn=None) + assert device.target_temperature == 25 + + # A missing setpoint still yields None + device._properties[Props.TEMP_SET.value] = None + assert device.target_temperature is None + diff --git a/tests/test_discovery.py b/tests/test_discovery.py index d341ff1..3bf728d 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -2,11 +2,13 @@ import json import socket from threading import Thread -from unittest.mock import MagicMock, PropertyMock, patch +from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch import pytest from greeclimate.discovery import Discovery, Listener +from greeclimate.device import Device +from greeclimate.deviceinfo import DeviceInfo from .common import ( DEFAULT_TIMEOUT, DISCOVERY_REQUEST, @@ -269,3 +271,204 @@ async def test_remove_listener(): result = discovery.remove_listener(listener) assert result is False + + +@pytest.mark.asyncio +async def test_discovery_sub_count(): + """Test that subCnt from scan response is passed to DeviceInfo.""" + discovery = Discovery(allow_loopback=True) + discovery.packet_received( + { + "pack": { + "mac": "aabbcc112233", + "cid": "aabbcc112233", + "name": "GatewayDevice", + "brand": "gree", + "model": "gree", + "ver": "V1.0.0", + "subCnt": 3, + } + }, + ("1.1.1.1", 7000), + ) + + await asyncio.gather(*discovery.tasks, return_exceptions=True) + + assert len(discovery.devices) == 1 + assert discovery.devices[0].sub_count == 3 + + +@pytest.mark.asyncio +async def test_discovery_no_sub_count(): + """Test that devices without subCnt default to 0.""" + discovery = Discovery(allow_loopback=True) + discovery.packet_received( + { + "pack": { + "mac": "aabbcc112233", + "cid": "aabbcc112233", + "name": "RegularDevice", + "brand": "gree", + "model": "gree", + "ver": "V1.0.0", + } + }, + ("1.1.1.1", 7000), + ) + + await asyncio.gather(*discovery.tasks, return_exceptions=True) + + assert len(discovery.devices) == 1 + assert discovery.devices[0].sub_count == 0 + + +@pytest.mark.asyncio +async def test_scan_queries_gateway_sub_devices(): + """Test that scan queries gateways for sub-devices and returns them.""" + discovery = Discovery(allow_loopback=True) + + gw_info = DeviceInfo("1.1.1.1", 7000, "aabbcc001122", "Gateway", sub_count=2) + + sub_infos = [ + DeviceInfo("1.1.1.1", 7000, "sub111111", "Sub1", gateway_key="test_key"), + DeviceInfo("1.1.1.1", 7000, "sub222222", "Sub2", gateway_key="test_key"), + ] + + async def fake_search(*args, **kwargs): + await discovery.device_found(gw_info) + + with patch.object(Discovery, "search_devices", side_effect=fake_search), \ + patch.object(Device, "bind", new_callable=AsyncMock), \ + patch.object(Device, "get_sub_devices", new_callable=AsyncMock, return_value=sub_infos), \ + patch.object(Device, "close"): + devices = await discovery.scan(wait_for=1) + + assert len(devices) == 2 + assert devices[0].mac == "sub111111" + assert devices[1].mac == "sub222222" + + +@pytest.mark.asyncio +async def test_scan_include_gateways(): + """Test that scan includes gateways when include_gateways=True.""" + discovery = Discovery(allow_loopback=True) + + gw_info = DeviceInfo("1.1.1.1", 7000, "aabbcc001122", "Gateway", sub_count=2) + + sub_infos = [ + DeviceInfo("1.1.1.1", 7000, "sub111111", "Sub1", gateway_key="test_key"), + ] + + async def fake_search(*args, **kwargs): + await discovery.device_found(gw_info) + + with patch.object(Discovery, "search_devices", side_effect=fake_search), \ + patch.object(Device, "bind", new_callable=AsyncMock), \ + patch.object(Device, "get_sub_devices", new_callable=AsyncMock, return_value=sub_infos), \ + patch.object(Device, "close"): + devices = await discovery.scan(wait_for=1, include_gateways=True) + + assert len(devices) == 2 + macs = {d.mac for d in devices} + assert "aabbcc001122" in macs + assert "sub111111" in macs + + +@pytest.mark.asyncio +async def test_scan_gateway_bind_failure(): + """Test that scan handles gateway bind failure gracefully.""" + from greeclimate.exceptions import DeviceNotBoundError + + discovery = Discovery(allow_loopback=True) + gw_info = DeviceInfo("1.1.1.1", 7000, "aabbcc001122", "Gateway", sub_count=2) + + async def fake_search(*args, **kwargs): + await discovery.device_found(gw_info) + + with patch.object(Discovery, "search_devices", side_effect=fake_search), \ + patch.object(Device, "bind", new_callable=AsyncMock, side_effect=DeviceNotBoundError), \ + patch.object(Device, "close", side_effect=RuntimeError): + devices = await discovery.scan(wait_for=1) + + assert len(devices) == 0 + + +@pytest.mark.asyncio +async def test_scan_gateway_timeout_failure(): + """Test that scan handles gateway timeout gracefully.""" + from greeclimate.exceptions import DeviceTimeoutError + + discovery = Discovery(allow_loopback=True) + gw_info = DeviceInfo("1.1.1.1", 7000, "aabbcc001122", "Gateway", sub_count=2) + + async def fake_search(*args, **kwargs): + await discovery.device_found(gw_info) + + with patch.object(Discovery, "search_devices", side_effect=fake_search), \ + patch.object(Device, "bind", new_callable=AsyncMock, side_effect=DeviceTimeoutError), \ + patch.object(Device, "close"): + devices = await discovery.scan(wait_for=1) + + assert len(devices) == 0 + + +@pytest.mark.asyncio +async def test_scan_include_gateways_notifies_listeners(): + """Test that gateway devices notify listeners when include_gateways=True.""" + discovery = Discovery(allow_loopback=True) + gw_info = DeviceInfo("1.1.1.1", 7000, "aabbcc001122", "Gateway", sub_count=2) + + listener = MagicMock(spec_set=Listener) + listener.device_found = AsyncMock() + discovery.add_listener(listener) + + sub_infos = [ + DeviceInfo("1.1.1.1", 7000, "sub111111", "Sub1", gateway_key="test_key"), + ] + + async def fake_search(*args, **kwargs): + await discovery.device_found(gw_info) + + with patch.object(Discovery, "search_devices", side_effect=fake_search), \ + patch.object(Device, "bind", new_callable=AsyncMock), \ + patch.object(Device, "get_sub_devices", new_callable=AsyncMock, return_value=sub_infos), \ + patch.object(Device, "close"): + await discovery.scan(wait_for=1, include_gateways=True) + + # Listener should be notified for both gateway and sub-device + found_macs = [call.args[0].mac for call in listener.device_found.call_args_list] + assert "aabbcc001122" in found_macs + assert "sub111111" in found_macs + + +@pytest.mark.asyncio +async def test_scan_exclude_gateways_skips_listener(): + """Test that gateway devices do NOT notify listeners when include_gateways=False.""" + discovery = Discovery(allow_loopback=True) + gw_info = DeviceInfo("1.1.1.1", 7000, "aabbcc001122", "Gateway", sub_count=1) + + listener = MagicMock(spec_set=Listener) + listener.device_found = AsyncMock() + discovery.add_listener(listener) + + sub_infos = [ + DeviceInfo("1.1.1.1", 7000, "sub111111", "Sub1", gateway_key="test_key"), + ] + + async def fake_search(*args, **kwargs): + await discovery.device_found(gw_info) + + with patch.object(Discovery, "search_devices", side_effect=fake_search), \ + patch.object(Device, "bind", new_callable=AsyncMock), \ + patch.object(Device, "get_sub_devices", new_callable=AsyncMock, return_value=sub_infos), \ + patch.object(Device, "close"): + devices = await discovery.scan(wait_for=1) + + # scan() return excludes gateways + assert len(devices) == 1 + assert devices[0].mac == "sub111111" + + # Listener should only be notified for sub-device, not gateway + found_macs = [call.args[0].mac for call in listener.device_found.call_args_list] + assert "aabbcc001122" not in found_macs + assert "sub111111" in found_macs diff --git a/tests/test_issues.py b/tests/test_issues.py index ba08eb0..49c642f 100644 --- a/tests/test_issues.py +++ b/tests/test_issues.py @@ -18,7 +18,7 @@ async def test_issue_69_TemSen_40_should_not_set_firmware_v4(): def fake_send(*args, **kwargs): device.handle_state_update(**mock_v3_state) - with patch.object(Device, "send", wraps=fake_send()): + with patch.object(Device, "send", side_effect=fake_send): await device.update_state() assert device.version is None diff --git a/tests/test_network.py b/tests/test_network.py index 7f7f14a..ec1aee4 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -343,6 +343,7 @@ def __init__(self): self.state = {} self.key = None self.unknown = False + self.sub_devices = None def handle_state_update(self, **kwargs) -> None: self.state = dict(kwargs) @@ -351,6 +352,9 @@ def handle_device_bound(self, key: str) -> None: self._ready.set() self.key = key + def handle_sublist_response(self, sub_devices: list) -> None: + self.sub_devices = sub_devices + def handle_unknown_packet(self, obj, addr: IPAddr) -> None: self.unknown = True @@ -572,4 +576,120 @@ def test_device_key_get_set(): # Assert assert protocol.device_key == key - + + +def test_handle_sublist_response(): + # Arrange + protocol = DeviceProtocol2Test() + sub_devices = [ + {"mac": "sub111111", "mid": "10001"}, + {"mac": "sub222222", "mid": "10002"}, + ] + + # Act + protocol.packet_received({ + 'pack': { + 't': 'sublist', + 'list': sub_devices, + } + }, ("0.0.0.0", 0)) + + # Assert + assert protocol.sub_devices == sub_devices + + +def test_resolve_response_type_fallback_to_obj_t(): + """Test _resolve_response_type falls back to obj.t when pack has no t.""" + protocol = DeviceProtocol2Test() + result = protocol._resolve_response_type({"t": "sublist", "pack": {}}) + assert result == "sublist" + + +def test_deviceinfo_eq_non_deviceinfo(): + """Test DeviceInfo equality with a non-DeviceInfo object.""" + info = DeviceInfo("1.1.1.1", 7000, "aabbcc112233", "Test") + assert info != "not a device info" + assert (info == 42) is False + + +def test_handle_sublist_response_empty(): + # Arrange + protocol = DeviceProtocol2Test() + + # Act + protocol.packet_received({ + 'pack': { + 't': 'sublist', + } + }, ("0.0.0.0", 0)) + + # Assert + assert protocol.sub_devices == [] + + +def test_create_sublist_message(): + # Arrange + device_info = DeviceInfo(*get_mock_info()) + protocol = DeviceProtocol2() + + # Act + result = protocol.create_sublist_message(device_info) + + # Assert + assert isinstance(result, dict) + assert result == { + 'cid': 'app', + 'i': 0, + 't': 'subList', + 'uid': 0, + 'tcid': device_info.mac, + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize("event_name, data",[ + (Response.SUBLIST, {'list': [{"mac": "sub111111"}]}), +]) +async def test_sublist_handler_callback(event_name, data): + # Arrange + protocol = DeviceProtocol2() + callback = MagicMock() + event_data = {'pack': {'t': event_name.value}} + event_data['pack'].update(data) + + # Act + protocol.add_handler(event_name, callback) + protocol.packet_received(event_data, ("0.0.0.0", 0)) + + # Assert + callback.assert_called_once_with([{"mac": "sub111111"}]) + + +def test_handlers_are_per_instance(): + """Each protocol instance must have its own handler registry. + + A shared class-level dict would cross-dispatch one device's callbacks to + another (e.g. a gateway, its sub-devices and standalone units all live at + once), breaking bind/state handling. Regression test for that. + """ + a = DeviceProtocol2() + b = DeviceProtocol2() + + cb_a = MagicMock() + cb_b = MagicMock() + a.add_handler(Response.RESULT, cb_a) + b.add_handler(Response.RESULT, cb_b) + + # The two registries must be distinct objects and not leak into each other + assert a._handlers is not b._handlers + assert cb_b not in a._handlers.get(Response.RESULT, []) + assert cb_a not in b._handlers.get(Response.RESULT, []) + + # Dispatching on b must only call b's callback + b.packet_received( + {"pack": {"t": Response.RESULT.value, "opt": ["Pow"], "val": [1]}}, + ("0.0.0.0", 0), + ) + cb_b.assert_called_once() + cb_a.assert_not_called() +