impress_sig_mesh_hacs/custom_components/sigmesh_gateway/sigmesh-gateway-panel.js
impressionyang 4c3eb62dfb 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 - 设备列表
2026-04-16 13:41:28 +08:00

551 lines
16 KiB
JavaScript

/**
* 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 已加载');