lifx-emulator 3.0.1__py3-none-any.whl → 3.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {lifx_emulator-3.0.1.dist-info → lifx_emulator-3.1.0.dist-info}/METADATA +3 -2
- {lifx_emulator-3.0.1.dist-info → lifx_emulator-3.1.0.dist-info}/RECORD +7 -6
- {lifx_emulator-3.0.1.dist-info → lifx_emulator-3.1.0.dist-info}/WHEEL +1 -1
- lifx_emulator_app/api/app.py +6 -1
- lifx_emulator_app/api/static/dashboard.js +588 -0
- lifx_emulator_app/api/templates/dashboard.html +1 -543
- {lifx_emulator-3.0.1.dist-info → lifx_emulator-3.1.0.dist-info}/entry_points.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lifx-emulator
|
|
3
|
-
Version: 3.0
|
|
3
|
+
Version: 3.1.0
|
|
4
4
|
Summary: Standalone LIFX Emulator with CLI and HTTP management API
|
|
5
5
|
Author-email: Avi Miller <me@dje.li>
|
|
6
6
|
Maintainer-email: Avi Miller <me@dje.li>
|
|
@@ -12,13 +12,14 @@ Classifier: Framework :: Pytest
|
|
|
12
12
|
Classifier: Intended Audience :: Developers
|
|
13
13
|
Classifier: Natural Language :: English
|
|
14
14
|
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
16
|
Classifier: Programming Language :: Python :: 3.11
|
|
16
17
|
Classifier: Programming Language :: Python :: 3.12
|
|
17
18
|
Classifier: Programming Language :: Python :: 3.13
|
|
18
19
|
Classifier: Programming Language :: Python :: 3.14
|
|
19
20
|
Classifier: Topic :: Software Development :: Testing
|
|
20
21
|
Classifier: Typing :: Typed
|
|
21
|
-
Requires-Python: >=3.
|
|
22
|
+
Requires-Python: >=3.10
|
|
22
23
|
Requires-Dist: cyclopts>=4.2.0
|
|
23
24
|
Requires-Dist: fastapi>=0.115.0
|
|
24
25
|
Requires-Dist: lifx-emulator-core>=2.4.0
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
lifx_emulator_app/__init__.py,sha256=AJahGiWKb8U8yLQbJX21takbf-SoxDxMOGxjJeM7M5c,222
|
|
2
2
|
lifx_emulator_app/__main__.py,sha256=f_VwgZfwOdp3zQshv8JQqBX7gocE9ATZbZ3wQndlBvo,22060
|
|
3
3
|
lifx_emulator_app/api/__init__.py,sha256=bpdhugx7PAop5IQhkKnWE-a1hfI9gJoJiNRG2CCWe5A,651
|
|
4
|
-
lifx_emulator_app/api/app.py,sha256=
|
|
4
|
+
lifx_emulator_app/api/app.py,sha256=QyHSzb0BYk341PlWLIcgHJHOYnFetcG0FQsVlVY_OX8,5126
|
|
5
5
|
lifx_emulator_app/api/models.py,sha256=fiX9hDmR1C12tzet-kGPmbaG_qiZfiprgy-uxwIcEsE,3970
|
|
6
6
|
lifx_emulator_app/api/mappers/__init__.py,sha256=-lGAg-s16eTMl2_D-3bPu-EMqD2kaPzXHqSrKCnmW2w,156
|
|
7
7
|
lifx_emulator_app/api/mappers/device_mapper.py,sha256=WAo-_PJ2kX3J4GUW_Sjoype1d_uaIh-PtXPfMUAujUY,4155
|
|
@@ -11,8 +11,9 @@ lifx_emulator_app/api/routers/monitoring.py,sha256=i82_s61caYd9UvMb4MqWPLP7LuFh5
|
|
|
11
11
|
lifx_emulator_app/api/routers/scenarios.py,sha256=pWXTliY9MIk-DCxDOZ1cjeAFgHn79ExvY-6lj-yHPWk,9747
|
|
12
12
|
lifx_emulator_app/api/services/__init__.py,sha256=cdKZItYE-KkMX44V9xJW_PHHnJoAw5I7Uw1a1YKHgMI,285
|
|
13
13
|
lifx_emulator_app/api/services/device_service.py,sha256=A2rCuZ1aAJ1tThKM6BYorKAjlaDgDaFCT5C9KUHfAvc,6303
|
|
14
|
-
lifx_emulator_app/api/
|
|
15
|
-
|
|
16
|
-
lifx_emulator-3.0.
|
|
17
|
-
lifx_emulator-3.0.
|
|
18
|
-
lifx_emulator-3.0.
|
|
14
|
+
lifx_emulator_app/api/static/dashboard.js,sha256=eJOtBzTLRPYmMVuft5GC8r7Ae6x_JWZs9nqrmKaOILA,20177
|
|
15
|
+
lifx_emulator_app/api/templates/dashboard.html,sha256=6vqMpsAtCBXASHOLnXn3_uZ-U5r7-P3FPo8_NJiMitk,10194
|
|
16
|
+
lifx_emulator-3.1.0.dist-info/METADATA,sha256=hOg5mr27_HE83CfaYyjaF_5N4BClqSa3iYQ8kLsr2ec,3225
|
|
17
|
+
lifx_emulator-3.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
18
|
+
lifx_emulator-3.1.0.dist-info/entry_points.txt,sha256=tNZHeJTPUXNxu_nuk99ArXLKgwYLhIVVxN7YiaiXBOA,66
|
|
19
|
+
lifx_emulator-3.1.0.dist-info/RECORD,,
|
lifx_emulator_app/api/app.py
CHANGED
|
@@ -14,6 +14,7 @@ from typing import TYPE_CHECKING
|
|
|
14
14
|
|
|
15
15
|
from fastapi import FastAPI, Request
|
|
16
16
|
from fastapi.responses import HTMLResponse
|
|
17
|
+
from fastapi.staticfiles import StaticFiles
|
|
17
18
|
from fastapi.templating import Jinja2Templates
|
|
18
19
|
|
|
19
20
|
if TYPE_CHECKING:
|
|
@@ -25,8 +26,9 @@ from lifx_emulator_app.api.routers.scenarios import create_scenarios_router
|
|
|
25
26
|
|
|
26
27
|
logger = logging.getLogger(__name__)
|
|
27
28
|
|
|
28
|
-
#
|
|
29
|
+
# Asset directories for web UI
|
|
29
30
|
TEMPLATES_DIR = Path(__file__).parent / "templates"
|
|
31
|
+
STATIC_DIR = Path(__file__).parent / "static"
|
|
30
32
|
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
|
31
33
|
|
|
32
34
|
|
|
@@ -102,6 +104,9 @@ The API is organized into three main routers:
|
|
|
102
104
|
"""Serve embedded web UI dashboard."""
|
|
103
105
|
return templates.TemplateResponse(request, "dashboard.html")
|
|
104
106
|
|
|
107
|
+
# Mount static files for JS/CSS assets (cached by browsers)
|
|
108
|
+
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|
109
|
+
|
|
105
110
|
# Include routers with server dependency injection
|
|
106
111
|
monitoring_router = create_monitoring_router(server)
|
|
107
112
|
devices_router = create_devices_router(server)
|
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LIFX Emulator Dashboard
|
|
3
|
+
* Real-time monitoring and device management interface
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
let updateInterval;
|
|
9
|
+
|
|
10
|
+
// DOM helper: create element with optional class and text
|
|
11
|
+
function createElement(tag, className, textContent) {
|
|
12
|
+
const el = document.createElement(tag);
|
|
13
|
+
if (className) el.className = className;
|
|
14
|
+
if (textContent !== undefined) el.textContent = textContent;
|
|
15
|
+
return el;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// DOM helper: create element with style
|
|
19
|
+
function createStyledElement(tag, style, textContent) {
|
|
20
|
+
const el = document.createElement(tag);
|
|
21
|
+
if (style) el.style.cssText = style;
|
|
22
|
+
if (textContent !== undefined) el.textContent = textContent;
|
|
23
|
+
return el;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Convert HSBK to RGB for display
|
|
27
|
+
function hsbkToRgb(hsbk) {
|
|
28
|
+
const h = hsbk.hue / 65535;
|
|
29
|
+
const s = hsbk.saturation / 65535;
|
|
30
|
+
const v = hsbk.brightness / 65535;
|
|
31
|
+
|
|
32
|
+
let r, g, b;
|
|
33
|
+
const i = Math.floor(h * 6);
|
|
34
|
+
const f = h * 6 - i;
|
|
35
|
+
const p = v * (1 - s);
|
|
36
|
+
const q = v * (1 - f * s);
|
|
37
|
+
const t = v * (1 - (1 - f) * s);
|
|
38
|
+
|
|
39
|
+
switch (i % 6) {
|
|
40
|
+
case 0: r = v; g = t; b = p; break;
|
|
41
|
+
case 1: r = q; g = v; b = p; break;
|
|
42
|
+
case 2: r = p; g = v; b = t; break;
|
|
43
|
+
case 3: r = p; g = q; b = v; break;
|
|
44
|
+
case 4: r = t; g = p; b = v; break;
|
|
45
|
+
case 5: r = v; g = p; b = q; break;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const red = Math.round(r * 255);
|
|
49
|
+
const green = Math.round(g * 255);
|
|
50
|
+
const blue = Math.round(b * 255);
|
|
51
|
+
return `rgb(${red}, ${green}, ${blue})`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function toggleZones(serial) {
|
|
55
|
+
const element = document.getElementById(`zones-${serial}`);
|
|
56
|
+
const toggle = document.getElementById(`zones-toggle-${serial}`);
|
|
57
|
+
if (element && toggle) {
|
|
58
|
+
const isShown = element.classList.toggle('show');
|
|
59
|
+
// Update toggle icon
|
|
60
|
+
toggle.textContent = isShown
|
|
61
|
+
? toggle.textContent.replace('▸', '▾')
|
|
62
|
+
: toggle.textContent.replace('▾', '▸');
|
|
63
|
+
// Save state to localStorage
|
|
64
|
+
localStorage.setItem(`zones-${serial}`, isShown ? 'show' : 'hide');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function toggleMetadata(serial) {
|
|
69
|
+
const element = document.getElementById(`metadata-${serial}`);
|
|
70
|
+
const toggle = document.getElementById(`metadata-toggle-${serial}`);
|
|
71
|
+
if (element && toggle) {
|
|
72
|
+
const isShown = element.classList.toggle('show');
|
|
73
|
+
// Update toggle icon
|
|
74
|
+
toggle.textContent = isShown
|
|
75
|
+
? toggle.textContent.replace('▸', '▾')
|
|
76
|
+
: toggle.textContent.replace('▾', '▸');
|
|
77
|
+
// Save state to localStorage
|
|
78
|
+
localStorage.setItem(`metadata-${serial}`, isShown ? 'show' : 'hide');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function restoreToggleStates(serial) {
|
|
83
|
+
// Restore zones toggle state
|
|
84
|
+
const zonesState = localStorage.getItem(`zones-${serial}`);
|
|
85
|
+
if (zonesState === 'show') {
|
|
86
|
+
const element = document.getElementById(`zones-${serial}`);
|
|
87
|
+
const toggle = document.getElementById(`zones-toggle-${serial}`);
|
|
88
|
+
if (element && toggle) {
|
|
89
|
+
element.classList.add('show');
|
|
90
|
+
toggle.textContent = toggle.textContent.replace('▸', '▾');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Restore metadata toggle state
|
|
95
|
+
const metadataState = localStorage.getItem(`metadata-${serial}`);
|
|
96
|
+
if (metadataState === 'show') {
|
|
97
|
+
const element = document.getElementById(`metadata-${serial}`);
|
|
98
|
+
const toggle = document.getElementById(`metadata-toggle-${serial}`);
|
|
99
|
+
if (element && toggle) {
|
|
100
|
+
element.classList.add('show');
|
|
101
|
+
toggle.textContent = toggle.textContent.replace('▸', '▾');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Create a stat display element
|
|
107
|
+
function createStatElement(label, value) {
|
|
108
|
+
const stat = createElement('div', 'stat');
|
|
109
|
+
stat.appendChild(createElement('span', 'stat-label', label));
|
|
110
|
+
stat.appendChild(createElement('span', 'stat-value', String(value)));
|
|
111
|
+
return stat;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function fetchStats() {
|
|
115
|
+
const statsContainer = document.getElementById('stats');
|
|
116
|
+
try {
|
|
117
|
+
const response = await fetch('/api/stats');
|
|
118
|
+
if (!response.ok) {
|
|
119
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
120
|
+
}
|
|
121
|
+
const stats = await response.json();
|
|
122
|
+
|
|
123
|
+
const uptimeValue = Math.floor(stats.uptime_seconds);
|
|
124
|
+
|
|
125
|
+
// Clear and rebuild stats using DOM APIs
|
|
126
|
+
statsContainer.textContent = '';
|
|
127
|
+
statsContainer.appendChild(createStatElement('Uptime', uptimeValue + 's'));
|
|
128
|
+
statsContainer.appendChild(createStatElement('Devices', stats.device_count));
|
|
129
|
+
statsContainer.appendChild(createStatElement('Packets RX', stats.packets_received));
|
|
130
|
+
statsContainer.appendChild(createStatElement('Packets TX', stats.packets_sent));
|
|
131
|
+
statsContainer.appendChild(createStatElement('Errors', stats.error_count));
|
|
132
|
+
|
|
133
|
+
// Show/hide activity log based on server configuration
|
|
134
|
+
const activityCard = document.getElementById('activity-card');
|
|
135
|
+
if (activityCard) {
|
|
136
|
+
const displayValue = (
|
|
137
|
+
stats.activity_enabled ? 'block' : 'none'
|
|
138
|
+
);
|
|
139
|
+
activityCard.style.display = displayValue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return stats.activity_enabled;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
console.error('Failed to fetch stats:', error);
|
|
145
|
+
|
|
146
|
+
// Clear and show error using DOM APIs
|
|
147
|
+
statsContainer.textContent = '';
|
|
148
|
+
const stat = createElement('div', 'stat');
|
|
149
|
+
const label = createElement('span', 'stat-label', 'Error loading stats');
|
|
150
|
+
label.style.color = '#d32f2f';
|
|
151
|
+
stat.appendChild(label);
|
|
152
|
+
stat.appendChild(createElement('span', 'stat-value', error.message));
|
|
153
|
+
statsContainer.appendChild(stat);
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Create a metadata row element
|
|
159
|
+
function createMetadataRow(label, value, valueStyle) {
|
|
160
|
+
const row = createElement('div', 'metadata-row');
|
|
161
|
+
row.appendChild(createElement('span', 'metadata-label', label));
|
|
162
|
+
const valueEl = createElement('span', 'metadata-value', value);
|
|
163
|
+
if (valueStyle) valueEl.style.cssText = valueStyle;
|
|
164
|
+
row.appendChild(valueEl);
|
|
165
|
+
return row;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Create a badge element
|
|
169
|
+
function createBadge(text, badgeClass) {
|
|
170
|
+
return createElement('span', `badge ${badgeClass}`, text);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Create zone segment element for multizone strips
|
|
174
|
+
function createZoneSegment(color) {
|
|
175
|
+
const segment = createElement('div', 'zone-segment');
|
|
176
|
+
segment.style.background = hsbkToRgb(color);
|
|
177
|
+
return segment;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Create tile zone element for matrix devices
|
|
181
|
+
function createTileZone(color) {
|
|
182
|
+
const zone = createElement('div', 'tile-zone');
|
|
183
|
+
zone.style.background = hsbkToRgb(color);
|
|
184
|
+
return zone;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Build metadata section for a device
|
|
188
|
+
function buildMetadataSection(dev) {
|
|
189
|
+
const container = document.createDocumentFragment();
|
|
190
|
+
|
|
191
|
+
// Metadata toggle
|
|
192
|
+
const toggle = createElement('div', 'metadata-toggle', '▸ Show metadata');
|
|
193
|
+
toggle.id = `metadata-toggle-${dev.serial}`;
|
|
194
|
+
toggle.addEventListener('click', () => toggleMetadata(dev.serial));
|
|
195
|
+
container.appendChild(toggle);
|
|
196
|
+
|
|
197
|
+
// Metadata display
|
|
198
|
+
const display = createElement('div', 'metadata-display');
|
|
199
|
+
display.id = `metadata-${dev.serial}`;
|
|
200
|
+
|
|
201
|
+
const uptimeSeconds = Math.floor(dev.uptime_ns / 1e9);
|
|
202
|
+
const firmware = `${dev.version_major}.${dev.version_minor}`;
|
|
203
|
+
|
|
204
|
+
// Build capabilities text
|
|
205
|
+
const capabilitiesMetadata = [];
|
|
206
|
+
if (dev.has_color) capabilitiesMetadata.push('Color');
|
|
207
|
+
if (dev.has_infrared) capabilitiesMetadata.push('Infrared');
|
|
208
|
+
if (dev.has_multizone) {
|
|
209
|
+
capabilitiesMetadata.push(`Multizone (${dev.zone_count} zones)`);
|
|
210
|
+
}
|
|
211
|
+
if (dev.has_extended_multizone) {
|
|
212
|
+
capabilitiesMetadata.push('Extended Multizone');
|
|
213
|
+
}
|
|
214
|
+
if (dev.has_matrix) {
|
|
215
|
+
capabilitiesMetadata.push(`Matrix (${dev.tile_count} tiles)`);
|
|
216
|
+
}
|
|
217
|
+
if (dev.has_hev) capabilitiesMetadata.push('HEV/Clean');
|
|
218
|
+
const capabilitiesText = capabilitiesMetadata.join(', ') || 'None';
|
|
219
|
+
|
|
220
|
+
display.appendChild(createMetadataRow('Firmware:', firmware));
|
|
221
|
+
display.appendChild(createMetadataRow('Vendor:', String(dev.vendor)));
|
|
222
|
+
display.appendChild(createMetadataRow('Product:', String(dev.product)));
|
|
223
|
+
display.appendChild(
|
|
224
|
+
createMetadataRow('Capabilities:', capabilitiesText, 'color: #4a9eff;')
|
|
225
|
+
);
|
|
226
|
+
display.appendChild(createMetadataRow('Group:', dev.group_label));
|
|
227
|
+
display.appendChild(createMetadataRow('Location:', dev.location_label));
|
|
228
|
+
display.appendChild(createMetadataRow('Uptime:', `${uptimeSeconds}s`));
|
|
229
|
+
display.appendChild(
|
|
230
|
+
createMetadataRow('WiFi Signal:', `${dev.wifi_signal.toFixed(1)} dBm`)
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
container.appendChild(display);
|
|
234
|
+
return container;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Build zones/tiles section for a device
|
|
238
|
+
function buildZonesSection(dev) {
|
|
239
|
+
const container = document.createDocumentFragment();
|
|
240
|
+
|
|
241
|
+
if (dev.has_multizone && dev.zone_colors && dev.zone_colors.length > 0) {
|
|
242
|
+
// Multizone strip display
|
|
243
|
+
const toggle = createElement(
|
|
244
|
+
'div', 'zones-toggle',
|
|
245
|
+
`▸ Show zones (${dev.zone_colors.length})`
|
|
246
|
+
);
|
|
247
|
+
toggle.id = `zones-toggle-${dev.serial}`;
|
|
248
|
+
toggle.addEventListener('click', () => toggleZones(dev.serial));
|
|
249
|
+
container.appendChild(toggle);
|
|
250
|
+
|
|
251
|
+
const display = createElement('div', 'zones-display');
|
|
252
|
+
display.id = `zones-${dev.serial}`;
|
|
253
|
+
const strip = createElement('div', 'zone-strip');
|
|
254
|
+
dev.zone_colors.forEach(color => {
|
|
255
|
+
strip.appendChild(createZoneSegment(color));
|
|
256
|
+
});
|
|
257
|
+
display.appendChild(strip);
|
|
258
|
+
container.appendChild(display);
|
|
259
|
+
|
|
260
|
+
} else if (dev.has_matrix && dev.tile_devices &&
|
|
261
|
+
dev.tile_devices.length > 0) {
|
|
262
|
+
// Tile matrix display
|
|
263
|
+
const toggle = createElement(
|
|
264
|
+
'div', 'zones-toggle',
|
|
265
|
+
`▸ Show tiles (${dev.tile_devices.length})`
|
|
266
|
+
);
|
|
267
|
+
toggle.id = `zones-toggle-${dev.serial}`;
|
|
268
|
+
toggle.addEventListener('click', () => toggleZones(dev.serial));
|
|
269
|
+
container.appendChild(toggle);
|
|
270
|
+
|
|
271
|
+
const display = createElement('div', 'zones-display');
|
|
272
|
+
display.id = `zones-${dev.serial}`;
|
|
273
|
+
const tilesContainer = createElement('div', 'tiles-container');
|
|
274
|
+
|
|
275
|
+
dev.tile_devices.forEach((tile, tileIndex) => {
|
|
276
|
+
const tileItem = createElement('div', 'tile-item');
|
|
277
|
+
|
|
278
|
+
// Tile label
|
|
279
|
+
const label = createStyledElement(
|
|
280
|
+
'div',
|
|
281
|
+
'font-size: 0.7em; color: #666; margin-bottom: 2px; text-align: center;',
|
|
282
|
+
`T${tileIndex + 1}`
|
|
283
|
+
);
|
|
284
|
+
tileItem.appendChild(label);
|
|
285
|
+
|
|
286
|
+
if (!tile.colors || tile.colors.length === 0) {
|
|
287
|
+
tileItem.appendChild(
|
|
288
|
+
createStyledElement('div', 'color: #666;', 'No color data')
|
|
289
|
+
);
|
|
290
|
+
} else {
|
|
291
|
+
const width = tile.width || 8;
|
|
292
|
+
const height = tile.height || 8;
|
|
293
|
+
const totalZones = width * height;
|
|
294
|
+
|
|
295
|
+
const grid = createElement('div', 'tile-grid');
|
|
296
|
+
grid.style.gridTemplateColumns = `repeat(${width}, 8px)`;
|
|
297
|
+
|
|
298
|
+
tile.colors.slice(0, totalZones).forEach(color => {
|
|
299
|
+
grid.appendChild(createTileZone(color));
|
|
300
|
+
});
|
|
301
|
+
tileItem.appendChild(grid);
|
|
302
|
+
}
|
|
303
|
+
tilesContainer.appendChild(tileItem);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
display.appendChild(tilesContainer);
|
|
307
|
+
container.appendChild(display);
|
|
308
|
+
|
|
309
|
+
} else if (dev.has_color && dev.color) {
|
|
310
|
+
// Single color swatch
|
|
311
|
+
const wrapper = createStyledElement('div', 'margin-top: 4px;');
|
|
312
|
+
const swatch = createElement('span', 'color-swatch');
|
|
313
|
+
swatch.style.background = hsbkToRgb(dev.color);
|
|
314
|
+
wrapper.appendChild(swatch);
|
|
315
|
+
wrapper.appendChild(
|
|
316
|
+
createStyledElement('span', 'color: #888; font-size: 0.75em;', 'Current color')
|
|
317
|
+
);
|
|
318
|
+
container.appendChild(wrapper);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return container;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Build a complete device card element
|
|
325
|
+
function buildDeviceCard(dev) {
|
|
326
|
+
const device = createElement('div', 'device');
|
|
327
|
+
|
|
328
|
+
// Device header
|
|
329
|
+
const header = createElement('div', 'device-header');
|
|
330
|
+
const headerInfo = createElement('div');
|
|
331
|
+
headerInfo.appendChild(createElement('div', 'device-serial', dev.serial));
|
|
332
|
+
headerInfo.appendChild(createElement('div', 'device-label', dev.label));
|
|
333
|
+
header.appendChild(headerInfo);
|
|
334
|
+
|
|
335
|
+
const deleteBtn = createElement('button', 'btn btn-delete', 'Del');
|
|
336
|
+
deleteBtn.addEventListener('click', () => deleteDevice(dev.serial));
|
|
337
|
+
header.appendChild(deleteBtn);
|
|
338
|
+
device.appendChild(header);
|
|
339
|
+
|
|
340
|
+
// Badges section
|
|
341
|
+
const badgesDiv = createElement('div');
|
|
342
|
+
|
|
343
|
+
// Power badge
|
|
344
|
+
const powerClass = dev.power_level > 0 ? 'badge-power-on' : 'badge-power-off';
|
|
345
|
+
const powerText = dev.power_level > 0 ? 'ON' : 'OFF';
|
|
346
|
+
badgesDiv.appendChild(createBadge(powerText, powerClass));
|
|
347
|
+
|
|
348
|
+
// Product badge
|
|
349
|
+
badgesDiv.appendChild(createBadge(`P${dev.product}`, 'badge-capability'));
|
|
350
|
+
|
|
351
|
+
// Capability badges
|
|
352
|
+
if (dev.has_color) {
|
|
353
|
+
badgesDiv.appendChild(createBadge('color', 'badge-capability'));
|
|
354
|
+
}
|
|
355
|
+
if (dev.has_infrared) {
|
|
356
|
+
badgesDiv.appendChild(createBadge('IR', 'badge-capability'));
|
|
357
|
+
}
|
|
358
|
+
if (dev.has_extended_multizone) {
|
|
359
|
+
badgesDiv.appendChild(
|
|
360
|
+
createBadge(`extended-mz×${dev.zone_count}`, 'badge-extended-mz')
|
|
361
|
+
);
|
|
362
|
+
} else if (dev.has_multizone) {
|
|
363
|
+
badgesDiv.appendChild(
|
|
364
|
+
createBadge(`multizone×${dev.zone_count}`, 'badge-capability')
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
if (dev.has_matrix) {
|
|
368
|
+
badgesDiv.appendChild(
|
|
369
|
+
createBadge(`matrix×${dev.tile_count}`, 'badge-capability')
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
if (dev.has_hev) {
|
|
373
|
+
badgesDiv.appendChild(createBadge('HEV', 'badge-capability'));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
device.appendChild(badgesDiv);
|
|
377
|
+
|
|
378
|
+
// Metadata section
|
|
379
|
+
device.appendChild(buildMetadataSection(dev));
|
|
380
|
+
|
|
381
|
+
// Zones section
|
|
382
|
+
device.appendChild(buildZonesSection(dev));
|
|
383
|
+
|
|
384
|
+
return device;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function fetchDevices() {
|
|
388
|
+
const devicesContainer = document.getElementById('devices');
|
|
389
|
+
try {
|
|
390
|
+
const response = await fetch('/api/devices');
|
|
391
|
+
if (!response.ok) {
|
|
392
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
393
|
+
}
|
|
394
|
+
const devices = await response.json();
|
|
395
|
+
|
|
396
|
+
document.getElementById('device-count').textContent = devices.length;
|
|
397
|
+
|
|
398
|
+
// Clear container
|
|
399
|
+
devicesContainer.textContent = '';
|
|
400
|
+
|
|
401
|
+
if (devices.length === 0) {
|
|
402
|
+
devicesContainer.appendChild(
|
|
403
|
+
createElement('div', 'no-devices', 'No devices emulated')
|
|
404
|
+
);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Build device cards using DOM APIs
|
|
409
|
+
devices.forEach(dev => {
|
|
410
|
+
devicesContainer.appendChild(buildDeviceCard(dev));
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
// Restore toggle states for all devices
|
|
414
|
+
devices.forEach(dev => restoreToggleStates(dev.serial));
|
|
415
|
+
} catch (error) {
|
|
416
|
+
console.error('Failed to fetch devices:', error);
|
|
417
|
+
|
|
418
|
+
// Clear and show error using DOM APIs
|
|
419
|
+
devicesContainer.textContent = '';
|
|
420
|
+
const errorDiv = createElement('div', 'no-devices', `Error loading devices: ${error.message}`);
|
|
421
|
+
errorDiv.style.color = '#d32f2f';
|
|
422
|
+
devicesContainer.appendChild(errorDiv);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Build an activity item element
|
|
427
|
+
function buildActivityItem(act) {
|
|
428
|
+
const item = createElement('div', 'activity-item');
|
|
429
|
+
|
|
430
|
+
const timestamp = act.timestamp * 1000;
|
|
431
|
+
const time = new Date(timestamp).toLocaleTimeString();
|
|
432
|
+
item.appendChild(createElement('span', 'activity-time', time));
|
|
433
|
+
|
|
434
|
+
const isRx = act.direction === 'rx';
|
|
435
|
+
const dirClass = isRx ? 'activity-rx' : 'activity-tx';
|
|
436
|
+
const dirLabel = isRx ? 'RX' : 'TX';
|
|
437
|
+
item.appendChild(createElement('span', dirClass, dirLabel));
|
|
438
|
+
|
|
439
|
+
item.appendChild(createElement('span', 'activity-packet', act.packet_name));
|
|
440
|
+
|
|
441
|
+
const device = act.device || act.target || 'N/A';
|
|
442
|
+
item.appendChild(createElement('span', 'device-serial', device));
|
|
443
|
+
|
|
444
|
+
item.appendChild(createStyledElement('span', 'color: #666', act.addr));
|
|
445
|
+
|
|
446
|
+
return item;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async function fetchActivity() {
|
|
450
|
+
const logElement = document.getElementById('activity-log');
|
|
451
|
+
try {
|
|
452
|
+
const response = await fetch('/api/activity');
|
|
453
|
+
if (!response.ok) {
|
|
454
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
455
|
+
}
|
|
456
|
+
const activities = await response.json();
|
|
457
|
+
|
|
458
|
+
// Clear container
|
|
459
|
+
logElement.textContent = '';
|
|
460
|
+
|
|
461
|
+
if (activities.length === 0) {
|
|
462
|
+
logElement.appendChild(
|
|
463
|
+
createStyledElement('div', 'color: #666', 'No activity yet')
|
|
464
|
+
);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Build activity items using DOM APIs (reversed order)
|
|
469
|
+
activities.slice().reverse().forEach(act => {
|
|
470
|
+
logElement.appendChild(buildActivityItem(act));
|
|
471
|
+
});
|
|
472
|
+
} catch (error) {
|
|
473
|
+
console.error('Failed to fetch activity:', error);
|
|
474
|
+
|
|
475
|
+
// Clear and show error using DOM APIs
|
|
476
|
+
logElement.textContent = '';
|
|
477
|
+
const errorDiv = createElement('div', null, `Error loading activity: ${error.message}`);
|
|
478
|
+
errorDiv.style.color = '#d32f2f';
|
|
479
|
+
logElement.appendChild(errorDiv);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function deleteDevice(serial) {
|
|
484
|
+
if (!confirm(`Delete device ${serial}?`)) return;
|
|
485
|
+
|
|
486
|
+
const response = await fetch(`/api/devices/${serial}`, {
|
|
487
|
+
method: 'DELETE'
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
if (response.ok) {
|
|
491
|
+
await updateAll();
|
|
492
|
+
} else {
|
|
493
|
+
alert('Failed to delete device');
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async function removeAllDevices() {
|
|
498
|
+
const deviceCount = document.getElementById('device-count').textContent;
|
|
499
|
+
if (deviceCount === '0') {
|
|
500
|
+
alert('No devices to remove');
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const line1 = (
|
|
505
|
+
`Remove all ${deviceCount} device(s) from the server?\n\n`
|
|
506
|
+
);
|
|
507
|
+
const line2 = (
|
|
508
|
+
'This will stop all devices from ' +
|
|
509
|
+
'responding to LIFX protocol packets, '
|
|
510
|
+
);
|
|
511
|
+
const line3 = 'but will not delete persistent storage.';
|
|
512
|
+
const confirmMsg = line1 + line2 + line3;
|
|
513
|
+
if (!confirm(confirmMsg)) return;
|
|
514
|
+
|
|
515
|
+
const response = await fetch('/api/devices', {
|
|
516
|
+
method: 'DELETE'
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
if (response.ok) {
|
|
520
|
+
const result = await response.json();
|
|
521
|
+
alert(result.message);
|
|
522
|
+
await updateAll();
|
|
523
|
+
} else {
|
|
524
|
+
alert('Failed to remove all devices');
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async function clearStorage() {
|
|
529
|
+
const confirmMsg = `Clear all persistent device state from storage?\n\n` +
|
|
530
|
+
`This will permanently delete all saved device state files. ` +
|
|
531
|
+
`Currently running devices will not be affected.\n\n` +
|
|
532
|
+
`This action cannot be undone.`;
|
|
533
|
+
if (!confirm(confirmMsg)) return;
|
|
534
|
+
|
|
535
|
+
const response = await fetch('/api/storage', {
|
|
536
|
+
method: 'DELETE'
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
if (response.ok) {
|
|
540
|
+
const result = await response.json();
|
|
541
|
+
alert(result.message);
|
|
542
|
+
} else if (response.status === 503) {
|
|
543
|
+
alert('Persistent storage is not enabled on this server');
|
|
544
|
+
} else {
|
|
545
|
+
alert('Failed to clear storage');
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function updateAll() {
|
|
550
|
+
const activityEnabled = await fetchStats();
|
|
551
|
+
const tasks = [fetchDevices()];
|
|
552
|
+
if (activityEnabled) {
|
|
553
|
+
tasks.push(fetchActivity());
|
|
554
|
+
}
|
|
555
|
+
await Promise.all(tasks);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Initialize dashboard when DOM is ready
|
|
559
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
560
|
+
// Set up add device form
|
|
561
|
+
const addDeviceForm = document.getElementById('add-device-form');
|
|
562
|
+
addDeviceForm.addEventListener('submit', async (e) => {
|
|
563
|
+
e.preventDefault();
|
|
564
|
+
|
|
565
|
+
const productId = parseInt(document.getElementById('product-id').value);
|
|
566
|
+
|
|
567
|
+
const response = await fetch('/api/devices', {
|
|
568
|
+
method: 'POST',
|
|
569
|
+
headers: {
|
|
570
|
+
'Content-Type': 'application/json'
|
|
571
|
+
},
|
|
572
|
+
body: JSON.stringify({ product_id: productId })
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
if (response.ok) {
|
|
576
|
+
await updateAll();
|
|
577
|
+
} else {
|
|
578
|
+
const error = await response.json();
|
|
579
|
+
alert(`Failed to create device: ${error.detail}`);
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
// Initial load
|
|
584
|
+
updateAll();
|
|
585
|
+
|
|
586
|
+
// Auto-refresh every 2 seconds
|
|
587
|
+
updateInterval = setInterval(updateAll, 2000);
|
|
588
|
+
});
|
|
@@ -352,548 +352,6 @@
|
|
|
352
352
|
</div>
|
|
353
353
|
</div>
|
|
354
354
|
|
|
355
|
-
<script>
|
|
356
|
-
let updateInterval;
|
|
357
|
-
|
|
358
|
-
// Convert HSBK to RGB for display
|
|
359
|
-
function hsbkToRgb(hsbk) {
|
|
360
|
-
const h = hsbk.hue / 65535;
|
|
361
|
-
const s = hsbk.saturation / 65535;
|
|
362
|
-
const v = hsbk.brightness / 65535;
|
|
363
|
-
|
|
364
|
-
let r, g, b;
|
|
365
|
-
const i = Math.floor(h * 6);
|
|
366
|
-
const f = h * 6 - i;
|
|
367
|
-
const p = v * (1 - s);
|
|
368
|
-
const q = v * (1 - f * s);
|
|
369
|
-
const t = v * (1 - (1 - f) * s);
|
|
370
|
-
|
|
371
|
-
switch (i % 6) {
|
|
372
|
-
case 0: r = v; g = t; b = p; break;
|
|
373
|
-
case 1: r = q; g = v; b = p; break;
|
|
374
|
-
case 2: r = p; g = v; b = t; break;
|
|
375
|
-
case 3: r = p; g = q; b = v; break;
|
|
376
|
-
case 4: r = t; g = p; b = v; break;
|
|
377
|
-
case 5: r = v; g = p; b = q; break;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
const red = Math.round(r * 255);
|
|
381
|
-
const green = Math.round(g * 255);
|
|
382
|
-
const blue = Math.round(b * 255);
|
|
383
|
-
return `rgb(${red}, ${green}, ${blue})`;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
function toggleZones(serial) {
|
|
387
|
-
const element = document.getElementById(`zones-${serial}`);
|
|
388
|
-
const toggle = document.getElementById(`zones-toggle-${serial}`);
|
|
389
|
-
if (element && toggle) {
|
|
390
|
-
const isShown = element.classList.toggle('show');
|
|
391
|
-
// Update toggle icon
|
|
392
|
-
toggle.textContent = isShown
|
|
393
|
-
? toggle.textContent.replace('▸', '▾')
|
|
394
|
-
: toggle.textContent.replace('▾', '▸');
|
|
395
|
-
// Save state to localStorage
|
|
396
|
-
localStorage.setItem(`zones-${serial}`, isShown ? 'show' : 'hide');
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
function toggleMetadata(serial) {
|
|
401
|
-
const element = document.getElementById(`metadata-${serial}`);
|
|
402
|
-
const toggle = document.getElementById(`metadata-toggle-${serial}`);
|
|
403
|
-
if (element && toggle) {
|
|
404
|
-
const isShown = element.classList.toggle('show');
|
|
405
|
-
// Update toggle icon
|
|
406
|
-
toggle.textContent = isShown
|
|
407
|
-
? toggle.textContent.replace('▸', '▾')
|
|
408
|
-
: toggle.textContent.replace('▾', '▸');
|
|
409
|
-
// Save state to localStorage
|
|
410
|
-
localStorage.setItem(`metadata-${serial}`, isShown ? 'show' : 'hide');
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
function restoreToggleStates(serial) {
|
|
415
|
-
// Restore zones toggle state
|
|
416
|
-
const zonesState = localStorage.getItem(`zones-${serial}`);
|
|
417
|
-
if (zonesState === 'show') {
|
|
418
|
-
const element = document.getElementById(`zones-${serial}`);
|
|
419
|
-
const toggle = document.getElementById(`zones-toggle-${serial}`);
|
|
420
|
-
if (element && toggle) {
|
|
421
|
-
element.classList.add('show');
|
|
422
|
-
toggle.textContent = toggle.textContent.replace('▸', '▾');
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
// Restore metadata toggle state
|
|
427
|
-
const metadataState = localStorage.getItem(`metadata-${serial}`);
|
|
428
|
-
if (metadataState === 'show') {
|
|
429
|
-
const element = document.getElementById(`metadata-${serial}`);
|
|
430
|
-
const toggle = document.getElementById(`metadata-toggle-${serial}`);
|
|
431
|
-
if (element && toggle) {
|
|
432
|
-
element.classList.add('show');
|
|
433
|
-
toggle.textContent = toggle.textContent.replace('▸', '▾');
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
async function fetchStats() {
|
|
439
|
-
try {
|
|
440
|
-
const response = await fetch('/api/stats');
|
|
441
|
-
if (!response.ok) {
|
|
442
|
-
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
443
|
-
}
|
|
444
|
-
const stats = await response.json();
|
|
445
|
-
|
|
446
|
-
const uptimeValue = Math.floor(stats.uptime_seconds);
|
|
447
|
-
const statsHtml = `
|
|
448
|
-
<div class="stat">
|
|
449
|
-
<span class="stat-label">Uptime</span>
|
|
450
|
-
<span class="stat-value">${uptimeValue}s</span>
|
|
451
|
-
</div>
|
|
452
|
-
<div class="stat">
|
|
453
|
-
<span class="stat-label">Devices</span>
|
|
454
|
-
<span class="stat-value">${stats.device_count}</span>
|
|
455
|
-
</div>
|
|
456
|
-
<div class="stat">
|
|
457
|
-
<span class="stat-label">Packets RX</span>
|
|
458
|
-
<span class="stat-value">${stats.packets_received}</span>
|
|
459
|
-
</div>
|
|
460
|
-
<div class="stat">
|
|
461
|
-
<span class="stat-label">Packets TX</span>
|
|
462
|
-
<span class="stat-value">${stats.packets_sent}</span>
|
|
463
|
-
</div>
|
|
464
|
-
<div class="stat">
|
|
465
|
-
<span class="stat-label">Errors</span>
|
|
466
|
-
<span class="stat-value">${stats.error_count}</span>
|
|
467
|
-
</div>
|
|
468
|
-
`;
|
|
469
|
-
document.getElementById('stats').innerHTML = statsHtml;
|
|
470
|
-
|
|
471
|
-
// Show/hide activity log based on server configuration
|
|
472
|
-
const activityCard = document.getElementById('activity-card');
|
|
473
|
-
if (activityCard) {
|
|
474
|
-
const displayValue = (
|
|
475
|
-
stats.activity_enabled ? 'block' : 'none'
|
|
476
|
-
);
|
|
477
|
-
activityCard.style.display = displayValue;
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
return stats.activity_enabled;
|
|
481
|
-
} catch (error) {
|
|
482
|
-
console.error('Failed to fetch stats:', error);
|
|
483
|
-
const errorLabelStyle = 'color: #d32f2f;';
|
|
484
|
-
const errorHtml = `
|
|
485
|
-
<div class="stat">
|
|
486
|
-
<span class="stat-label" style="${errorLabelStyle}">
|
|
487
|
-
Error loading stats
|
|
488
|
-
</span>
|
|
489
|
-
<span class="stat-value">${error.message}</span>
|
|
490
|
-
</div>
|
|
491
|
-
`;
|
|
492
|
-
document.getElementById('stats').innerHTML = errorHtml;
|
|
493
|
-
return false;
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
async function fetchDevices() {
|
|
498
|
-
try {
|
|
499
|
-
const response = await fetch('/api/devices');
|
|
500
|
-
if (!response.ok) {
|
|
501
|
-
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
502
|
-
}
|
|
503
|
-
const devices = await response.json();
|
|
504
|
-
|
|
505
|
-
document.getElementById('device-count').textContent = devices.length;
|
|
506
|
-
|
|
507
|
-
if (devices.length === 0) {
|
|
508
|
-
const noDevicesHtml = (
|
|
509
|
-
'<div class="no-devices">No devices emulated</div>'
|
|
510
|
-
);
|
|
511
|
-
document.getElementById('devices').innerHTML = noDevicesHtml;
|
|
512
|
-
return;
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
const devicesHtml = devices.map(dev => {
|
|
516
|
-
const capabilities = [];
|
|
517
|
-
const capabilityBadges = [];
|
|
518
|
-
|
|
519
|
-
if (dev.has_color) capabilities.push('color');
|
|
520
|
-
if (dev.has_infrared) capabilities.push('IR');
|
|
521
|
-
|
|
522
|
-
// Show extended-mz badge instead of multizone when both are present
|
|
523
|
-
if (dev.has_extended_multizone) {
|
|
524
|
-
const badgeHtml = (
|
|
525
|
-
'<span class="badge badge-extended-mz">' +
|
|
526
|
-
`extended-mz×${dev.zone_count}</span>`
|
|
527
|
-
);
|
|
528
|
-
capabilityBadges.push(badgeHtml);
|
|
529
|
-
} else if (dev.has_multizone) {
|
|
530
|
-
capabilities.push(`multizone×${dev.zone_count}`);
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
if (dev.has_matrix) capabilities.push(`matrix×${dev.tile_count}`);
|
|
534
|
-
if (dev.has_hev) capabilities.push('HEV');
|
|
535
|
-
|
|
536
|
-
const powerBadge = dev.power_level > 0
|
|
537
|
-
? '<span class="badge badge-power-on">ON</span>'
|
|
538
|
-
: '<span class="badge badge-power-off">OFF</span>';
|
|
539
|
-
|
|
540
|
-
// Generate capabilities list for metadata
|
|
541
|
-
const capabilitiesMetadata = [];
|
|
542
|
-
if (dev.has_color) capabilitiesMetadata.push('Color');
|
|
543
|
-
if (dev.has_infrared) {
|
|
544
|
-
capabilitiesMetadata.push('Infrared');
|
|
545
|
-
}
|
|
546
|
-
if (dev.has_multizone) {
|
|
547
|
-
capabilitiesMetadata.push(
|
|
548
|
-
`Multizone (${dev.zone_count} zones)`
|
|
549
|
-
);
|
|
550
|
-
}
|
|
551
|
-
if (dev.has_extended_multizone) {
|
|
552
|
-
capabilitiesMetadata.push('Extended Multizone');
|
|
553
|
-
}
|
|
554
|
-
if (dev.has_matrix) {
|
|
555
|
-
capabilitiesMetadata.push(
|
|
556
|
-
`Matrix (${dev.tile_count} tiles)`
|
|
557
|
-
);
|
|
558
|
-
}
|
|
559
|
-
if (dev.has_hev) capabilitiesMetadata.push('HEV/Clean');
|
|
560
|
-
const capabilitiesText = (
|
|
561
|
-
capabilitiesMetadata.join(', ') || 'None'
|
|
562
|
-
);
|
|
563
|
-
|
|
564
|
-
// Generate metadata display
|
|
565
|
-
const uptimeSeconds = Math.floor(dev.uptime_ns / 1e9);
|
|
566
|
-
const metaToggleId = `metadata-toggle-${dev.serial}`;
|
|
567
|
-
const metaToggleClick = `toggleMetadata('${dev.serial}')`;
|
|
568
|
-
const metadataHtml = `
|
|
569
|
-
<div
|
|
570
|
-
class="metadata-toggle"
|
|
571
|
-
id="${metaToggleId}"
|
|
572
|
-
onclick="${metaToggleClick}"
|
|
573
|
-
>
|
|
574
|
-
▸ Show metadata
|
|
575
|
-
</div>
|
|
576
|
-
<div id="metadata-${dev.serial}" class="metadata-display">
|
|
577
|
-
<div class="metadata-row">
|
|
578
|
-
<span class="metadata-label">Firmware:</span>
|
|
579
|
-
<span class="metadata-value">
|
|
580
|
-
${dev.version_major}.${dev.version_minor}
|
|
581
|
-
</span>
|
|
582
|
-
</div>
|
|
583
|
-
<div class="metadata-row">
|
|
584
|
-
<span class="metadata-label">Vendor:</span>
|
|
585
|
-
<span class="metadata-value">${dev.vendor}</span>
|
|
586
|
-
</div>
|
|
587
|
-
<div class="metadata-row">
|
|
588
|
-
<span class="metadata-label">Product:</span>
|
|
589
|
-
<span class="metadata-value">${dev.product}</span>
|
|
590
|
-
</div>
|
|
591
|
-
<div class="metadata-row">
|
|
592
|
-
<span class="metadata-label">Capabilities:</span>
|
|
593
|
-
<span
|
|
594
|
-
class="metadata-value"
|
|
595
|
-
style="color: #4a9eff;"
|
|
596
|
-
>${capabilitiesText}</span>
|
|
597
|
-
</div>
|
|
598
|
-
<div class="metadata-row">
|
|
599
|
-
<span class="metadata-label">Group:</span>
|
|
600
|
-
<span class="metadata-value">${dev.group_label}</span>
|
|
601
|
-
</div>
|
|
602
|
-
<div class="metadata-row">
|
|
603
|
-
<span class="metadata-label">Location:</span>
|
|
604
|
-
<span class="metadata-value">${dev.location_label}</span>
|
|
605
|
-
</div>
|
|
606
|
-
<div class="metadata-row">
|
|
607
|
-
<span class="metadata-label">Uptime:</span>
|
|
608
|
-
<span class="metadata-value">${uptimeSeconds}s</span>
|
|
609
|
-
</div>
|
|
610
|
-
<div class="metadata-row">
|
|
611
|
-
<span class="metadata-label">WiFi Signal:</span>
|
|
612
|
-
<span class="metadata-value">
|
|
613
|
-
${dev.wifi_signal.toFixed(1)} dBm
|
|
614
|
-
</span>
|
|
615
|
-
</div>
|
|
616
|
-
</div>
|
|
617
|
-
`;
|
|
618
|
-
|
|
619
|
-
// Generate zones display
|
|
620
|
-
let zonesHtml = '';
|
|
621
|
-
if (dev.has_multizone && dev.zone_colors &&
|
|
622
|
-
dev.zone_colors.length > 0
|
|
623
|
-
) {
|
|
624
|
-
const zoneSegments = dev.zone_colors.map(color => {
|
|
625
|
-
const rgb = hsbkToRgb(color);
|
|
626
|
-
const bgStyle = `background: ${rgb};`;
|
|
627
|
-
return `<div class="zone-segment" style="${bgStyle}"></div>`;
|
|
628
|
-
}).join('');
|
|
629
|
-
|
|
630
|
-
const zoneCount = dev.zone_colors.length;
|
|
631
|
-
const toggleId = `zones-toggle-${dev.serial}`;
|
|
632
|
-
const toggleClick = `toggleZones('${dev.serial}')`;
|
|
633
|
-
zonesHtml = `
|
|
634
|
-
<div
|
|
635
|
-
class="zones-toggle"
|
|
636
|
-
id="${toggleId}"
|
|
637
|
-
onclick="${toggleClick}"
|
|
638
|
-
>
|
|
639
|
-
▸ Show zones (${zoneCount})
|
|
640
|
-
</div>
|
|
641
|
-
<div id="zones-${dev.serial}" class="zones-display">
|
|
642
|
-
<div class="zone-strip">${zoneSegments}</div>
|
|
643
|
-
</div>
|
|
644
|
-
`;
|
|
645
|
-
} else if (dev.has_matrix && dev.tile_devices &&
|
|
646
|
-
dev.tile_devices.length > 0) {
|
|
647
|
-
// Render actual tile zones
|
|
648
|
-
const tilesHtml = dev.tile_devices.map((tile, tileIndex) => {
|
|
649
|
-
if (!tile.colors || tile.colors.length === 0) {
|
|
650
|
-
return '<div style="color: #666;">No color data</div>';
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
const width = tile.width || 8;
|
|
654
|
-
const height = tile.height || 8;
|
|
655
|
-
const totalzones = width * height;
|
|
656
|
-
|
|
657
|
-
// Create grid of zones
|
|
658
|
-
const slicedColors = tile.colors.slice(0, totalzones);
|
|
659
|
-
const zonesHtml = slicedColors.map(color => {
|
|
660
|
-
const rgb = hsbkToRgb(color);
|
|
661
|
-
const bgStyle = `background: ${rgb};`;
|
|
662
|
-
return `<div class="tile-zone" style="${bgStyle}"></div>`;
|
|
663
|
-
}).join('');
|
|
664
|
-
|
|
665
|
-
const labelStyle = (
|
|
666
|
-
'font-size: 0.7em; color: #666; ' +
|
|
667
|
-
'margin-bottom: 2px; text-align: center;'
|
|
668
|
-
);
|
|
669
|
-
const gridStyle = (
|
|
670
|
-
`grid-template-columns: repeat(${width}, 8px);`
|
|
671
|
-
);
|
|
672
|
-
return `
|
|
673
|
-
<div class="tile-item">
|
|
674
|
-
<div style="${labelStyle}">
|
|
675
|
-
T${tileIndex + 1}
|
|
676
|
-
</div>
|
|
677
|
-
<div class="tile-grid" style="${gridStyle}">
|
|
678
|
-
${zonesHtml}
|
|
679
|
-
</div>
|
|
680
|
-
</div>
|
|
681
|
-
`;
|
|
682
|
-
}).join('');
|
|
683
|
-
|
|
684
|
-
const tileCount = dev.tile_devices.length;
|
|
685
|
-
const toggleId = `zones-toggle-${dev.serial}`;
|
|
686
|
-
const toggleClick = `toggleZones('${dev.serial}')`;
|
|
687
|
-
zonesHtml = `
|
|
688
|
-
<div
|
|
689
|
-
class="zones-toggle"
|
|
690
|
-
id="${toggleId}"
|
|
691
|
-
onclick="${toggleClick}"
|
|
692
|
-
>
|
|
693
|
-
▸ Show tiles (${tileCount})
|
|
694
|
-
</div>
|
|
695
|
-
<div id="zones-${dev.serial}" class="zones-display">
|
|
696
|
-
<div class="tiles-container">
|
|
697
|
-
${tilesHtml}
|
|
698
|
-
</div>
|
|
699
|
-
</div>
|
|
700
|
-
`;
|
|
701
|
-
} else if (dev.has_color && dev.color) {
|
|
702
|
-
const rgb = hsbkToRgb(dev.color);
|
|
703
|
-
const swatchStyle = `background: ${rgb};`;
|
|
704
|
-
const textStyle = 'color: #888; font-size: 0.75em;';
|
|
705
|
-
zonesHtml = `
|
|
706
|
-
<div style="margin-top: 4px;">
|
|
707
|
-
<span class="color-swatch" style="${swatchStyle}"></span>
|
|
708
|
-
<span style="${textStyle}">Current color</span>
|
|
709
|
-
</div>
|
|
710
|
-
`;
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
return `
|
|
714
|
-
<div class="device">
|
|
715
|
-
<div class="device-header">
|
|
716
|
-
<div>
|
|
717
|
-
<div class="device-serial">${dev.serial}</div>
|
|
718
|
-
<div class="device-label">${dev.label}</div>
|
|
719
|
-
</div>
|
|
720
|
-
<button
|
|
721
|
-
class="btn btn-delete"
|
|
722
|
-
onclick="deleteDevice('${dev.serial}')"
|
|
723
|
-
>Del</button>
|
|
724
|
-
</div>
|
|
725
|
-
<div>
|
|
726
|
-
${powerBadge}
|
|
727
|
-
<span class="badge badge-capability">P${dev.product}</span>
|
|
728
|
-
${capabilities.map(c => (
|
|
729
|
-
`<span class="badge badge-capability">${c}</span>`
|
|
730
|
-
)).join('')}
|
|
731
|
-
${capabilityBadges.join('')}
|
|
732
|
-
</div>
|
|
733
|
-
${metadataHtml}
|
|
734
|
-
${zonesHtml}
|
|
735
|
-
</div>
|
|
736
|
-
`;
|
|
737
|
-
}).join('');
|
|
738
|
-
|
|
739
|
-
document.getElementById('devices').innerHTML = devicesHtml;
|
|
740
|
-
|
|
741
|
-
// Restore toggle states for all devices
|
|
742
|
-
devices.forEach(dev => restoreToggleStates(dev.serial));
|
|
743
|
-
} catch (error) {
|
|
744
|
-
console.error('Failed to fetch devices:', error);
|
|
745
|
-
const errorStyle = 'color: #d32f2f;';
|
|
746
|
-
const errorHtml = (
|
|
747
|
-
`<div class="no-devices" style="${errorStyle}">` +
|
|
748
|
-
`Error loading devices: ${error.message}</div>`
|
|
749
|
-
);
|
|
750
|
-
document.getElementById('devices').innerHTML = errorHtml;
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
async function fetchActivity() {
|
|
755
|
-
try {
|
|
756
|
-
const response = await fetch('/api/activity');
|
|
757
|
-
if (!response.ok) {
|
|
758
|
-
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
759
|
-
}
|
|
760
|
-
const activities = await response.json();
|
|
761
|
-
|
|
762
|
-
const activityHtml = activities.slice().reverse().map(act => {
|
|
763
|
-
const timestamp = act.timestamp * 1000;
|
|
764
|
-
const time = new Date(timestamp).toLocaleTimeString();
|
|
765
|
-
const isRx = act.direction === 'rx';
|
|
766
|
-
const dirClass = isRx ? 'activity-rx' : 'activity-tx';
|
|
767
|
-
const dirLabel = isRx ? 'RX' : 'TX';
|
|
768
|
-
const device = act.device || act.target || 'N/A';
|
|
769
|
-
|
|
770
|
-
return `
|
|
771
|
-
<div class="activity-item">
|
|
772
|
-
<span class="activity-time">${time}</span>
|
|
773
|
-
<span class="${dirClass}">${dirLabel}</span>
|
|
774
|
-
<span class="activity-packet">${act.packet_name}</span>
|
|
775
|
-
<span class="device-serial">${device}</span>
|
|
776
|
-
<span style="color: #666">${act.addr}</span>
|
|
777
|
-
</div>
|
|
778
|
-
`;
|
|
779
|
-
}).join('');
|
|
780
|
-
|
|
781
|
-
const noActivity = '<div style="color: #666">No activity yet</div>';
|
|
782
|
-
const logElement = document.getElementById('activity-log');
|
|
783
|
-
logElement.innerHTML = activityHtml || noActivity;
|
|
784
|
-
} catch (error) {
|
|
785
|
-
console.error('Failed to fetch activity:', error);
|
|
786
|
-
const errorStyle = 'color: #d32f2f;';
|
|
787
|
-
const errorHtml = (
|
|
788
|
-
`<div style="${errorStyle}">` +
|
|
789
|
-
`Error loading activity: ${error.message}</div>`
|
|
790
|
-
);
|
|
791
|
-
document.getElementById('activity-log').innerHTML = errorHtml;
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
async function deleteDevice(serial) {
|
|
796
|
-
if (!confirm(`Delete device ${serial}?`)) return;
|
|
797
|
-
|
|
798
|
-
const response = await fetch(`/api/devices/${serial}`, {
|
|
799
|
-
method: 'DELETE'
|
|
800
|
-
});
|
|
801
|
-
|
|
802
|
-
if (response.ok) {
|
|
803
|
-
await updateAll();
|
|
804
|
-
} else {
|
|
805
|
-
alert('Failed to delete device');
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
async function removeAllDevices() {
|
|
810
|
-
const deviceCount = document.getElementById('device-count').textContent;
|
|
811
|
-
if (deviceCount === '0') {
|
|
812
|
-
alert('No devices to remove');
|
|
813
|
-
return;
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
const line1 = (
|
|
817
|
-
`Remove all ${deviceCount} device(s) from the server?\\n\\n`
|
|
818
|
-
);
|
|
819
|
-
const line2 = (
|
|
820
|
-
'This will stop all devices from ' +
|
|
821
|
-
'responding to LIFX protocol packets, '
|
|
822
|
-
);
|
|
823
|
-
const line3 = 'but will not delete persistent storage.';
|
|
824
|
-
const confirmMsg = line1 + line2 + line3;
|
|
825
|
-
if (!confirm(confirmMsg)) return;
|
|
826
|
-
|
|
827
|
-
const response = await fetch('/api/devices', {
|
|
828
|
-
method: 'DELETE'
|
|
829
|
-
});
|
|
830
|
-
|
|
831
|
-
if (response.ok) {
|
|
832
|
-
const result = await response.json();
|
|
833
|
-
alert(result.message);
|
|
834
|
-
await updateAll();
|
|
835
|
-
} else {
|
|
836
|
-
alert('Failed to remove all devices');
|
|
837
|
-
}
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
async function clearStorage() {
|
|
841
|
-
const confirmMsg = `Clear all persistent device state from storage?\\n\\n` +
|
|
842
|
-
`This will permanently delete all saved device state files. ` +
|
|
843
|
-
`Currently running devices will not be affected.\\n\\n` +
|
|
844
|
-
`This action cannot be undone.`;
|
|
845
|
-
if (!confirm(confirmMsg)) return;
|
|
846
|
-
|
|
847
|
-
const response = await fetch('/api/storage', {
|
|
848
|
-
method: 'DELETE'
|
|
849
|
-
});
|
|
850
|
-
|
|
851
|
-
if (response.ok) {
|
|
852
|
-
const result = await response.json();
|
|
853
|
-
alert(result.message);
|
|
854
|
-
} else if (response.status === 503) {
|
|
855
|
-
alert('Persistent storage is not enabled on this server');
|
|
856
|
-
} else {
|
|
857
|
-
alert('Failed to clear storage');
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
const addDeviceForm = document.getElementById('add-device-form');
|
|
862
|
-
addDeviceForm.addEventListener('submit', async (e) => {
|
|
863
|
-
e.preventDefault();
|
|
864
|
-
|
|
865
|
-
const productId = parseInt(document.getElementById('product-id').value);
|
|
866
|
-
|
|
867
|
-
const response = await fetch('/api/devices', {
|
|
868
|
-
method: 'POST',
|
|
869
|
-
headers: {
|
|
870
|
-
'Content-Type': 'application/json'
|
|
871
|
-
},
|
|
872
|
-
body: JSON.stringify({ product_id: productId })
|
|
873
|
-
});
|
|
874
|
-
|
|
875
|
-
if (response.ok) {
|
|
876
|
-
await updateAll();
|
|
877
|
-
} else {
|
|
878
|
-
const error = await response.json();
|
|
879
|
-
alert(`Failed to create device: ${error.detail}`);
|
|
880
|
-
}
|
|
881
|
-
});
|
|
882
|
-
|
|
883
|
-
async function updateAll() {
|
|
884
|
-
const activityEnabled = await fetchStats();
|
|
885
|
-
const tasks = [fetchDevices()];
|
|
886
|
-
if (activityEnabled) {
|
|
887
|
-
tasks.push(fetchActivity());
|
|
888
|
-
}
|
|
889
|
-
await Promise.all(tasks);
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
// Initial load
|
|
893
|
-
updateAll();
|
|
894
|
-
|
|
895
|
-
// Auto-refresh every 2 seconds
|
|
896
|
-
updateInterval = setInterval(updateAll, 2000);
|
|
897
|
-
</script>
|
|
355
|
+
<script src="/static/dashboard.js"></script>
|
|
898
356
|
</body>
|
|
899
357
|
</html>
|
|
File without changes
|