feat: 添加配网和分组管理功能

新增功能:
- 配网管理模块 (provisioning.py): 支持设备扫描、配网、超时处理
- 配网配置步骤: UI 配置流程增加配网参数配置(Network Key, App Key 等)
- 分组管理:支持 SIG 分组和 VENDOR 分组的加入/删除操作
- HA 服务调用:7 个配网和分组相关的服务

文件变更:
- const.py: 添加配网相关常量(CONF_NETWORK_KEY, PROV_TIMEOUT 等)
- config_flow.py: 增加 prov_config 配置步骤和 OptionsFlow 菜单
- provisioning.py: 新建配网管理器(ProvisioningManager 类)
- coordinator.py: 集成配网管理器,添加配网状态管理方法
- services.py: 新建服务定义和注册
- services.yaml: HA 服务定义文件
- __init__.py: 集成服务注册和卸载
- PRD.md: 更新服务调用接口和配置参数文档

配网功能说明:
- 首次使用需配置 Network Key, App Key, Network ID, IV Index
- 配网超时时间:180 秒
- 组地址范围:0xC000 - 0xCFFF
- 支持 SIG 标准分组和 VENDOR 自定义分组
This commit is contained in:
impressionyang 2026-04-16 12:05:13 +08:00
parent f1f2c77af4
commit d21e7f1b3f
8 changed files with 1429 additions and 23 deletions

95
PRD.md
View File

@ -569,21 +569,91 @@ class SigMeshGatewayCoordinator:
async def async_request_refresh() -> None
```
### 8.2 服务调用接口TODO
### 8.2 服务调用接口
已实现的服务调用接口:
```yaml
# service.yaml
sigmesh_gateway.send_command:
# sigmesh_gateway.start_scan - 开始扫描设备
start_scan:
name: 开始扫描设备
description: 开始扫描可用的 Bluetooth Mesh 设备
# sigmesh_gateway.stop_provisioning - 停止配网
stop_provisioning:
name: 停止配网
description: 停止当前的配网操作
# sigmesh_gateway.start_provisioning - 开始配网
start_provisioning:
fields:
device_id:
description: 设备 ID
example: "AA:BB:CC:DD:EE:FF"
device_address:
description: 要配网的设备地址16 进制字符串)
example: "001A"
# sigmesh_gateway.bind_appkey - 绑定 App Key
bind_appkey:
fields:
device_address:
description: 设备地址
example: "001A"
element_address:
description: 元素地址(默认为 0
example: 0
# sigmesh_gateway.add_to_group - 添加到组
add_to_group:
fields:
target_address:
description: 目标设备地址
example: "001A"
element_address:
description: 元素地址
example: 0
group_address:
description: 组地址(建议使用 0xC000 以上)
example: "C001"
model_id:
description: Model ID
example: 4352
is_sig:
description: 是否为 SIG 标准分组
example: true
# sigmesh_gateway.remove_from_group - 从组移除
remove_from_group:
fields:
target_address:
description: 目标设备地址
example: "001A"
element_address:
description: 元素地址
example: 0
group_address:
description: 组地址
example: "C001"
model_id:
description: Model ID
example: 4352
is_sig:
description: 是否为 SIG 标准分组
example: true
# sigmesh_gateway.send_vendor_command - 发送 VENDOR 命令
send_vendor_command:
fields:
target_address:
description: 目标设备地址
example: "001A"
element_address:
description: 元素地址
example: 0
opcode:
description: 操作码
example: "0x8202"
description: VENDOR 操作码
example: "1102"
payload:
description: 数据负载
example: "01"
description: 数据负载16 进制)
example: "0000"
```
---
@ -596,6 +666,11 @@ sigmesh_gateway.send_command:
|------|------|------|--------|------|
| serial_device | string | 是 | /dev/ttyUSB0 | 串口设备路径 |
| baudrate | int | 否 | 115200 | 波特率 |
| network_key | string | 否 | 32 个 0 | 网络密钥16 字节32 字符十六进制) |
| app_key | string | 否 | 32 个 0 | 应用密钥16 字节32 字符十六进制) |
| network_id | string | 否 | "0000" | 网络 ID2 字节4 字符十六进制) |
| iv_index | int | 否 | 0 | IV Index4 字节) |
| group_address | string | 否 | "0xC000" | 组地址起始值16 进制字符串) |
### 9.2 选项参数

View File

@ -8,9 +8,22 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .const import (
CONF_APP_KEY,
CONF_GROUP_ADDRESS,
CONF_IV_INDEX,
CONF_NETWORK_ID,
CONF_NETWORK_KEY,
DEFAULT_APP_KEY,
DEFAULT_GROUP_ADDRESS_START,
DEFAULT_IV_INDEX,
DEFAULT_NETWORK_ID,
DEFAULT_NETWORK_KEY,
DOMAIN,
)
from .coordinator import SigMeshGatewayCoordinator
from .serial_reader import SerialReader
from .services import setup_services
_LOGGER = logging.getLogger(__name__)
@ -22,6 +35,9 @@ PLATFORMS: list[Platform] = [
Platform.DEVICE_TRACKER,
]
# 全局协调器字典(用于服务调用)
_coordinators: dict[str, SigMeshGatewayCoordinator] = {}
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""设置 SigMesh Gateway 配置入口."""
@ -32,6 +48,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
baudrate = entry.data.get("baudrate", 115200)
poll_interval = entry.options.get("poll_interval", 30)
# 获取配网配置(首次使用需要)
network_key = entry.data.get(CONF_NETWORK_KEY, DEFAULT_NETWORK_KEY)
app_key = entry.data.get(CONF_APP_KEY, DEFAULT_APP_KEY)
network_id = entry.data.get(CONF_NETWORK_ID, DEFAULT_NETWORK_ID)
iv_index = entry.data.get(CONF_IV_INDEX, DEFAULT_IV_INDEX)
group_address_start = entry.data.get(CONF_GROUP_ADDRESS, hex(DEFAULT_GROUP_ADDRESS_START))
if isinstance(group_address_start, str):
group_address_start = int(group_address_start, 16)
# 创建串口读取器
serial_reader = SerialReader(
device=device,
@ -43,6 +68,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass=hass,
serial_reader=serial_reader,
poll_interval=poll_interval,
network_key=network_key,
app_key=app_key,
network_id=network_id,
iv_index=iv_index,
group_address_start=group_address_start,
)
# 存储 coordinator
@ -52,6 +82,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"serial_reader": serial_reader,
}
# 注册全局协调器
_coordinators[entry.entry_id] = coordinator
# 设置服务(仅第一次)
if not hass.services.has_service(DOMAIN, "start_scan"):
setup_services(hass, _coordinators)
# 启动协调器
await coordinator.start()
@ -85,6 +122,19 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# 停止协调器
await coordinator.stop()
# 移除全局协调器
_coordinators.pop(entry.entry_id, None)
# 如果所有集成都已卸载,移除服务
if not _coordinators:
hass.services.async_remove(DOMAIN, "start_scan")
hass.services.async_remove(DOMAIN, "stop_provisioning")
hass.services.async_remove(DOMAIN, "start_provisioning")
hass.services.async_remove(DOMAIN, "bind_appkey")
hass.services.async_remove(DOMAIN, "add_to_group")
hass.services.async_remove(DOMAIN, "remove_from_group")
hass.services.async_remove(DOMAIN, "send_vendor_command")
return unload_ok

View File

@ -11,10 +11,22 @@ from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector
from .const import (
CONF_APP_KEY,
CONF_BAUDRATE,
CONF_GROUP_ADDRESS,
CONF_IV_INDEX,
CONF_NETWORK_ID,
CONF_NETWORK_KEY,
CONF_SERIAL_DEVICE,
DEFAULT_APP_KEY,
DEFAULT_BAUDRATE,
DEFAULT_GROUP_ADDRESS_START,
DEFAULT_IV_INDEX,
DEFAULT_NAME,
DEFAULT_NETWORK_ID,
DEFAULT_NETWORK_KEY,
DOMAIN,
PROV_TIMEOUT,
)
@ -23,18 +35,36 @@ class SigMeshGatewayConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1
def __init__(self) -> None:
"""初始化配置流程."""
self._errors: dict[str, str] = {}
self._user_input: dict[str, Any] | None = None
self._prov_config: dict[str, Any] | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""处理用户配置步骤."""
errors = {}
"""处理用户配置步骤 - 串口配置."""
self._errors = {}
if user_input is not None:
# TODO: 验证串口连接
return self.async_create_entry(
title=user_input.get(CONF_SERIAL_DEVICE, DEFAULT_NAME),
data=user_input,
# 验证串口连接
try:
import serial
test_serial = serial.Serial(
user_input[CONF_SERIAL_DEVICE],
baudrate=user_input.get(CONF_BAUDRATE, DEFAULT_BAUDRATE),
timeout=0.5,
)
test_serial.close()
self._user_input = user_input
# 进入配网配置步骤
return await self.async_step_prov_config()
except serial.SerialException as e:
self._errors[CONF_SERIAL_DEVICE] = f"无法打开串口:{e}"
except Exception as e:
self._errors["base"] = f"验证失败:{e}"
# 获取可用串口列表
try:
@ -59,10 +89,55 @@ class SigMeshGatewayConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
vol.Required(CONF_SERIAL_DEVICE, default="/dev/ttyUSB0"): selector.SelectSelector(
selector.SelectSelectorConfig(options=port_list),
),
vol.Required("baudrate", default=DEFAULT_BAUDRATE): vol.Coerce(int),
vol.Required(CONF_BAUDRATE, default=DEFAULT_BAUDRATE): vol.Coerce(int),
}
),
errors=errors,
errors=self._errors,
)
async def async_step_prov_config(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""处理配网配置步骤 - 首次使用需要配置。
根据文档USB dongle 首次使用需要设置配网参数
- Network Key (16 字节网络密钥)
- App Key (16 字节应用密钥)
- Network ID (2 字节网络 ID)
- IV Index (4 字节)
"""
self._errors = {}
if user_input is not None:
self._prov_config = user_input
# 保存所有配置,完成配置流程
return self.async_create_entry(
title=self._user_input.get(CONF_SERIAL_DEVICE, DEFAULT_NAME),
data={
**self._user_input,
**user_input,
},
)
# 配网配置表单
return self.async_show_form(
step_id="prov_config",
data_schema=vol.Schema(
{
vol.Required(CONF_NETWORK_KEY, default=DEFAULT_NETWORK_KEY): str,
vol.Required(CONF_APP_KEY, default=DEFAULT_APP_KEY): str,
vol.Required(CONF_NETWORK_ID, default=DEFAULT_NETWORK_ID): str,
vol.Required(CONF_IV_INDEX, default=DEFAULT_IV_INDEX): int,
vol.Required(
CONF_GROUP_ADDRESS,
default=hex(DEFAULT_GROUP_ADDRESS_START),
): str,
}
),
description_placeholders={
"timeout": PROV_TIMEOUT,
},
errors=self._errors,
)
@callback
@ -79,14 +154,27 @@ class SigMeshGatewayOptionsFlow(config_entries.OptionsFlow):
def __init__(self) -> None:
"""初始化选项流程."""
self._errors: dict[str, str] = {}
self._prov_action: str | None = None
async def async_step_init(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""管理选项."""
# 显示主菜单
return self.async_show_menu(
step_id="init",
menu_options=["poll_config", "prov_action", "group_config"],
)
async def async_step_poll_config(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""配置轮询间隔."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
return self.async_show_form(
step_id="init",
step_id="poll_config",
data_schema=vol.Schema(
{
vol.Required(
@ -96,3 +184,158 @@ class SigMeshGatewayOptionsFlow(config_entries.OptionsFlow):
}
),
)
async def async_step_prov_action(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""配网操作选择."""
if user_input is not None:
self._prov_action = user_input.get("action")
if self._prov_action == "start_scan":
return await self.async_step_start_scan()
elif self._prov_action == "stop_prov":
return await self.async_step_stop_prov()
elif self._prov_action == "bind_appkey":
return await self.async_step_bind_appkey()
return self.async_show_form(
step_id="prov_action",
data_schema=vol.Schema(
{
vol.Required("action"): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(value="start_scan", label="开始扫描设备"),
selector.SelectOptionDict(value="stop_prov", label="停止配网"),
selector.SelectOptionDict(value="bind_appkey", label="绑定 App Key"),
],
),
),
}
),
)
async def async_step_start_scan(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""开始扫描设备."""
if user_input is not None:
# TODO: 调用配网管理器开始扫描
return self.async_create_entry(title="", data={})
return self.async_show_form(
step_id="start_scan",
description_placeholders={"timeout": PROV_TIMEOUT},
data_schema=vol.Schema(
{
vol.Required("confirm"): bool,
}
),
)
async def async_step_stop_prov(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""停止配网."""
if user_input is not None:
# TODO: 调用配网管理器停止配网
return self.async_create_entry(title="", data={})
return self.async_show_form(
step_id="stop_prov",
data_schema=vol.Schema(
{
vol.Required("confirm"): bool,
}
),
)
async def async_step_bind_appkey(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""绑定 App Key."""
if user_input is not None:
# TODO: 调用配网管理器绑定 App Key
return self.async_create_entry(title="", data={})
return self.async_show_form(
step_id="bind_appkey",
data_schema=vol.Schema(
{
vol.Required("device_address"): str,
vol.Required("element_address", default=0): vol.Coerce(int),
}
),
)
async def async_step_group_config(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""分组配置."""
if user_input is not None:
action = user_input.get("group_action")
if action == "add_to_group":
return await self.async_step_add_to_group()
elif action == "remove_from_group":
return await self.async_step_remove_from_group()
return self.async_show_form(
step_id="group_config",
data_schema=vol.Schema(
{
vol.Required("group_action"): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(value="add_to_group", label="添加到组"),
selector.SelectOptionDict(value="remove_from_group", label="从组移除"),
],
),
),
}
),
)
async def async_step_add_to_group(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""添加设备到组."""
if user_input is not None:
# TODO: 调用配网管理器添加设备到组
return self.async_create_entry(title="", data={})
return self.async_show_form(
step_id="add_to_group",
data_schema=vol.Schema(
{
vol.Required("target_address"): str,
vol.Required("element_address", default=0): vol.Coerce(int),
vol.Required("group_address", default=hex(DEFAULT_GROUP_ADDRESS_START)): str,
vol.Required("model_id", default=4352): vol.Coerce(int), # 默认 0x1100
vol.Required("is_sig", default=True): bool,
}
),
description_placeholders={
"default_group": hex(DEFAULT_GROUP_ADDRESS_START),
},
)
async def async_step_remove_from_group(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""从组中移除设备."""
if user_input is not None:
# TODO: 调用配网管理器移除设备
return self.async_create_entry(title="", data={})
return self.async_show_form(
step_id="remove_from_group",
data_schema=vol.Schema(
{
vol.Required("target_address"): str,
vol.Required("element_address", default=0): vol.Coerce(int),
vol.Required("group_address", default=hex(DEFAULT_GROUP_ADDRESS_START)): str,
vol.Required("model_id", default=4352): vol.Coerce(int),
vol.Required("is_sig", default=True): bool,
}
),
)

View File

@ -16,6 +16,27 @@ DEFAULT_TIMEOUT = 5
CONF_SERIAL_DEVICE = "serial_device"
CONF_BAUDRATE = "baudrate"
# 配网配置USB dongle 首次使用需要)
CONF_NETWORK_KEY = "network_key" # 16 字节网络密钥
CONF_APP_KEY = "app_key" # 16 字节应用密钥
CONF_NETWORK_ID = "network_id" # 2 字节网络 ID
CONF_IV_INDEX = "iv_index" # 4 字节 IV Index
# 默认配网参数
DEFAULT_NETWORK_KEY = "00000000000000000000000000000000" # 32 字符十六进制
DEFAULT_APP_KEY = "00000000000000000000000000000000"
DEFAULT_NETWORK_ID = "0000"
DEFAULT_IV_INDEX = 0
# 配网超时
PROV_TIMEOUT = 180 # 配网超时时间(秒)
PROV_POLL_INTERVAL = 2 # 配网轮询间隔(秒)
# 组地址配置
CONF_GROUP_ADDRESS = "group_address"
DEFAULT_GROUP_ADDRESS_START = 0xC000 # 组地址起始值
DEFAULT_GROUP_ADDRESS_END = 0xCFFF # 组地址结束值
class MeshModelId(IntEnum):
"""蓝牙 Mesh 模型 ID."""
@ -76,6 +97,33 @@ class MeshOpcode(IntEnum):
# 电池
BATTERY_STATUS = 0x820C
# 配网相关
PROV_LINK_OPEN = 0x00
PROV_LINK_CLOSE = 0x01
PROV_INVITE = 0x02
PROV_START = 0x03
PROV_AUTH_START = 0x04
PROV_COMPLETE = 0x05
PROV_FAILED = 0x06
# 配置相关
CFG_NETKEY_ADD = 0x8000
CFG_NETKEY_UPDATE = 0x8001
CFG_APPKEY_ADD = 0x8003
CFG_APPKEY_UPDATE = 0x8004
CFG_MODEL_APP_BIND = 0x8006
CFG_MODEL_SUBSCRIBE = 0x8007
CFG_MODEL_PUBLISH = 0x8008
class MeshSigOp(IntEnum):
"""SIG 操作码(用于分组等)。"""
# 组管理
SIG_GROUP_ADD = 0x801B # 加入组
SIG_GROUP_DELETE = 0x801D # 删除组
SIG_GROUP_STATUS = 0x801C # 组状态(预留)
class MeshPropertyId(IntEnum):
"""Mesh 属性 ID - 用于解析传感器数据."""

View File

@ -10,7 +10,20 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
from .const import (
CONF_APP_KEY,
CONF_GROUP_ADDRESS,
CONF_IV_INDEX,
CONF_NETWORK_ID,
CONF_NETWORK_KEY,
DEFAULT_APP_KEY,
DEFAULT_GROUP_ADDRESS_START,
DEFAULT_IV_INDEX,
DEFAULT_NETWORK_ID,
DEFAULT_NETWORK_KEY,
DOMAIN,
)
from .provisioning import ProvDevice, ProvState, ProvisioningManager
from .protocol_parser import (
DeviceManager,
DeviceState,
@ -34,6 +47,11 @@ class SigMeshGatewayCoordinator(DataUpdateCoordinator[dict[str, DeviceState]]):
hass: HomeAssistant,
serial_reader: SerialReader,
poll_interval: int = 30,
network_key: str = DEFAULT_NETWORK_KEY,
app_key: str = DEFAULT_APP_KEY,
network_id: str = DEFAULT_NETWORK_ID,
iv_index: int = DEFAULT_IV_INDEX,
group_address_start: int = DEFAULT_GROUP_ADDRESS_START,
) -> None:
"""初始化协调器."""
self.hass = hass
@ -43,8 +61,17 @@ class SigMeshGatewayCoordinator(DataUpdateCoordinator[dict[str, DeviceState]]):
self._parser = ProtocolParser()
self._device_manager = DeviceManager()
# 初始化配网管理器
self._prov_manager = ProvisioningManager(
serial_reader=serial_reader,
network_key=network_key,
app_key=app_key,
network_id=network_id,
)
# 设置回调
self._setup_callbacks()
self._setup_prov_callbacks()
super().__init__(
hass,
@ -78,6 +105,36 @@ class SigMeshGatewayCoordinator(DataUpdateCoordinator[dict[str, DeviceState]]):
on_disconnect=on_disconnect_handler,
)
def _setup_prov_callbacks(self) -> None:
"""设置配网管理器的回调函数."""
def on_prov_state_change(state: ProvState) -> None:
"""处理配网状态变更."""
_LOGGER.info("配网状态变更:%s", state.value)
# 通知 HA 刷新配置
self.hass.async_create_task(self._notify_prov_state_change(state))
def on_device_found(device: ProvDevice) -> None:
"""处理设备发现."""
_LOGGER.info("发现新设备:%s", device.mac_address)
def on_prov_complete(device: ProvDevice) -> None:
"""处理配网完成."""
_LOGGER.info("配网完成:%s", device.mac_address)
self._prov_manager.set_callbacks(
on_state_change=on_prov_state_change,
on_device_found=on_device_found,
on_prov_complete=on_prov_complete,
)
async def _notify_prov_state_change(self, state: ProvState) -> None:
"""通知配网状态变更(可选:触发 HA 事件)。"""
# 触发 HA 自定义事件
self.hass.bus.fire(
f"{DOMAIN}_prov_state",
{"state": state.value},
)
async def _handle_mesh_message(self, event: MeshMessageEvent) -> None:
"""处理 Mesh 消息事件."""
try:
@ -121,9 +178,15 @@ class SigMeshGatewayCoordinator(DataUpdateCoordinator[dict[str, DeviceState]]):
self._device_manager.add_device(
event.mac_address, event.element_count or 1
)
# 通知配网管理器
await self._prov_manager.handle_device_joined(
event.mac_address, event.element_count or 1
)
elif event.event_type == "left":
_LOGGER.info("设备离开:%s", event.mac_address)
self._device_manager.remove_device(event.mac_address)
# 通知配网管理器
self._prov_manager.handle_device_left(event.mac_address)
# 刷新状态
self.async_update_listeners()
@ -184,3 +247,90 @@ class SigMeshGatewayCoordinator(DataUpdateCoordinator[dict[str, DeviceState]]):
for device in self._device_manager.get_all_devices()
if device.model_id == model_id
]
# ==================== 配网管理方法 ====================
@property
def prov_manager(self) -> ProvisioningManager:
"""获取配网管理器."""
return self._prov_manager
@property
def prov_state(self) -> ProvState:
"""获取当前配网状态."""
return self._prov_manager.state
async def start_scanning(self) -> None:
"""开始扫描设备."""
await self._prov_manager.start_scanning()
async def start_provisioning(self, device_address: str) -> None:
"""开始配网指定设备。
Args:
device_address: 设备地址16 进制字符串
"""
await self._prov_manager.start_provisioning(device_address)
async def stop_provisioning(self) -> None:
"""停止配网."""
await self._prov_manager.stop_provisioning()
async def bind_app_key(self, device_address: str, element_address: int) -> None:
"""绑定 App Key。
Args:
device_address: 设备地址
element_address: 元素地址
"""
await self._prov_manager.bind_app_key(device_address, element_address)
async def add_device_to_group(
self,
target_address: str,
element_address: int,
group_address: int,
model_id: int,
is_sig: bool = True,
) -> None:
"""添加设备到组。
Args:
target_address: 目标设备地址
element_address: 元素地址
group_address: 组地址
model_id: Model ID
is_sig: 是否为 SIG 标准分组
"""
await self._prov_manager.add_to_group(
target_address, element_address, group_address, model_id, is_sig
)
async def remove_device_from_group(
self,
target_address: str,
element_address: int,
group_address: int,
model_id: int,
is_sig: bool = True,
) -> None:
"""从组中移除设备。
Args:
target_address: 目标设备地址
element_address: 元素地址
group_address: 组地址
model_id: Model ID
is_sig: 是否为 SIG 标准分组
"""
await self._prov_manager.remove_from_group(
target_address, element_address, group_address, model_id, is_sig
)
def get_next_group_address(self) -> int:
"""获取下一个可用组地址."""
return self._prov_manager.get_next_group_address()
def get_prov_devices(self) -> dict[str, ProvDevice]:
"""获取配网设备列表."""
return self._prov_manager.devices

View File

@ -0,0 +1,472 @@
"""SigMesh Gateway 配网管理模块."""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from typing import Callable
from .const import (
DEFAULT_APP_KEY,
DEFAULT_GROUP_ADDRESS_START,
DEFAULT_NETWORK_KEY,
DEFAULT_NETWORK_ID,
MeshSigOp,
PROV_TIMEOUT,
)
from .serial_reader import SerialReader
_LOGGER = logging.getLogger(__name__)
class ProvState(Enum):
"""配网状态."""
IDLE = "idle" # 空闲
SCANNING = "scanning" # 扫描中
PROV_STARTING = "prov_starting" # 配网启动中
PROV_IN_PROGRESS = "prov_in_progress" # 配网进行中
PROV_COMPLETED = "prov_completed" # 配网完成
PROV_FAILED = "prov_failed" # 配网失败
TIMEOUT = "timeout" # 超时
@dataclass
class ProvDevice:
"""配网设备信息."""
mac_address: str
element_count: int
unicast_address: int | None = None
model_id: int | None = None
joined_at: datetime | None = None
@dataclass
class GroupConfig:
"""组配置信息."""
group_address: int
model_id: int
element_address: int
class ProvisioningManager:
"""配网管理器."""
def __init__(
self,
serial_reader: SerialReader,
network_key: str = DEFAULT_NETWORK_KEY,
app_key: str = DEFAULT_APP_KEY,
network_id: str = DEFAULT_NETWORK_ID,
) -> None:
"""初始化配网管理器."""
self.serial_reader = serial_reader
self.network_key = network_key
self.app_key = app_key
self.network_id = network_id
self._state = ProvState.IDLE
self._devices: dict[str, ProvDevice] = {}
self._group_configs: dict[int, list[GroupConfig]] = {}
self._prov_timeout_handle: asyncio.TimerHandle | None = None
self._scan_result: list[dict] | None = None
# 回调
self._on_state_change_callback: Callable[[ProvState], None] | None = None
self._on_device_found_callback: Callable[[ProvDevice], None] | None = None
self._on_prov_complete_callback: Callable[[ProvDevice], None] | None = None
@property
def state(self) -> ProvState:
"""获取当前配网状态."""
return self._state
@property
def devices(self) -> dict[str, ProvDevice]:
"""获取已配网设备列表."""
return self._devices
def set_callbacks(
self,
on_state_change: Callable[[ProvState], None] | None = None,
on_device_found: Callable[[ProvDevice], None] | None = None,
on_prov_complete: Callable[[ProvDevice], None] | None = None,
) -> None:
"""设置回调函数."""
self._on_state_change_callback = on_state_change
self._on_device_found_callback = on_device_found
self._on_prov_complete_callback = on_prov_complete
def _set_state(self, state: ProvState) -> None:
"""设置配网状态."""
self._state = state
_LOGGER.info("配网状态变更:%s", state.value)
if self._on_state_change_callback:
self._on_state_change_callback(state)
def _start_prov_timeout(self) -> None:
"""启动配网超时计时器."""
if self._prov_timeout_handle:
self._prov_timeout_handle.cancel()
async def timeout_handler() -> None:
_LOGGER.warning("配网超时(%d 秒)", PROV_TIMEOUT)
self._set_state(ProvState.TIMEOUT)
await self.stop_provisioning()
loop = asyncio.get_event_loop()
self._prov_timeout_handle = loop.call_later(PROV_TIMEOUT, lambda: asyncio.create_task(timeout_handler()))
def _cancel_prov_timeout(self) -> None:
"""取消配网超时计时器."""
if self._prov_timeout_handle:
self._prov_timeout_handle.cancel()
self._prov_timeout_handle = None
async def start_scanning(self) -> None:
"""开始扫描设备."""
if self._state not in [ProvState.IDLE, ProvState.PROV_COMPLETED, ProvState.PROV_FAILED]:
_LOGGER.warning("无法开始扫描,当前状态:%s", self._state.value)
return
self._set_state(ProvState.SCANNING)
self._devices = {}
self._scan_result = []
# 发送扫描命令
# 注意:实际扫描由网关自动广播触发,这里只需等待设备上报
_LOGGER.info("开始扫描设备,等待设备上报...")
async def start_provisioning(self, device_address: str) -> None:
"""开始配网指定设备。
Args:
device_address: 设备地址16 进制字符串
"""
if self._state != ProvState.SCANNING:
_LOGGER.warning("无法开始配网,当前状态:%s", self._state.value)
return
self._set_state(ProvState.PROV_STARTING)
try:
# 1. 发送配网启动命令
# 格式AT+PROV=START,<address>
cmd = f"AT+PROV=START,{device_address}"
await self.serial_reader.write_command(cmd)
# 2. 启动超时计时器
self._start_prov_timeout()
# 3. 等待配网完成
self._set_state(ProvState.PROV_IN_PROGRESS)
except Exception as e:
_LOGGER.error("启动配网失败:%s", e)
self._set_state(ProvState.PROV_FAILED)
self._cancel_prov_timeout()
async def stop_provisioning(self) -> None:
"""停止配网."""
self._cancel_prov_timeout()
try:
# 发送停止配网命令
cmd = "AT+PROV=STOP"
await self.serial_reader.write_command(cmd)
except Exception as e:
_LOGGER.warning("停止配网命令失败:%s", e)
self._set_state(ProvState.IDLE)
async def bind_app_key(self, device_address: str, element_address: int) -> None:
"""绑定 App Key。
Args:
device_address: 设备地址
element_address: 元素地址
"""
try:
# 发送绑定 App Key 命令
# 格式AT+PROV=BIND,<device_address>,<element_address>
cmd = f"AT+PROV=BIND,{device_address},{element_address}"
await self.serial_reader.write_command(cmd)
_LOGGER.info("绑定 App Key 完成:%s, 元素:%d", device_address, element_address)
except Exception as e:
_LOGGER.error("绑定 App Key 失败:%s", e)
async def add_to_group(
self,
target_address: str,
element_address: int,
group_address: int,
model_id: int,
is_sig: bool = True,
) -> None:
"""添加设备到组。
Args:
target_address: 目标设备地址
element_address: 元素地址
group_address: 组地址
model_id: Model ID
is_sig: 是否为 SIG 标准分组
"""
try:
if is_sig:
# SIG 分组命令
# e8 ff 00 00 00 00 02 01 <target_addr> 80 1b <element_addr> <group_addr> <model_id>
target_bytes = bytes.fromhex(target_address.zfill(4))
element_bytes = element_address.to_bytes(2, "big")
group_bytes = group_address.to_bytes(2, "big")
model_bytes = model_id.to_bytes(2, "big")
# 构建命令帧
cmd_frame = (
b"\xe8\xff\x00\x00\x00\x00\x02\x01"
+ target_bytes
+ b"\x80\x1b"
+ element_bytes
+ group_bytes
+ model_bytes
+ b"\x00\x10"
)
# 转换为 16 进制字符串发送
cmd_hex = cmd_frame.hex().upper()
cmd = f"AT+MESH=TX,{cmd_hex}"
else:
# VENDOR 分组命令
# e8 ff 00 00 00 00 02 01 <target_addr> 80 1b <element_addr> <group_addr> <model_id> <fixed>
target_bytes = bytes.fromhex(target_address.zfill(4))
element_bytes = element_address.to_bytes(2, "big")
group_bytes = group_address.to_bytes(2, "big")
model_bytes = model_id.to_bytes(2, "big")
cmd_frame = (
b"\xe8\xff\x00\x00\x00\x00\x02\x01"
+ target_bytes
+ b"\x80\x1b"
+ element_bytes
+ group_bytes
+ model_bytes
+ b"\x00\x00"
)
cmd_hex = cmd_frame.hex().upper()
cmd = f"AT+MESH=TX,{cmd_hex}"
await self.serial_reader.write_command(cmd)
_LOGGER.info(
"添加设备到组:%s, 元素:%d, 组地址:0x%04X, Model:0x%04X",
target_address,
element_address,
group_address,
model_id,
)
# 记录组配置
if group_address not in self._group_configs:
self._group_configs[group_address] = []
self._group_configs[group_address].append(
GroupConfig(
group_address=group_address,
model_id=model_id,
element_address=element_address,
)
)
except Exception as e:
_LOGGER.error("添加设备到组失败:%s", e)
async def remove_from_group(
self,
target_address: str,
element_address: int,
group_address: int,
model_id: int,
is_sig: bool = True,
) -> None:
"""从组中移除设备。
Args:
target_address: 目标设备地址
element_address: 元素地址
group_address: 组地址
model_id: Model ID
is_sig: 是否为 SIG 标准分组
"""
try:
if is_sig:
# SIG 删除组命令
# e8 ff 00 00 00 00 02 01 <target_addr> 80 1d <element_addr> <group_addr> <model_id>
target_bytes = bytes.fromhex(target_address.zfill(4))
element_bytes = element_address.to_bytes(2, "big")
group_bytes = group_address.to_bytes(2, "big")
model_bytes = model_id.to_bytes(2, "big")
cmd_frame = (
b"\xe8\xff\x00\x00\x00\x00\x02\x01"
+ target_bytes
+ b"\x80\x1d"
+ element_bytes
+ group_bytes
+ model_bytes
+ b"\x00\x10"
)
cmd_hex = cmd_frame.hex().upper()
cmd = f"AT+MESH=TX,{cmd_hex}"
else:
# VENDOR 删除组命令(类似,使用 80 1d 操作码)
target_bytes = bytes.fromhex(target_address.zfill(4))
element_bytes = element_address.to_bytes(2, "big")
group_bytes = group_address.to_bytes(2, "big")
model_bytes = model_id.to_bytes(2, "big")
cmd_frame = (
b"\xe8\xff\x00\x00\x00\x00\x02\x01"
+ target_bytes
+ b"\x80\x1d"
+ element_bytes
+ group_bytes
+ model_bytes
+ b"\x00\x00"
)
cmd_hex = cmd_frame.hex().upper()
cmd = f"AT+MESH=TX,{cmd_hex}"
await self.serial_reader.write_command(cmd)
_LOGGER.info(
"从组中移除设备:%s, 元素:%d, 组地址:0x%04X, Model:0x%04X",
target_address,
element_address,
group_address,
model_id,
)
# 移除组配置记录
if group_address in self._group_configs:
self._group_configs[group_address] = [
cfg
for cfg in self._group_configs[group_address]
if cfg.element_address != element_address and cfg.model_id != model_id
]
if not self._group_configs[group_address]:
del self._group_configs[group_address]
except Exception as e:
_LOGGER.error("从组中移除设备失败:%s", e)
def get_next_group_address(self) -> int:
"""获取下一个可用组地址."""
used_addresses = set(self._group_configs.keys())
for addr in range(DEFAULT_GROUP_ADDRESS_START, DEFAULT_GROUP_ADDRESS_END + 1):
if addr not in used_addresses:
return addr
raise RuntimeError("组地址已用尽")
def get_group_config(self, group_address: int) -> list[GroupConfig] | None:
"""获取指定组地址的配置。
Args:
group_address: 组地址
Returns:
组配置列表None 表示未找到
"""
return self._group_configs.get(group_address)
async def send_vendor_command(
self,
target_address: str,
element_address: int,
opcode: int,
payload: bytes,
) -> None:
"""发送 VENDOR 命令。
Args:
target_address: 目标设备地址
element_address: 元素地址
opcode: 操作码
payload: 数据负载
"""
try:
# VENDOR 命令帧格式
target_bytes = bytes.fromhex(target_address.zfill(4))
element_bytes = element_address.to_bytes(2, "big")
opcode_bytes = opcode.to_bytes(2, "big")
cmd_frame = (
b"\xe8\xff\x00\x00\x00\x00\x02\x01"
+ target_bytes
+ opcode_bytes
+ element_bytes
+ payload
)
cmd_hex = cmd_frame.hex().upper()
cmd = f"AT+MESH=TX,{cmd_hex}"
await self.serial_reader.write_command(cmd)
_LOGGER.info(
"发送 VENDOR 命令:目标=%s, 元素=%d, Opcode=0x%04X",
target_address,
element_address,
opcode,
)
except Exception as e:
_LOGGER.error("发送 VENDOR 命令失败:%s", e)
async def handle_device_joined(self, mac_address: str, element_count: int) -> None:
"""处理设备加入事件。
Args:
mac_address: 设备 MAC 地址
element_count: 元素数量
"""
device = ProvDevice(
mac_address=mac_address,
element_count=element_count,
joined_at=datetime.now(),
)
self._devices[mac_address] = device
_LOGGER.info("设备加入:%s, 元素数量:%d", mac_address, element_count)
if self._on_device_found_callback:
self._on_device_found_callback(device)
# 配网完成,取消超时
if self._state == ProvState.PROV_IN_PROGRESS:
self._cancel_prov_timeout()
self._set_state(ProvState.PROV_COMPLETED)
if self._on_prov_complete_callback:
self._on_prov_complete_callback(device)
def handle_device_left(self, mac_address: str) -> None:
"""处理设备离开事件。
Args:
mac_address: 设备 MAC 地址
"""
if mac_address in self._devices:
del self._devices[mac_address]
_LOGGER.info("设备离开:%s", mac_address)
def get_device(self, mac_address: str) -> ProvDevice | None:
"""获取配网设备信息。
Args:
mac_address: 设备 MAC 地址
Returns:
配网设备信息None 表示未找到
"""
return self._devices.get(mac_address)

View File

@ -0,0 +1,186 @@
"""SigMesh Gateway 服务定义。"""
import voluptuous as vol
from homeassistant.helpers import config_validation as cv
from .const import CONF_GROUP_ADDRESS, CONF_NETWORK_KEY, DOMAIN
# 服务常量
SERVICE_START_SCAN = "start_scan"
SERVICE_STOP_PROVISIONING = "stop_provisioning"
SERVICE_START_PROVISIONING = "start_provisioning"
SERVICE_BIND_APPKEY = "bind_appkey"
SERVICE_ADD_TO_GROUP = "add_to_group"
SERVICE_REMOVE_FROM_GROUP = "remove_from_group"
SERVICE_SEND_VENDOR_COMMAND = "send_vendor_command"
# 服务 Schema
SERVICE_SCHEMA_START_SCAN = {}
SERVICE_SCHEMA_STOP_PROVISIONING = {}
SERVICE_SCHEMA_START_PROVISIONING = {
vol.Required("device_address"): cv.string,
}
SERVICE_SCHEMA_BIND_APPKEY = {
vol.Required("device_address"): cv.string,
vol.Required("element_address", default=0): vol.Coerce(int),
}
SERVICE_SCHEMA_ADD_TO_GROUP = {
vol.Required("target_address"): cv.string,
vol.Required("element_address", default=0): vol.Coerce(int),
vol.Required("group_address"): cv.string,
vol.Required("model_id", default=4352): vol.Coerce(int),
vol.Optional("is_sig", default=True): cv.boolean,
}
SERVICE_SCHEMA_REMOVE_FROM_GROUP = {
vol.Required("target_address"): cv.string,
vol.Required("element_address", default=0): vol.Coerce(int),
vol.Required("group_address"): cv.string,
vol.Required("model_id", default=4352): vol.Coerce(int),
vol.Optional("is_sig", default=True): cv.boolean,
}
SERVICE_SCHEMA_SEND_VENDOR_COMMAND = {
vol.Required("target_address"): cv.string,
vol.Required("element_address", default=0): vol.Coerce(int),
vol.Required("opcode"): cv.string,
vol.Required("payload"): cv.string,
}
def setup_services(hass, coordinators: dict) -> None:
"""设置 HA 服务。
Args:
hass: HomeAssistant 实例
coordinators: 协调器字典 {entry_id: coordinator}
"""
async def handle_start_scan(call) -> None:
"""处理开始扫描服务调用."""
for coordinator in coordinators.values():
await coordinator.start_scanning()
async def handle_stop_provisioning(call) -> None:
"""处理停止配网服务调用."""
for coordinator in coordinators.values():
await coordinator.stop_provisioning()
async def handle_start_provisioning(call) -> None:
"""处理开始配网服务调用."""
device_address = call.data.get("device_address")
for coordinator in coordinators.values():
await coordinator.start_provisioning(device_address)
async def handle_bind_appkey(call) -> None:
"""处理绑定 App Key 服务调用."""
device_address = call.data.get("device_address")
element_address = call.data.get("element_address", 0)
for coordinator in coordinators.values():
await coordinator.bind_app_key(device_address, element_address)
async def handle_add_to_group(call) -> None:
"""处理添加设备到组服务调用."""
target_address = call.data.get("target_address")
element_address = call.data.get("element_address", 0)
group_address = call.data.get("group_address")
model_id = call.data.get("model_id", 4352)
is_sig = call.data.get("is_sig", True)
# 解析组地址(支持 hex 字符串)
if isinstance(group_address, str):
group_address = int(group_address, 16)
for coordinator in coordinators.values():
await coordinator.add_device_to_group(
target_address, element_address, group_address, model_id, is_sig
)
async def handle_remove_from_group(call) -> None:
"""处理从组中移除设备服务调用."""
target_address = call.data.get("target_address")
element_address = call.data.get("element_address", 0)
group_address = call.data.get("group_address")
model_id = call.data.get("model_id", 4352)
is_sig = call.data.get("is_sig", True)
# 解析组地址(支持 hex 字符串)
if isinstance(group_address, str):
group_address = int(group_address, 16)
for coordinator in coordinators.values():
await coordinator.remove_device_from_group(
target_address, element_address, group_address, model_id, is_sig
)
async def handle_send_vendor_command(call) -> None:
"""处理发送 VENDOR 命令服务调用."""
target_address = call.data.get("target_address")
element_address = call.data.get("element_address", 0)
opcode = call.data.get("opcode")
payload = call.data.get("payload")
# 解析操作码和负载
if isinstance(opcode, str):
opcode = int(opcode, 16)
if isinstance(payload, str):
payload = bytes.fromhex(payload)
for coordinator in coordinators.values():
await coordinator.prov_manager.send_vendor_command(
target_address, element_address, opcode, payload
)
# 注册服务
hass.services.async_register(
DOMAIN,
SERVICE_START_SCAN,
handle_start_scan,
schema=vol.Schema(SERVICE_SCHEMA_START_SCAN),
)
hass.services.async_register(
DOMAIN,
SERVICE_STOP_PROVISIONING,
handle_stop_provisioning,
schema=vol.Schema(SERVICE_SCHEMA_STOP_PROVISIONING),
)
hass.services.async_register(
DOMAIN,
SERVICE_START_PROVISIONING,
handle_start_provisioning,
schema=vol.Schema(SERVICE_SCHEMA_START_PROVISIONING),
)
hass.services.async_register(
DOMAIN,
SERVICE_BIND_APPKEY,
handle_bind_appkey,
schema=vol.Schema(SERVICE_SCHEMA_BIND_APPKEY),
)
hass.services.async_register(
DOMAIN,
SERVICE_ADD_TO_GROUP,
handle_add_to_group,
schema=vol.Schema(SERVICE_SCHEMA_ADD_TO_GROUP),
)
hass.services.async_register(
DOMAIN,
SERVICE_REMOVE_FROM_GROUP,
handle_remove_from_group,
schema=vol.Schema(SERVICE_SCHEMA_REMOVE_FROM_GROUP),
)
hass.services.async_register(
DOMAIN,
SERVICE_SEND_VENDOR_COMMAND,
handle_send_vendor_command,
schema=vol.Schema(SERVICE_SCHEMA_SEND_VENDOR_COMMAND),
)

View File

@ -0,0 +1,182 @@
# SigMesh Gateway 服务定义
# 开始扫描设备
start_scan:
name: 开始扫描设备
description: 开始扫描可用的 Bluetooth Mesh 设备
fields: {}
# 停止配网
stop_provisioning:
name: 停止配网
description: 停止当前的配网操作
fields: {}
# 开始配网
start_provisioning:
name: 开始配网
description: 开始配网指定的设备
fields:
device_address:
name: 设备地址
description: 要配网的设备地址16 进制字符串)
example: "001A"
required: true
selector:
text:
# 绑定 App Key
bind_appkey:
name: 绑定 App Key
description: 为已配网设备绑定 App Key
fields:
device_address:
name: 设备地址
description: 设备地址16 进制字符串)
example: "001A"
required: true
selector:
text:
element_address:
name: 元素地址
description: 元素地址(默认为 0
example: 0
default: 0
required: false
selector:
number:
min: 0
max: 255
# 添加设备到组
add_to_group:
name: 添加到组
description: 将设备添加到指定的组地址
fields:
target_address:
name: 目标设备地址
description: 目标设备地址16 进制字符串)
example: "001A"
required: true
selector:
text:
element_address:
name: 元素地址
description: 元素地址(默认为 0
example: 0
default: 0
required: false
selector:
number:
min: 0
max: 255
group_address:
name: 组地址
description: 组地址16 进制字符串,建议使用 0xC000 以上)
example: "C001"
required: true
selector:
text:
model_id:
name: Model ID
description: Model ID16 进制,默认 0x1100 传感器)
example: 4352
default: 4352
required: false
selector:
number:
min: 0
max: 65535
is_sig:
name: SIG 标准分组
description: 是否为 SIG 标准分组(默认为 true
example: true
default: true
required: false
selector:
boolean:
# 从组中移除设备
remove_from_group:
name: 从组移除
description: 将设备从指定的组地址移除
fields:
target_address:
name: 目标设备地址
description: 目标设备地址16 进制字符串)
example: "001A"
required: true
selector:
text:
element_address:
name: 元素地址
description: 元素地址(默认为 0
example: 0
default: 0
required: false
selector:
number:
min: 0
max: 255
group_address:
name: 组地址
description: 组地址16 进制字符串)
example: "C001"
required: true
selector:
text:
model_id:
name: Model ID
description: Model ID16 进制,默认 0x1100 传感器)
example: 4352
default: 4352
required: false
selector:
number:
min: 0
max: 65535
is_sig:
name: SIG 标准分组
description: 是否为 SIG 标准分组(默认为 true
example: true
default: true
required: false
selector:
boolean:
# 发送 VENDOR 命令
send_vendor_command:
name: 发送 VENDOR 命令
description: 发送 VENDOR 自定义命令到设备
fields:
target_address:
name: 目标设备地址
description: 目标设备地址16 进制字符串)
example: "001A"
required: true
selector:
text:
element_address:
name: 元素地址
description: 元素地址(默认为 0
example: 0
default: 0
required: false
selector:
number:
min: 0
max: 255
opcode:
name: 操作码
description: VENDOR 操作码16 进制字符串)
example: "1102"
required: true
selector:
text:
payload:
name: 数据负载
description: 数据负载16 进制字符串,不含空格)
example: "0000"
required: true
selector:
text: