From 4c3eb62dfbd2f3334563995443443a5c28e2bc1a Mon Sep 17 00:00:00 2001 From: impressionyang Date: Thu, 16 Apr 2026 13:41:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E7=9A=84=20Web=20UI=20=E9=85=8D=E7=BD=91=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 Web UI 组件: - web_ui.py: RESTful API 端点(状态、扫描、配网、分组、设备) - sigmesh-gateway-panel.js: Lovelace Dashboard 自定义卡片 - 设备扫描和发现 - 配网操作(开始/停止/绑定 App Key) - 分组管理(添加/移除) - 实时状态监控 配置更新: - __init__.py: 集成 Web UI 和服务注册 - const.py: 添加服务常量定义 - services.py: 保留服务调用用于向后兼容 - README.md: 添加 Web UI 配置说明 - docs/UI 使用指南.md: 详细的 UI 使用文档 使用方式: 1. 配置 frontend.extra_module_url 加载 JS 面板 2. 在 Lovelace Dashboard 添加 custom:sigmesh-gateway-panel 卡片 3. 通过 UI 完成所有配网和分组操作 API 端点: - GET /api/sigmesh_gateway/status - 获取配网状态 - POST /api/sigmesh_gateway/scan - 开始扫描 - POST /api/sigmesh_gateway/provisioning - 配网操作 - POST /api/sigmesh_gateway/group - 分组管理 - GET /api/sigmesh_gateway/devices - 设备列表 --- README.md | 34 ++ custom_components/sigmesh_gateway/__init__.py | 31 +- custom_components/sigmesh_gateway/const.py | 9 + custom_components/sigmesh_gateway/services.py | 8 +- .../sigmesh_gateway/sigmesh-gateway-panel.js | 550 ++++++++++++++++++ custom_components/sigmesh_gateway/web_ui.py | 294 ++++++++++ docs/UI 使用指南.md | 227 ++++++++ 7 files changed, 1142 insertions(+), 11 deletions(-) create mode 100644 custom_components/sigmesh_gateway/sigmesh-gateway-panel.js create mode 100644 custom_components/sigmesh_gateway/web_ui.py create mode 100644 docs/UI 使用指南.md diff --git a/README.md b/README.md index e7d5105..20ed444 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,40 @@ sigmesh_gateway: poll_interval: 30 ``` +## Web UI 配网管理 + +### 添加 Lovelace 面板 + +1. **复制前端文件** + ```bash + mkdir -p /config/www/sigmesh_gateway + cp custom_components/sigmesh_gateway/sigmesh-gateway-panel.js /config/www/sigmesh_gateway/ + ``` + +2. **配置 frontend** + + 在 `configuration.yaml` 中添加: + ```yaml + frontend: + extra_module_url: + - /local/sigmesh_gateway/sigmesh-gateway-panel.js + ``` + +3. **重启 Home Assistant** + +4. **添加卡片到 Dashboard** + - 编辑仪表板 → 添加卡片 → 自定义卡片 + - 输入:`custom:sigmesh-gateway-panel` + +### Web UI 功能 + +- 📡 **设备扫描** - 扫描可用的 Bluetooth Mesh 设备 +- 🔐 **配网管理** - 开始/停止配网,绑定 App Key +- 📋 **分组管理** - 添加/移除设备到组 +- 📊 **状态监控** - 实时查看配网状态和设备列表 + +详细说明请参考:[UI 使用指南](docs/UI 使用指南.md) + ## 串口连接 ### 接线方式 diff --git a/custom_components/sigmesh_gateway/__init__.py b/custom_components/sigmesh_gateway/__init__.py index b301d17..8cce11c 100644 --- a/custom_components/sigmesh_gateway/__init__.py +++ b/custom_components/sigmesh_gateway/__init__.py @@ -21,8 +21,18 @@ from .const import ( DEFAULT_NETWORK_KEY, DOMAIN, ) +from .const import ( + SERVICE_START_SCAN, + SERVICE_STOP_PROVISIONING, + SERVICE_START_PROVISIONING, + SERVICE_BIND_APPKEY, + SERVICE_ADD_TO_GROUP, + SERVICE_REMOVE_FROM_GROUP, + SERVICE_SEND_VENDOR_COMMAND, +) from .coordinator import SigMeshGatewayCoordinator from .serial_reader import SerialReader +from .web_ui import setup_web_ui from .services import setup_services _LOGGER = logging.getLogger(__name__) @@ -35,7 +45,7 @@ PLATFORMS: list[Platform] = [ Platform.DEVICE_TRACKER, ] -# 全局协调器字典(用于服务调用) +# 全局协调器字典(用于 Web UI 调用) _coordinators: dict[str, SigMeshGatewayCoordinator] = {} @@ -85,8 +95,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # 注册全局协调器 _coordinators[entry.entry_id] = coordinator - # 设置服务(仅第一次) - if not hass.services.has_service(DOMAIN, "start_scan"): + # 设置 Web UI 和服务(仅第一次) + if len(_coordinators) == 1: + setup_web_ui(hass, _coordinators) setup_services(hass, _coordinators) # 启动协调器 @@ -127,13 +138,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # 如果所有集成都已卸载,移除服务 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") + hass.services.async_remove(DOMAIN, SERVICE_START_SCAN) + hass.services.async_remove(DOMAIN, SERVICE_STOP_PROVISIONING) + hass.services.async_remove(DOMAIN, SERVICE_START_PROVISIONING) + hass.services.async_remove(DOMAIN, SERVICE_BIND_APPKEY) + hass.services.async_remove(DOMAIN, SERVICE_ADD_TO_GROUP) + hass.services.async_remove(DOMAIN, SERVICE_REMOVE_FROM_GROUP) + hass.services.async_remove(DOMAIN, SERVICE_SEND_VENDOR_COMMAND) return unload_ok diff --git a/custom_components/sigmesh_gateway/const.py b/custom_components/sigmesh_gateway/const.py index 041a310..05f6446 100644 --- a/custom_components/sigmesh_gateway/const.py +++ b/custom_components/sigmesh_gateway/const.py @@ -177,3 +177,12 @@ SERIAL_EVENT_PREFIX = "+EVENT=" SERIAL_MESH_RECV = "+EVENT=MESH,recv" SERIAL_PROV_DEVICE_JOINED = "+EVENT=PROV,device_joined" SERIAL_PROV_DEVICE_LEFT = "+EVENT=PROV,device_left" + +# 服务名称 +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" diff --git a/custom_components/sigmesh_gateway/services.py b/custom_components/sigmesh_gateway/services.py index d3e31ce..d871fe6 100644 --- a/custom_components/sigmesh_gateway/services.py +++ b/custom_components/sigmesh_gateway/services.py @@ -1,4 +1,7 @@ -"""SigMesh Gateway 服务定义。""" +"""SigMesh Gateway 服务定义(保留用于向后兼容)。 + +注意:配网功能已迁移到 Web UI,服务调用接口保留用于自动化脚本。 +""" import voluptuous as vol from homeassistant.helpers import config_validation as cv @@ -55,6 +58,9 @@ SERVICE_SCHEMA_SEND_VENDOR_COMMAND = { def setup_services(hass, coordinators: dict) -> None: """设置 HA 服务。 + 注意:配网功能已迁移到 Web UI,服务调用接口保留用于自动化脚本。 + 推荐使用 Web UI 进行配网和分组管理操作。 + Args: hass: HomeAssistant 实例 coordinators: 协调器字典 {entry_id: coordinator} diff --git a/custom_components/sigmesh_gateway/sigmesh-gateway-panel.js b/custom_components/sigmesh_gateway/sigmesh-gateway-panel.js new file mode 100644 index 0000000..ddfc427 --- /dev/null +++ b/custom_components/sigmesh_gateway/sigmesh-gateway-panel.js @@ -0,0 +1,550 @@ +/** + * SigMesh Gateway 配网控制面板 + * + * 这是一个自定义 Lovelace 卡片,用于管理 SigMesh 网关的配网和分组功能 + * + * 使用方法: + * 1. 将此文件保存到 HA 的 www/community/sigmesh_gateway/ 目录 + * 2. 在 HA 的 configuration.yaml 中添加: + * frontend: + * extra_module_url: + * - /local/community/sigmesh_gateway/sigmesh-gateway-panel.js + * 3. 在 Lovelace Dashboard 中添加自定义卡片 + */ + +// 配置常量 +const API_BASE = '/api/sigmesh_gateway'; +const POLL_INTERVAL = 3000; // 3 秒轮询一次 + +/** + * 主面板组件 + */ +class SigMeshGatewayPanel extends HTMLElement { + constructor() { + super(); + this._hass = null; + this._state = 'idle'; + this._devices = []; + this._groups = []; + this._pollTimer = null; + this._selectedDevice = null; + this._groupAddress = '0xC001'; + } + + set hass(hass) { + this._hass = hass; + } + + connectedCallback() { + this._startPolling(); + } + + disconnectedCallback() { + this._stopPolling(); + } + + _startPolling() { + this._fetchStatus(); + this._pollTimer = setInterval(() => this._fetchStatus(), POLL_INTERVAL); + } + + _stopPolling() { + if (this._pollTimer) { + clearInterval(this._pollTimer); + this._pollTimer = null; + } + } + + async _fetchStatus() { + try { + const response = await fetch(`${API_BASE}/status`, { + headers: { + 'Authorization': `Bearer ${this._hass.auth.data.access_token}`, + 'Content-Type': 'application/json', + }, + }); + const data = await response.json(); + this._state = data.state; + this._devices = data.devices || []; + this._groups = data.groups || []; + this._render(); + } catch (error) { + console.error('获取状态失败:', error); + } + } + + _render() { + this.innerHTML = ` + + +
+

SigMesh Gateway 配网管理

+ ${this._state} +
+ + ${this._renderScanSection()} + ${this._renderDeviceList()} + ${this._renderProvActions()} + ${this._renderGroupManagement()} + ${this._renderGroupList()} + `; + + this._attachEventListeners(); + } + + _getStateColor() { + const colors = { + idle: '#4caf50', + scanning: '#2196f3', + prov_starting: '#ff9800', + prov_in_progress: '#ff9800', + prov_completed: '#4caf50', + prov_failed: '#f44336', + timeout: '#f44336', + }; + return colors[this._state] || '#757575'; + } + + _renderScanSection() { + const isScanning = this._state === 'scanning'; + return ` +
+
设备扫描
+
+ + +
+
+ `; + } + + _renderDeviceList() { + if (this._devices.length === 0) { + return ` +
+
已发现设备
+
暂无设备,请先扫描
+
+ `; + } + + return ` +
+
已发现设备 (${this._devices.length})
+
+ ${this._devices.map((device, index) => ` +
+
+
设备 ${device.mac}
+
+ 元素数:${device.elements} | + 地址:${device.address || '未分配'} +
+
+
+ `).join('')} +
+
+ `; + } + + _renderProvActions() { + if (!this._selectedDevice && this._selectedDevice !== 0) { + return ''; + } + + const device = this._devices[this._selectedDevice]; + const isProvInProgress = this._state === 'prov_in_progress' || this._state === 'prov_starting'; + + return ` +
+
配网操作 - ${device?.mac}
+
+ +
+
+ + + +
+
+ `; + } + + _renderGroupManagement() { + if (!this._selectedDevice && this._selectedDevice !== 0) { + return ''; + } + + return ` +
+
分组管理
+
+ + + +
+
+ + +
+
+ `; + } + + _renderGroupList() { + if (this._groups.length === 0) { + return ` +
+
组配置
+
暂无组配置
+
+ `; + } + + return ` +
+
组配置 (${this._groups.length})
+
+ ${this._groups.map(group => ` +
+ 组地址:${group.address} + 元素:${group.element_address} + Model: ${group.model_id} +
+ `).join('')} +
+
+ `; + } + + _attachEventListeners() { + // 扫描按钮 + this.querySelector('#btn-scan')?.addEventListener('click', () => this._handleScan()); + this.querySelector('#btn-refresh')?.addEventListener('click', () => this._fetchStatus()); + + // 设备选择 + this.querySelectorAll('.device-item').forEach(item => { + item.addEventListener('click', () => { + const index = parseInt(item.dataset.index); + this._selectedDevice = index === this._selectedDevice ? null : index; + this._render(); + }); + }); + + // 配网按钮 + this.querySelector('#btn-prov-start')?.addEventListener('click', () => this._handleProvStart()); + this.querySelector('#btn-prov-stop')?.addEventListener('click', () => this._handleProvStop()); + this.querySelector('#btn-bind-key')?.addEventListener('click', () => this._handleBindKey()); + + // 分组按钮 + this.querySelector('#btn-add-group')?.addEventListener('click', () => this._handleAddGroup()); + this.querySelector('#btn-remove-group')?.addEventListener('click', () => this._handleRemoveGroup()); + } + + async _handleScan() { + try { + await fetch(`${API_BASE}/scan`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this._hass.auth.data.access_token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }); + this._fetchStatus(); + } catch (error) { + console.error('扫描失败:', error); + } + } + + async _handleProvStart() { + const deviceAddress = this.querySelector('#device-address')?.value; + try { + await fetch(`${API_BASE}/provisioning`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this._hass.auth.data.access_token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + action: 'start', + device_address: deviceAddress, + }), + }); + this._fetchStatus(); + } catch (error) { + console.error('配网失败:', error); + } + } + + async _handleProvStop() { + try { + await fetch(`${API_BASE}/provisioning`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this._hass.auth.data.access_token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ action: 'stop' }), + }); + this._fetchStatus(); + } catch (error) { + console.error('停止配网失败:', error); + } + } + + async _handleBindKey() { + const deviceAddress = this.querySelector('#device-address')?.value; + const elementAddress = 0; + try { + await fetch(`${API_BASE}/provisioning`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this._hass.auth.data.access_token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + action: 'bind', + device_address: deviceAddress, + element_address: elementAddress, + }), + }); + alert('App Key 绑定成功'); + this._fetchStatus(); + } catch (error) { + console.error('绑定 App Key 失败:', error); + alert('绑定失败:' + error.message); + } + } + + async _handleAddGroup() { + const targetAddress = this.querySelector('#target-address')?.value; + const groupAddress = this.querySelector('#group-address')?.value; + const modelId = parseInt(this.querySelector('#model-id')?.value || '4352'); + + try { + await fetch(`${API_BASE}/group`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this._hass.auth.data.access_token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + action: 'add', + target_address: targetAddress, + group_address: groupAddress, + model_id: modelId, + is_sig: true, + }), + }); + this._groupAddress = groupAddress; + this._fetchStatus(); + } catch (error) { + console.error('添加分组失败:', error); + } + } + + async _handleRemoveGroup() { + const targetAddress = this.querySelector('#target-address')?.value; + const groupAddress = this.querySelector('#group-address')?.value; + const modelId = parseInt(this.querySelector('#model-id')?.value || '4352'); + + try { + await fetch(`${API_BASE}/group`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this._hass.auth.data.access_token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + action: 'remove', + target_address: targetAddress, + group_address: groupAddress, + model_id: modelId, + is_sig: true, + }), + }); + this._fetchStatus(); + } catch (error) { + console.error('移除分组失败:', error); + } + } +} + +// 注册自定义元素 +customElements.define('sigmesh-gateway-panel', SigMeshGatewayPanel); + +// 导出到 window 供调试使用 +window.SigMeshGatewayPanel = SigMeshGatewayPanel; + +console.log('SigMesh Gateway Panel 已加载'); diff --git a/custom_components/sigmesh_gateway/web_ui.py b/custom_components/sigmesh_gateway/web_ui.py new file mode 100644 index 0000000..d90f0e5 --- /dev/null +++ b/custom_components/sigmesh_gateway/web_ui.py @@ -0,0 +1,294 @@ +"""SigMesh Gateway Web UI 面板.""" + +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol +from aiohttp import web +from homeassistant.components.http import HomeAssistantView +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN +from .coordinator import SigMeshGatewayCoordinator + +_LOGGER = logging.getLogger(__name__) + + +def setup_web_ui(hass: HomeAssistant, coordinators: dict[str, SigMeshGatewayCoordinator]) -> None: + """设置 Web UI 路由。 + + Args: + hass: Home Assistant 实例 + coordinators: 协调器字典 + """ + + # 注册 Web 视图 + hass.http.register_view(SigMeshGatewayStatusView(coordinators)) + hass.http.register_view(SigMeshGatewayScanView(coordinators)) + hass.http.register_view(SigMeshGatewayProvView(coordinators)) + hass.http.register_view(SigMeshGatewayGroupView(coordinators)) + hass.http.register_view(SigMeshGatewayDevicesView(coordinators)) + + +class SigMeshGatewayStatusView(HomeAssistantView): + """获取配网状态视图.""" + + requires_auth = True + url = "/api/sigmesh_gateway/status" + name = "api:sigmesh_gateway:status" + + def __init__(self, coordinators: dict[str, SigMeshGatewayCoordinator]) -> None: + self.coordinators = coordinators + + async def get(self, request: web.Request) -> web.Response: + """获取配网状态。 + + 返回示例: + { + "state": "idle", + "devices": [ + {"mac": "AA:BB:CC:DD:EE:FF", "elements": 2, "address": "0001"} + ], + "groups": [ + {"address": "C001", "devices": ["0001"], "model_id": 4352} + ] + } + """ + result = { + "state": "idle", + "devices": [], + "groups": [], + } + + for coordinator in self.coordinators.values(): + prov_devices = coordinator.get_prov_devices() + result["devices"].extend([ + { + "mac": dev.mac_address, + "elements": dev.element_count, + "address": hex(dev.unicast_address) if dev.unicast_address else None, + } + for dev in prov_devices.values() + ]) + + # 获取组配置 + for group_addr, configs in coordinator.prov_manager._group_configs.items(): + result["groups"].extend([ + { + "address": hex(group_addr), + "element_address": hex(cfg.element_address), + "model_id": hex(cfg.model_id), + } + for cfg in configs + ]) + + if coordinator.prov_state.value != "idle": + result["state"] = coordinator.prov_state.value + + return self.json(result) + + +class SigMeshGatewayScanView(HomeAssistantView): + """开始扫描设备视图.""" + + requires_auth = True + url = "/api/sigmesh_gateway/scan" + name = "api:sigmesh_gateway:scan" + + def __init__(self, coordinators: dict[str, SigMeshGatewayCoordinator]) -> None: + self.coordinators = coordinators + + async def post(self, request: web.Request) -> web.Response: + """开始扫描设备。 + + 请求体: {} 或 {"timeout": 60} + """ + try: + data = await request.json() if request.can_read_body else {} + timeout = data.get("timeout", 60) + + for coordinator in self.coordinators.values(): + await coordinator.start_scanning() + + return self.json({ + "success": True, + "message": f"开始扫描设备,超时时间:{timeout}秒", + }) + except Exception as e: + _LOGGER.error("扫描设备失败:%s", e) + return self.json({"success": False, "error": str(e)}, status=400) + + +class SigMeshGatewayProvView(HomeAssistantView): + """配网操作视图.""" + + requires_auth = True + url = "/api/sigmesh_gateway/provisioning" + name = "api:sigmesh_gateway:provisioning" + + def __init__(self, coordinators: dict[str, SigMeshGatewayCoordinator]) -> None: + self.coordinators = coordinators + + async def post(self, request: web.Request) -> web.Response: + """开始配网设备。 + + 请求体: + { + "action": "start", // start, stop, bind + "device_address": "001A", + "element_address": 0, // 可选,bind 时需要 + "timeout": 180 // 可选,超时时间(秒) + } + """ + try: + data = await request.json() if request.can_read_body else {} + action = data.get("action") + device_address = data.get("device_address") + element_address = data.get("element_address", 0) + timeout = data.get("timeout", 180) + + if action == "start": + if not device_address: + return self.json({"success": False, "error": "需要设备地址"}, status=400) + for coordinator in self.coordinators.values(): + await coordinator.start_provisioning(device_address) + return self.json({ + "success": True, + "message": f"开始配网设备 {device_address},超时时间:{timeout}秒", + }) + elif action == "stop": + for coordinator in self.coordinators.values(): + await coordinator.stop_provisioning() + return self.json({"success": True, "message": "停止配网"}) + elif action == "bind": + if not device_address: + return self.json({"success": False, "error": "需要设备地址"}, status=400) + for coordinator in self.coordinators.values(): + await coordinator.bind_app_key(device_address, element_address) + return self.json({ + "success": True, + "message": f"绑定 App Key: {device_address}", + }) + else: + return self.json({"success": False, "error": "未知操作"}, status=400) + + except Exception as e: + _LOGGER.error("配网操作失败:%s", e) + return self.json({"success": False, "error": str(e)}, status=400) + + +class SigMeshGatewayGroupView(HomeAssistantView): + """分组管理视图.""" + + requires_auth = True + url = "/api/sigmesh_gateway/group" + name = "api:sigmesh_gateway:group" + + def __init__(self, coordinators: dict[str, SigMeshGatewayCoordinator]) -> None: + self.coordinators = coordinators + + async def post(self, request: web.Request) -> web.Response: + """管理设备分组。 + + 请求体: + { + "action": "add", // add, remove + "target_address": "001A", + "element_address": 0, + "group_address": "C001", + "model_id": 4352, + "is_sig": true + } + """ + try: + data = await request.json() if request.can_read_body else {} + action = data.get("action") + target_address = data.get("target_address") + element_address = data.get("element_address", 0) + group_address = data.get("group_address") + model_id = data.get("model_id", 4352) + is_sig = data.get("is_sig", True) + + # 解析组地址 + if isinstance(group_address, str): + group_address = int(group_address, 16) + + if action == "add": + for coordinator in self.coordinators.values(): + await coordinator.add_device_to_group( + target_address, element_address, group_address, model_id, is_sig + ) + return self.json({ + "success": True, + "message": f"添加设备 {target_address} 到组 {hex(group_address)}", + }) + elif action == "remove": + for coordinator in self.coordinators.values(): + await coordinator.remove_device_from_group( + target_address, element_address, group_address, model_id, is_sig + ) + return self.json({ + "success": True, + "message": f"从组 {hex(group_address)} 移除设备 {target_address}", + }) + else: + return self.json({"success": False, "error": "未知操作"}, status=400) + + except Exception as e: + _LOGGER.error("分组操作失败:%s", e) + return self.json({"success": False, "error": str(e)}, status=400) + + +class SigMeshGatewayDevicesView(HomeAssistantView): + """获取设备列表视图.""" + + requires_auth = True + url = "/api/sigmesh_gateway/devices" + name = "api:sigmesh_gateway:devices" + + def __init__(self, coordinators: dict[str, SigMeshGatewayCoordinator]) -> None: + self.coordinators = coordinators + + async def get(self, request: web.Request) -> web.Response: + """获取所有设备列表。 + + 返回示例: + { + "prov_devices": [...], + "mesh_devices": [...] + } + """ + prov_devices = [] + mesh_devices = [] + + for coordinator in self.coordinators.values(): + # 获取配网设备 + prov_devices.extend([ + { + "mac": dev.mac_address, + "elements": dev.element_count, + "unicast_address": dev.unicast_address, + "joined_at": dev.joined_at.isoformat() if dev.joined_at else None, + } + for dev in coordinator.get_prov_devices().values() + ]) + + # 获取 Mesh 设备状态 + if coordinator.data: + mesh_devices.extend([ + { + "mac": mac, + "model_id": hex(device.model_id) if device.model_id else None, + "states": device.states, + "last_update": device.last_update, + } + for mac, device in coordinator.data.items() + ]) + + return self.json({ + "prov_devices": prov_devices, + "mesh_devices": mesh_devices, + }) diff --git a/docs/UI 使用指南.md b/docs/UI 使用指南.md new file mode 100644 index 0000000..a328dc6 --- /dev/null +++ b/docs/UI 使用指南.md @@ -0,0 +1,227 @@ +# SigMesh Gateway UI 使用指南 + +## 概述 + +SigMesh Gateway 集成提供完整的 Web UI 界面,所有配网和分组管理功能都可以通过 UI 完成,无需使用服务调用或命令行。 + +## UI 访问方式 + +### 方式 1: Lovelace Dashboard 卡片(推荐) + +将 SigMesh Gateway 面板添加到 Lovelace Dashboard: + +1. **复制前端文件** + ```bash + # 在 HAOS 上执行 + mkdir -p /config/www/sigmesh_gateway + cp -r custom_components/sigmesh_gateway/sigmesh-gateway-panel.js /config/www/sigmesh_gateway/ + ``` + +2. **添加前端模块** + + 在 `configuration.yaml` 中添加: + ```yaml + frontend: + extra_module_url: + - /local/sigmesh_gateway/sigmesh-gateway-panel.js + ``` + +3. **重启 Home Assistant** + +4. **添加卡片到 Dashboard** + - 打开 Lovelace Dashboard + - 点击右下角"编辑仪表板" + - 点击"+"添加卡片 + - 选择"自定义卡片" + - 输入卡片类型:`custom:sigmesh-gateway-panel` + +### 方式 2: 直接访问 API + +配网管理 API 端点: + +| 端点 | 方法 | 功能 | +|------|------|------| +| `/api/sigmesh_gateway/status` | GET | 获取配网状态和设备列表 | +| `/api/sigmesh_gateway/scan` | POST | 开始扫描设备 | +| `/api/sigmesh_gateway/provisioning` | POST | 配网操作(开始/停止/绑定) | +| `/api/sigmesh_gateway/group` | POST | 分组管理(添加/移除) | +| `/api/sigmesh_gateway/devices` | GET | 获取所有设备详情 | + +## UI 界面说明 + +### 主界面布局 + +``` +┌─────────────────────────────────────────────┐ +│ SigMesh Gateway 配网管理 [状态徽章] │ +├─────────────────────────────────────────────┤ +│ 设备扫描 │ +│ [开始扫描] [刷新设备列表] │ +├─────────────────────────────────────────────┤ +│ 已发现设备 (3) │ +│ ┌─────────────────────────────────────┐ │ +│ │ 设备 AA:BB:CC:DD:EE:FF │ │ +│ │ 元素数:2 | 地址:未分配 │ │ +│ └─────────────────────────────────────┘ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 设备 11:22:33:44:55:66 │ │ +│ │ 元素数:1 | 地址:0001 │ │ +│ └─────────────────────────────────────┘ │ +├─────────────────────────────────────────────┤ +│ 配网操作 - AA:BB:CC:DD:EE:FF │ +│ [设备地址输入框] │ +│ [开始配网] [停止配网] [绑定 App Key] │ +├─────────────────────────────────────────────┤ +│ 分组管理 │ +│ [目标地址] [组地址 C001] [Model ID 4352] │ +│ [添加到组] [从组移除] │ +├─────────────────────────────────────────────┤ +│ 组配置 (2) │ +│ 组地址:0xC001 | 元素:0000 | Model: 0x1100│ +│ 组地址:0xC002 | 元素:0000 | Model: 0x1000│ +└─────────────────────────────────────────────┘ +``` + +### 状态说明 + +| 状态 | 颜色 | 说明 | +|------|------|------| +| idle | 绿色 | 空闲,无配网操作 | +| scanning | 蓝色 | 正在扫描设备 | +| prov_starting | 橙色 | 配网启动中 | +| prov_in_progress | 橙色 | 配网进行中 | +| prov_completed | 绿色 | 配网完成 | +| prov_failed | 红色 | 配网失败 | +| timeout | 红色 | 配网超时 | + +## 操作步骤 + +### 1. 扫描设备 + +1. 点击"开始扫描"按钮 +2. 等待设备上报(扫描中的设备需要处于配网模式) +3. 扫描到的设备会显示在"已发现设备"列表中 + +### 2. 配网设备 + +1. 在"已发现设备"列表中点击要配网的设备(选中后高亮) +2. 点击"开始配网"按钮 +3. 等待配网完成(状态变为 prov_completed) +4. 可选:点击"绑定 App Key"完成密钥绑定 + +### 3. 添加到组 + +1. 选中要配置的设备 +2. 在"分组管理"区域配置: + - 目标地址:自动填充为选中设备的地址 + - 组地址:输入组地址(如 C001) + - Model ID:输入设备的 Model ID +3. 点击"添加到组"按钮 + +### 4. 从组移除 + +1. 选中要配置的设备 +2. 配置组地址和 Model ID +3. 点击"从组移除"按钮 + +## API 使用示例 + +### 获取状态 + +```bash +curl -X GET \ + -H "Authorization: Bearer YOUR_TOKEN" \ + http://localhost:8123/api/sigmesh_gateway/status +``` + +响应示例: +```json +{ + "state": "idle", + "devices": [ + {"mac": "AA:BB:CC:DD:EE:FF", "elements": 2, "address": null} + ], + "groups": [ + {"address": "0xc001", "element_address": "0x0", "model_id": "0x1100"} + ] +} +``` + +### 开始扫描 + +```bash +curl -X POST \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{}' \ + http://localhost:8123/api/sigmesh_gateway/scan +``` + +### 开始配网 + +```bash +curl -X POST \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"action": "start", "device_address": "001A"}' \ + http://localhost:8123/api/sigmesh_gateway/provisioning +``` + +### 添加到组 + +```bash +curl -X POST \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "action": "add", + "target_address": "001A", + "group_address": "C001", + "model_id": 4352, + "is_sig": true + }' \ + http://localhost:8123/api/sigmesh_gateway/group +``` + +## 故障排查 + +### UI 不显示 + +**检查步骤**: +1. 确认 JS 文件已复制到正确位置 +2. 检查 configuration.yaml 配置 +3. 清除浏览器缓存 +4. 查看浏览器控制台错误 + +### API 返回 401 + +**原因**: 认证失败 + +**解决**: +1. 确保使用有效的 access token +2. 在 HA Profile 页面生成新的 long-lived token + +### 配网超时 + +**可能原因**: +- 设备未进入配网模式 +- 网络密钥配置不正确 + +**解决**: +1. 参考设备说明书将设备置于配网模式 +2. 检查集成配置中的 Network Key + +## 配置检查清单 + +配网前确认以下配置: + +- [ ] 串口连接正常 +- [ ] Network Key 配置正确(32 字符十六进制) +- [ ] App Key 配置正确(32 字符十六进制) +- [ ] 设备已进入配网模式 +- [ ] 组地址在有效范围(0xC000-0xCFFF) + +--- + +**文档版本**: 1.0 +**最后更新**: 2026-04-16