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,, + 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 80 1b + 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 80 1b + 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 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\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) diff --git a/custom_components/sigmesh_gateway/services.py b/custom_components/sigmesh_gateway/services.py new file mode 100644 index 0000000..d3e31ce --- /dev/null +++ b/custom_components/sigmesh_gateway/services.py @@ -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), + ) diff --git a/custom_components/sigmesh_gateway/services.yaml b/custom_components/sigmesh_gateway/services.yaml new file mode 100644 index 0000000..5862d5c --- /dev/null +++ b/custom_components/sigmesh_gateway/services.yaml @@ -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 ID(16 进制,默认 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 ID(16 进制,默认 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: