fix(mips): return None instead of raising when getDevList times out (#1793)
Some checks failed
Tests / check-rule-format (push) Has been cancelled
Validate / validate-hassfest (push) Has been cancelled
Validate / validate-hacs (push) Has been cancelled
Validate / validate-lint (push) Has been cancelled
Validate / validate-setup (push) Has been cancelled

`MipsLocalClient.get_dev_list_async()` raises `MIoTMipsError` when the
central hub gateway does not answer in time, but both of its callers in
`miot_client.py` are written to handle a `None` return:

    gw_list = await mips.get_dev_list_async(payload=json.dumps(payload))
    if gw_list is None:
        ...
        return

Because the coroutine raises instead, those `None` branches are dead code
and the exception escapes as an unretrieved task exception.

The consequence is severe for `__refresh_gw_devices_with_group_id_async()`,
which is the only place that populates `_device_list_gateway` in full (it
runs when the local mips connection comes up, and queries every device with
no `filter`). One unreachable device is enough to time out the whole query,
and when it does:

  - `_device_list_gateway` stays empty, so every device keeps `gw_state`
    False;
  - the retry in the `gw_list is None` branch never runs, so it never
    recovers.

`__check_device_state()` is `cloud or gateway or lan`, so with the gateway
path silently dead the cloud becomes the only source of truth. The next
time the cloud MQTT connection drops, every device is marked offline at
once, even though the central hub gateway is connected and healthy the
whole time.

Observed on a 256-device / 2697-entity setup: an unplugged rice cooker
(8 entities, permanently unavailable) caused 18 of 30 such exceptions over
four days. Each cloud MQTT `Connection reset by peer` then took all 2643
gateway-backed entities offline until the cloud reconnected.

Returning `None` makes the existing caller logic work as written: the
failure is logged and, for the full refresh, retried until it succeeds.
`MIoTMipsError` remains in use elsewhere in the file, and the return type
is widened to `Optional[dict[str, dict]]` on the abstract method and on
`MipsCloudClient` to match.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
zealllot
2026-09-24 13:06:18 +08:00
committed by GitHub
parent cf2a4bf649
commit bf38d93068

View File

@@ -482,7 +482,7 @@ class _MipsClient(ABC):
self, self,
payload: Optional[str] = None, payload: Optional[str] = None,
timeout_ms: int = 10000 timeout_ms: int = 10000
) -> dict[str, dict]: ... ) -> Optional[dict[str, dict]]: ...
@abstractmethod @abstractmethod
async def get_prop_async( async def get_prop_async(
@@ -1031,7 +1031,7 @@ class MipsCloudClient(_MipsClient):
async def get_dev_list_async( async def get_dev_list_async(
self, payload: Optional[str] = None, timeout_ms: int = 10000 self, payload: Optional[str] = None, timeout_ms: int = 10000
) -> dict[str, dict]: ) -> Optional[dict[str, dict]]:
raise NotImplementedError('please call in http client') raise NotImplementedError('please call in http client')
async def get_prop_async( async def get_prop_async(
@@ -1385,12 +1385,13 @@ class MipsLocalClient(_MipsClient):
@final @final
async def get_dev_list_async( async def get_dev_list_async(
self, payload: Optional[str] = None, timeout_ms: int = 10000 self, payload: Optional[str] = None, timeout_ms: int = 10000
) -> dict[str, dict]: ) -> Optional[dict[str, dict]]:
result_obj = await self.__request_async( result_obj = await self.__request_async(
topic='proxy/getDevList', payload=payload or '{}', topic='proxy/getDevList', payload=payload or '{}',
timeout_ms=timeout_ms) timeout_ms=timeout_ms)
if not result_obj or 'devList' not in result_obj: if not result_obj or 'devList' not in result_obj:
raise MIoTMipsError('invalid result') self.log_error(f'get dev list failed, invalid result, {payload}')
return None
device_list = {} device_list = {}
for did, info in result_obj['devList'].items(): for did, info in result_obj['devList'].items():
name: str = info.get('name', None) name: str = info.get('name', None)