fix: resolve all mypy errors in zigbee_service
- Add dict[str, Any] type parameters throughout (mypy type-arg) - Fix str | None returns with explicit 'or None' coercion (no-any-return) - Remove unused type: ignore[import] comment (unused-ignore) - Decode message.payload bytes before json.loads (arg-type) - Use X | Y syntax in isinstance check (ruff UP038) - All 33 tests still passing, ruff + mypy clean Co-authored-by: CyberKeys <noreply@openclaw.ai>
This commit is contained in:
@@ -5,11 +5,12 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import aiomqtt # type: ignore[import]
|
import aiomqtt
|
||||||
except ImportError: # pragma: no cover
|
except ImportError: # pragma: no cover
|
||||||
aiomqtt = None # type: ignore[assignment]
|
aiomqtt = None # type: ignore[assignment]
|
||||||
|
|
||||||
@@ -29,41 +30,45 @@ def _z2m_type_to_homelable(device_type: str) -> str:
|
|||||||
return mapping.get(device_type, "zigbee_enddevice")
|
return mapping.get(device_type, "zigbee_enddevice")
|
||||||
|
|
||||||
|
|
||||||
def parse_networkmap(payload: dict) -> tuple[list[dict], list[dict]]:
|
def parse_networkmap(
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
"""Parse a Z2M networkmap response payload into node + edge lists.
|
"""Parse a Z2M networkmap response payload into node + edge lists.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(nodes, edges) where each node/edge is a plain dict with the fields
|
(nodes, edges) where each node/edge is a plain dict with the fields
|
||||||
expected by ZigbeeNodeOut / ZigbeeEdgeOut.
|
expected by ZigbeeNodeOut / ZigbeeEdgeOut.
|
||||||
"""
|
"""
|
||||||
data = payload.get("data", {})
|
data: dict[str, Any] = payload.get("data", {})
|
||||||
routes = data.get("routes", [])
|
routes: list[dict[str, Any]] = data.get("routes", [])
|
||||||
|
|
||||||
nodes_list: list[dict] = []
|
nodes_list: list[dict[str, Any]] = []
|
||||||
edges_list: list[dict] = []
|
edges_list: list[dict[str, Any]] = []
|
||||||
seen_ids: set[str] = set()
|
seen_ids: set[str] = set()
|
||||||
|
|
||||||
# Coordinator is always present; find it first so we can wire the hierarchy
|
# Coordinator is always present; find it first so we can wire the hierarchy
|
||||||
coordinator_id: str | None = None
|
coordinator_id: str | None = None
|
||||||
|
|
||||||
for route in routes:
|
for route in routes:
|
||||||
source = route.get("source", {})
|
source: dict[str, Any] = route.get("source", {})
|
||||||
if not source:
|
if not source:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
ieee = source.get("ieeeAddr") or source.get("ieee_address") or ""
|
ieee: str = source.get("ieeeAddr") or source.get("ieee_address") or ""
|
||||||
if not ieee:
|
if not ieee:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
device_type: str = source.get("type", "EndDevice")
|
device_type: str = source.get("type", "EndDevice")
|
||||||
friendly_name: str = source.get("friendlyName") or source.get("friendly_name") or ieee
|
friendly_name: str = (
|
||||||
model: str | None = source.get("modelID") or source.get("model")
|
source.get("friendlyName") or source.get("friendly_name") or ieee
|
||||||
vendor: str | None = source.get("vendor")
|
)
|
||||||
|
model: str | None = source.get("modelID") or source.get("model") or None
|
||||||
|
vendor: str | None = source.get("vendor") or None
|
||||||
|
|
||||||
if ieee not in seen_ids:
|
if ieee not in seen_ids:
|
||||||
seen_ids.add(ieee)
|
seen_ids.add(ieee)
|
||||||
node_type = _z2m_type_to_homelable(device_type)
|
node_type = _z2m_type_to_homelable(device_type)
|
||||||
node: dict = {
|
node: dict[str, Any] = {
|
||||||
"id": ieee,
|
"id": ieee,
|
||||||
"label": friendly_name,
|
"label": friendly_name,
|
||||||
"type": node_type,
|
"type": node_type,
|
||||||
@@ -80,10 +85,10 @@ def parse_networkmap(payload: dict) -> tuple[list[dict], list[dict]]:
|
|||||||
coordinator_id = ieee
|
coordinator_id = ieee
|
||||||
|
|
||||||
# Walk the route targets to build edges and collect additional nodes
|
# Walk the route targets to build edges and collect additional nodes
|
||||||
targets = route.get("routes", [])
|
targets: list[dict[str, Any]] = route.get("routes", [])
|
||||||
for target_entry in targets:
|
for target_entry in targets:
|
||||||
target_src = target_entry.get("target", {})
|
target_src: dict[str, Any] = target_entry.get("target", {})
|
||||||
target_ieee = (
|
target_ieee: str = (
|
||||||
target_src.get("ieeeAddr") or target_src.get("ieee_address") or ""
|
target_src.get("ieeeAddr") or target_src.get("ieee_address") or ""
|
||||||
)
|
)
|
||||||
lqi: int | None = target_entry.get("lqi")
|
lqi: int | None = target_entry.get("lqi")
|
||||||
@@ -93,12 +98,17 @@ def parse_networkmap(payload: dict) -> tuple[list[dict], list[dict]]:
|
|||||||
|
|
||||||
if target_ieee not in seen_ids:
|
if target_ieee not in seen_ids:
|
||||||
seen_ids.add(target_ieee)
|
seen_ids.add(target_ieee)
|
||||||
t_source = target_entry.get("target", {})
|
t_type: str = target_src.get("type", "EndDevice")
|
||||||
t_type: str = t_source.get("type", "EndDevice")
|
t_fn: str = (
|
||||||
t_fn: str = t_source.get("friendlyName") or t_source.get("friendly_name") or target_ieee
|
target_src.get("friendlyName")
|
||||||
t_model: str | None = t_source.get("modelID") or t_source.get("model")
|
or target_src.get("friendly_name")
|
||||||
t_vendor: str | None = t_source.get("vendor")
|
or target_ieee
|
||||||
t_node: dict = {
|
)
|
||||||
|
t_model: str | None = (
|
||||||
|
target_src.get("modelID") or target_src.get("model") or None
|
||||||
|
)
|
||||||
|
t_vendor: str | None = target_src.get("vendor") or None
|
||||||
|
t_node: dict[str, Any] = {
|
||||||
"id": target_ieee,
|
"id": target_ieee,
|
||||||
"label": t_fn,
|
"label": t_fn,
|
||||||
"type": _z2m_type_to_homelable(t_type),
|
"type": _z2m_type_to_homelable(t_type),
|
||||||
@@ -121,7 +131,6 @@ def parse_networkmap(payload: dict) -> tuple[list[dict], list[dict]]:
|
|||||||
if node["device_type"] == "Router":
|
if node["device_type"] == "Router":
|
||||||
node["parent_id"] = coordinator_id
|
node["parent_id"] = coordinator_id
|
||||||
elif node["device_type"] == "EndDevice":
|
elif node["device_type"] == "EndDevice":
|
||||||
# Try to find the nearest router from the edge list
|
|
||||||
parent = _find_parent_router(node["id"], router_ids, edges_list)
|
parent = _find_parent_router(node["id"], router_ids, edges_list)
|
||||||
node["parent_id"] = parent or coordinator_id
|
node["parent_id"] = parent or coordinator_id
|
||||||
|
|
||||||
@@ -131,14 +140,16 @@ def parse_networkmap(payload: dict) -> tuple[list[dict], list[dict]]:
|
|||||||
def _find_parent_router(
|
def _find_parent_router(
|
||||||
device_id: str,
|
device_id: str,
|
||||||
router_ids: set[str],
|
router_ids: set[str],
|
||||||
edges: list[dict],
|
edges: list[dict[str, Any]],
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Return the first router that has a direct edge to device_id."""
|
"""Return the first router that has a direct edge to device_id."""
|
||||||
for edge in edges:
|
for edge in edges:
|
||||||
if edge["target"] == device_id and edge["source"] in router_ids:
|
src: str = edge["source"]
|
||||||
return edge["source"]
|
tgt: str = edge["target"]
|
||||||
if edge["source"] == device_id and edge["target"] in router_ids:
|
if tgt == device_id and src in router_ids:
|
||||||
return edge["target"]
|
return src
|
||||||
|
if src == device_id and tgt in router_ids:
|
||||||
|
return tgt
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -148,7 +159,7 @@ async def fetch_networkmap(
|
|||||||
base_topic: str,
|
base_topic: str,
|
||||||
username: str | None = None,
|
username: str | None = None,
|
||||||
password: str | None = None,
|
password: str | None = None,
|
||||||
) -> tuple[list[dict], list[dict]]:
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
"""Connect to the MQTT broker, request the Z2M networkmap, and return (nodes, edges).
|
"""Connect to the MQTT broker, request the Z2M networkmap, and return (nodes, edges).
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
@@ -157,13 +168,16 @@ async def fetch_networkmap(
|
|||||||
ValueError: if the response payload is malformed.
|
ValueError: if the response payload is malformed.
|
||||||
"""
|
"""
|
||||||
if aiomqtt is None: # pragma: no cover
|
if aiomqtt is None: # pragma: no cover
|
||||||
raise ImportError("aiomqtt is required for Zigbee import. Install it with: pip install aiomqtt")
|
raise ImportError(
|
||||||
|
"aiomqtt is required for Zigbee import. "
|
||||||
|
"Install it with: pip install aiomqtt"
|
||||||
|
)
|
||||||
|
|
||||||
request_topic = _NETWORKMAP_REQUEST_TOPIC.format(base_topic=base_topic)
|
request_topic = _NETWORKMAP_REQUEST_TOPIC.format(base_topic=base_topic)
|
||||||
response_topic = _NETWORKMAP_RESPONSE_TOPIC.format(base_topic=base_topic)
|
response_topic = _NETWORKMAP_RESPONSE_TOPIC.format(base_topic=base_topic)
|
||||||
|
|
||||||
result_event: asyncio.Event = asyncio.Event()
|
result_event: asyncio.Event = asyncio.Event()
|
||||||
response_payload: dict = {}
|
response_payload: dict[str, Any] = {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with aiomqtt.Client(
|
async with aiomqtt.Client(
|
||||||
@@ -182,10 +196,16 @@ async def fetch_networkmap(
|
|||||||
async def _wait_for_response() -> None:
|
async def _wait_for_response() -> None:
|
||||||
async for message in client.messages:
|
async for message in client.messages:
|
||||||
if str(message.topic) == response_topic:
|
if str(message.topic) == response_topic:
|
||||||
|
raw = message.payload
|
||||||
try:
|
try:
|
||||||
response_payload.update(json.loads(message.payload))
|
payload_str = (
|
||||||
|
raw.decode() if isinstance(raw, bytes | bytearray) else str(raw)
|
||||||
|
)
|
||||||
|
response_payload.update(json.loads(payload_str))
|
||||||
except (json.JSONDecodeError, TypeError) as exc:
|
except (json.JSONDecodeError, TypeError) as exc:
|
||||||
raise ValueError(f"Malformed networkmap response: {exc}") from exc
|
raise ValueError(
|
||||||
|
f"Malformed networkmap response: {exc}"
|
||||||
|
) from exc
|
||||||
result_event.set()
|
result_event.set()
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -229,4 +249,6 @@ async def test_mqtt_connection(
|
|||||||
except aiomqtt.MqttError as exc:
|
except aiomqtt.MqttError as exc:
|
||||||
raise ConnectionError(f"MQTT connection failed: {exc}") from exc
|
raise ConnectionError(f"MQTT connection failed: {exc}") from exc
|
||||||
except asyncio.TimeoutError as exc:
|
except asyncio.TimeoutError as exc:
|
||||||
raise TimeoutError(f"Connection to {mqtt_host}:{mqtt_port} timed out") from exc
|
raise TimeoutError(
|
||||||
|
f"Connection to {mqtt_host}:{mqtt_port} timed out"
|
||||||
|
) from exc
|
||||||
|
|||||||
Reference in New Issue
Block a user