lifx-emulator 3.0.1__tar.gz → 3.1.0__tar.gz

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.
Files changed (26) hide show
  1. {lifx_emulator-3.0.1 → lifx_emulator-3.1.0}/CHANGELOG.md +16 -0
  2. {lifx_emulator-3.0.1 → lifx_emulator-3.1.0}/PKG-INFO +3 -2
  3. {lifx_emulator-3.0.1 → lifx_emulator-3.1.0}/pyproject.toml +5 -4
  4. {lifx_emulator-3.0.1 → lifx_emulator-3.1.0}/src/lifx_emulator_app/api/app.py +6 -1
  5. lifx_emulator-3.1.0/src/lifx_emulator_app/api/static/dashboard.js +588 -0
  6. lifx_emulator-3.1.0/src/lifx_emulator_app/api/templates/dashboard.html +357 -0
  7. lifx_emulator-3.0.1/src/lifx_emulator_app/api/templates/dashboard.html +0 -899
  8. {lifx_emulator-3.0.1 → lifx_emulator-3.1.0}/.gitignore +0 -0
  9. {lifx_emulator-3.0.1 → lifx_emulator-3.1.0}/README.md +0 -0
  10. {lifx_emulator-3.0.1 → lifx_emulator-3.1.0}/src/lifx_emulator_app/__init__.py +0 -0
  11. {lifx_emulator-3.0.1 → lifx_emulator-3.1.0}/src/lifx_emulator_app/__main__.py +0 -0
  12. {lifx_emulator-3.0.1 → lifx_emulator-3.1.0}/src/lifx_emulator_app/api/__init__.py +0 -0
  13. {lifx_emulator-3.0.1 → lifx_emulator-3.1.0}/src/lifx_emulator_app/api/mappers/__init__.py +0 -0
  14. {lifx_emulator-3.0.1 → lifx_emulator-3.1.0}/src/lifx_emulator_app/api/mappers/device_mapper.py +0 -0
  15. {lifx_emulator-3.0.1 → lifx_emulator-3.1.0}/src/lifx_emulator_app/api/models.py +0 -0
  16. {lifx_emulator-3.0.1 → lifx_emulator-3.1.0}/src/lifx_emulator_app/api/routers/__init__.py +0 -0
  17. {lifx_emulator-3.0.1 → lifx_emulator-3.1.0}/src/lifx_emulator_app/api/routers/devices.py +0 -0
  18. {lifx_emulator-3.0.1 → lifx_emulator-3.1.0}/src/lifx_emulator_app/api/routers/monitoring.py +0 -0
  19. {lifx_emulator-3.0.1 → lifx_emulator-3.1.0}/src/lifx_emulator_app/api/routers/scenarios.py +0 -0
  20. {lifx_emulator-3.0.1 → lifx_emulator-3.1.0}/src/lifx_emulator_app/api/services/__init__.py +0 -0
  21. {lifx_emulator-3.0.1 → lifx_emulator-3.1.0}/src/lifx_emulator_app/api/services/device_service.py +0 -0
  22. {lifx_emulator-3.0.1 → lifx_emulator-3.1.0}/tests/conftest.py +0 -0
  23. {lifx_emulator-3.0.1 → lifx_emulator-3.1.0}/tests/test_api.py +0 -0
  24. {lifx_emulator-3.0.1 → lifx_emulator-3.1.0}/tests/test_api_validation.py +0 -0
  25. {lifx_emulator-3.0.1 → lifx_emulator-3.1.0}/tests/test_cli.py +0 -0
  26. {lifx_emulator-3.0.1 → lifx_emulator-3.1.0}/tests/test_cli_validation.py +0 -0
@@ -2,6 +2,22 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v3.1.0 (2026-01-11)
6
+
7
+ ### Features
8
+
9
+ - Add Python 3.10 support
10
+ ([`c19eee5`](https://github.com/Djelibeybi/lifx-emulator/commit/c19eee5181fc3e0e3b4ef9fc3e6d47308dce7a0f))
11
+
12
+
13
+ ## v3.0.2 (2025-12-24)
14
+
15
+ ### Bug Fixes
16
+
17
+ - **api**: Eliminate XSS vulnerabilities and extract dashboard JavaScript
18
+ ([`8302a09`](https://github.com/Djelibeybi/lifx-emulator/commit/8302a0947b326e73f6c2f15de85986a464a307ad))
19
+
20
+
5
21
  ## v3.0.1 (2025-11-26)
6
22
 
7
23
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lifx-emulator
3
- Version: 3.0.1
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.11
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,9 +1,9 @@
1
1
  [project]
2
2
  name = "lifx-emulator"
3
- version = "3.0.1"
3
+ version = "3.1.0"
4
4
  description = "Standalone LIFX Emulator with CLI and HTTP management API"
5
5
  readme = "README.md"
6
- requires-python = ">=3.11"
6
+ requires-python = ">=3.10"
7
7
  dependencies = [
8
8
  "lifx-emulator-core>=2.4.0",
9
9
  "cyclopts>=4.2.0",
@@ -27,6 +27,7 @@ classifiers = [
27
27
  "Intended Audience :: Developers",
28
28
  "Natural Language :: English",
29
29
  "Operating System :: OS Independent",
30
+ "Programming Language :: Python :: 3.10",
30
31
  "Programming Language :: Python :: 3.11",
31
32
  "Programming Language :: Python :: 3.12",
32
33
  "Programming Language :: Python :: 3.13",
@@ -47,7 +48,7 @@ packages = ["src/lifx_emulator_app"]
47
48
 
48
49
  [tool.pyright]
49
50
  typeCheckingMode = "standard"
50
- pythonVersion = "3.11"
51
+ pythonVersion = "3.10"
51
52
  include = ["src"]
52
53
  exclude = ["**/__pycache__"]
53
54
 
@@ -63,7 +64,7 @@ asyncio_default_fixture_loop_scope = "function"
63
64
  [tool.semantic_release]
64
65
  commit_parser = "conventional-monorepo"
65
66
  commit_message = """\
66
- chore(release): lifx-emulator@{version}`
67
+ chore(release): lifx-emulator@{version}
67
68
 
68
69
  Automatically generated by python-semantic-release
69
70
  """
@@ -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
- # Template directory for web UI
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
+ });