diff --git a/PRD.md b/PRD.md index 4c41321..d7a8158 100644 --- a/PRD.md +++ b/PRD.md @@ -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" | 网络 ID(2 字节,4 字符十六进制) | +| iv_index | int | 否 | 0 | IV Index(4 字节) | +| group_address | string | 否 | "0xC000" | 组地址起始值(16 进制字符串) | ### 9.2 选项参数 diff --git a/custom_components/sigmesh_gateway/__init__.py b/custom_components/sigmesh_gateway/__init__.py index dc40358..b301d17 100644 --- a/custom_components/sigmesh_gateway/__init__.py +++ b/custom_components/sigmesh_gateway/__init__.py @@ -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 diff --git a/custom_components/sigmesh_gateway/config_flow.py b/custom_components/sigmesh_gateway/config_flow.py index f1d5262..8b9062f 100644 --- a/custom_components/sigmesh_gateway/config_flow.py +++ b/custom_components/sigmesh_gateway/config_flow.py @@ -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, + } + ), + ) diff --git a/custom_components/sigmesh_gateway/const.py b/custom_components/sigmesh_gateway/const.py index 24ccb51..041a310 100644 --- a/custom_components/sigmesh_gateway/const.py +++ b/custom_components/sigmesh_gateway/const.py @@ -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 - 用于解析传感器数据.""" diff --git a/custom_components/sigmesh_gateway/coordinator.py b/custom_components/sigmesh_gateway/coordinator.py index 778ecc2..1f2c313 100644 --- a/custom_components/sigmesh_gateway/coordinator.py +++ b/custom_components/sigmesh_gateway/coordinator.py @@ -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 diff --git a/custom_components/sigmesh_gateway/provisioning.py b/custom_components/sigmesh_gateway/provisioning.py new file mode 100644 index 0000000..0804cf9 --- /dev/null +++ b/custom_components/sigmesh_gateway/provisioning.py @@ -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,
+ 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,