问题:Custom element not found: sigmesh-gateway-panel 修复内容: 1. sigmesh-gateway-panel.js: - 添加 window.customCards 注册 - 添加卡片元数据(name, description, preview) - 更新使用说明 2. hacs.json: - 添加 frontend.extra_module_url 配置 - 添加 category: integration 3. 新增 deploy.sh 部署脚本: - 自动复制集成文件和 Lovelace 卡片 - 设置正确权限 - 提示用户配置 frontend 4. README.md: - 添加部署脚本使用说明 - 添加常见问题排查(卡片加载错误) - 区分手动部署和 HACS 安装两种方式 部署步骤: 1. chmod +x deploy.sh && ./deploy.sh 2. 在 configuration.yaml 添加 frontend.extra_module_url 3. ha core restart 4. 清除浏览器缓存后添加卡片
649 lines
18 KiB
JavaScript
649 lines
18 KiB
JavaScript
/**
|
|
* SigMesh Gateway 配网控制面板 - Lovelace 自定义卡片
|
|
*
|
|
* 使用方法:
|
|
* 1. 在 HACS 中安装本集成后,卡片会自动可用
|
|
* 2. 或者手动复制此文件到 HA 的 www/sigmesh_gateway/ 目录
|
|
* 3. 在 HA 的 configuration.yaml 中添加:
|
|
* frontend:
|
|
* extra_module_url:
|
|
* - /local/sigmesh_gateway/sigmesh-gateway-panel.js
|
|
* 4. 在 Lovelace Dashboard 中添加自定义卡片: custom:sigmesh-gateway-panel
|
|
*/
|
|
|
|
// 配置常量
|
|
const API_BASE = '/api/sigmesh_gateway';
|
|
const POLL_INTERVAL = 3000;
|
|
|
|
// 注册到全局自定义卡片列表
|
|
if (!window.customCards) {
|
|
window.customCards = [];
|
|
}
|
|
window.customCards.push({
|
|
type: 'sigmesh-gateway-panel',
|
|
name: 'SigMesh Gateway 配网管理',
|
|
description: '管理 SigMesh 网关的配网和分组功能',
|
|
preview: true,
|
|
documentationURL: 'https://github.com/impress-sig-mesh/sigmesh_gateway',
|
|
});
|
|
|
|
/**
|
|
* Lovelace 卡片组件
|
|
*/
|
|
class SigMeshGatewayCard extends HTMLElement {
|
|
static get placeholder() {
|
|
return {
|
|
type: 'custom:sigmesh-gateway-panel',
|
|
label: 'SigMesh Gateway 配网管理'
|
|
};
|
|
}
|
|
|
|
static getConfigElement() {
|
|
// 返回配置元素(如果需要配置界面)
|
|
return document.createElement('div');
|
|
}
|
|
|
|
static getStubConfig() {
|
|
return {};
|
|
}
|
|
|
|
constructor() {
|
|
super();
|
|
this._hass = null;
|
|
this._config = null;
|
|
this._state = 'idle';
|
|
this._devices = [];
|
|
this._groups = [];
|
|
this._pollTimer = null;
|
|
this._selectedDevice = null;
|
|
this._groupAddress = '0xC001';
|
|
}
|
|
|
|
set hass(hass) {
|
|
this._hass = hass;
|
|
if (!this.hasAttribute('rendered')) {
|
|
this._render();
|
|
this.setAttribute('rendered', 'true');
|
|
}
|
|
this._updateState();
|
|
}
|
|
|
|
setConfig(config) {
|
|
this._config = config;
|
|
}
|
|
|
|
getCardSize() {
|
|
return 10;
|
|
}
|
|
|
|
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() {
|
|
if (!this._hass) return;
|
|
|
|
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._updateState();
|
|
} catch (error) {
|
|
console.error('获取状态失败:', error);
|
|
}
|
|
}
|
|
|
|
_updateState() {
|
|
if (!this.shadowRoot) return;
|
|
|
|
// 更新状态徽章
|
|
const statusBadge = this.shadowRoot.querySelector('.status-badge');
|
|
if (statusBadge) {
|
|
statusBadge.textContent = this._state;
|
|
statusBadge.style.background = this._getStateColor();
|
|
}
|
|
|
|
// 更新设备列表
|
|
this._renderDeviceList();
|
|
|
|
// 更新组列表
|
|
this._renderGroupList();
|
|
}
|
|
|
|
_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';
|
|
}
|
|
|
|
_render() {
|
|
this.attachShadow({ mode: 'open' });
|
|
|
|
this.shadowRoot.innerHTML = `
|
|
<style>
|
|
:host {
|
|
display: block;
|
|
}
|
|
ha-card {
|
|
padding: 16px;
|
|
}
|
|
.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;
|
|
background: var(--primary-color);
|
|
color: white;
|
|
}
|
|
.button:hover {
|
|
opacity: 0.8;
|
|
}
|
|
.button-secondary {
|
|
background: var(--secondary-background-color);
|
|
color: var(--primary-text-color);
|
|
}
|
|
.button-danger {
|
|
background: var(--error-color, #f44336);
|
|
}
|
|
.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);
|
|
}
|
|
ha-svg-icon {
|
|
width: 20px;
|
|
height: 20px;
|
|
}
|
|
</style>
|
|
|
|
<ha-card>
|
|
<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()}
|
|
</ha-card>
|
|
`;
|
|
|
|
this._attachEventListeners();
|
|
}
|
|
|
|
_renderScanSection() {
|
|
const isScanning = this._state === 'scanning';
|
|
return `
|
|
<div class="section">
|
|
<div class="section-title">设备扫描</div>
|
|
<div class="button-group">
|
|
<button class="button" 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" 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" 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>
|
|
`;
|
|
}
|
|
|
|
_renderDeviceList() {
|
|
const deviceListEl = this.shadowRoot.querySelector('.device-list');
|
|
if (!deviceListEl) return;
|
|
|
|
deviceListEl.innerHTML = 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('');
|
|
}
|
|
|
|
_renderGroupList() {
|
|
const groupListEl = this.shadowRoot.querySelector('.group-list');
|
|
if (!groupListEl) return;
|
|
|
|
if (this._groups.length === 0) {
|
|
groupListEl.parentElement.querySelector('.empty-state')?.remove();
|
|
groupListEl.innerHTML = 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('');
|
|
} else {
|
|
groupListEl.innerHTML = 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('');
|
|
}
|
|
}
|
|
|
|
_attachEventListeners() {
|
|
if (!this.shadowRoot) return;
|
|
|
|
// 扫描按钮
|
|
this.shadowRoot.querySelector('#btn-scan')?.addEventListener('click', () => this._handleScan());
|
|
this.shadowRoot.querySelector('#btn-refresh')?.addEventListener('click', () => this._fetchStatus());
|
|
|
|
// 设备选择
|
|
this.shadowRoot.querySelectorAll('.device-item').forEach(item => {
|
|
item.addEventListener('click', () => {
|
|
const index = parseInt(item.dataset.index);
|
|
this._selectedDevice = index === this._selectedDevice ? null : index;
|
|
this._render();
|
|
});
|
|
});
|
|
|
|
// 配网按钮
|
|
this.shadowRoot.querySelector('#btn-prov-start')?.addEventListener('click', () => this._handleProvStart());
|
|
this.shadowRoot.querySelector('#btn-prov-stop')?.addEventListener('click', () => this._handleProvStop());
|
|
this.shadowRoot.querySelector('#btn-bind-key')?.addEventListener('click', () => this._handleBindKey());
|
|
|
|
// 分组按钮
|
|
this.shadowRoot.querySelector('#btn-add-group')?.addEventListener('click', () => this._handleAddGroup());
|
|
this.shadowRoot.querySelector('#btn-remove-group')?.addEventListener('click', () => this._handleRemoveGroup());
|
|
}
|
|
|
|
async _handleScan() {
|
|
if (!this._hass) return;
|
|
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() {
|
|
if (!this._hass) return;
|
|
const deviceAddress = this.shadowRoot?.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() {
|
|
if (!this._hass) return;
|
|
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() {
|
|
if (!this._hass) return;
|
|
const deviceAddress = this.shadowRoot?.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() {
|
|
if (!this._hass) return;
|
|
const targetAddress = this.shadowRoot?.querySelector('#target-address')?.value;
|
|
const groupAddress = this.shadowRoot?.querySelector('#group-address')?.value;
|
|
const modelId = parseInt(this.shadowRoot?.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() {
|
|
if (!this._hass) return;
|
|
const targetAddress = this.shadowRoot?.querySelector('#target-address')?.value;
|
|
const groupAddress = this.shadowRoot?.querySelector('#group-address')?.value;
|
|
const modelId = parseInt(this.shadowRoot?.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', SigMeshGatewayCard);
|
|
|
|
// 添加到 window 供调试
|
|
window.SigMeshGatewayCard = SigMeshGatewayCard;
|
|
|
|
console.log('SigMesh Gateway Panel 已加载');
|