From 41c22f6c1d1d5d5726bd3277a0dc19f3a37ba0f0 Mon Sep 17 00:00:00 2001 From: meirlo <6350224+meirlo@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:34:35 +0300 Subject: [PATCH 1/4] Fix VRF sub-device discovery: defer + concurrent subList, robust parsing Two problems made multi-split (VRF) gateways discover unreliably: 1. Sub-device (subList) fetching ran inline inside the UDP receive loop. The subList query has its own retries/timeouts, so it blocked the loop and ate the discovery time budget, causing other devices' broadcast replies to be dropped (e.g. finding 3 of 4 units). Gateways are now collected during the loop and queried afterwards, concurrently via asyncio.gather, keeping discovery fast and complete. 2. A gateway was only treated as such when subCnt > 1, so single-unit gateways were missed. Any subCnt > 0 is now handled as a gateway. get_subunits_list is rewritten to send subList unencrypted and handle both response shapes seen in the wild: the sub-unit list at the top level, or wrapped in a pack encrypted with the gateway's bound device key (bind first, then decrypt). A small self-contained _fetch_subunits_raw helper does the raw UDP exchange since the response has no pack to feed through FetchResult. --- custom_components/gree/gree_protocol.py | 182 ++++++++++++++++++------ 1 file changed, 141 insertions(+), 41 deletions(-) diff --git a/custom_components/gree/gree_protocol.py b/custom_components/gree/gree_protocol.py index 98aef2a..fa9cd35 100644 --- a/custom_components/gree/gree_protocol.py +++ b/custom_components/gree/gree_protocol.py @@ -421,6 +421,8 @@ async def discover_gree_devices(hass, timeout=5, extra_networks=None, extra_host DISCOVERY_MESSAGE = b'{"t":"scan"}' devices = [] + gateways: list[dict] = [] + seen_gateways: set[str] = set() seen_device_ids: set[tuple[str, str]] = set() sockets: list[tuple[socket.socket, list[str], str]] = [] @@ -564,30 +566,21 @@ async def discover_gree_devices(hass, timeout=5, extra_networks=None, extra_host "model": pack_json.get("model", "gree"), "version": pack_json.get("ver", ""), } - # If subCnt > 1, fetch sub-device list - if sub_cnt > 1: - try: - _LOGGER.debug(f"Fetching sub-devices for {mac_addr} (subCnt={sub_cnt})") - sub_devices = await get_subunits_list(mac_addr, addr[0], BROADCAST_PORT) - for sub_device in sub_devices.get("list", []): - sub_mac = sub_device.get("mac", "") - if sub_mac: - sub_device_info = { - "name": f"{device_info['name']}_{sub_mac[:4]}", - "host": addr[0], - "port": BROADCAST_PORT, - "mac": f"{sub_mac}@{mac_addr}", - "brand": device_info["brand"], - "model": sub_device.get("mid", device_info["model"]), - "version": device_info["version"], - } - device_key = (sub_device_info["host"], sub_device_info["mac"]) - if device_key not in seen_device_ids: - seen_device_ids.add(device_key) - devices.append(sub_device_info) - _LOGGER.debug(f"Discovered sub-device: {sub_device_info}") - except Exception as e: - _LOGGER.error(f"Error fetching sub-devices for {mac_addr}: {e}") + # If this is a gateway (has sub-units), defer + # fetching its sub-device list until AFTER the + # receive loop. The subList query involves its + # own retries/timeouts; running it inline here + # would block the receive loop and consume the + # discovery time budget, causing other devices' + # broadcast replies to be dropped (e.g. finding + # 3 of 4 units). A VRF gateway reports every + # connected indoor unit, including when it only + # has one. + if sub_cnt > 0: + if mac_addr not in seen_gateways: + seen_gateways.add(mac_addr) + gateways.append(device_info) + _LOGGER.debug(f"Discovered gateway {mac_addr} (subCnt={sub_cnt}), deferring sub-device fetch") else: device_key = (device_info["host"], device_info["mac"]) if device_key not in seen_device_ids: @@ -607,6 +600,38 @@ async def discover_gree_devices(hass, timeout=5, extra_networks=None, extra_host with suppress(Exception): sock.close() + # Now that the receive loop is done (and no longer racing the discovery + # time budget), query each gateway for its sub-devices. These queries run + # concurrently to keep discovery fast. + if gateways: + _LOGGER.debug(f"Fetching sub-devices for {len(gateways)} gateway(s)") + results = await asyncio.gather( + *(get_subunits_list(gw["mac"], gw["host"], gw["port"]) for gw in gateways), + return_exceptions=True, + ) + for gw, sub_result in zip(gateways, results): + if isinstance(sub_result, Exception): + _LOGGER.error(f"Error fetching sub-devices for {gw['mac']}: {sub_result}") + continue + for sub_device in sub_result.get("list", []): + sub_mac = sub_device.get("mac", "") + if not sub_mac: + continue + sub_device_info = { + "name": f"{gw['name']}_{sub_mac[:4]}", + "host": gw["host"], + "port": gw["port"], + "mac": f"{sub_mac}@{gw['mac']}", + "brand": gw["brand"], + "model": sub_device.get("mid", gw["model"]), + "version": gw["version"], + } + device_key = (sub_device_info["host"], sub_device_info["mac"]) + if device_key not in seen_device_ids: + seen_device_ids.add(device_key) + devices.append(sub_device_info) + _LOGGER.debug(f"Discovered sub-device: {sub_device_info}") + _LOGGER.debug(f"Discovery completed, found {len(devices)} devices") return devices @@ -640,30 +665,105 @@ async def detect_device_encryption(mac_addr, ip_addr, port): _LOGGER.error(f"Could not determine encryption version for device {mac_addr}") return None +async def _fetch_subunits_raw(ip_addr, port, json_payload, max_retries=8): + """Send a subList query and return the raw decoded JSON response. + + Unlike FetchResult, this does not require an encrypted ``pack`` field in the + response. Gree gateways answer the ``subList`` query with the sub-device + ``list`` at the top level of the (unencrypted) JSON payload, so we must not + assume a ``pack`` is present. + """ + timeout = 2 + loop = asyncio.get_running_loop() + payload = json_payload.encode("utf-8") + for attempt in range(max_retries): + clientSock = None + try: + clientSock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + clientSock.setblocking(False) + await asyncio.wait_for(loop.sock_sendto(clientSock, payload, (ip_addr, port)), timeout=timeout) + data, _ = await asyncio.wait_for(loop.sock_recvfrom(clientSock, 64000), timeout=timeout) + received_json = simplejson.loads(data) + _LOGGER.debug(f"_fetch_subunits_raw: raw response: {received_json}") + return received_json + except Exception as e: + _LOGGER.debug(f"subList attempt {attempt + 1}/{max_retries} failed for {ip_addr}:{port}: {type(e).__name__}: {e}") + if attempt == max_retries - 1: + raise + finally: + if clientSock: + with suppress(Exception): + clientSock.close() + + if attempt < max_retries - 1: + await asyncio.sleep(0.5 + (attempt * 0.3)) + return None + + async def get_subunits_list(mac_addr, ip_addr, port): """ - Fetch the list of sub-devices for a Gree device. + Fetch the list of sub-devices for a Gree gateway device. + + The gateway answers a ``subList`` query with the list of connected units. + Depending on firmware the ``list`` is either returned at the top level of + the response, or wrapped in an encrypted ``pack``. When it is encrypted the + gateway uses its own *bound* device key (not the generic key), so we bind + to the gateway first and decrypt the response with the returned key. """ try: - # Prepare the payload - encryption_version = await detect_device_encryption(mac_addr, ip_addr, port) - - json_payload = f'{{"mac":"{mac_addr}", "i":"1"}}' - if encryption_version == 1: - cipher = AES.new(GENERIC_GREE_DEVICE_KEY.encode("utf8"), AES.MODE_ECB) - pack = base64.b64encode(cipher.encrypt(Pad(json_payload).encode("utf8"))).decode("utf-8") - else: - pack, tag = EncryptGCM(GENERIC_GREE_DEVICE_KEY_GCM, json_payload) - cipher = GetGCMCipher(GENERIC_GREE_DEVICE_KEY_GCM) - + # subList is a protocol-level query. Send it unencrypted (no pack). + # ``i:0`` marks a normal (non-bind/scan) request. jsonPayloadToSend = ( - f'{{"cid": "app","i": 1,"pack": "{pack}","t":"subList","tcid":"{str(mac_addr)}","uid": 0}}' + f'{{"cid":"app","i":0,"t":"subList","tcid":"{str(mac_addr)}","uid":0}}' ) - # Use FetchResult to send and receive data - result = await FetchResult(cipher, ip_addr, port, jsonPayloadToSend, encryption_version=encryption_version) - _LOGGER.debug(f"get_subunits_list: FetchResult: {result}") - return result + received_json = await _fetch_subunits_raw(ip_addr, port, jsonPayloadToSend) + if not received_json: + return {"list": []} + + # The list may be at the top level (some firmwares) ... + if isinstance(received_json.get("list"), list): + _LOGGER.debug(f"get_subunits_list: found {len(received_json['list'])} sub-units (top-level)") + return {"list": received_json["list"]} + + # ... or inside an encrypted pack. The pack is encrypted with the + # gateway's *bound* device key, so bind to obtain it, then decrypt. + if "pack" in received_json: + encryption_version = await detect_device_encryption(mac_addr, ip_addr, port) + if encryption_version == 1: + device_key = await GetDeviceKey(mac_addr, ip_addr, port) + if not device_key: + _LOGGER.error(f"get_subunits_list: could not bind to gateway {mac_addr} (v1)") + return {"list": []} + cipher = AES.new(device_key, AES.MODE_ECB) + decoded_pack = base64.b64decode(received_json["pack"]) + decrypted_pack = cipher.decrypt(decoded_pack) + elif encryption_version == 2: + device_key = await GetDeviceKeyGCM(mac_addr, ip_addr, port) + if not device_key: + _LOGGER.error(f"get_subunits_list: could not bind to gateway {mac_addr} (v2)") + return {"list": []} + cipher = GetGCMCipher(device_key) + decoded_pack = base64.b64decode(received_json["pack"]) + decrypted_pack = cipher.decrypt(decoded_pack) + tag = received_json.get("tag") + if tag: + with suppress(Exception): + cipher.verify(base64.b64decode(tag)) + else: + _LOGGER.error(f"get_subunits_list: unknown encryption for gateway {mac_addr}") + return {"list": []} + + decoded_text = decrypted_pack.decode("utf-8", errors="ignore").replace("\x0f", "") + last_brace = decoded_text.rfind("}") + if last_brace != -1: + decoded_text = decoded_text[: last_brace + 1] + pack_json = simplejson.loads(decoded_text) + _LOGGER.debug(f"get_subunits_list: decrypted pack: {pack_json}") + return {"list": pack_json.get("list", [])} + + _LOGGER.warning(f"get_subunits_list: unexpected subList response for {mac_addr}: {received_json}") + return {"list": []} except Exception as e: _LOGGER.error(f"Error fetching sub-device list for {mac_addr}: {e}") return {"list": []} \ No newline at end of file From 90a1bdc9137b9e30415d24f0008004e986c63736 Mon Sep 17 00:00:00 2001 From: meirlo <6350224+meirlo@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:52:22 +0300 Subject: [PATCH 2/4] subList: query both encrypted forms and union sub-units by MAC The gateway only answers a subList query when it is encrypted, and it encrypts the *response* with a different key depending on the request envelope: - i:0 / t:pack -> response encrypted with the gateway's bound device key. - i:1 / t:subList (classic app form) -> response encrypted with the generic key, like scan/bind. Different WiFi-module firmwares answer different forms, and some gateways return a slightly different subset of units in each form. get_subunits_list now binds once, queries both forms, and unions the results by MAC. This handles both module families and never returns fewer units than either form alone. Verified on a GR-Gcloud V3.2.M gateway: device-key form returns 4 units, generic-key form returns 3, merged result is the full 4, alongside a standalone non-VRF unit discovered normally. --- custom_components/gree/gree_protocol.py | 163 +++++++++++++----------- 1 file changed, 91 insertions(+), 72 deletions(-) diff --git a/custom_components/gree/gree_protocol.py b/custom_components/gree/gree_protocol.py index fa9cd35..d8fb65b 100644 --- a/custom_components/gree/gree_protocol.py +++ b/custom_components/gree/gree_protocol.py @@ -665,105 +665,124 @@ async def detect_device_encryption(mac_addr, ip_addr, port): _LOGGER.error(f"Could not determine encryption version for device {mac_addr}") return None -async def _fetch_subunits_raw(ip_addr, port, json_payload, max_retries=8): - """Send a subList query and return the raw decoded JSON response. +async def _subunits_send_ecb(ip_addr, port, payload, decrypt_key, max_retries=3): + """Send a subList payload and decrypt the (v1/ECB) response with decrypt_key. - Unlike FetchResult, this does not require an encrypted ``pack`` field in the - response. Gree gateways answer the ``subList`` query with the sub-device - ``list`` at the top level of the (unencrypted) JSON payload, so we must not - assume a ``pack`` is present. + Returns the sub-unit ``list`` (possibly empty) if a well-formed response + arrives, otherwise None (no reply / undecodable), so the caller can try the + next form. """ timeout = 2 loop = asyncio.get_running_loop() - payload = json_payload.encode("utf-8") + data_bytes = payload.encode("utf-8") for attempt in range(max_retries): clientSock = None try: clientSock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) clientSock.setblocking(False) - await asyncio.wait_for(loop.sock_sendto(clientSock, payload, (ip_addr, port)), timeout=timeout) + await asyncio.wait_for(loop.sock_sendto(clientSock, data_bytes, (ip_addr, port)), timeout=timeout) data, _ = await asyncio.wait_for(loop.sock_recvfrom(clientSock, 64000), timeout=timeout) - received_json = simplejson.loads(data) - _LOGGER.debug(f"_fetch_subunits_raw: raw response: {received_json}") - return received_json + recv = simplejson.loads(data) + raw_pack = recv.get("pack") + if not raw_pack: + return None + decrypted = AES.new(decrypt_key, AES.MODE_ECB).decrypt(base64.b64decode(raw_pack)) + text = decrypted.decode("utf-8", errors="ignore").replace("\x0f", "") + last = text.rfind("}") + if last != -1: + text = text[: last + 1] + parsed = simplejson.loads(text) + if isinstance(parsed.get("list"), list): + return parsed["list"] + return None except Exception as e: - _LOGGER.debug(f"subList attempt {attempt + 1}/{max_retries} failed for {ip_addr}:{port}: {type(e).__name__}: {e}") - if attempt == max_retries - 1: - raise + _LOGGER.debug(f"_subunits_send_ecb attempt {attempt + 1}/{max_retries} failed for {ip_addr}:{port}: {type(e).__name__}: {e}") finally: if clientSock: with suppress(Exception): clientSock.close() - if attempt < max_retries - 1: - await asyncio.sleep(0.5 + (attempt * 0.3)) + await asyncio.sleep(0.4 + attempt * 0.3) return None async def get_subunits_list(mac_addr, ip_addr, port): - """ - Fetch the list of sub-devices for a Gree gateway device. - - The gateway answers a ``subList`` query with the list of connected units. - Depending on firmware the ``list`` is either returned at the top level of - the response, or wrapped in an encrypted ``pack``. When it is encrypted the - gateway uses its own *bound* device key (not the generic key), so we bind - to the gateway first and decrypt the response with the returned key. + """Fetch the list of sub-devices for a Gree gateway device. + + The subList query is answered encrypted, but *how* depends on the request + envelope, which varies by WiFi-module firmware: + + * Device-key form (``i:0`` / ``t:"pack"``): the response is encrypted with + the gateway's *bound* device key. This is what GR-Gcloud V3.2.M answers. + * Generic-key form (``i:1`` / ``t:"subList"``, the classic app form): the + response is encrypted with the *generic* key (like scan/bind). Some + modules only answer this form. + + On v1 we query *both* forms and union the results by MAC: different + firmwares answer different forms, and some gateways return a slightly + different subset in each, so merging maximises the units we recover and + never returns fewer than either form alone. v2 (GCM) modules use the + device-key form only. """ try: - # subList is a protocol-level query. Send it unencrypted (no pack). - # ``i:0`` marks a normal (non-bind/scan) request. - jsonPayloadToSend = ( - f'{{"cid":"app","i":0,"t":"subList","tcid":"{str(mac_addr)}","uid":0}}' - ) + encryption_version = await detect_device_encryption(mac_addr, ip_addr, port) - received_json = await _fetch_subunits_raw(ip_addr, port, jsonPayloadToSend) - if not received_json: + if encryption_version == 2: + device_key = await GetDeviceKeyGCM(mac_addr, ip_addr, port) + if not device_key: + _LOGGER.error(f"get_subunits_list: could not bind to gateway {mac_addr} (v2)") + return {"list": []} + inner = f'{{"mac":"{mac_addr}","t":"subList","i":0}}' + enc_pack, tag = EncryptGCM(device_key, inner) + payload = f'{{"cid":"app","i":0,"pack":"{enc_pack}","t":"pack","tcid":"{mac_addr}","uid":0,"tag":"{tag}"}}' + result = await FetchResult(GetGCMCipher(device_key), ip_addr, port, payload, encryption_version=2) + if isinstance(result, dict) and isinstance(result.get("list"), list): + _LOGGER.debug(f"get_subunits_list: found {len(result['list'])} sub-units (v2)") + return {"list": result["list"]} + _LOGGER.warning(f"get_subunits_list: unexpected v2 subList response for {mac_addr}: {result}") return {"list": []} - # The list may be at the top level (some firmwares) ... - if isinstance(received_json.get("list"), list): - _LOGGER.debug(f"get_subunits_list: found {len(received_json['list'])} sub-units (top-level)") - return {"list": received_json["list"]} - - # ... or inside an encrypted pack. The pack is encrypted with the - # gateway's *bound* device key, so bind to obtain it, then decrypt. - if "pack" in received_json: - encryption_version = await detect_device_encryption(mac_addr, ip_addr, port) - if encryption_version == 1: - device_key = await GetDeviceKey(mac_addr, ip_addr, port) - if not device_key: - _LOGGER.error(f"get_subunits_list: could not bind to gateway {mac_addr} (v1)") - return {"list": []} - cipher = AES.new(device_key, AES.MODE_ECB) - decoded_pack = base64.b64decode(received_json["pack"]) - decrypted_pack = cipher.decrypt(decoded_pack) - elif encryption_version == 2: - device_key = await GetDeviceKeyGCM(mac_addr, ip_addr, port) - if not device_key: - _LOGGER.error(f"get_subunits_list: could not bind to gateway {mac_addr} (v2)") - return {"list": []} - cipher = GetGCMCipher(device_key) - decoded_pack = base64.b64decode(received_json["pack"]) - decrypted_pack = cipher.decrypt(decoded_pack) - tag = received_json.get("tag") - if tag: - with suppress(Exception): - cipher.verify(base64.b64decode(tag)) - else: - _LOGGER.error(f"get_subunits_list: unknown encryption for gateway {mac_addr}") - return {"list": []} + if encryption_version != 1: + _LOGGER.error(f"get_subunits_list: unknown encryption for gateway {mac_addr}") + return {"list": []} - decoded_text = decrypted_pack.decode("utf-8", errors="ignore").replace("\x0f", "") - last_brace = decoded_text.rfind("}") - if last_brace != -1: - decoded_text = decoded_text[: last_brace + 1] - pack_json = simplejson.loads(decoded_text) - _LOGGER.debug(f"get_subunits_list: decrypted pack: {pack_json}") - return {"list": pack_json.get("list", [])} + device_key = await GetDeviceKey(mac_addr, ip_addr, port) + if not device_key: + _LOGGER.error(f"get_subunits_list: could not bind to gateway {mac_addr} (v1)") + return {"list": []} + generic_key = GENERIC_GREE_DEVICE_KEY.encode("utf8") + + # Device-key form: i:0 / t:pack -> response encrypted with the device key. + inner = f'{{"mac":"{mac_addr}","t":"subList","i":0}}' + pack = base64.b64encode(AES.new(device_key, AES.MODE_ECB).encrypt(Pad(inner).encode("utf8"))).decode("utf-8") + payload = f'{{"cid":"app","i":0,"pack":"{pack}","t":"pack","tcid":"{mac_addr}","uid":0}}' + device_units = await _subunits_send_ecb(ip_addr, port, payload, device_key) + + # Generic-key form: i:1 / t:subList -> response encrypted with generic key. + inner_fb = f'{{"mac":"{mac_addr}","i":1}}' + pack_fb = base64.b64encode(AES.new(device_key, AES.MODE_ECB).encrypt(Pad(inner_fb).encode("utf8"))).decode("utf-8") + payload_fb = f'{{"cid":"app","i":1,"pack":"{pack_fb}","t":"subList","tcid":"{mac_addr}","uid":0}}' + generic_units = await _subunits_send_ecb(ip_addr, port, payload_fb, generic_key) + + if device_units is None and generic_units is None: + _LOGGER.warning(f"get_subunits_list: no subList form answered for {mac_addr}") + return {"list": []} - _LOGGER.warning(f"get_subunits_list: unexpected subList response for {mac_addr}: {received_json}") - return {"list": []} + # Union both forms by MAC, preserving first-seen order (device-key first). + merged: list = [] + seen: set = set() + for units in ((device_units or []), (generic_units or [])): + for unit in units: + unit_mac = unit.get("mac") + if unit_mac and unit_mac not in seen: + seen.add(unit_mac) + merged.append(unit) + + _LOGGER.debug( + f"get_subunits_list: {mac_addr} device-key={len(device_units) if device_units is not None else 'n/a'}, " + f"generic-key={len(generic_units) if generic_units is not None else 'n/a'}, merged={len(merged)}" + ) + return {"list": merged} except Exception as e: _LOGGER.error(f"Error fetching sub-device list for {mac_addr}: {e}") return {"list": []} \ No newline at end of file From 6638e7a237110e5d3a07dc48cf1b17d35a8305c5 Mon Sep 17 00:00:00 2001 From: meirlo <6350224+meirlo@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:10:49 +0300 Subject: [PATCH 3/4] udp: connect sockets to target so concurrent probes don't cross replies When several gateways are probed concurrently (asyncio.gather), the unconnected UDP sockets could receive datagrams intended for another in-flight request - observed in the wild as one gateway's bind call returning a different gateway's key. Connecting the socket to the target peer makes the kernel drop datagrams from any other address, correlating each reply to its request. Applied to both FetchResult and the subList helper. FetchResult is only ever called with unicast device IPs, so this does not affect broadcast discovery. --- custom_components/gree/gree_protocol.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/custom_components/gree/gree_protocol.py b/custom_components/gree/gree_protocol.py index d8fb65b..3501247 100644 --- a/custom_components/gree/gree_protocol.py +++ b/custom_components/gree/gree_protocol.py @@ -131,9 +131,15 @@ async def FetchResult(cipher, ip_addr, port, json_data, encryption_version=1, ma try: clientSock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) clientSock.settimeout(timeout) + # Connect the socket to the target device so the kernel only + # delivers datagrams from that peer. Unconnected UDP sockets can + # receive replies meant for other concurrent requests (observed: + # one gateway returning another gateway's bind key when several are + # probed at once). + clientSock.connect((ip_addr, port)) # Send data to device - clientSock.sendto(bytes(json_data, "utf-8"), (ip_addr, port)) + clientSock.send(bytes(json_data, "utf-8")) # Receive response with event loop yielding data, _ = await asyncio.wait_for(asyncio.get_event_loop().run_in_executor(None, clientSock.recvfrom, 64000), timeout=timeout) @@ -680,8 +686,13 @@ async def _subunits_send_ecb(ip_addr, port, payload, decrypt_key, max_retries=3) try: clientSock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) clientSock.setblocking(False) - await asyncio.wait_for(loop.sock_sendto(clientSock, data_bytes, (ip_addr, port)), timeout=timeout) - data, _ = await asyncio.wait_for(loop.sock_recvfrom(clientSock, 64000), timeout=timeout) + # Connect the socket to the target so the kernel drops datagrams + # from any other peer. Without this, concurrent probes to multiple + # gateways can receive each other's replies (observed: one gateway + # returning another's bind key). + await loop.sock_connect(clientSock, (ip_addr, port)) + await asyncio.wait_for(loop.sock_sendall(clientSock, data_bytes), timeout=timeout) + data = await asyncio.wait_for(loop.sock_recv(clientSock, 64000), timeout=timeout) recv = simplejson.loads(data) raw_pack = recv.get("pack") if not raw_pack: From 626a7dafc2f8af416b6ac05deb1d5b311fe11900 Mon Sep 17 00:00:00 2001 From: meirlo <6350224+meirlo@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:34:08 +0300 Subject: [PATCH 4/4] subList: also query the subDev form for older W06-class modules Older gateway WiFi modules (e.g. W06, hid 362001067012+U-W06AV30.bin, ver V1.1.0.0) do not answer the subList command at all - they enumerate sub-units via a 'subDev' command wrapped in a device-key pack, returning the same subList-shaped response. Captured from a real W06 gateway. get_subunits_list now also issues the subDev form (with uid:0) and unions its result with the subList forms. Confirmed on a GR-Gcloud V3.2.M gateway the subDev form returns the full unit list too (device-key=4, generic-key=3, subDev=4, merged=4), so it is a good universal path and adds no regression. --- custom_components/gree/gree_protocol.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/custom_components/gree/gree_protocol.py b/custom_components/gree/gree_protocol.py index 3501247..57c38c2 100644 --- a/custom_components/gree/gree_protocol.py +++ b/custom_components/gree/gree_protocol.py @@ -775,14 +775,22 @@ async def get_subunits_list(mac_addr, ip_addr, port): payload_fb = f'{{"cid":"app","i":1,"pack":"{pack_fb}","t":"subList","tcid":"{mac_addr}","uid":0}}' generic_units = await _subunits_send_ecb(ip_addr, port, payload_fb, generic_key) - if device_units is None and generic_units is None: + # subDev form: older W06-class modules answer a "subDev" command (not + # "subList") wrapped in a device-key pack. The response is a subList + # encrypted with the device key. + inner_sd = f'{{"cid":"{mac_addr}","i":0,"mac":"{mac_addr}","t":"subDev"}}' + pack_sd = base64.b64encode(AES.new(device_key, AES.MODE_ECB).encrypt(Pad(inner_sd).encode("utf8"))).decode("utf-8") + payload_sd = f'{{"cid":"app","i":0,"pack":"{pack_sd}","t":"pack","tcid":"{mac_addr}","uid":0}}' + subdev_units = await _subunits_send_ecb(ip_addr, port, payload_sd, device_key) + + if device_units is None and generic_units is None and subdev_units is None: _LOGGER.warning(f"get_subunits_list: no subList form answered for {mac_addr}") return {"list": []} - # Union both forms by MAC, preserving first-seen order (device-key first). + # Union all forms by MAC, preserving first-seen order. merged: list = [] seen: set = set() - for units in ((device_units or []), (generic_units or [])): + for units in ((device_units or []), (generic_units or []), (subdev_units or [])): for unit in units: unit_mac = unit.get("mac") if unit_mac and unit_mac not in seen: @@ -791,7 +799,8 @@ async def get_subunits_list(mac_addr, ip_addr, port): _LOGGER.debug( f"get_subunits_list: {mac_addr} device-key={len(device_units) if device_units is not None else 'n/a'}, " - f"generic-key={len(generic_units) if generic_units is not None else 'n/a'}, merged={len(merged)}" + f"generic-key={len(generic_units) if generic_units is not None else 'n/a'}, " + f"subDev={len(subdev_units) if subdev_units is not None else 'n/a'}, merged={len(merged)}" ) return {"list": merged} except Exception as e: