impress_sig_mesh_hacs/custom_components/sigmesh_gateway/serial_reader.py
impressionyang b4643fa408 feat: 添加调试日志用于排查扫描问题
1. provisioning.py: 添加 start_scanning 调用日志
2. config_flow.py: 添加 coordinator 调用日志
3. serial_reader.py: 添加命令发送和接收的原始数据日志
2026-04-16 16:25:06 +08:00

320 lines
10 KiB
Python

"""SigMesh Gateway 串口读取器模块."""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime
from typing import Callable
import serial
import serial.tools.list_ports
from homeassistant.core import HomeAssistant
from .const import (
DEFAULT_BAUDRATE,
SERIAL_EVENT_PREFIX,
SERIAL_MESH_RECV,
SERIAL_PROV_DEVICE_JOINED,
SERIAL_PROV_DEVICE_LEFT,
)
_LOGGER = logging.getLogger(__name__)
@dataclass
class SerialDataEvent:
"""串口数据事件."""
timestamp: datetime
raw_data: bytes
decoded_line: str
@dataclass
class MeshMessageEvent:
"""Mesh 消息事件."""
timestamp: datetime
src_address: str
dst_address: str
opcode: int
payload: bytes
@dataclass
class ProvDeviceEvent:
"""配网设备事件."""
timestamp: datetime
event_type: str # "joined" or "left"
mac_address: str
element_count: int | None = None
class SerialReader:
"""异步串口读取器."""
def __init__(
self,
device: str,
baudrate: int = DEFAULT_BAUDRATE,
bytesize: int = 8,
parity: str = "N",
stopbits: int = 1,
timeout: float = 0.1,
) -> None:
"""初始化串口读取器."""
self.device = device
self.baudrate = baudrate
self.bytesize = bytesize
self.parity = parity
self.stopbits = stopbits
self.timeout = timeout
self._serial: serial.Serial | None = None
self._running = False
self._read_task: asyncio.Task | None = None
self._buffer = bytearray()
# 回调函数
self._on_data_callback: Callable[[SerialDataEvent], None] | None = None
self._on_mesh_message_callback: Callable[[MeshMessageEvent], None] | None = None
self._on_prov_device_callback: Callable[[ProvDeviceEvent], None] | None = None
self._on_disconnect_callback: Callable[[], None] | None = None
@property
def is_connected(self) -> bool:
"""检查串口是否已连接."""
return self._serial is not None and self._serial.is_open
def set_callbacks(
self,
on_data: Callable[[SerialDataEvent], None] | None = None,
on_mesh_message: Callable[[MeshMessageEvent], None] | None = None,
on_prov_device: Callable[[ProvDeviceEvent], None] | None = None,
on_disconnect: Callable[[], None] | None = None,
) -> None:
"""设置回调函数."""
self._on_data_callback = on_data
self._on_mesh_message_callback = on_mesh_message
self._on_prov_device_callback = on_prov_device
self._on_disconnect_callback = on_disconnect
def _parse_event_line(self, line: str) -> None:
"""解析事件行."""
if not line.startswith(SERIAL_EVENT_PREFIX):
return
_LOGGER.debug("解析事件:%s", line)
# 触发通用数据回调
if self._on_data_callback:
self._on_data_callback(
SerialDataEvent(
timestamp=datetime.now(),
raw_data=line.encode(),
decoded_line=line,
)
)
# Mesh 消息接收
if line.startswith(SERIAL_MESH_RECV):
self._parse_mesh_message(line)
# 设备加入
elif line.startswith(SERIAL_PROV_DEVICE_JOINED):
self._parse_prov_device_joined(line)
# 设备离开
elif line.startswith(SERIAL_PROV_DEVICE_LEFT):
self._parse_prov_device_left(line)
def _parse_mesh_message(self, line: str) -> None:
"""解析 Mesh 消息."""
# 格式:+EVENT=MESH,recv,<src>,<dst>,<opcode>,<hex_payload>
try:
parts = line.split(",")
if len(parts) < 5:
_LOGGER.warning("无效的 Mesh 消息格式:%s", line)
return
src_addr = parts[2]
dst_addr = parts[3]
opcode = int(parts[4], 16) if parts[4].startswith("0x") else int(parts[4])
# 解析 payload (十六进制字符串)
payload_hex = parts[5] if len(parts) > 5 else ""
payload = bytes.fromhex(payload_hex) if payload_hex else b""
if self._on_mesh_message_callback:
self._on_mesh_message_callback(
MeshMessageEvent(
timestamp=datetime.now(),
src_address=src_addr,
dst_address=dst_addr,
opcode=opcode,
payload=payload,
)
)
except (ValueError, IndexError) as e:
_LOGGER.error("解析 Mesh 消息失败:%s, 错误:%s", line, e)
def _parse_prov_device_joined(self, line: str) -> None:
"""解析设备加入事件."""
# 格式:+EVENT=PROV,device_joined,<mac>,<element_count>
try:
parts = line.split(",")
if len(parts) < 4:
_LOGGER.warning("无效的设备加入格式:%s", line)
return
mac_addr = parts[2]
element_count = int(parts[3])
if self._on_prov_device_callback:
self._on_prov_device_callback(
ProvDeviceEvent(
timestamp=datetime.now(),
event_type="joined",
mac_address=mac_addr,
element_count=element_count,
)
)
except (ValueError, IndexError) as e:
_LOGGER.error("解析设备加入事件失败:%s, 错误:%s", line, e)
def _parse_prov_device_left(self, line: str) -> None:
"""解析设备离开事件."""
# 格式:+EVENT=PROV,device_left,<mac>
try:
parts = line.split(",")
if len(parts) < 3:
_LOGGER.warning("无效的设备离开格式:%s", line)
return
mac_addr = parts[2]
if self._on_prov_device_callback:
self._on_prov_device_callback(
ProvDeviceEvent(
timestamp=datetime.now(),
event_type="left",
mac_address=mac_addr,
element_count=None,
)
)
except (ValueError, IndexError) as e:
_LOGGER.error("解析设备离开事件失败:%s, 错误:%s", line, e)
async def connect(self) -> None:
"""连接串口."""
if self.is_connected:
_LOGGER.warning("串口已连接")
return
try:
self._serial = serial.serial_for_url(
self.device,
baudrate=self.baudrate,
bytesize=self.bytesize,
parity=self.parity,
stopbits=self.stopbits,
timeout=self.timeout,
exclusive=True,
)
self._running = True
_LOGGER.info(
"串口已连接:%s, 波特率:%d",
self.device,
self.baudrate,
)
except serial.SerialException as e:
_LOGGER.error("串口连接失败:%s, 错误:%s", self.device, e)
raise
async def disconnect(self) -> None:
"""断开串口连接."""
self._running = False
if self._read_task:
self._read_task.cancel()
try:
await self._read_task
except asyncio.CancelledError:
pass
self._read_task = None
if self._serial:
self._serial.close()
self._serial = None
_LOGGER.info("串口已断开:%s", self.device)
async def start_reading(self) -> None:
"""开始读取串口数据."""
if not self.is_connected:
raise RuntimeError("串口未连接")
self._read_task = asyncio.create_task(self._read_loop())
async def _read_loop(self) -> None:
"""串口读取循环."""
_LOGGER.debug("开始串口读取循环")
while self._running:
try:
if self._serial and self._serial.in_waiting:
data = self._serial.read(self._serial.in_waiting)
self._buffer.extend(data)
# 按行处理
while b"\r\n" in self._buffer:
line_bytes, self._buffer = self._buffer.split(b"\r\n", 1)
try:
line = line_bytes.decode("utf-8").strip()
_LOGGER.debug("串口接收:%s", line)
_LOGGER.debug("串口接收原始数据:%s", line_bytes.hex().upper())
self._parse_event_line(line)
except UnicodeDecodeError as e:
_LOGGER.warning("解码失败:%s, 错误:%s", line_bytes, e)
await asyncio.sleep(0.01) # 避免 CPU 占用过高
except asyncio.CancelledError:
_LOGGER.debug("读取循环被取消")
break
except Exception as e:
_LOGGER.error("读取循环错误:%s", e)
if self._on_disconnect_callback:
self._on_disconnect_callback()
break
_LOGGER.debug("读取循环结束")
async def write(self, data: bytes) -> int:
"""写入数据到串口."""
if not self.is_connected:
raise RuntimeError("串口未连接")
return self._serial.write(data) # type: ignore[union-attr]
async def write_command(self, command: str) -> int:
"""写入 AT 命令."""
cmd_bytes = f"{command}\r\n".encode()
_LOGGER.debug("发送命令:%s (原始数据:%s)", command, cmd_bytes.hex())
try:
result = await self.write(cmd_bytes)
_LOGGER.debug("命令发送成功,发送了 %d 字节", result)
return result
except Exception as e:
_LOGGER.error("命令发送失败:%s", e)
raise
def list_serial_ports() -> list[dict[str, str]]:
"""列出可用的串口端口."""
ports = serial.tools.list_ports.comports()
return [
{"device": p.device, "description": p.description, "hwid": p.hwid}
for p in ports
]