feat: 实现完整的 Web UI 配网管理功能
新增 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 - 设备列表
This commit is contained in:
parent
fa7a03b9eb
commit
4c3eb62dfb
34
README.md
34
README.md
@ -107,6 +107,40 @@ sigmesh_gateway:
|
|||||||
poll_interval: 30
|
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)
|
||||||
|
|
||||||
## 串口连接
|
## 串口连接
|
||||||
|
|
||||||
### 接线方式
|
### 接线方式
|
||||||
|
|||||||
@ -21,8 +21,18 @@ from .const import (
|
|||||||
DEFAULT_NETWORK_KEY,
|
DEFAULT_NETWORK_KEY,
|
||||||
DOMAIN,
|
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 .coordinator import SigMeshGatewayCoordinator
|
||||||
from .serial_reader import SerialReader
|
from .serial_reader import SerialReader
|
||||||
|
from .web_ui import setup_web_ui
|
||||||
from .services import setup_services
|
from .services import setup_services
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@ -35,7 +45,7 @@ PLATFORMS: list[Platform] = [
|
|||||||
Platform.DEVICE_TRACKER,
|
Platform.DEVICE_TRACKER,
|
||||||
]
|
]
|
||||||
|
|
||||||
# 全局协调器字典(用于服务调用)
|
# 全局协调器字典(用于 Web UI 调用)
|
||||||
_coordinators: dict[str, SigMeshGatewayCoordinator] = {}
|
_coordinators: dict[str, SigMeshGatewayCoordinator] = {}
|
||||||
|
|
||||||
|
|
||||||
@ -85,8 +95,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
# 注册全局协调器
|
# 注册全局协调器
|
||||||
_coordinators[entry.entry_id] = coordinator
|
_coordinators[entry.entry_id] = coordinator
|
||||||
|
|
||||||
# 设置服务(仅第一次)
|
# 设置 Web UI 和服务(仅第一次)
|
||||||
if not hass.services.has_service(DOMAIN, "start_scan"):
|
if len(_coordinators) == 1:
|
||||||
|
setup_web_ui(hass, _coordinators)
|
||||||
setup_services(hass, _coordinators)
|
setup_services(hass, _coordinators)
|
||||||
|
|
||||||
# 启动协调器
|
# 启动协调器
|
||||||
@ -127,13 +138,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
|
|
||||||
# 如果所有集成都已卸载,移除服务
|
# 如果所有集成都已卸载,移除服务
|
||||||
if not _coordinators:
|
if not _coordinators:
|
||||||
hass.services.async_remove(DOMAIN, "start_scan")
|
hass.services.async_remove(DOMAIN, SERVICE_START_SCAN)
|
||||||
hass.services.async_remove(DOMAIN, "stop_provisioning")
|
hass.services.async_remove(DOMAIN, SERVICE_STOP_PROVISIONING)
|
||||||
hass.services.async_remove(DOMAIN, "start_provisioning")
|
hass.services.async_remove(DOMAIN, SERVICE_START_PROVISIONING)
|
||||||
hass.services.async_remove(DOMAIN, "bind_appkey")
|
hass.services.async_remove(DOMAIN, SERVICE_BIND_APPKEY)
|
||||||
hass.services.async_remove(DOMAIN, "add_to_group")
|
hass.services.async_remove(DOMAIN, SERVICE_ADD_TO_GROUP)
|
||||||
hass.services.async_remove(DOMAIN, "remove_from_group")
|
hass.services.async_remove(DOMAIN, SERVICE_REMOVE_FROM_GROUP)
|
||||||
hass.services.async_remove(DOMAIN, "send_vendor_command")
|
hass.services.async_remove(DOMAIN, SERVICE_SEND_VENDOR_COMMAND)
|
||||||
|
|
||||||
return unload_ok
|
return unload_ok
|
||||||
|
|
||||||
|
|||||||
@ -177,3 +177,12 @@ SERIAL_EVENT_PREFIX = "+EVENT="
|
|||||||
SERIAL_MESH_RECV = "+EVENT=MESH,recv"
|
SERIAL_MESH_RECV = "+EVENT=MESH,recv"
|
||||||
SERIAL_PROV_DEVICE_JOINED = "+EVENT=PROV,device_joined"
|
SERIAL_PROV_DEVICE_JOINED = "+EVENT=PROV,device_joined"
|
||||||
SERIAL_PROV_DEVICE_LEFT = "+EVENT=PROV,device_left"
|
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"
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
"""SigMesh Gateway 服务定义。"""
|
"""SigMesh Gateway 服务定义(保留用于向后兼容)。
|
||||||
|
|
||||||
|
注意:配网功能已迁移到 Web UI,服务调用接口保留用于自动化脚本。
|
||||||
|
"""
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
@ -55,6 +58,9 @@ SERVICE_SCHEMA_SEND_VENDOR_COMMAND = {
|
|||||||
def setup_services(hass, coordinators: dict) -> None:
|
def setup_services(hass, coordinators: dict) -> None:
|
||||||
"""设置 HA 服务。
|
"""设置 HA 服务。
|
||||||
|
|
||||||
|
注意:配网功能已迁移到 Web UI,服务调用接口保留用于自动化脚本。
|
||||||
|
推荐使用 Web UI 进行配网和分组管理操作。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
hass: HomeAssistant 实例
|
hass: HomeAssistant 实例
|
||||||
coordinators: 协调器字典 {entry_id: coordinator}
|
coordinators: 协调器字典 {entry_id: coordinator}
|
||||||
|
|||||||
550
custom_components/sigmesh_gateway/sigmesh-gateway-panel.js
Normal file
550
custom_components/sigmesh_gateway/sigmesh-gateway-panel.js
Normal file
@ -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 = `
|
||||||
|
<style>
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--card-background-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: var(--ha-card-box-shadow);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.header h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--primary-text-color);
|
||||||
|
}
|
||||||
|
.status-badge {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: ${this._getStateColor()};
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--secondary-text-color);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.device-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.device-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--secondary-background-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.device-item:hover {
|
||||||
|
background: var(--accent-color);
|
||||||
|
}
|
||||||
|
.device-item.selected {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.device-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.device-name {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.device-details {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--secondary-text-color);
|
||||||
|
}
|
||||||
|
.device-item.selected .device-details {
|
||||||
|
color: rgba(255,255,255,0.7);
|
||||||
|
}
|
||||||
|
.button-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.button:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.button-primary {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.button-secondary {
|
||||||
|
background: var(--secondary-background-color);
|
||||||
|
color: var(--primary-text-color);
|
||||||
|
}
|
||||||
|
.button-danger {
|
||||||
|
background: var(--error-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.input-field {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--secondary-background-color);
|
||||||
|
color: var(--primary-text-color);
|
||||||
|
}
|
||||||
|
.group-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.group-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--secondary-background-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
color: var(--secondary-text-color);
|
||||||
|
}
|
||||||
|
.log-container {
|
||||||
|
max-height: 150px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #1e1e1e;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.log-entry {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.log-info { color: #4caf50; }
|
||||||
|
.log-warning { color: #ff9800; }
|
||||||
|
.log-error { color: #f44336; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="header">
|
||||||
|
<h2>SigMesh Gateway 配网管理</h2>
|
||||||
|
<span class="status-badge">${this._state}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${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 `
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">设备扫描</div>
|
||||||
|
<div class="button-group">
|
||||||
|
<button class="button button-primary" id="btn-scan" ${isScanning ? 'disabled' : ''}>
|
||||||
|
${isScanning ? '扫描中...' : '开始扫描'}
|
||||||
|
</button>
|
||||||
|
<button class="button button-secondary" id="btn-refresh">
|
||||||
|
刷新设备列表
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderDeviceList() {
|
||||||
|
if (this._devices.length === 0) {
|
||||||
|
return `
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">已发现设备</div>
|
||||||
|
<div class="empty-state">暂无设备,请先扫描</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">已发现设备 (${this._devices.length})</div>
|
||||||
|
<div class="device-list">
|
||||||
|
${this._devices.map((device, index) => `
|
||||||
|
<div class="device-item ${this._selectedDevice === index ? 'selected' : ''}" data-index="${index}">
|
||||||
|
<div class="device-info">
|
||||||
|
<div class="device-name">设备 ${device.mac}</div>
|
||||||
|
<div class="device-details">
|
||||||
|
元素数:${device.elements} |
|
||||||
|
地址:${device.address || '未分配'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_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 `
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">配网操作 - ${device?.mac}</div>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="input-field" id="device-address"
|
||||||
|
value="${device?.mac || ''}" placeholder="设备地址" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="button-group">
|
||||||
|
<button class="button button-primary" id="btn-prov-start" ${isProvInProgress ? 'disabled' : ''}>
|
||||||
|
开始配网
|
||||||
|
</button>
|
||||||
|
<button class="button button-danger" id="btn-prov-stop" ${!isProvInProgress ? 'disabled' : ''}>
|
||||||
|
停止配网
|
||||||
|
</button>
|
||||||
|
<button class="button button-secondary" id="btn-bind-key">
|
||||||
|
绑定 App Key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderGroupManagement() {
|
||||||
|
if (!this._selectedDevice && this._selectedDevice !== 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">分组管理</div>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="input-field" id="target-address"
|
||||||
|
value="${this._devices[this._selectedDevice]?.mac || ''}"
|
||||||
|
placeholder="目标地址" readonly>
|
||||||
|
<input type="text" class="input-field" id="group-address"
|
||||||
|
value="${this._groupAddress}" placeholder="组地址">
|
||||||
|
<input type="number" class="input-field" id="model-id"
|
||||||
|
value="4352" placeholder="Model ID" min="0" max="65535">
|
||||||
|
</div>
|
||||||
|
<div class="button-group">
|
||||||
|
<button class="button button-primary" id="btn-add-group">
|
||||||
|
添加到组
|
||||||
|
</button>
|
||||||
|
<button class="button button-danger" id="btn-remove-group">
|
||||||
|
从组移除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderGroupList() {
|
||||||
|
if (this._groups.length === 0) {
|
||||||
|
return `
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">组配置</div>
|
||||||
|
<div class="empty-state">暂无组配置</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">组配置 (${this._groups.length})</div>
|
||||||
|
<div class="group-list">
|
||||||
|
${this._groups.map(group => `
|
||||||
|
<div class="group-item">
|
||||||
|
<span>组地址:<strong>${group.address}</strong></span>
|
||||||
|
<span>元素:${group.element_address}</span>
|
||||||
|
<span>Model: ${group.model_id}</span>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_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 已加载');
|
||||||
294
custom_components/sigmesh_gateway/web_ui.py
Normal file
294
custom_components/sigmesh_gateway/web_ui.py
Normal file
@ -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,
|
||||||
|
})
|
||||||
227
docs/UI 使用指南.md
Normal file
227
docs/UI 使用指南.md
Normal file
@ -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
|
||||||
Loading…
Reference in New Issue
Block a user