@switchbot/homebridge-switchbot
Version:
The SwitchBot plugin allows you to access your SwitchBot device(s) from HomeKit.
967 lines • 39.9 kB
JavaScript
// Move regexes to module scope to avoid re-compilation on every call
// import type { DEVICE_TYPES } from './constants.js' // Removed unused import
const SPACES_REGEX = /\s/g;
const CAMELCASE_REGEX = /([A-Z])/g;
const FIRST_CHAR_REGEX = /^./;
async function copyTextWithFallback(text) {
try {
await navigator.clipboard.writeText(text);
}
catch {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.cssText = 'position:fixed;left:-9999px;opacity:0;pointer-events:none';
document.body.appendChild(textarea);
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
document.execCommand('copy');
textarea.remove();
}
}
/**
* Get RSSI signal quality level and color based on dBm value
* @param rssi Signal strength in dBm (typically -30 to -90)
* @returns Object with quality level, color, and description
*/
export function getRssiSignalQuality(rssi) {
if (!rssi || rssi === 0) {
return {
level: 'unknown',
color: '#999',
bgColor: '#f5f5f5',
description: 'Signal strength unknown',
bars: 0,
};
}
const dbm = Math.floor(rssi);
if (dbm > -60) {
return {
level: 'excellent',
color: '#34a853',
bgColor: '#e8f5e9',
description: `Excellent (${dbm} dBm)`,
bars: 4,
};
}
else if (dbm > -75) {
return {
level: 'good',
color: '#fbbc04',
bgColor: '#fffde7',
description: `Good (${dbm} dBm)`,
bars: 3,
};
}
else if (dbm > -85) {
return {
level: 'fair',
color: '#ff9800',
bgColor: '#fff3e0',
description: `Fair (${dbm} dBm)`,
bars: 2,
};
}
else {
return {
level: 'poor',
color: '#ea4335',
bgColor: '#ffebee',
description: `Poor (${dbm} dBm) - unreliable`,
bars: 1,
};
}
}
/**
* Create visual signal strength indicator bars
* @param rssi Signal strength in dBm
* @returns HTML element showing filled bars
*/
export function renderSignalBars(rssi) {
const quality = getRssiSignalQuality(rssi);
const container = document.createElement('span');
container.style.display = 'inline-flex';
container.style.gap = '2px';
container.style.alignItems = 'center';
container.style.marginLeft = '8px';
container.style.fontSize = '12px';
// Create 4 bars
for (let i = 1; i <= 4; i++) {
const bar = document.createElement('span');
bar.style.height = `${i * 3}px`;
bar.style.width = '3px';
bar.style.borderRadius = '1px';
bar.style.border = `1px solid ${quality.color}`;
if (i <= quality.bars) {
bar.style.backgroundColor = quality.color;
}
else {
bar.style.backgroundColor = 'transparent';
}
container.appendChild(bar);
}
// Add tooltip
container.title = quality.description;
return container;
}
/**
* Create signal quality badge with color
* @param rssi Signal strength in dBm
* @returns HTML element showing quality level
*/
export function renderSignalQualityBadge(rssi) {
const quality = getRssiSignalQuality(rssi);
const badge = document.createElement('span');
badge.textContent = quality.level.charAt(0).toUpperCase() + quality.level.slice(1);
badge.style.cssText = `
background: ${quality.color};
color: white;
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
`;
badge.title = quality.description;
return badge;
}
export function renderBadge(text, style) {
const badge = document.createElement('span');
badge.textContent = text;
badge.style.cssText = style;
return badge;
}
export function renderConnectionBadge(connectionType) {
if (!connectionType) {
return null;
}
const badge = renderBadge(connectionType, '');
if (connectionType === 'BLE') {
badge.style.cssText
= 'background: #4285f4; color: white; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 8px;';
}
else if (connectionType === 'Both') {
badge.style.cssText
= 'background: #34a853; color: white; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 8px;';
}
else {
badge.style.cssText
= 'background: #9e9e9e; color: white; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 8px;';
}
return badge;
}
export function renderIRBadge() {
return renderBadge('IR', 'background: #ff6b35; color: white; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 8px;');
}
function normalizeId(value) {
return String(value ?? '').trim().toLowerCase();
}
function scrollToConfiguredDevice(deviceId) {
const normalizedId = normalizeId(deviceId);
const target = document.querySelector(`[data-device-id="${normalizedId}"]`);
if (!target) {
return;
}
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
const originalOutline = target.style.outline;
const originalBackground = target.style.background;
target.style.outline = '2px solid var(--switchbot-red, #ef4444)';
target.style.background = 'rgba(239, 68, 68, 0.08)';
setTimeout(() => {
target.style.outline = originalOutline;
target.style.background = originalBackground;
}, 1800);
}
function createConnectionTestControls(device) {
const controls = document.createElement('div');
controls.style.display = 'inline-flex';
controls.style.alignItems = 'center';
controls.style.gap = '6px';
const button = document.createElement('button');
button.textContent = 'Test Connection';
button.className = 'secondary';
button.style.padding = '4px 9px';
button.style.fontSize = '11px';
const status = document.createElement('span');
status.style.fontSize = '10px';
status.style.opacity = '0.85';
status.style.whiteSpace = 'normal';
status.style.overflowWrap = 'anywhere';
button.onclick = async () => {
const startedAt = Date.now();
button.disabled = true;
button.textContent = 'Testing...';
status.textContent = 'Checking...';
status.style.color = '#6b7280';
try {
const { testDeviceConnection } = await import('./api.js');
const result = await testDeviceConnection({
deviceId: String(device?.id || device?.deviceId || ''),
connectionType: device?.connectionType,
address: device?.address,
});
const measuredLatency = Number(result?.latencyMs) > 0
? Number(result.latencyMs)
: Date.now() - startedAt;
if (result?.success) {
const method = result?.method || 'Auto';
status.textContent = `✓ ${method} · ${measuredLatency}ms`;
status.style.color = '#16a34a';
}
else {
const detail = result?.message ? ` · ${result.message}` : '';
status.textContent = `✗ Failed · ${measuredLatency}ms${detail}`;
status.style.color = '#dc2626';
}
}
catch (e) {
status.textContent = `✗ Failed · ${Date.now() - startedAt}ms`;
status.style.color = '#dc2626';
}
finally {
button.disabled = false;
button.textContent = 'Test Connection';
}
};
controls.appendChild(button);
controls.appendChild(status);
return controls;
}
function formatLastSeen(value) {
if (!value) {
return 'N/A';
}
try {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
}
catch (_e) {
return String(value);
}
}
export function renderDeviceDetailsPanel(device) {
const details = document.createElement('div');
details.className = 'device-details-panel';
details.style.borderTop = '1px solid #ddd';
details.style.padding = '8px';
details.style.borderRadius = '4px';
details.style.fontSize = '12px';
details.style.marginTop = '4px';
// --- Battery history trending ---
// Persist battery readings in localStorage per device
const batteryHistoryKey = `batteryHistory:${device?.id || device?.deviceId}`;
let batteryHistory = [];
try {
const raw = localStorage.getItem(batteryHistoryKey);
if (raw) {
batteryHistory = JSON.parse(raw);
}
}
catch (e) {
// Optionally log or handle error
}
const now = Date.now();
if (typeof device?.battery === 'number') {
// Only add if different from last or >1h since last
const last = batteryHistory.at(-1);
if (!last || last.value !== device.battery || now - last.ts > 60 * 60 * 1000) {
batteryHistory.push({ value: device.battery, ts: now });
// Keep only last 30 entries (about a month if daily)
if (batteryHistory.length > 30) {
batteryHistory = batteryHistory.slice(-30);
}
try {
localStorage.setItem(batteryHistoryKey, JSON.stringify(batteryHistory));
}
catch (e) {
// Optionally log or handle error
}
}
}
const rows = [
{ label: 'Name', value: String(device?.name || device?.configDeviceName || 'N/A') },
{ label: 'Device ID', value: String(device?.id || device?.deviceId || 'N/A'), copyable: !!(device?.id || device?.deviceId) },
{ label: 'MAC Address', value: String(device?.address || 'N/A'), copyable: !!device?.address },
{ label: 'Device Type', value: String(device?.type || device?.configDeviceType || 'N/A') },
{ label: 'Model', value: String(device?.model || 'N/A') },
{ label: 'Hub ID', value: String(device?.hubDeviceId || 'N/A') },
{ label: 'Battery', value: device?.battery !== undefined && device?.battery !== null ? `${device.battery}%` : 'N/A' },
{ label: 'Firmware', value: String(device?.version || device?.firmware || 'N/A') },
{ label: 'Cloud Service', value: device?.enabled === false ? 'Disabled' : 'Enabled' },
{ label: 'Last Seen', value: formatLastSeen(device?.lastSeen || device?.lastseen || device?.updatedAt) },
];
for (const row of rows) {
const line = document.createElement('div');
line.style.display = 'flex';
line.style.alignItems = 'center';
line.style.justifyContent = 'space-between';
line.style.gap = '8px';
line.style.padding = '2px 0';
const label = document.createElement('span');
label.style.fontWeight = '600';
label.style.minWidth = '110px';
label.textContent = `${row.label}:`;
const valueWrap = document.createElement('span');
valueWrap.style.display = 'inline-flex';
valueWrap.style.alignItems = 'center';
valueWrap.style.gap = '6px';
valueWrap.style.flex = '1';
valueWrap.style.justifyContent = 'flex-end';
valueWrap.style.minWidth = '0';
const value = document.createElement('span');
value.style.fontFamily = 'monospace';
value.style.fontSize = '11px';
value.style.opacity = '0.9';
value.style.whiteSpace = 'normal';
value.style.overflowWrap = 'anywhere';
value.style.wordBreak = 'break-word';
value.style.textAlign = 'right';
value.textContent = row.value;
valueWrap.appendChild(value);
if (row.copyable && row.value && row.value !== 'N/A') {
const copyBtn = document.createElement('button');
copyBtn.textContent = '📋';
copyBtn.title = `Copy ${row.label}`;
copyBtn.style.padding = '2px 6px';
copyBtn.style.fontSize = '10px';
copyBtn.style.lineHeight = '1';
copyBtn.style.background = '#e5e7eb';
copyBtn.style.color = '#111827';
copyBtn.onclick = async () => {
try {
await navigator.clipboard.writeText(row.value);
copyBtn.textContent = '✓';
setTimeout(() => {
copyBtn.textContent = '📋';
}, 1200);
}
catch (_e) {
copyBtn.textContent = '!';
setTimeout(() => {
copyBtn.textContent = '📋';
}, 1200);
}
};
valueWrap.appendChild(copyBtn);
}
line.appendChild(label);
line.appendChild(valueWrap);
details.appendChild(line);
// If this is the Battery row, add a sparkline chart below
if (row.label === 'Battery' && Array.isArray(batteryHistory) && batteryHistory.length > 1) {
const chart = document.createElement('div');
chart.style.margin = '2px 0 8px 0';
chart.style.width = '100%';
chart.style.height = '28px';
chart.style.display = 'flex';
// SVG sparkline
const w = 120;
const h = 24;
const pad = 2;
const min = Math.min(...batteryHistory.map(b => b.value), 100);
const max = Math.max(...batteryHistory.map(b => b.value), 0);
const range = max - min || 1;
const points = batteryHistory.map((b, i) => {
const x = pad + (i * (w - 2 * pad)) / (batteryHistory.length - 1);
const y = pad + (h - 2 * pad) * (1 - (b.value - min) / range);
return `${x},${y}`;
}).join(' ');
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', String(w));
svg.setAttribute('height', String(h));
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
svg.style.display = 'block';
svg.style.background = '#f3f4f6';
svg.style.borderRadius = '3px';
svg.style.marginTop = '2px';
svg.style.boxShadow = '0 1px 2px #0001';
// Polyline for trend
const polyline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
polyline.setAttribute('points', points);
polyline.setAttribute('fill', 'none');
polyline.setAttribute('stroke', '#2563eb');
polyline.setAttribute('stroke-width', '2');
svg.appendChild(polyline);
// Dots for each point
batteryHistory.forEach((b, i) => {
const x = pad + (i * (w - 2 * pad)) / (batteryHistory.length - 1);
const y = pad + (h - 2 * pad) * (1 - (b.value - min) / range);
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.setAttribute('cx', String(x));
circle.setAttribute('cy', String(y));
circle.setAttribute('r', '2.5');
circle.setAttribute('fill', '#2563eb');
svg.appendChild(circle);
});
// Min/max labels
const minLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text');
minLabel.setAttribute('x', '2');
minLabel.setAttribute('y', String(h - 2));
minLabel.setAttribute('font-size', '9');
minLabel.setAttribute('fill', '#888');
minLabel.textContent = `${min}%`;
svg.appendChild(minLabel);
const maxLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text');
maxLabel.setAttribute('x', String(w - 18));
maxLabel.setAttribute('y', '10');
maxLabel.setAttribute('font-size', '9');
maxLabel.setAttribute('fill', '#888');
maxLabel.textContent = `${max}%`;
svg.appendChild(maxLabel);
chart.appendChild(svg);
details.appendChild(chart);
}
}
// --- Expose advanced/extra features dynamically ---
const featureKeys = [
'airQuality',
'pm25',
'pm10',
'voc',
'co2',
'humidity',
'temperature',
'preset',
'mode',
'presetMode',
'direction',
'calibration',
'multiCommand',
'extendedInfo',
'segmentedControl',
'features',
'capabilities',
'state',
];
const shown = new Set(rows.map(r => r.label.toLowerCase().replace(SPACES_REGEX, '')));
for (const key of featureKeys) {
if (device && device[key] !== undefined && !shown.has(key.toLowerCase())) {
const line = document.createElement('div');
line.style.display = 'flex';
line.style.alignItems = 'center';
line.style.justifyContent = 'space-between';
line.style.gap = '8px';
line.style.padding = '2px 0';
const label = document.createElement('span');
label.style.fontWeight = '600';
label.style.minWidth = '110px';
label.textContent = `${key.replace(CAMELCASE_REGEX, ' $1').replace(FIRST_CHAR_REGEX, s => s.toUpperCase())}:`;
const value = document.createElement('span');
value.style.fontFamily = 'monospace';
value.style.fontSize = '11px';
value.style.opacity = '0.9';
value.style.whiteSpace = 'normal';
value.style.overflowWrap = 'anywhere';
value.style.wordBreak = 'break-word';
value.style.textAlign = 'right';
value.textContent = typeof device[key] === 'object' ? JSON.stringify(device[key]) : String(device[key]);
line.appendChild(label);
line.appendChild(value);
details.appendChild(line);
}
}
return details;
}
export async function renderDiscoveredDevices(devices, options = {}) {
const ul = document.createElement('ul');
ul.className = 'device-grid';
ul.style.maxHeight = '400px';
ul.style.overflowY = 'auto';
ul.style.marginTop = '12px';
ul.style.padding = '0';
ul.style.listStyle = 'none';
const { addDeviceToConfig } = await import('./discovery.js');
const { loadConfiguredDevices } = await import('./devices.js');
const configuredIds = options.configuredIds ?? new Set();
const selectedIds = options.selectedIds ?? new Set();
const onToggleSelect = options.onToggleSelect;
for (const d of devices) {
// Defensive check: warn if device is missing id, name, or type
if (!d || (!d.id && !d.deviceId) || (!d.name && !d.type)) {
console.warn('[SwitchBot][Discovery][renderDiscoveredDevices] Device missing required fields:', d);
}
const deviceId = normalizeId(d.id);
const alreadyAdded = configuredIds.has(deviceId);
const li = document.createElement('li');
li.className = 'device-item';
li.style.display = 'flex';
li.style.flexDirection = 'column';
li.style.alignItems = 'stretch';
li.style.justifyContent = 'flex-start';
li.style.padding = '5px 8px';
li.style.marginBottom = '0';
li.style.borderRadius = '5px';
li.style.transition = 'all 0.2s ease';
const info = document.createElement('div');
info.style.flex = '1 1 auto';
info.style.width = '100%';
info.style.minWidth = '0';
const nameContainer = document.createElement('div');
nameContainer.style.display = 'flex';
nameContainer.style.alignItems = 'center';
nameContainer.style.marginBottom = '0';
nameContainer.style.flexWrap = 'wrap';
nameContainer.style.gap = '4px';
const name = document.createElement('div');
name.style.fontWeight = '500';
name.style.fontSize = '13px';
name.textContent = d.name || d.id;
const selectCheckbox = document.createElement('input');
selectCheckbox.type = 'checkbox';
selectCheckbox.style.width = 'auto';
selectCheckbox.style.margin = '0 2px 0 0';
selectCheckbox.checked = selectedIds.has(deviceId);
if (alreadyAdded) {
selectCheckbox.disabled = true;
selectCheckbox.title = 'Already configured';
}
selectCheckbox.onchange = () => {
onToggleSelect?.(d, selectCheckbox.checked);
// Notify listeners (e.g., batch buttons) of selection change
window.dispatchEvent(new CustomEvent('discovery-selection-changed'));
};
nameContainer.appendChild(selectCheckbox);
nameContainer.appendChild(name);
// Show firmware update available indicator if present
if (d.firmwareUpdateAvailable) {
const fwBadge = document.createElement('span');
fwBadge.textContent = 'Update Available';
fwBadge.style.cssText = 'background: #fb923c; color: #111; padding: 1px 7px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;';
fwBadge.title = 'A firmware update is available for this device.';
nameContainer.appendChild(fwBadge);
}
// Show offline/unreachable indicator if device is offline
let offline = false;
const lastSeen = d.lastSeen || d.lastseen || d.updatedAt;
if (typeof d.offline === 'boolean') {
offline = d.offline;
}
else if (lastSeen) {
try {
const last = new Date(lastSeen).getTime();
if (!Number.isNaN(last)) {
if (Date.now() - last > 1000 * 60 * 60) { // 1 hour
offline = true;
}
}
}
catch { }
}
if (offline) {
const offlineBadge = document.createElement('span');
offlineBadge.textContent = 'Offline';
offlineBadge.style.cssText = 'background: #dc2626; color: white; padding: 1px 7px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;';
offlineBadge.title = 'Device is offline or unreachable.';
nameContainer.appendChild(offlineBadge);
}
const expandedDetails = document.createElement('div');
expandedDetails.style.display = 'none';
expandedDetails.appendChild(renderDeviceDetailsPanel(d));
const expandBtn = document.createElement('button');
expandBtn.textContent = '▾';
expandBtn.title = 'Show details';
expandBtn.style.padding = '2px 6px';
expandBtn.style.fontSize = '11px';
expandBtn.style.marginLeft = '4px';
expandBtn.style.background = '#e5e7eb';
expandBtn.style.color = '#111827';
expandBtn.style.transition = 'transform 0.2s ease';
expandBtn.onclick = () => {
const isHidden = expandedDetails.style.display === 'none';
expandedDetails.style.display = isHidden ? 'block' : 'none';
expandBtn.style.transform = isHidden ? 'rotate(180deg)' : 'rotate(0deg)';
};
nameContainer.appendChild(expandBtn);
const duplicateBadge = document.createElement('span');
duplicateBadge.textContent = alreadyAdded ? '✓ Already Added' : '➕ New Device';
duplicateBadge.style.cssText = alreadyAdded
? 'background: #16a34a; color: white; padding: 1px 5px; border-radius: 3px; font-size: 9px; font-weight: 600;'
: 'background: #2563eb; color: white; padding: 1px 5px; border-radius: 3px; font-size: 9px; font-weight: 600;';
nameContainer.appendChild(duplicateBadge);
// Add connection type badge
if (d.connectionType) {
const badge = renderConnectionBadge(d.connectionType);
if (badge) {
nameContainer.appendChild(badge);
}
}
// Add IR badge if it's an IR device
if (d.isIR) {
nameContainer.appendChild(renderIRBadge());
}
// Add signal strength visualization (only for BLE/wireless devices)
if (d.rssi !== undefined && d.rssi !== null && d.rssi !== 0) {
nameContainer.appendChild(renderSignalBars(d.rssi));
nameContainer.appendChild(renderSignalQualityBadge(d.rssi));
}
// Add battery warning indicator if battery < 20%
if (typeof d.battery === 'number' && d.battery < 20) {
const batteryWarn = document.createElement('span');
batteryWarn.textContent = `⚠️ ${d.battery}%`;
batteryWarn.style.cssText
= d.battery < 10
? 'background: #dc2626; color: white; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;'
: 'background: #fbbf24; color: #111; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;';
batteryWarn.title = d.battery < 10 ? 'Battery critically low' : 'Battery low';
nameContainer.appendChild(batteryWarn);
}
// Defensive check: warn if device is missing id, name, or type (for details panel)
if (!d || (!d.id && !d.deviceId) || (!d.name && !d.type)) {
console.warn('[SwitchBot][Discovery][renderDeviceDetailsPanel] Device missing required fields:', d);
}
const details = document.createElement('div');
details.style.fontSize = '10px';
details.style.opacity = '0.7';
details.style.marginTop = '0';
details.style.fontFamily = 'monospace';
details.style.whiteSpace = 'normal';
details.style.overflowWrap = 'anywhere';
details.style.wordBreak = 'break-word';
let detailsText = `ID: ${d.id} | Type: ${d.type} | Model: ${d.model || 'N/A'}`;
if (d.hubDeviceId) {
detailsText += ` | Hub: ${d.hubDeviceId}`;
}
if (d.address) {
detailsText += ` | MAC: ${d.address}`;
}
details.textContent = detailsText;
info.appendChild(nameContainer);
info.appendChild(details);
info.appendChild(expandedDetails);
const addBtn = document.createElement('button');
addBtn.textContent = alreadyAdded ? 'Already Added' : 'Add to Config';
addBtn.style.marginLeft = '0';
addBtn.style.marginTop = '2px';
addBtn.style.padding = '4px 9px';
addBtn.style.fontSize = '11px';
addBtn.style.whiteSpace = 'nowrap';
addBtn.style.flexShrink = '0';
addBtn.disabled = alreadyAdded;
if (alreadyAdded) {
addBtn.style.opacity = '0.65';
addBtn.style.cursor = 'not-allowed';
addBtn.style.background = '#6b7280';
}
addBtn.onclick = async () => {
if (alreadyAdded) {
return;
}
await addDeviceToConfig(d);
};
if (alreadyAdded) {
const viewBtn = document.createElement('button');
viewBtn.textContent = 'View in Config';
viewBtn.className = 'secondary';
viewBtn.style.marginLeft = '0';
viewBtn.style.padding = '4px 9px';
viewBtn.style.fontSize = '11px';
viewBtn.onclick = async () => {
await loadConfiguredDevices();
scrollToConfiguredDevice(d.id);
};
li.appendChild(info);
const actions = document.createElement('div');
actions.className = 'device-actions';
actions.style.display = 'flex';
actions.style.alignItems = 'center';
actions.style.flexWrap = 'wrap';
actions.style.justifyContent = 'flex-start';
actions.style.marginLeft = '0';
actions.style.width = '100%';
actions.style.marginTop = '2px';
actions.style.gap = '5px';
actions.appendChild(viewBtn);
actions.appendChild(addBtn);
actions.appendChild(createConnectionTestControls(d));
li.appendChild(actions);
ul.appendChild(li);
continue;
}
const actions = document.createElement('div');
actions.className = 'device-actions';
actions.style.display = 'flex';
actions.style.flexWrap = 'wrap';
actions.style.justifyContent = 'flex-start';
actions.style.marginLeft = '0';
actions.style.width = '100%';
actions.style.marginTop = '2px';
actions.style.gap = '5px';
actions.appendChild(addBtn);
actions.appendChild(createConnectionTestControls(d));
li.appendChild(info);
li.appendChild(actions);
ul.appendChild(li);
}
return ul;
}
/**
* Filter devices by connection type and search query
* @param devices Discovered devices array
* @param connectionType Filter: 'all' | 'ble' | 'api' | 'both' | 'ir'
* @param searchQuery Search term to match against name/id/type
* @returns Filtered devices array
*/
export function filterDevices(devices, connectionType = 'all', searchQuery = '') {
let filtered = [...devices];
// Filter by connection type
if (connectionType !== 'all') {
filtered = filtered.filter((d) => {
if (connectionType === 'ir') {
return d.isIR === true;
}
if (connectionType === 'ble') {
return d.connectionType === 'BLE' || d.connectionType?.includes('BLE');
}
if (connectionType === 'api') {
return d.connectionType === 'OpenAPI' || d.connectionType === 'API' || d.connectionType?.includes('API');
}
if (connectionType === 'both') {
return d.connectionType === 'Both' || d.connectionType?.includes('Both');
}
return true;
});
}
// Filter by search query
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter((d) => {
const name = (d.name || '').toLowerCase();
const id = (d.id || '').toLowerCase();
const type = (d.type || '').toLowerCase();
const model = (d.model || '').toLowerCase();
return name.includes(query) || id.includes(query) || type.includes(query) || model.includes(query);
});
}
return filtered;
}
/**
* Sort devices by specified criteria
* @param devices Devices array to sort
* @param sortBy Sort criterion: 'name' | 'signal' | 'type' | 'connection'
* @returns Sorted devices array
*/
export function sortDevices(devices, sortBy = 'name') {
const sorted = [...devices];
switch (sortBy) {
case 'signal': {
// Sort by RSSI descending (strongest signal first)
sorted.sort((a, b) => {
const aRssi = a.rssi || 0;
const bRssi = b.rssi || 0;
return bRssi - aRssi; // Descending order (higher is stronger)
});
break;
}
case 'type': {
// Sort by device type alphabetically
sorted.sort((a, b) => {
const aType = (a.type || '').localeCompare(b.type || '');
return aType;
});
break;
}
case 'connection': {
// Sort by connection type: Both > BLE > OpenAPI > Others
const connectionOrder = {
Both: 0,
BLE: 1,
OpenAPI: 2,
API: 2,
Unknown: 3,
};
sorted.sort((a, b) => {
const aOrder = connectionOrder[a.connectionType || 'Unknown'] ?? 3;
const bOrder = connectionOrder[b.connectionType || 'Unknown'] ?? 3;
return aOrder - bOrder;
});
break;
}
case 'name':
default: {
// Sort by name alphabetically
sorted.sort((a, b) => (a.name || a.id || '').localeCompare(b.name || b.id || ''));
break;
}
}
return sorted;
}
/**
* Get filter/sort preferences from localStorage
* @returns Object with current filter and sort preferences
*/
export function getDiscoveryPreferences() {
try {
const stored = localStorage.getItem('discoveryPreferences');
if (stored) {
return JSON.parse(stored);
}
}
catch (_e) {
// Ignore parse errors
}
return {
connectionType: 'all',
sortBy: 'name',
searchQuery: '',
};
}
/**
* Save filter/sort preferences to localStorage
* @param preferences Preferences object to save
* @param preferences.connectionType Connection type filter
* @param preferences.sortBy Sort criterion
* @param preferences.searchQuery Search query string
*/
export function setDiscoveryPreferences(preferences) {
try {
localStorage.setItem('discoveryPreferences', JSON.stringify(preferences));
}
catch (_e) {
// Ignore storage errors
}
}
export function renderDeviceList(list) {
const ul = document.getElementById('devices');
const status = document.getElementById('status');
const removeAllContainer = document.getElementById('removeAllContainer');
if (!ul || !status) {
return;
}
if (!list.length) {
status.textContent = 'No devices found in config.';
ul.innerHTML = '';
// Hide remove all button when no devices
if (removeAllContainer) {
removeAllContainer.style.display = 'none';
}
return;
}
status.textContent = `Found ${list.length} device(s)`;
ul.classList.add('device-grid');
ul.style.padding = '0';
ul.innerHTML = '';
// Show remove all button when devices exist
if (removeAllContainer) {
removeAllContainer.style.display = 'block';
}
for (const d of list) {
const li = document.createElement('li');
li.className = 'device-item';
li.setAttribute('data-device-id', normalizeId(d.id));
li.style.display = 'flex';
li.style.flexDirection = 'column';
li.style.alignItems = 'stretch';
li.style.padding = '5px 8px';
li.style.marginBottom = '0';
const info = document.createElement('div');
info.style.flex = '1 1 auto';
info.style.width = '100%';
info.style.minWidth = '0';
const nameContainer = document.createElement('div');
nameContainer.style.display = 'flex';
nameContainer.style.alignItems = 'center';
nameContainer.style.marginBottom = '0';
nameContainer.style.flexWrap = 'wrap';
nameContainer.style.gap = '4px';
const name = document.createElement('div');
name.style.fontWeight = '500';
name.style.fontSize = '13px';
name.textContent = d.configDeviceName || d.name || d.id;
const expandedDetails = document.createElement('div');
expandedDetails.style.display = 'none';
expandedDetails.appendChild(renderDeviceDetailsPanel(d));
const expandBtn = document.createElement('button');
expandBtn.textContent = '▾';
expandBtn.title = 'Show details';
expandBtn.style.padding = '2px 6px';
expandBtn.style.fontSize = '11px';
expandBtn.style.marginLeft = '4px';
expandBtn.style.background = '#e5e7eb';
expandBtn.style.color = '#111827';
expandBtn.style.transition = 'transform 0.2s ease';
expandBtn.onclick = () => {
const isHidden = expandedDetails.style.display === 'none';
expandedDetails.style.display = isHidden ? 'block' : 'none';
expandBtn.style.transform = isHidden ? 'rotate(180deg)' : 'rotate(0deg)';
};
nameContainer.appendChild(name);
nameContainer.appendChild(expandBtn);
// Add signal strength visualization if RSSI is available
if (d.rssi !== undefined && d.rssi !== null && d.rssi !== 0) {
nameContainer.appendChild(renderSignalBars(d.rssi));
nameContainer.appendChild(renderSignalQualityBadge(d.rssi));
}
const meta = document.createElement('div');
meta.style.opacity = '0.7';
meta.style.fontSize = '10px';
meta.style.fontFamily = 'monospace';
const deviceIdentifier = d.deviceId || d.id;
const id = `ID: ${deviceIdentifier}`;
const typeText = d.configDeviceType || d.type ? `Type: ${d.configDeviceType || d.type}` : '';
const connText = d.connectionPreference ? `Conn: ${d.connectionPreference}` : '';
const roomText = d.room ? `Room: ${d.room}` : '';
meta.textContent = [id, typeText, connText, roomText].filter(Boolean).join(' | ');
info.appendChild(nameContainer);
info.appendChild(meta);
info.appendChild(expandedDetails);
const buttons = document.createElement('div');
buttons.className = 'device-actions';
buttons.style.display = 'flex';
buttons.style.flexWrap = 'wrap';
buttons.style.justifyContent = 'flex-start';
buttons.style.marginLeft = '0';
buttons.style.width = '100%';
buttons.style.marginTop = '2px';
buttons.style.gap = '5px';
const editBtn = document.createElement('button');
editBtn.textContent = '✏️ Edit';
editBtn.style.padding = '4px 9px';
editBtn.style.fontSize = '11px';
editBtn.onclick = async () => {
const { editDevice } = await import('./modals.js');
await editDevice(d);
};
const copyBtn = document.createElement('button');
copyBtn.textContent = 'Copy ID';
copyBtn.style.padding = '4px 9px';
copyBtn.style.fontSize = '11px';
copyBtn.addEventListener('click', async () => {
try {
if (deviceIdentifier) {
await copyTextWithFallback(deviceIdentifier);
}
copyBtn.textContent = 'Copied';
copyBtn.classList.add('success');
setTimeout(() => {
copyBtn.textContent = 'Copy ID';
copyBtn.classList.remove('success');
}, 1200);
}
catch (e) {
copyBtn.textContent = 'Failed';
copyBtn.classList.add('error');
setTimeout(() => {
copyBtn.textContent = 'Copy ID';
copyBtn.classList.remove('error');
}, 1200);
}
});
const deleteBtn = document.createElement('button');
deleteBtn.textContent = '🗑️ Delete';
deleteBtn.style.padding = '4px 9px';
deleteBtn.style.fontSize = '11px';
deleteBtn.style.background = '#ef4444';
deleteBtn.onclick = async () => {
const { deleteDeviceFromConfig } = await import('./devices-delete.js');
await deleteDeviceFromConfig(d.id || d.deviceId, d.name || d.id || d.deviceId);
};
buttons.appendChild(editBtn);
buttons.appendChild(copyBtn);
buttons.appendChild(deleteBtn);
buttons.appendChild(createConnectionTestControls(d));
li.appendChild(info);
li.appendChild(buttons);
ul.appendChild(li);
}
// No return value needed for void function
}
//# sourceMappingURL=render.js.map