lifx-emulator 2.3.0__py3-none-any.whl → 2.4.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/__main__.py +13 -4
- lifx_emulator/api/models.py +2 -2
- lifx_emulator/api/templates/dashboard.html +8 -8
- lifx_emulator/devices/device.py +58 -2
- lifx_emulator/devices/states.py +6 -2
- lifx_emulator/factories/__init__.py +2 -0
- lifx_emulator/factories/builder.py +4 -2
- lifx_emulator/factories/factory.py +35 -4
- lifx_emulator/handlers/light_handlers.py +155 -20
- lifx_emulator/handlers/tile_handlers.py +22 -24
- lifx_emulator/products/generator.py +77 -35
- lifx_emulator/products/registry.py +46 -12
- lifx_emulator/products/specs.py +2 -2
- lifx_emulator/products/specs.yml +46 -12
- {lifx_emulator-2.3.0.dist-info → lifx_emulator-2.4.0.dist-info}/METADATA +1 -1
- {lifx_emulator-2.3.0.dist-info → lifx_emulator-2.4.0.dist-info}/RECORD +19 -19
- {lifx_emulator-2.3.0.dist-info → lifx_emulator-2.4.0.dist-info}/WHEEL +0 -0
- {lifx_emulator-2.3.0.dist-info → lifx_emulator-2.4.0.dist-info}/entry_points.txt +0 -0
- {lifx_emulator-2.3.0.dist-info → lifx_emulator-2.4.0.dist-info}/licenses/LICENSE +0 -0
lifx_emulator/__main__.py
CHANGED
|
@@ -21,6 +21,7 @@ from lifx_emulator.factories import (
|
|
|
21
21
|
create_hev_light,
|
|
22
22
|
create_infrared_light,
|
|
23
23
|
create_multizone_light,
|
|
24
|
+
create_switch,
|
|
24
25
|
create_tile_device,
|
|
25
26
|
)
|
|
26
27
|
from lifx_emulator.products.registry import get_registry
|
|
@@ -239,6 +240,7 @@ async def run(
|
|
|
239
240
|
hev: Annotated[int, cyclopts.Parameter(group=device_group)] = 0,
|
|
240
241
|
multizone: Annotated[int, cyclopts.Parameter(group=device_group)] = 0,
|
|
241
242
|
tile: Annotated[int, cyclopts.Parameter(group=device_group)] = 0,
|
|
243
|
+
switch: Annotated[int, cyclopts.Parameter(group=device_group)] = 0,
|
|
242
244
|
# Multizone Options
|
|
243
245
|
multizone_zones: Annotated[
|
|
244
246
|
int | None, cyclopts.Parameter(group=multizone_group)
|
|
@@ -284,11 +286,12 @@ async def run(
|
|
|
284
286
|
multizone_extended: Enable extended multizone support (Beam).
|
|
285
287
|
Set --no-multizone-extended for basic multizone (Z) devices.
|
|
286
288
|
tile: Number of tile/matrix chain devices.
|
|
289
|
+
switch: Number of LIFX Switch devices (relays, no lighting).
|
|
287
290
|
tile_count: Number of tiles per device. Uses product defaults if not
|
|
288
291
|
specified (5 for Tile, 1 for Candle/Ceiling).
|
|
289
|
-
tile_width: Width of each tile in
|
|
292
|
+
tile_width: Width of each tile in zones. Uses product defaults if not
|
|
290
293
|
specified (8 for most devices).
|
|
291
|
-
tile_height: Height of each tile in
|
|
294
|
+
tile_height: Height of each tile in zones. Uses product defaults if
|
|
292
295
|
not specified (8 for most devices).
|
|
293
296
|
serial_prefix: Serial number prefix as 6 hex characters.
|
|
294
297
|
serial_start: Starting serial suffix for auto-incrementing device serials.
|
|
@@ -310,7 +313,7 @@ async def run(
|
|
|
310
313
|
lifx-emulator --color 2 --multizone 1 --tile 1 --api --verbose
|
|
311
314
|
|
|
312
315
|
Create only specific device types:
|
|
313
|
-
lifx-emulator --color 0 --infrared 3 --hev 2
|
|
316
|
+
lifx-emulator --color 0 --infrared 3 --hev 2 --switch 2
|
|
314
317
|
|
|
315
318
|
Custom serial prefix:
|
|
316
319
|
lifx-emulator --serial-prefix cafe00 --color 5
|
|
@@ -410,6 +413,7 @@ async def run(
|
|
|
410
413
|
and infrared == 0
|
|
411
414
|
and hev == 0
|
|
412
415
|
and multizone == 0
|
|
416
|
+
and switch == 0
|
|
413
417
|
):
|
|
414
418
|
color = 0
|
|
415
419
|
|
|
@@ -423,6 +427,7 @@ async def run(
|
|
|
423
427
|
and hev == 0
|
|
424
428
|
and multizone == 0
|
|
425
429
|
and tile == 0
|
|
430
|
+
and switch == 0
|
|
426
431
|
):
|
|
427
432
|
color = 0
|
|
428
433
|
|
|
@@ -467,13 +472,17 @@ async def run(
|
|
|
467
472
|
)
|
|
468
473
|
)
|
|
469
474
|
|
|
475
|
+
# Create switch devices
|
|
476
|
+
for _ in range(switch):
|
|
477
|
+
devices.append(create_switch(get_serial(), storage=storage))
|
|
478
|
+
|
|
470
479
|
if not devices:
|
|
471
480
|
if persistent:
|
|
472
481
|
logger.warning("No devices configured. Server will run with no devices.")
|
|
473
482
|
logger.info("Use API (--api) or restart with device flags to add devices.")
|
|
474
483
|
else:
|
|
475
484
|
logger.error(
|
|
476
|
-
"No devices configured. Use --color, --multizone, --tile, "
|
|
485
|
+
"No devices configured. Use --color, --multizone, --tile, --switch, "
|
|
477
486
|
"etc. to add devices."
|
|
478
487
|
)
|
|
479
488
|
return
|
lifx_emulator/api/models.py
CHANGED
|
@@ -25,10 +25,10 @@ class DeviceCreateRequest(BaseModel):
|
|
|
25
25
|
None, description="Number of tiles for matrix devices", ge=0, le=100
|
|
26
26
|
)
|
|
27
27
|
tile_width: int | None = Field(
|
|
28
|
-
None, description="Width of each tile in
|
|
28
|
+
None, description="Width of each tile in zones", ge=1, le=256
|
|
29
29
|
)
|
|
30
30
|
tile_height: int | None = Field(
|
|
31
|
-
None, description="Height of each tile in
|
|
31
|
+
None, description="Height of each tile in zones", ge=1, le=256
|
|
32
32
|
)
|
|
33
33
|
firmware_major: int | None = Field(
|
|
34
34
|
None, description="Firmware major version", ge=0, le=255
|
|
@@ -163,7 +163,7 @@
|
|
|
163
163
|
gap: 2px;
|
|
164
164
|
margin-top: 4px;
|
|
165
165
|
}
|
|
166
|
-
.tile-
|
|
166
|
+
.tile-zone {
|
|
167
167
|
width: 8px;
|
|
168
168
|
height: 8px;
|
|
169
169
|
border-radius: 1px;
|
|
@@ -644,7 +644,7 @@
|
|
|
644
644
|
`;
|
|
645
645
|
} else if (dev.has_matrix && dev.tile_devices &&
|
|
646
646
|
dev.tile_devices.length > 0) {
|
|
647
|
-
// Render actual tile
|
|
647
|
+
// Render actual tile zones
|
|
648
648
|
const tilesHtml = dev.tile_devices.map((tile, tileIndex) => {
|
|
649
649
|
if (!tile.colors || tile.colors.length === 0) {
|
|
650
650
|
return '<div style="color: #666;">No color data</div>';
|
|
@@ -652,14 +652,14 @@
|
|
|
652
652
|
|
|
653
653
|
const width = tile.width || 8;
|
|
654
654
|
const height = tile.height || 8;
|
|
655
|
-
const
|
|
655
|
+
const totalzones = width * height;
|
|
656
656
|
|
|
657
|
-
// Create grid of
|
|
658
|
-
const slicedColors = tile.colors.slice(0,
|
|
659
|
-
const
|
|
657
|
+
// Create grid of zones
|
|
658
|
+
const slicedColors = tile.colors.slice(0, totalzones);
|
|
659
|
+
const zonesHtml = slicedColors.map(color => {
|
|
660
660
|
const rgb = hsbkToRgb(color);
|
|
661
661
|
const bgStyle = `background: ${rgb};`;
|
|
662
|
-
return `<div class="tile-
|
|
662
|
+
return `<div class="tile-zone" style="${bgStyle}"></div>`;
|
|
663
663
|
}).join('');
|
|
664
664
|
|
|
665
665
|
const labelStyle = (
|
|
@@ -675,7 +675,7 @@
|
|
|
675
675
|
T${tileIndex + 1}
|
|
676
676
|
</div>
|
|
677
677
|
<div class="tile-grid" style="${gridStyle}">
|
|
678
|
-
${
|
|
678
|
+
${zonesHtml}
|
|
679
679
|
</div>
|
|
680
680
|
</div>
|
|
681
681
|
`;
|
lifx_emulator/devices/device.py
CHANGED
|
@@ -90,10 +90,10 @@ class EmulatedLifxDevice:
|
|
|
90
90
|
if self.state.has_matrix and self.state.tile_count > 0:
|
|
91
91
|
if not self.state.tile_devices:
|
|
92
92
|
for i in range(self.state.tile_count):
|
|
93
|
-
|
|
93
|
+
zones = self.state.tile_width * self.state.tile_height
|
|
94
94
|
tile_colors = [
|
|
95
95
|
LightHsbk(hue=0, saturation=0, brightness=32768, kelvin=3500)
|
|
96
|
-
for _ in range(
|
|
96
|
+
for _ in range(zones)
|
|
97
97
|
]
|
|
98
98
|
|
|
99
99
|
self.state.tile_devices.append(
|
|
@@ -215,6 +215,35 @@ class EmulatedLifxDevice:
|
|
|
215
215
|
header.size = LIFX_HEADER_SIZE + payload_size
|
|
216
216
|
return header
|
|
217
217
|
|
|
218
|
+
def _should_handle_packet(self, pkt_type: int) -> bool:
|
|
219
|
+
"""Check if device should handle a packet type based on capabilities.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
pkt_type: Packet type number
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
True if device should handle, False if should return StateUnhandled
|
|
226
|
+
"""
|
|
227
|
+
# Device.* packets are always handled (2-59)
|
|
228
|
+
if 2 <= pkt_type <= 59:
|
|
229
|
+
return True
|
|
230
|
+
|
|
231
|
+
# Light.* packets (101-149) require light capabilities
|
|
232
|
+
# Switches (devices with relays) don't support light operations
|
|
233
|
+
if 101 <= pkt_type <= 149:
|
|
234
|
+
return not self.state.has_relays
|
|
235
|
+
|
|
236
|
+
# MultiZone.* packets (501-512) require multizone capability
|
|
237
|
+
if 501 <= pkt_type <= 512:
|
|
238
|
+
return self.state.has_multizone
|
|
239
|
+
|
|
240
|
+
# Tile.* packets (701-720) require matrix capability
|
|
241
|
+
if 701 <= pkt_type <= 720:
|
|
242
|
+
return self.state.has_matrix
|
|
243
|
+
|
|
244
|
+
# Unknown packets - let handler decide
|
|
245
|
+
return True
|
|
246
|
+
|
|
218
247
|
def process_packet(
|
|
219
248
|
self, header: LifxHeader, packet: Any | None
|
|
220
249
|
) -> list[tuple[LifxHeader, Any]]:
|
|
@@ -229,6 +258,33 @@ class EmulatedLifxDevice:
|
|
|
229
258
|
logger.info("Dropping packet type %s per scenario", header.pkt_type)
|
|
230
259
|
return responses
|
|
231
260
|
|
|
261
|
+
# Check if device should handle this packet type (capability-based filtering)
|
|
262
|
+
if not self._should_handle_packet(header.pkt_type):
|
|
263
|
+
# Return StateUnhandled for unsupported packet types
|
|
264
|
+
state_unhandled = Device.StateUnhandled(unhandled_type=header.pkt_type)
|
|
265
|
+
unhandled_payload = state_unhandled.pack()
|
|
266
|
+
unhandled_header = self._create_response_header(
|
|
267
|
+
header.source,
|
|
268
|
+
header.sequence,
|
|
269
|
+
state_unhandled.PKT_TYPE,
|
|
270
|
+
len(unhandled_payload),
|
|
271
|
+
)
|
|
272
|
+
responses.append((unhandled_header, state_unhandled))
|
|
273
|
+
|
|
274
|
+
# Still send acknowledgment if requested
|
|
275
|
+
if header.ack_required:
|
|
276
|
+
ack_packet = Device.Acknowledgement()
|
|
277
|
+
ack_payload = ack_packet.pack()
|
|
278
|
+
ack_header = self._create_response_header(
|
|
279
|
+
header.source,
|
|
280
|
+
header.sequence,
|
|
281
|
+
ack_packet.PKT_TYPE,
|
|
282
|
+
len(ack_payload),
|
|
283
|
+
)
|
|
284
|
+
responses.append((ack_header, ack_packet))
|
|
285
|
+
|
|
286
|
+
return responses
|
|
287
|
+
|
|
232
288
|
# Update uptime
|
|
233
289
|
self.state.uptime_ns = self.get_uptime_ns()
|
|
234
290
|
|
lifx_emulator/devices/states.py
CHANGED
|
@@ -100,10 +100,10 @@ class TileFramebuffers:
|
|
|
100
100
|
"""Get framebuffer by index, creating it if needed."""
|
|
101
101
|
if fb_index not in self.framebuffers:
|
|
102
102
|
# Initialize with default black color
|
|
103
|
-
|
|
103
|
+
zones = width * height
|
|
104
104
|
self.framebuffers[fb_index] = [
|
|
105
105
|
LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500)
|
|
106
|
-
for _ in range(
|
|
106
|
+
for _ in range(zones)
|
|
107
107
|
]
|
|
108
108
|
return self.framebuffers[fb_index]
|
|
109
109
|
|
|
@@ -183,6 +183,8 @@ class DeviceState:
|
|
|
183
183
|
has_extended_multizone: bool = False
|
|
184
184
|
has_matrix: bool = False
|
|
185
185
|
has_hev: bool = False
|
|
186
|
+
has_relays: bool = False
|
|
187
|
+
has_buttons: bool = False
|
|
186
188
|
|
|
187
189
|
# Attribute routing map: maps attribute prefixes to state objects
|
|
188
190
|
# This eliminates ~360 lines of property boilerplate
|
|
@@ -347,6 +349,8 @@ class DeviceState:
|
|
|
347
349
|
"has_extended_multizone",
|
|
348
350
|
"has_matrix",
|
|
349
351
|
"has_hev",
|
|
352
|
+
"has_relays",
|
|
353
|
+
"has_buttons",
|
|
350
354
|
} or name.startswith("_"):
|
|
351
355
|
object.__setattr__(self, name, value)
|
|
352
356
|
return
|
|
@@ -15,6 +15,7 @@ from lifx_emulator.factories.factory import (
|
|
|
15
15
|
create_hev_light,
|
|
16
16
|
create_infrared_light,
|
|
17
17
|
create_multizone_light,
|
|
18
|
+
create_switch,
|
|
18
19
|
create_tile_device,
|
|
19
20
|
)
|
|
20
21
|
from lifx_emulator.factories.firmware_config import FirmwareConfig
|
|
@@ -32,6 +33,7 @@ __all__ = [
|
|
|
32
33
|
"create_infrared_light",
|
|
33
34
|
"create_hev_light",
|
|
34
35
|
"create_multizone_light",
|
|
36
|
+
"create_switch",
|
|
35
37
|
"create_tile_device",
|
|
36
38
|
"create_color_temperature_light",
|
|
37
39
|
]
|
|
@@ -136,8 +136,8 @@ class DeviceBuilder:
|
|
|
136
136
|
"""Set tile dimensions for matrix devices.
|
|
137
137
|
|
|
138
138
|
Args:
|
|
139
|
-
width: Tile width in
|
|
140
|
-
height: Tile height in
|
|
139
|
+
width: Tile width in zones
|
|
140
|
+
height: Tile height in zones
|
|
141
141
|
|
|
142
142
|
Returns:
|
|
143
143
|
Self for method chaining
|
|
@@ -257,6 +257,8 @@ class DeviceBuilder:
|
|
|
257
257
|
has_extended_multizone=has_extended_multizone,
|
|
258
258
|
has_matrix=self._product_info.has_matrix,
|
|
259
259
|
has_hev=self._product_info.has_hev,
|
|
260
|
+
has_relays=self._product_info.has_relays,
|
|
261
|
+
has_buttons=self._product_info.has_buttons,
|
|
260
262
|
)
|
|
261
263
|
|
|
262
264
|
# 10. Restore saved state if persistence enabled
|
|
@@ -104,8 +104,8 @@ def create_tile_device(
|
|
|
104
104
|
Args:
|
|
105
105
|
serial: Optional serial
|
|
106
106
|
tile_count: Optional tile count (uses product default)
|
|
107
|
-
tile_width: Optional tile width in
|
|
108
|
-
tile_height: Optional tile height in
|
|
107
|
+
tile_width: Optional tile width in zones (uses product default)
|
|
108
|
+
tile_height: Optional tile height in zones (uses product default)
|
|
109
109
|
firmware_version: Optional firmware version tuple (major, minor)
|
|
110
110
|
storage: Optional storage for persistence
|
|
111
111
|
scenario_manager: Optional scenario manager
|
|
@@ -141,6 +141,37 @@ def create_color_temperature_light(
|
|
|
141
141
|
) # LIFX Mini White to Warm
|
|
142
142
|
|
|
143
143
|
|
|
144
|
+
def create_switch(
|
|
145
|
+
serial: str | None = None,
|
|
146
|
+
product_id: int = 70,
|
|
147
|
+
firmware_version: tuple[int, int] | None = None,
|
|
148
|
+
storage: DevicePersistenceAsyncFile | None = None,
|
|
149
|
+
scenario_manager: HierarchicalScenarioManager | None = None,
|
|
150
|
+
) -> EmulatedLifxDevice:
|
|
151
|
+
"""Create a LIFX Switch device.
|
|
152
|
+
|
|
153
|
+
Switches have has_relays and has_buttons capabilities but no lighting.
|
|
154
|
+
They respond with StateUnhandled (223) to Light, MultiZone, and Tile packets.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
serial: Device serial number (auto-generated if None)
|
|
158
|
+
product_id: Switch product ID (default: 70 - LIFX Switch)
|
|
159
|
+
firmware_version: Optional firmware version (major, minor)
|
|
160
|
+
storage: Optional persistence backend
|
|
161
|
+
scenario_manager: Optional scenario manager for testing
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
EmulatedLifxDevice configured as a switch
|
|
165
|
+
"""
|
|
166
|
+
return create_device(
|
|
167
|
+
product_id,
|
|
168
|
+
serial=serial,
|
|
169
|
+
firmware_version=firmware_version,
|
|
170
|
+
storage=storage,
|
|
171
|
+
scenario_manager=scenario_manager,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
144
175
|
def create_device(
|
|
145
176
|
product_id: int,
|
|
146
177
|
serial: str | None = None,
|
|
@@ -164,8 +195,8 @@ def create_device(
|
|
|
164
195
|
zone_count: Number of zones for multizone devices (auto-determined)
|
|
165
196
|
extended_multizone: Enable extended multizone requests
|
|
166
197
|
tile_count: Number of tiles for matrix devices (default: 5)
|
|
167
|
-
tile_width: Width of each tile in
|
|
168
|
-
tile_height: Height of each tile in
|
|
198
|
+
tile_width: Width of each tile in zones (default: 8)
|
|
199
|
+
tile_height: Height of each tile in zones (default: 8)
|
|
169
200
|
firmware_version: Optional firmware version tuple (major, minor).
|
|
170
201
|
If not specified, uses 3.70 for extended_multizone
|
|
171
202
|
or 2.60 otherwise
|
|
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any
|
|
|
7
7
|
|
|
8
8
|
from lifx_emulator.handlers.base import PacketHandler
|
|
9
9
|
from lifx_emulator.protocol.packets import Light
|
|
10
|
-
from lifx_emulator.protocol.protocol_types import LightLastHevCycleResult
|
|
10
|
+
from lifx_emulator.protocol.protocol_types import LightHsbk, LightLastHevCycleResult
|
|
11
11
|
|
|
12
12
|
if TYPE_CHECKING:
|
|
13
13
|
from lifx_emulator.devices import DeviceState
|
|
@@ -15,6 +15,72 @@ if TYPE_CHECKING:
|
|
|
15
15
|
logger = logging.getLogger(__name__)
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
def _compute_average_color(colors: list[LightHsbk]) -> LightHsbk:
|
|
19
|
+
"""Compute average HSBK color from a list of LightHsbk colors.
|
|
20
|
+
|
|
21
|
+
Uses circular mean for hue to correctly handle hue wraparound
|
|
22
|
+
(e.g., average of 10° and 350° is 0°, not 180°).
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
colors: List of LightHsbk colors to average
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
LightHsbk with averaged values using circular mean for hue
|
|
29
|
+
"""
|
|
30
|
+
import math
|
|
31
|
+
|
|
32
|
+
if not colors:
|
|
33
|
+
return LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500)
|
|
34
|
+
|
|
35
|
+
# Convert uint16 values to proper ranges and calculate circular mean
|
|
36
|
+
hue_x_total = 0.0
|
|
37
|
+
hue_y_total = 0.0
|
|
38
|
+
saturation_total = 0.0
|
|
39
|
+
brightness_total = 0.0
|
|
40
|
+
kelvin_total = 0
|
|
41
|
+
|
|
42
|
+
for color in colors:
|
|
43
|
+
# Convert uint16 hue (0-65535) to degrees (0-360)
|
|
44
|
+
hue_deg = round(float(color.hue) * 360 / 0x10000, 2)
|
|
45
|
+
|
|
46
|
+
# Convert uint16 sat/bright (0-65535) to float (0-1)
|
|
47
|
+
sat_float = round(float(color.saturation) / 0xFFFF, 4)
|
|
48
|
+
bright_float = round(float(color.brightness) / 0xFFFF, 4)
|
|
49
|
+
|
|
50
|
+
# Circular mean calculation for hue using sin/cos
|
|
51
|
+
hue_x_total += math.sin(hue_deg * 2.0 * math.pi / 360)
|
|
52
|
+
hue_y_total += math.cos(hue_deg * 2.0 * math.pi / 360)
|
|
53
|
+
|
|
54
|
+
# Regular sums for other components
|
|
55
|
+
saturation_total += sat_float
|
|
56
|
+
brightness_total += bright_float
|
|
57
|
+
kelvin_total += color.kelvin
|
|
58
|
+
|
|
59
|
+
# Calculate circular mean for hue
|
|
60
|
+
hue = math.atan2(hue_x_total, hue_y_total) / (2.0 * math.pi)
|
|
61
|
+
if hue < 0.0:
|
|
62
|
+
hue += 1.0
|
|
63
|
+
hue *= 360
|
|
64
|
+
hue = round(hue, 4)
|
|
65
|
+
|
|
66
|
+
# Calculate arithmetic means for other components
|
|
67
|
+
saturation = round(saturation_total / len(colors), 4)
|
|
68
|
+
brightness = round(brightness_total / len(colors), 4)
|
|
69
|
+
kelvin = round(kelvin_total / len(colors))
|
|
70
|
+
|
|
71
|
+
# Convert back to uint16 values
|
|
72
|
+
uint16_hue = int(round(0x10000 * hue) / 360) % 0x10000
|
|
73
|
+
uint16_saturation = int(round(0xFFFF * saturation))
|
|
74
|
+
uint16_brightness = int(round(0xFFFF * brightness))
|
|
75
|
+
|
|
76
|
+
return LightHsbk(
|
|
77
|
+
hue=uint16_hue,
|
|
78
|
+
saturation=uint16_saturation,
|
|
79
|
+
brightness=uint16_brightness,
|
|
80
|
+
kelvin=kelvin,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
18
84
|
class GetColorHandler(PacketHandler):
|
|
19
85
|
"""Handle LightGet (101) -> LightState (107)."""
|
|
20
86
|
|
|
@@ -23,9 +89,24 @@ class GetColorHandler(PacketHandler):
|
|
|
23
89
|
def handle(
|
|
24
90
|
self, device_state: DeviceState, packet: Any | None, res_required: bool
|
|
25
91
|
) -> list[Any]:
|
|
92
|
+
# For multizone/matrix devices, compute average color from all zones
|
|
93
|
+
# This provides backwards compatibility with clients that don't use
|
|
94
|
+
# zone-specific or tile-specific packets
|
|
95
|
+
color_to_return = device_state.color
|
|
96
|
+
|
|
97
|
+
if device_state.has_multizone and device_state.zone_colors:
|
|
98
|
+
# Return average of all zone colors
|
|
99
|
+
color_to_return = _compute_average_color(device_state.zone_colors)
|
|
100
|
+
elif device_state.has_matrix and device_state.tile_devices:
|
|
101
|
+
# Collect all zone colors from all tiles
|
|
102
|
+
all_zones = []
|
|
103
|
+
for tile in device_state.tile_devices:
|
|
104
|
+
all_zones.extend(tile["colors"])
|
|
105
|
+
color_to_return = _compute_average_color(all_zones)
|
|
106
|
+
|
|
26
107
|
return [
|
|
27
108
|
Light.StateColor(
|
|
28
|
-
color=
|
|
109
|
+
color=color_to_return,
|
|
29
110
|
power=device_state.power_level,
|
|
30
111
|
label=device_state.label,
|
|
31
112
|
)
|
|
@@ -46,10 +127,36 @@ class SetColorHandler(PacketHandler):
|
|
|
46
127
|
if packet:
|
|
47
128
|
device_state.color = packet.color
|
|
48
129
|
c = packet.color
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
130
|
+
|
|
131
|
+
# For backwards compatibility: propagate color to all zones
|
|
132
|
+
# Multizone devices: update all zone colors
|
|
133
|
+
if device_state.has_multizone and device_state.zone_colors:
|
|
134
|
+
for i in range(len(device_state.zone_colors)):
|
|
135
|
+
device_state.zone_colors[i] = packet.color
|
|
136
|
+
logger.info(
|
|
137
|
+
f"Color set to HSBK({c.hue}, {c.saturation}, "
|
|
138
|
+
f"{c.brightness}, {c.kelvin}) across all "
|
|
139
|
+
f"{len(device_state.zone_colors)} zones, "
|
|
140
|
+
f"duration={packet.duration}ms"
|
|
141
|
+
)
|
|
142
|
+
# Matrix devices: update all tile zones
|
|
143
|
+
elif device_state.has_matrix and device_state.tile_devices:
|
|
144
|
+
total_zones = 0
|
|
145
|
+
for tile in device_state.tile_devices:
|
|
146
|
+
for i in range(len(tile["colors"])):
|
|
147
|
+
tile["colors"][i] = packet.color
|
|
148
|
+
total_zones += len(tile["colors"])
|
|
149
|
+
logger.info(
|
|
150
|
+
f"Color set to HSBK({c.hue}, {c.saturation}, "
|
|
151
|
+
f"{c.brightness}, {c.kelvin}) across all {total_zones} zones, "
|
|
152
|
+
f"duration={packet.duration}ms"
|
|
153
|
+
)
|
|
154
|
+
else:
|
|
155
|
+
# Simple color device
|
|
156
|
+
logger.info(
|
|
157
|
+
f"Color set to HSBK({c.hue}, {c.saturation}, "
|
|
158
|
+
f"{c.brightness}, {c.kelvin}), duration={packet.duration}ms"
|
|
159
|
+
)
|
|
53
160
|
|
|
54
161
|
if res_required:
|
|
55
162
|
return [
|
|
@@ -120,6 +227,17 @@ class SetWaveformHandler(PacketHandler):
|
|
|
120
227
|
if not packet.transient:
|
|
121
228
|
device_state.color = packet.color
|
|
122
229
|
|
|
230
|
+
# For backwards compatibility: propagate color to all zones
|
|
231
|
+
# Multizone devices: update all zone colors
|
|
232
|
+
if device_state.has_multizone and device_state.zone_colors:
|
|
233
|
+
for i in range(len(device_state.zone_colors)):
|
|
234
|
+
device_state.zone_colors[i] = packet.color
|
|
235
|
+
# Matrix devices: update all tile zones
|
|
236
|
+
elif device_state.has_matrix and device_state.tile_devices:
|
|
237
|
+
for tile in device_state.tile_devices:
|
|
238
|
+
for i in range(len(tile["colors"])):
|
|
239
|
+
tile["colors"][i] = packet.color
|
|
240
|
+
|
|
123
241
|
logger.info(
|
|
124
242
|
f"Waveform set: type={packet.waveform}, "
|
|
125
243
|
f"transient={packet.transient}, period={packet.period}ms, "
|
|
@@ -127,13 +245,9 @@ class SetWaveformHandler(PacketHandler):
|
|
|
127
245
|
)
|
|
128
246
|
|
|
129
247
|
if res_required:
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
power=device_state.power_level,
|
|
134
|
-
label=device_state.label,
|
|
135
|
-
)
|
|
136
|
-
]
|
|
248
|
+
# Use GetColorHandler to get proper averaged color if needed
|
|
249
|
+
handler = GetColorHandler()
|
|
250
|
+
return handler.handle(device_state, None, res_required)
|
|
137
251
|
return []
|
|
138
252
|
|
|
139
253
|
|
|
@@ -168,6 +282,31 @@ class SetWaveformOptionalHandler(PacketHandler):
|
|
|
168
282
|
if packet.set_kelvin:
|
|
169
283
|
device_state.color.kelvin = packet.color.kelvin
|
|
170
284
|
|
|
285
|
+
# Backwards compatibility propagates color changes to zones
|
|
286
|
+
# Multizone devices: update all zone colors
|
|
287
|
+
if device_state.has_multizone and device_state.zone_colors:
|
|
288
|
+
for zone_color in device_state.zone_colors:
|
|
289
|
+
if packet.set_hue:
|
|
290
|
+
zone_color.hue = packet.color.hue
|
|
291
|
+
if packet.set_saturation:
|
|
292
|
+
zone_color.saturation = packet.color.saturation
|
|
293
|
+
if packet.set_brightness:
|
|
294
|
+
zone_color.brightness = packet.color.brightness
|
|
295
|
+
if packet.set_kelvin:
|
|
296
|
+
zone_color.kelvin = packet.color.kelvin
|
|
297
|
+
# Matrix devices: update all tile zones
|
|
298
|
+
elif device_state.has_matrix and device_state.tile_devices:
|
|
299
|
+
for tile in device_state.tile_devices:
|
|
300
|
+
for zone_color in tile["colors"]:
|
|
301
|
+
if packet.set_hue:
|
|
302
|
+
zone_color.hue = packet.color.hue
|
|
303
|
+
if packet.set_saturation:
|
|
304
|
+
zone_color.saturation = packet.color.saturation
|
|
305
|
+
if packet.set_brightness:
|
|
306
|
+
zone_color.brightness = packet.color.brightness
|
|
307
|
+
if packet.set_kelvin:
|
|
308
|
+
zone_color.kelvin = packet.color.kelvin
|
|
309
|
+
|
|
171
310
|
# Store the waveform color (all components)
|
|
172
311
|
device_state.waveform_color = packet.color
|
|
173
312
|
|
|
@@ -180,13 +319,9 @@ class SetWaveformOptionalHandler(PacketHandler):
|
|
|
180
319
|
)
|
|
181
320
|
|
|
182
321
|
if res_required:
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
power=device_state.power_level,
|
|
187
|
-
label=device_state.label,
|
|
188
|
-
)
|
|
189
|
-
]
|
|
322
|
+
# Use GetColorHandler to get proper averaged color if needed
|
|
323
|
+
handler = GetColorHandler()
|
|
324
|
+
return handler.handle(device_state, None, res_required)
|
|
190
325
|
return []
|
|
191
326
|
|
|
192
327
|
|
|
@@ -142,13 +142,13 @@ class Get64Handler(PacketHandler):
|
|
|
142
142
|
# regardless of which fb_index is in the request
|
|
143
143
|
tile_colors = tile["colors"]
|
|
144
144
|
|
|
145
|
-
# Calculate how many rows fit in 64
|
|
145
|
+
# Calculate how many rows fit in 64 zones
|
|
146
146
|
rows_to_return = 64 // rect.width if rect.width > 0 else 1
|
|
147
147
|
rows_to_return = min(rows_to_return, tile_height - rect.y)
|
|
148
148
|
|
|
149
149
|
# Extract colors from the requested rectangle
|
|
150
150
|
colors = []
|
|
151
|
-
|
|
151
|
+
zones_extracted = 0
|
|
152
152
|
|
|
153
153
|
for row in range(rows_to_return):
|
|
154
154
|
y = rect.y + row
|
|
@@ -157,22 +157,22 @@ class Get64Handler(PacketHandler):
|
|
|
157
157
|
|
|
158
158
|
for col in range(rect.width):
|
|
159
159
|
x = rect.x + col
|
|
160
|
-
if x >= tile_width or
|
|
160
|
+
if x >= tile_width or zones_extracted >= 64:
|
|
161
161
|
colors.append(
|
|
162
162
|
LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500)
|
|
163
163
|
)
|
|
164
|
-
|
|
164
|
+
zones_extracted += 1
|
|
165
165
|
continue
|
|
166
166
|
|
|
167
|
-
# Calculate
|
|
168
|
-
|
|
169
|
-
if
|
|
170
|
-
colors.append(tile_colors[
|
|
167
|
+
# Calculate zone index in flat color array
|
|
168
|
+
zone_idx = y * tile_width + x
|
|
169
|
+
if zone_idx < len(tile_colors):
|
|
170
|
+
colors.append(tile_colors[zone_idx])
|
|
171
171
|
else:
|
|
172
172
|
colors.append(
|
|
173
173
|
LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500)
|
|
174
174
|
)
|
|
175
|
-
|
|
175
|
+
zones_extracted += 1
|
|
176
176
|
|
|
177
177
|
# Pad to exactly 64 colors
|
|
178
178
|
while len(colors) < 64:
|
|
@@ -226,11 +226,11 @@ class Set64Handler(PacketHandler):
|
|
|
226
226
|
return []
|
|
227
227
|
|
|
228
228
|
# Update colors in the specified rectangle
|
|
229
|
-
# Calculate how many rows fit in 64
|
|
229
|
+
# Calculate how many rows fit in 64 zones
|
|
230
230
|
rows_to_write = 64 // rect.width if rect.width > 0 else 1
|
|
231
231
|
rows_to_write = min(rows_to_write, tile_height - rect.y)
|
|
232
232
|
|
|
233
|
-
|
|
233
|
+
zones_written = 0
|
|
234
234
|
for row in range(rows_to_write):
|
|
235
235
|
y = rect.y + row
|
|
236
236
|
if y >= tile_height:
|
|
@@ -238,20 +238,18 @@ class Set64Handler(PacketHandler):
|
|
|
238
238
|
|
|
239
239
|
for col in range(rect.width):
|
|
240
240
|
x = rect.x + col
|
|
241
|
-
if x >= tile_width or
|
|
242
|
-
|
|
241
|
+
if x >= tile_width or zones_written >= 64:
|
|
242
|
+
zones_written += 1
|
|
243
243
|
continue
|
|
244
244
|
|
|
245
|
-
# Calculate
|
|
246
|
-
|
|
247
|
-
if
|
|
248
|
-
packet.colors
|
|
249
|
-
|
|
250
|
-
target_colors[pixel_idx] = packet.colors[pixels_written]
|
|
251
|
-
pixels_written += 1
|
|
245
|
+
# Calculate zone index in flat color array
|
|
246
|
+
zone_idx = y * tile_width + x
|
|
247
|
+
if zone_idx < len(target_colors) and zones_written < len(packet.colors):
|
|
248
|
+
target_colors[zone_idx] = packet.colors[zones_written]
|
|
249
|
+
zones_written += 1
|
|
252
250
|
|
|
253
251
|
logger.info(
|
|
254
|
-
f"Tile {tile_index} FB{fb_index} set {
|
|
252
|
+
f"Tile {tile_index} FB{fb_index} set {zones_written} colors at "
|
|
255
253
|
f"({rect.x},{rect.y}), duration={packet.duration}ms"
|
|
256
254
|
)
|
|
257
255
|
|
|
@@ -316,7 +314,7 @@ class CopyFrameBufferHandler(PacketHandler):
|
|
|
316
314
|
width = packet.width
|
|
317
315
|
height = packet.height
|
|
318
316
|
|
|
319
|
-
|
|
317
|
+
zones_copied = 0
|
|
320
318
|
for row in range(height):
|
|
321
319
|
src_row = src_y + row
|
|
322
320
|
dst_row = dst_y + row
|
|
@@ -336,10 +334,10 @@ class CopyFrameBufferHandler(PacketHandler):
|
|
|
336
334
|
|
|
337
335
|
if src_idx < len(src_colors) and dst_idx < len(dst_colors):
|
|
338
336
|
dst_colors[dst_idx] = src_colors[src_idx]
|
|
339
|
-
|
|
337
|
+
zones_copied += 1
|
|
340
338
|
|
|
341
339
|
logger.info(
|
|
342
|
-
f"Tile {tile_index} copied {
|
|
340
|
+
f"Tile {tile_index} copied {zones_copied} zones from "
|
|
343
341
|
f"FB{src_fb_index}({src_x},{src_y}) to "
|
|
344
342
|
f"FB{dst_fb_index}({dst_x},{dst_y}), "
|
|
345
343
|
f"size={width}x{height}, duration={packet.duration}ms"
|
|
@@ -174,7 +174,6 @@ def generate_product_definitions(
|
|
|
174
174
|
code_lines.append("PRODUCTS: dict[int, ProductInfo] = {")
|
|
175
175
|
|
|
176
176
|
product_count = 0
|
|
177
|
-
skipped_count = 0
|
|
178
177
|
for vendor_data in all_vendors:
|
|
179
178
|
vendor_id = vendor_data.get("vid", 1)
|
|
180
179
|
defaults = vendor_data.get("defaults", {})
|
|
@@ -186,11 +185,6 @@ def generate_product_definitions(
|
|
|
186
185
|
name = product["name"]
|
|
187
186
|
features = {**default_features, **product.get("features", {})}
|
|
188
187
|
|
|
189
|
-
# Skip switch products (devices with relays) - these are not lights
|
|
190
|
-
if features.get("relays"):
|
|
191
|
-
skipped_count += 1
|
|
192
|
-
continue
|
|
193
|
-
|
|
194
188
|
# Build capabilities
|
|
195
189
|
capabilities = _build_capabilities(features)
|
|
196
190
|
|
|
@@ -223,8 +217,6 @@ def generate_product_definitions(
|
|
|
223
217
|
code_lines.append("")
|
|
224
218
|
|
|
225
219
|
print(f"Generated {product_count} product definitions")
|
|
226
|
-
if skipped_count > 0:
|
|
227
|
-
print(f"Skipped {skipped_count} switch products (relays only)")
|
|
228
220
|
return "\n".join(code_lines)
|
|
229
221
|
|
|
230
222
|
|
|
@@ -373,16 +365,17 @@ class ProductInfo:
|
|
|
373
365
|
"""Format product capabilities as a human-readable string.
|
|
374
366
|
|
|
375
367
|
Returns:
|
|
376
|
-
Comma-separated capability string (e.g., "
|
|
368
|
+
Comma-separated capability string (e.g., "color, infrared, multizone")
|
|
377
369
|
"""
|
|
378
370
|
caps = []
|
|
379
371
|
|
|
380
|
-
|
|
372
|
+
if self.has_buttons:
|
|
373
|
+
caps.append("buttons")
|
|
374
|
+
|
|
381
375
|
if self.has_relays:
|
|
382
|
-
|
|
383
|
-
caps.append("switch")
|
|
376
|
+
caps.append("relays")
|
|
384
377
|
elif self.has_color:
|
|
385
|
-
caps.append("
|
|
378
|
+
caps.append("color")
|
|
386
379
|
else:
|
|
387
380
|
# Check temperature range to determine white light type
|
|
388
381
|
if self.temperature_range:
|
|
@@ -409,9 +402,7 @@ class ProductInfo:
|
|
|
409
402
|
caps.append("HEV")
|
|
410
403
|
if self.has_chain:
|
|
411
404
|
caps.append("chain")
|
|
412
|
-
|
|
413
|
-
# Only show buttons if not already identified as switch
|
|
414
|
-
caps.append("buttons")
|
|
405
|
+
|
|
415
406
|
|
|
416
407
|
return ", ".join(caps) if caps else "unknown"
|
|
417
408
|
|
|
@@ -465,10 +456,6 @@ class ProductRegistry:
|
|
|
465
456
|
prod_features = product.get("features", {})
|
|
466
457
|
features: dict[str, Any] = {**default_features, **prod_features}
|
|
467
458
|
|
|
468
|
-
# Skip switch products (devices with relays) - these are not lights
|
|
469
|
-
if features.get("relays"):
|
|
470
|
-
continue
|
|
471
|
-
|
|
472
459
|
# Build capabilities bitfield
|
|
473
460
|
capabilities = 0
|
|
474
461
|
if features.get("color"):
|
|
@@ -667,7 +654,7 @@ def _discover_new_products(
|
|
|
667
654
|
products_data: dict[str, Any] | list[dict[str, Any]],
|
|
668
655
|
existing_specs: dict[int, dict[str, Any]],
|
|
669
656
|
) -> list[dict[str, Any]]:
|
|
670
|
-
"""Find new multizone or
|
|
657
|
+
"""Find new multizone, matrix, or switch products that need specs templates.
|
|
671
658
|
|
|
672
659
|
Args:
|
|
673
660
|
products_data: Parsed products.json data
|
|
@@ -691,21 +678,19 @@ def _discover_new_products(
|
|
|
691
678
|
pid = product["pid"]
|
|
692
679
|
features = {**default_features, **product.get("features", {})}
|
|
693
680
|
|
|
694
|
-
# Skip switch products (devices with relays) - these are not lights
|
|
695
|
-
if features.get("relays"):
|
|
696
|
-
continue
|
|
697
|
-
|
|
698
681
|
# Check if this product needs specs template
|
|
699
682
|
if pid not in existing_specs:
|
|
700
683
|
is_multizone = features.get("multizone", False)
|
|
701
684
|
is_matrix = features.get("matrix", False)
|
|
685
|
+
is_switch = features.get("relays", False)
|
|
702
686
|
|
|
703
|
-
if is_multizone or is_matrix:
|
|
687
|
+
if is_multizone or is_matrix or is_switch:
|
|
704
688
|
new_product = {
|
|
705
689
|
"pid": pid,
|
|
706
690
|
"name": product["name"],
|
|
707
691
|
"multizone": is_multizone,
|
|
708
692
|
"matrix": is_matrix,
|
|
693
|
+
"switch": is_switch,
|
|
709
694
|
"extended_multizone": False,
|
|
710
695
|
}
|
|
711
696
|
|
|
@@ -732,7 +717,12 @@ def _add_product_templates(
|
|
|
732
717
|
for product in new_products:
|
|
733
718
|
product_name = product["name"].replace('"', '\\"')
|
|
734
719
|
|
|
735
|
-
if product["
|
|
720
|
+
if product["switch"]:
|
|
721
|
+
existing_specs[product["pid"]] = {
|
|
722
|
+
"relay_count": 2,
|
|
723
|
+
"notes": product_name,
|
|
724
|
+
}
|
|
725
|
+
elif product["multizone"]:
|
|
736
726
|
existing_specs[product["pid"]] = {
|
|
737
727
|
"default_zone_count": 16,
|
|
738
728
|
"min_zone_count": 1,
|
|
@@ -752,28 +742,32 @@ def _add_product_templates(
|
|
|
752
742
|
|
|
753
743
|
def _categorize_products(
|
|
754
744
|
existing_specs: dict[int, dict[str, Any]],
|
|
755
|
-
) -> tuple[list[int], list[int]]:
|
|
756
|
-
"""Categorize products into multizone and matrix.
|
|
745
|
+
) -> tuple[list[int], list[int], list[int]]:
|
|
746
|
+
"""Categorize products into switch, multizone, and matrix.
|
|
757
747
|
|
|
758
748
|
Args:
|
|
759
749
|
existing_specs: Product specs dictionary
|
|
760
750
|
|
|
761
751
|
Returns:
|
|
762
|
-
Tuple of (sorted_multizone_pids, sorted_matrix_pids)
|
|
752
|
+
Tuple of (sorted_switch_pids, sorted_multizone_pids, sorted_matrix_pids)
|
|
763
753
|
"""
|
|
754
|
+
switch_pids = []
|
|
764
755
|
multizone_pids = []
|
|
765
756
|
matrix_pids = []
|
|
766
757
|
|
|
767
758
|
for pid, specs in existing_specs.items():
|
|
768
|
-
if "
|
|
759
|
+
if "relay_count" in specs:
|
|
760
|
+
switch_pids.append(pid)
|
|
761
|
+
elif "tile_width" in specs or "tile_height" in specs:
|
|
769
762
|
matrix_pids.append(pid)
|
|
770
763
|
elif "default_zone_count" in specs:
|
|
771
764
|
multizone_pids.append(pid)
|
|
772
765
|
|
|
766
|
+
switch_pids.sort()
|
|
773
767
|
multizone_pids.sort()
|
|
774
768
|
matrix_pids.sort()
|
|
775
769
|
|
|
776
|
-
return multizone_pids, matrix_pids
|
|
770
|
+
return switch_pids, multizone_pids, matrix_pids
|
|
777
771
|
|
|
778
772
|
|
|
779
773
|
def _generate_yaml_header() -> list[str]:
|
|
@@ -806,8 +800,8 @@ def _generate_yaml_header() -> list[str]:
|
|
|
806
800
|
"# default_tile_count: <number> # Default number of tiles in chain",
|
|
807
801
|
"# min_tile_count: <number> # Minimum tiles supported",
|
|
808
802
|
"# max_tile_count: <number> # Maximum tiles supported",
|
|
809
|
-
"# tile_width: <number> # Width of each tile in
|
|
810
|
-
"# tile_height: <number> # Height of each tile in
|
|
803
|
+
"# tile_width: <number> # Width of each tile in zones",
|
|
804
|
+
"# tile_height: <number> # Height of each tile in zones",
|
|
811
805
|
"#",
|
|
812
806
|
"# # Host firmware version (optional, overrides auto firmware selection)",
|
|
813
807
|
"# default_firmware_major: <number> # Firmware major version (e.g., 3)",
|
|
@@ -833,6 +827,53 @@ def _generate_yaml_header() -> list[str]:
|
|
|
833
827
|
]
|
|
834
828
|
|
|
835
829
|
|
|
830
|
+
def _generate_switch_section(
|
|
831
|
+
switch_pids: list[int], existing_specs: dict[int, dict[str, Any]]
|
|
832
|
+
) -> list[str]:
|
|
833
|
+
"""Generate YAML lines for switch products section.
|
|
834
|
+
|
|
835
|
+
Args:
|
|
836
|
+
switch_pids: Sorted list of switch product IDs
|
|
837
|
+
existing_specs: Product specs dictionary
|
|
838
|
+
|
|
839
|
+
Returns:
|
|
840
|
+
List of YAML lines
|
|
841
|
+
"""
|
|
842
|
+
if not switch_pids:
|
|
843
|
+
return []
|
|
844
|
+
|
|
845
|
+
lines = [
|
|
846
|
+
" # ========================================",
|
|
847
|
+
" # Switch Products (Relays)",
|
|
848
|
+
" # ========================================",
|
|
849
|
+
"",
|
|
850
|
+
]
|
|
851
|
+
|
|
852
|
+
for pid in switch_pids:
|
|
853
|
+
specs = existing_specs[pid]
|
|
854
|
+
name = specs.get("notes", f"Product {pid}").split(" - ")[0]
|
|
855
|
+
|
|
856
|
+
lines.append(f" {pid}: # {name}")
|
|
857
|
+
lines.append(f" relay_count: {specs['relay_count']}")
|
|
858
|
+
|
|
859
|
+
# Add firmware version if present
|
|
860
|
+
if "default_firmware_major" in specs and "default_firmware_minor" in specs:
|
|
861
|
+
lines.append(
|
|
862
|
+
f" default_firmware_major: {specs['default_firmware_major']}"
|
|
863
|
+
)
|
|
864
|
+
lines.append(
|
|
865
|
+
f" default_firmware_minor: {specs['default_firmware_minor']}"
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
notes = specs.get("notes", "")
|
|
869
|
+
if notes:
|
|
870
|
+
notes_escaped = notes.replace('"', '\\"')
|
|
871
|
+
lines.append(f' notes: "{notes_escaped}"')
|
|
872
|
+
lines.append("")
|
|
873
|
+
|
|
874
|
+
return lines
|
|
875
|
+
|
|
876
|
+
|
|
836
877
|
def _generate_multizone_section(
|
|
837
878
|
multizone_pids: list[int], existing_specs: dict[int, dict[str, Any]]
|
|
838
879
|
) -> list[str]:
|
|
@@ -964,12 +1005,13 @@ def update_specs_file(
|
|
|
964
1005
|
_add_product_templates(new_products, existing_specs)
|
|
965
1006
|
|
|
966
1007
|
# Categorize products and sort
|
|
967
|
-
multizone_pids, matrix_pids = _categorize_products(existing_specs)
|
|
1008
|
+
switch_pids, multizone_pids, matrix_pids = _categorize_products(existing_specs)
|
|
968
1009
|
|
|
969
1010
|
# Build YAML content
|
|
970
1011
|
lines = _generate_yaml_header()
|
|
971
1012
|
lines.extend(_generate_multizone_section(multizone_pids, existing_specs))
|
|
972
1013
|
lines.extend(_generate_matrix_section(matrix_pids, existing_specs))
|
|
1014
|
+
lines.extend(_generate_switch_section(switch_pids, existing_specs))
|
|
973
1015
|
|
|
974
1016
|
# Write the new file
|
|
975
1017
|
with open(specs_path, "w") as f:
|
|
@@ -134,16 +134,17 @@ class ProductInfo:
|
|
|
134
134
|
"""Format product capabilities as a human-readable string.
|
|
135
135
|
|
|
136
136
|
Returns:
|
|
137
|
-
Comma-separated capability string (e.g., "
|
|
137
|
+
Comma-separated capability string (e.g., "color, infrared, multizone")
|
|
138
138
|
"""
|
|
139
139
|
caps = []
|
|
140
140
|
|
|
141
|
-
|
|
141
|
+
if self.has_buttons:
|
|
142
|
+
caps.append("buttons")
|
|
143
|
+
|
|
142
144
|
if self.has_relays:
|
|
143
|
-
|
|
144
|
-
caps.append("switch")
|
|
145
|
+
caps.append("relays")
|
|
145
146
|
elif self.has_color:
|
|
146
|
-
caps.append("
|
|
147
|
+
caps.append("color")
|
|
147
148
|
else:
|
|
148
149
|
# Check temperature range to determine white light type
|
|
149
150
|
if self.temperature_range:
|
|
@@ -170,9 +171,6 @@ class ProductInfo:
|
|
|
170
171
|
caps.append("HEV")
|
|
171
172
|
if self.has_chain:
|
|
172
173
|
caps.append("chain")
|
|
173
|
-
if self.has_buttons and not self.has_relays:
|
|
174
|
-
# Only show buttons if not already identified as switch
|
|
175
|
-
caps.append("buttons")
|
|
176
174
|
|
|
177
175
|
return ", ".join(caps) if caps else "unknown"
|
|
178
176
|
|
|
@@ -505,6 +503,22 @@ PRODUCTS: dict[int, ProductInfo] = {
|
|
|
505
503
|
temperature_range=TemperatureRange(min=1500, max=9000),
|
|
506
504
|
min_ext_mz_firmware=None,
|
|
507
505
|
),
|
|
506
|
+
70: ProductInfo(
|
|
507
|
+
pid=70,
|
|
508
|
+
name="LIFX Switch",
|
|
509
|
+
vendor=1,
|
|
510
|
+
capabilities=ProductCapability.RELAYS | ProductCapability.BUTTONS,
|
|
511
|
+
temperature_range=None,
|
|
512
|
+
min_ext_mz_firmware=None,
|
|
513
|
+
),
|
|
514
|
+
71: ProductInfo(
|
|
515
|
+
pid=71,
|
|
516
|
+
name="LIFX Switch",
|
|
517
|
+
vendor=1,
|
|
518
|
+
capabilities=ProductCapability.RELAYS | ProductCapability.BUTTONS,
|
|
519
|
+
temperature_range=None,
|
|
520
|
+
min_ext_mz_firmware=None,
|
|
521
|
+
),
|
|
508
522
|
81: ProductInfo(
|
|
509
523
|
pid=81,
|
|
510
524
|
name="LIFX Candle White to Warm",
|
|
@@ -545,6 +559,14 @@ PRODUCTS: dict[int, ProductInfo] = {
|
|
|
545
559
|
temperature_range=TemperatureRange(min=2700, max=2700),
|
|
546
560
|
min_ext_mz_firmware=None,
|
|
547
561
|
),
|
|
562
|
+
89: ProductInfo(
|
|
563
|
+
pid=89,
|
|
564
|
+
name="LIFX Switch",
|
|
565
|
+
vendor=1,
|
|
566
|
+
capabilities=ProductCapability.RELAYS | ProductCapability.BUTTONS,
|
|
567
|
+
temperature_range=None,
|
|
568
|
+
min_ext_mz_firmware=None,
|
|
569
|
+
),
|
|
548
570
|
90: ProductInfo(
|
|
549
571
|
pid=90,
|
|
550
572
|
name="LIFX Clean",
|
|
@@ -681,6 +703,22 @@ PRODUCTS: dict[int, ProductInfo] = {
|
|
|
681
703
|
temperature_range=TemperatureRange(min=1500, max=9000),
|
|
682
704
|
min_ext_mz_firmware=None,
|
|
683
705
|
),
|
|
706
|
+
115: ProductInfo(
|
|
707
|
+
pid=115,
|
|
708
|
+
name="LIFX Switch",
|
|
709
|
+
vendor=1,
|
|
710
|
+
capabilities=ProductCapability.RELAYS | ProductCapability.BUTTONS,
|
|
711
|
+
temperature_range=None,
|
|
712
|
+
min_ext_mz_firmware=None,
|
|
713
|
+
),
|
|
714
|
+
116: ProductInfo(
|
|
715
|
+
pid=116,
|
|
716
|
+
name="LIFX Switch",
|
|
717
|
+
vendor=1,
|
|
718
|
+
capabilities=ProductCapability.RELAYS | ProductCapability.BUTTONS,
|
|
719
|
+
temperature_range=None,
|
|
720
|
+
min_ext_mz_firmware=None,
|
|
721
|
+
),
|
|
684
722
|
117: ProductInfo(
|
|
685
723
|
pid=117,
|
|
686
724
|
name="LIFX Z US",
|
|
@@ -1321,10 +1359,6 @@ class ProductRegistry:
|
|
|
1321
1359
|
prod_features = product.get("features", {})
|
|
1322
1360
|
features: dict[str, Any] = {**default_features, **prod_features}
|
|
1323
1361
|
|
|
1324
|
-
# Skip switch products (devices with relays) - these are not lights
|
|
1325
|
-
if features.get("relays"):
|
|
1326
|
-
continue
|
|
1327
|
-
|
|
1328
1362
|
# Build capabilities bitfield
|
|
1329
1363
|
capabilities = 0
|
|
1330
1364
|
if features.get("color"):
|
lifx_emulator/products/specs.py
CHANGED
|
@@ -25,8 +25,8 @@ class ProductSpecs:
|
|
|
25
25
|
default_tile_count: Default number of tiles in chain
|
|
26
26
|
min_tile_count: Minimum tiles supported
|
|
27
27
|
max_tile_count: Maximum tiles supported
|
|
28
|
-
tile_width: Width of each tile in
|
|
29
|
-
tile_height: Height of each tile in
|
|
28
|
+
tile_width: Width of each tile in zones
|
|
29
|
+
tile_height: Height of each tile in zones
|
|
30
30
|
default_firmware_major: Default firmware major version
|
|
31
31
|
default_firmware_minor: Default firmware minor version
|
|
32
32
|
notes: Human-readable notes about this product
|
lifx_emulator/products/specs.yml
CHANGED
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
# default_tile_count: <number> # Default number of tiles in chain
|
|
22
22
|
# min_tile_count: <number> # Minimum tiles supported
|
|
23
23
|
# max_tile_count: <number> # Maximum tiles supported
|
|
24
|
-
# tile_width: <number> # Width of each tile in
|
|
25
|
-
# tile_height: <number> # Height of each tile in
|
|
24
|
+
# tile_width: <number> # Width of each tile in zones
|
|
25
|
+
# tile_height: <number> # Height of each tile in zones
|
|
26
26
|
#
|
|
27
|
-
# # Host firmware version (optional, overrides
|
|
27
|
+
# # Host firmware version (optional, overrides auto firmware selection)
|
|
28
28
|
# default_firmware_major: <number> # Firmware major version (e.g., 3)
|
|
29
29
|
# default_firmware_minor: <number> # Firmware minor version (e.g., 70)
|
|
30
30
|
#
|
|
@@ -33,9 +33,9 @@
|
|
|
33
33
|
#
|
|
34
34
|
# Firmware Version Notes:
|
|
35
35
|
# ----------------------
|
|
36
|
-
# If default_firmware_major and default_firmware_minor are both specified
|
|
37
|
-
#
|
|
38
|
-
# of that
|
|
36
|
+
# If default_firmware_major and default_firmware_minor are both specified
|
|
37
|
+
# they will be used as the default firmware version when creating devices
|
|
38
|
+
# of that type. This overrides the automatic firmware version selection
|
|
39
39
|
# based on extended_multizone capability (which defaults to 3.70 for extended
|
|
40
40
|
# multizone or 2.60 for non-extended).
|
|
41
41
|
#
|
|
@@ -167,29 +167,29 @@ products:
|
|
|
167
167
|
# Matrix Products (Tiles, Candles, etc.)
|
|
168
168
|
# ========================================
|
|
169
169
|
|
|
170
|
-
55: # LIFX Tile, 8x8
|
|
170
|
+
55: # LIFX Tile, 8x8 zone matrix, chainable up to 5
|
|
171
171
|
default_tile_count: 5
|
|
172
172
|
min_tile_count: 1
|
|
173
173
|
max_tile_count: 5
|
|
174
174
|
tile_width: 8
|
|
175
175
|
tile_height: 8
|
|
176
|
-
notes: LIFX Tile, 8x8
|
|
176
|
+
notes: LIFX Tile, 8x8 zone matrix, chainable up to 5
|
|
177
177
|
|
|
178
|
-
57: # LIFX Candle, 5x6
|
|
178
|
+
57: # LIFX Candle, 5x6 zone matrix, single unit
|
|
179
179
|
default_tile_count: 1
|
|
180
180
|
min_tile_count: 1
|
|
181
181
|
max_tile_count: 1
|
|
182
182
|
tile_width: 5
|
|
183
183
|
tile_height: 6
|
|
184
|
-
notes: LIFX Candle, 5x6
|
|
184
|
+
notes: LIFX Candle, 5x6 zone matrix, single unit
|
|
185
185
|
|
|
186
|
-
68: # LIFX Candle variant, 5x6
|
|
186
|
+
68: # LIFX Candle variant, 5x6 zone matrix
|
|
187
187
|
default_tile_count: 1
|
|
188
188
|
min_tile_count: 1
|
|
189
189
|
max_tile_count: 1
|
|
190
190
|
tile_width: 5
|
|
191
191
|
tile_height: 6
|
|
192
|
-
notes: LIFX Candle variant, 5x6
|
|
192
|
+
notes: LIFX Candle variant, 5x6 zone matrix
|
|
193
193
|
|
|
194
194
|
137: # LIFX Candle Color US 5x6 variant
|
|
195
195
|
default_tile_count: 1
|
|
@@ -350,3 +350,37 @@ products:
|
|
|
350
350
|
tile_width: 3
|
|
351
351
|
tile_height: 2
|
|
352
352
|
notes: LIFX Round Path Intl
|
|
353
|
+
|
|
354
|
+
# ========================================
|
|
355
|
+
# Switch Products (Relays)
|
|
356
|
+
# ========================================
|
|
357
|
+
|
|
358
|
+
70: # LIFX Switch
|
|
359
|
+
relay_count: 2
|
|
360
|
+
default_firmware_major: 4
|
|
361
|
+
default_firmware_minor: 100
|
|
362
|
+
notes: LIFX Switch
|
|
363
|
+
|
|
364
|
+
71: # LIFX Switch
|
|
365
|
+
relay_count: 2
|
|
366
|
+
default_firmware_major: 4
|
|
367
|
+
default_firmware_minor: 100
|
|
368
|
+
notes: LIFX Switch
|
|
369
|
+
|
|
370
|
+
89: # LIFX Switch
|
|
371
|
+
relay_count: 2
|
|
372
|
+
default_firmware_major: 4
|
|
373
|
+
default_firmware_minor: 100
|
|
374
|
+
notes: LIFX Switch
|
|
375
|
+
|
|
376
|
+
115: # LIFX Switch
|
|
377
|
+
relay_count: 2
|
|
378
|
+
default_firmware_major: 4
|
|
379
|
+
default_firmware_minor: 100
|
|
380
|
+
notes: LIFX Switch
|
|
381
|
+
|
|
382
|
+
116: # LIFX Switch
|
|
383
|
+
relay_count: 2
|
|
384
|
+
default_firmware_major: 4
|
|
385
|
+
default_firmware_minor: 100
|
|
386
|
+
notes: LIFX Switch
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
lifx_emulator/__init__.py,sha256=vjhtpAQRSsUZtaUGCQKbmPALvwZ_BF8Mko8w6jzVqBw,819
|
|
2
|
-
lifx_emulator/__main__.py,sha256=
|
|
2
|
+
lifx_emulator/__main__.py,sha256=0rNT1ua1uckq9f2l7g6wIC5VDj9kmWzuL-UZBwm5_tE,22057
|
|
3
3
|
lifx_emulator/constants.py,sha256=DFZkUsdewE-x_3MgO28tMGkjUCWPeYc3xLj_EXViGOw,1032
|
|
4
4
|
lifx_emulator/server.py,sha256=r2JYFcpZIqqhue-Nfq7FbN0KfC3XDf3XDb6b43DsiCk,16438
|
|
5
5
|
lifx_emulator/api/__init__.py,sha256=FoEPw_In5-H_BDQ-XIIONvgj-UqIDVtejIEVRv9qmV8,647
|
|
6
6
|
lifx_emulator/api/app.py,sha256=IxK8sC7MgdtkoLz8iXcEt02nPDaVgdKJgEiGnzTs-YE,4880
|
|
7
|
-
lifx_emulator/api/models.py,sha256=
|
|
7
|
+
lifx_emulator/api/models.py,sha256=qFNo0sOl31yuZLtWmLroSW6f6jck-RhP05tx972xsWA,3971
|
|
8
8
|
lifx_emulator/api/mappers/__init__.py,sha256=ZPCOQR9odcwn0C58AjFW6RvBXe5gOll_QS5lAabgorQ,152
|
|
9
9
|
lifx_emulator/api/mappers/device_mapper.py,sha256=EGOpdao9ZS-vT4T8IoV-AoN5WucTnqpQO92dYizo3vw,4151
|
|
10
10
|
lifx_emulator/api/routers/__init__.py,sha256=kbMefnuXrEsYeMA9J4YK_wVs87_XcH7hwkEifR-zgMc,369
|
|
@@ -13,33 +13,33 @@ lifx_emulator/api/routers/monitoring.py,sha256=qgVBNm6iMESf1W6EE22DvLalMnxkr0pRb
|
|
|
13
13
|
lifx_emulator/api/routers/scenarios.py,sha256=0axSQ9r6rByvXLvqRqOU2ma5nTvZgZ0IIzEXdtzoPnM,9743
|
|
14
14
|
lifx_emulator/api/services/__init__.py,sha256=ttjjZfAxbDQC_Ep0LkXjopNiVZOFPsFDSOHhBN98v5s,277
|
|
15
15
|
lifx_emulator/api/services/device_service.py,sha256=r3uFWApC8sVQMCuuzkyjm27K4LDpZnnHmQNgXWX40ok,6294
|
|
16
|
-
lifx_emulator/api/templates/dashboard.html,sha256=
|
|
16
|
+
lifx_emulator/api/templates/dashboard.html,sha256=h-PeOH_La5bVOUBcXmTY2leRlMdL8D8yJ-NCx3S16-A,33792
|
|
17
17
|
lifx_emulator/devices/__init__.py,sha256=QlBTPnFErJcSKLvGyeDwemh7xcpjYvB_L5siKsjr3s8,1089
|
|
18
|
-
lifx_emulator/devices/device.py,sha256=
|
|
18
|
+
lifx_emulator/devices/device.py,sha256=yEOXc_xr1X45bJzG2qB-A-oIHwnA8qqYlIsFialobGc,15780
|
|
19
19
|
lifx_emulator/devices/manager.py,sha256=XDrT82um5sgNpNihLj5RsNvHqdVI1bK9YY2eBzWIcf0,8162
|
|
20
20
|
lifx_emulator/devices/observers.py,sha256=-KnUgFcKdhlNo7CNVstP-u0wU2W0JAGg055ZPV15Sj0,3874
|
|
21
21
|
lifx_emulator/devices/persistence.py,sha256=9Mhj46-xrweOmyzjORCi2jKIwa8XJWpQ5CgaKcw6U98,10513
|
|
22
22
|
lifx_emulator/devices/state_restorer.py,sha256=eDsRSW-2RviP_0Qlk2DHqMaB-zhV0X1cNQECv2lD1qc,9809
|
|
23
23
|
lifx_emulator/devices/state_serializer.py,sha256=aws4LUmXBJS8oBrQziJtlV0XMvCTm5X4dGkGlO_QHcM,6281
|
|
24
|
-
lifx_emulator/devices/states.py,sha256=
|
|
25
|
-
lifx_emulator/factories/__init__.py,sha256=
|
|
26
|
-
lifx_emulator/factories/builder.py,sha256=
|
|
24
|
+
lifx_emulator/devices/states.py,sha256=kNv-VV1UCDxPduixU1-5xBGKRzeCfE-bYzzEh_1GnUU,12204
|
|
25
|
+
lifx_emulator/factories/__init__.py,sha256=CsryMcf_80hTjOAgrukA6vRZaZow_2VQkSewrpP9gEI,1210
|
|
26
|
+
lifx_emulator/factories/builder.py,sha256=xs3g3_-euUqgdcBu_3umPZb-xlzDeoDeOrwEGJShOwA,12164
|
|
27
27
|
lifx_emulator/factories/default_config.py,sha256=FTcxKDfeTmO49GTSki8nxnEIZQzR0Lg0hL_PwHUrkVQ,4828
|
|
28
|
-
lifx_emulator/factories/factory.py,sha256=
|
|
28
|
+
lifx_emulator/factories/factory.py,sha256=MyGG-pW7EV2BFP5ZzgMuFF5TfNFvfyFDoE5dmd3LC8w,8623
|
|
29
29
|
lifx_emulator/factories/firmware_config.py,sha256=tPN5Hq-uNb1xzW9Q0A9jD-G0-NaGfINcD0i1XZRUMoE,2711
|
|
30
30
|
lifx_emulator/factories/serial_generator.py,sha256=MbaXoommsj76ho8_ZoKuUDnffDf98YvwQiXZSWsUsEs,2507
|
|
31
31
|
lifx_emulator/handlers/__init__.py,sha256=3Hj1hRo3yL3E7GKwG9TaYh33ymk_N3bRiQ8nvqSQULA,1306
|
|
32
32
|
lifx_emulator/handlers/base.py,sha256=0avCLXY_rNlw16PpJ5JrRCwXNE4uMpBqF3PfSfNJ0b8,1654
|
|
33
33
|
lifx_emulator/handlers/device_handlers.py,sha256=1AmslA4Ut6L7b3SfduDdvnQizTpzUB3KKWBXmp4WYLQ,9462
|
|
34
|
-
lifx_emulator/handlers/light_handlers.py,sha256=
|
|
34
|
+
lifx_emulator/handlers/light_handlers.py,sha256=255aoiIjSIL63kbHQa6wqUpEwFzFFx7SG6P1nWM9jgU,17769
|
|
35
35
|
lifx_emulator/handlers/multizone_handlers.py,sha256=2dYsitq0KzEaxEAJmz7ixtir1tvFMOAnfkBQqslqbPM,7914
|
|
36
36
|
lifx_emulator/handlers/registry.py,sha256=s1ht4PmPhXhAcwu1hoY4yW39wy3SPJBMY-9Uxd0FWuE,3292
|
|
37
|
-
lifx_emulator/handlers/tile_handlers.py,sha256=
|
|
37
|
+
lifx_emulator/handlers/tile_handlers.py,sha256=L4fNKGTSSIxRuqKrfDrMSrNPvDJr3aIuaEqbhRCOt04,17176
|
|
38
38
|
lifx_emulator/products/__init__.py,sha256=qcNop_kRYFF3zSjNemzQEgu3jPrIxfyQyLv9GsnaLEI,627
|
|
39
|
-
lifx_emulator/products/generator.py,sha256=
|
|
40
|
-
lifx_emulator/products/registry.py,sha256=
|
|
41
|
-
lifx_emulator/products/specs.py,sha256=
|
|
42
|
-
lifx_emulator/products/specs.yml,sha256=
|
|
39
|
+
lifx_emulator/products/generator.py,sha256=fvrhw_b7shLCtEtUFxWF5VBEQAeSrsaiXxoGIP5Vn4g,34675
|
|
40
|
+
lifx_emulator/products/registry.py,sha256=1SZ3fXVFFL8jhKYIZBqwtIQDN3qL1Lvf86P3N1_Kdx8,47323
|
|
41
|
+
lifx_emulator/products/specs.py,sha256=epqz2DPyNOOOFHhmI_wlk7iEbgN0vCugHz-hWx9FlAI,8728
|
|
42
|
+
lifx_emulator/products/specs.yml,sha256=6hh7V-953uN4t3WD2rY9Nn8zKFZuQDHgYVo7LgZcGEA,10399
|
|
43
43
|
lifx_emulator/protocol/__init__.py,sha256=-wjC-wBcb7fxi5I-mJr2Ad8K2YRflJFdLLdobfD-W1Q,56
|
|
44
44
|
lifx_emulator/protocol/base.py,sha256=V6t0baSgIXjrsz2dBuUn_V9xwradSqMxBFJHAUtnfCs,15368
|
|
45
45
|
lifx_emulator/protocol/const.py,sha256=ilhv-KcQpHtKh2MDCaIbMLQAsxKO_uTaxyR63v1W8cc,226
|
|
@@ -55,8 +55,8 @@ lifx_emulator/scenarios/__init__.py,sha256=CGjudoWvyysvFj2xej11N2cr3mYROGtRb9zVH
|
|
|
55
55
|
lifx_emulator/scenarios/manager.py,sha256=1esxRdz74UynNk1wb86MGZ2ZFAuMzByuu74nRe3D-Og,11163
|
|
56
56
|
lifx_emulator/scenarios/models.py,sha256=BKS_fGvrbkGe-vK3arZ0w2f9adS1UZhiOoKpu7GENnc,4099
|
|
57
57
|
lifx_emulator/scenarios/persistence.py,sha256=3vjtPNFYfag38tUxuqxkGpWhQ7uBitc1rLroSAuw9N8,8881
|
|
58
|
-
lifx_emulator-2.
|
|
59
|
-
lifx_emulator-2.
|
|
60
|
-
lifx_emulator-2.
|
|
61
|
-
lifx_emulator-2.
|
|
62
|
-
lifx_emulator-2.
|
|
58
|
+
lifx_emulator-2.4.0.dist-info/METADATA,sha256=qohy51CSwGIwGXstHI26VBqfY3_aYWgBEQDYE4nEnI8,4549
|
|
59
|
+
lifx_emulator-2.4.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
60
|
+
lifx_emulator-2.4.0.dist-info/entry_points.txt,sha256=R9C_K_tTgt6yXEmhzH4r2Yx2Tu1rLlnYzeG4RFUVzSc,62
|
|
61
|
+
lifx_emulator-2.4.0.dist-info/licenses/LICENSE,sha256=eBz48GRA3gSiWn3rYZAz2Ewp35snnhV9cSqkVBq7g3k,1832
|
|
62
|
+
lifx_emulator-2.4.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|