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 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 pixels. Uses product defaults if not
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 pixels. Uses product defaults if
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
@@ -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 pixels", ge=1, le=256
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 pixels", ge=1, le=256
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-pixel {
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 pixels
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 totalPixels = width * height;
655
+ const totalzones = width * height;
656
656
 
657
- // Create grid of pixels
658
- const slicedColors = tile.colors.slice(0, totalPixels);
659
- const pixelsHtml = slicedColors.map(color => {
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-pixel" style="${bgStyle}"></div>`;
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
- ${pixelsHtml}
678
+ ${zonesHtml}
679
679
  </div>
680
680
  </div>
681
681
  `;
@@ -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
- pixels = self.state.tile_width * self.state.tile_height
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(pixels)
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
 
@@ -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
- pixels = width * height
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(pixels)
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 pixels
140
- height: Tile height in pixels
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 pixels (uses product default)
108
- tile_height: Optional tile height in pixels (uses product default)
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 pixels (default: 8)
168
- tile_height: Height of each tile in pixels (default: 8)
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=device_state.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
- logger.info(
50
- f"Color set to HSBK({c.hue}, {c.saturation}, "
51
- f"{c.brightness}, {c.kelvin}), duration={packet.duration}ms"
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
- return [
131
- Light.StateColor(
132
- color=device_state.color,
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
- return [
184
- Light.StateColor(
185
- color=device_state.color,
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 pixels
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
- pixels_extracted = 0
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 pixels_extracted >= 64:
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
- pixels_extracted += 1
164
+ zones_extracted += 1
165
165
  continue
166
166
 
167
- # Calculate pixel index in flat color array
168
- pixel_idx = y * tile_width + x
169
- if pixel_idx < len(tile_colors):
170
- colors.append(tile_colors[pixel_idx])
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
- pixels_extracted += 1
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 pixels
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
- pixels_written = 0
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 pixels_written >= 64:
242
- pixels_written += 1
241
+ if x >= tile_width or zones_written >= 64:
242
+ zones_written += 1
243
243
  continue
244
244
 
245
- # Calculate pixel index in flat color array
246
- pixel_idx = y * tile_width + x
247
- if pixel_idx < len(target_colors) and pixels_written < len(
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 {pixels_written} colors at "
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
- pixels_copied = 0
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
- pixels_copied += 1
337
+ zones_copied += 1
340
338
 
341
339
  logger.info(
342
- f"Tile {tile_index} copied {pixels_copied} pixels from "
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., "full color, infrared, multizone")
368
+ Comma-separated capability string (e.g., "color, infrared, multizone")
377
369
  """
378
370
  caps = []
379
371
 
380
- # Determine base light type
372
+ if self.has_buttons:
373
+ caps.append("buttons")
374
+
381
375
  if self.has_relays:
382
- # Devices with relays are switches, not lights
383
- caps.append("switch")
376
+ caps.append("relays")
384
377
  elif self.has_color:
385
- caps.append("full color")
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
- if self.has_buttons and not self.has_relays:
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 matrix products that need specs templates.
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["multizone"]:
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 "tile_width" in specs or "tile_height" in specs:
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 pixels",
810
- "# tile_height: <number> # Height of each tile in pixels",
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., "full color, infrared, multizone")
137
+ Comma-separated capability string (e.g., "color, infrared, multizone")
138
138
  """
139
139
  caps = []
140
140
 
141
- # Determine base light type
141
+ if self.has_buttons:
142
+ caps.append("buttons")
143
+
142
144
  if self.has_relays:
143
- # Devices with relays are switches, not lights
144
- caps.append("switch")
145
+ caps.append("relays")
145
146
  elif self.has_color:
146
- caps.append("full color")
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"):
@@ -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 pixels
29
- tile_height: Height of each tile in pixels
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
@@ -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 pixels
25
- # tile_height: <number> # Height of each tile in pixels
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 automatic firmware selection)
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 for a
37
- # product, they will be used as the default firmware version when creating devices
38
- # of that product type. This overrides the automatic firmware version selection
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 pixel matrix, chainable up to 5
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 pixel matrix, chainable up to 5
176
+ notes: LIFX Tile, 8x8 zone matrix, chainable up to 5
177
177
 
178
- 57: # LIFX Candle, 5x6 pixel matrix, single unit
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 pixel matrix, single unit
184
+ notes: LIFX Candle, 5x6 zone matrix, single unit
185
185
 
186
- 68: # LIFX Candle variant, 5x6 pixel matrix
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 pixel matrix
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lifx-emulator
3
- Version: 2.3.0
3
+ Version: 2.4.0
4
4
  Summary: LIFX Emulator for testing LIFX LAN protocol libraries
5
5
  Author-email: Avi Miller <me@dje.li>
6
6
  Maintainer-email: Avi Miller <me@dje.li>
@@ -1,10 +1,10 @@
1
1
  lifx_emulator/__init__.py,sha256=vjhtpAQRSsUZtaUGCQKbmPALvwZ_BF8Mko8w6jzVqBw,819
2
- lifx_emulator/__main__.py,sha256=zaul9OQhN5csqOqxGWXkVrlurfo2R_-YvM6URk4QAME,21680
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=eBx80Ece_4Wv6aqxb1CsZEob9CF0WmR9oJGz3hh14x8,3973
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=YXQ9jrs30DZIxtMWFE4E2HqmsgHQ-NeWTTQxQ-7BfHk,33800
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=LIVXURglYsYMC6_88sAWzoJKkq_HSZEOu4xruRtcZKs,13650
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=O__VtgK97-ZHxZ2qgOKp9-fDG8HcmlTVkGYONwot8iQ,12094
25
- lifx_emulator/factories/__init__.py,sha256=yN8i_Hu_cFEryWZmh0TiOQvWEYFVIApQSs4xeb0EfBk,1170
26
- lifx_emulator/factories/builder.py,sha256=OaDqQDGkAyZCSO-4HAsFSd5UzsHpHvRyBk-Fotl1mAY,12056
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=VQfU5M8zrpFyNHjpGP1q-3bpek9MltBdoAUSvIvt7Bs,7583
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=Ryz-_fzoVCT6DBkXhW9YCOYJYaMRcBOIguL3HrQXhAw,11471
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=tniYndUbtWPTu7YznfMKsWUBu2UmGsSuOYRws5IBL0s,17239
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=5zcq0iKgwjtg0gePnBEOdIkumesvfzEcKRdBZFPyGtk,33538
40
- lifx_emulator/products/registry.py,sha256=qkm2xgGZo_ds3wAbYplLu4gb0cxhjZXjnCc1V8etpHw,46517
41
- lifx_emulator/products/specs.py,sha256=RxToCvu-iwF6QMcmYgI5CblLOr8hpDZ3k3WT1fZcKe8,8730
42
- lifx_emulator/products/specs.yml,sha256=eM_r1lTRQHii4dyvnsZWx1P7NzDSZzJl03aR86TY564,9683
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.3.0.dist-info/METADATA,sha256=PsbeOthCdD824wzYnw8vf_rKKze3Yk1DPC6NtpkZv1Q,4549
59
- lifx_emulator-2.3.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
60
- lifx_emulator-2.3.0.dist-info/entry_points.txt,sha256=R9C_K_tTgt6yXEmhzH4r2Yx2Tu1rLlnYzeG4RFUVzSc,62
61
- lifx_emulator-2.3.0.dist-info/licenses/LICENSE,sha256=eBz48GRA3gSiWn3rYZAz2Ewp35snnhV9cSqkVBq7g3k,1832
62
- lifx_emulator-2.3.0.dist-info/RECORD,,
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,,