Files
ledgrab/server/tests/test_hue_segment.py
alexei.dolgolyov 39b0554444 feat: roadmap round two (2026-06-23) — per-pixel smart-lights + integrations
A) Per-pixel smart-lights
- LIFX multizone (SetExtendedColorZones msg 510, <=82 zones) + Tile
  (SetTileState64 715), auto-detected on connect with single-colour
  fallback; lifx_per_zone threaded like nanoleaf_per_panel
- Hue gradient-lightstrip mapping: Entertainment v2 frame now keyed by
  channel id (was 1 light=1 LED), channels discovered on connect;
  hue_gradient_mode toggle (default on)

B) Integrations bundle
- Outbound webhook automation action (Discord/IFTTT/Zapier/Node-RED),
  SSRF-gated via validate_polling_url at both save and fire time; fires
  on activate/deactivate, best-effort, audited
- Home Assistant MQTT auto-discovery: read-only binary_sensors per
  automation + connectivity, availability via birth/will, cleanup on
  disable/delete, live state from the engine

Shared: pixel_reduce.resample_to_n nearest-neighbour helper.
57 new tests (lifx_multizone, hue_segment, webhook_action, ha_discovery).
Gate: ruff + tsc + build clean, pytest 2719 passed / 2 skipped.
2026-06-23 00:50:22 +03:00

141 lines
5.2 KiB
Python

"""Tests for Hue gradient-lightstrip segment (channel) mapping.
The DTLS handshake and the actual gradient rendering need a real bridge +
gradient strip to validate; here we lock down the parts that DON'T: channel
discovery/ordering from an entertainment_configuration payload, the v2 frame
builder's channel-id keying (vs the legacy per-light index), the resample on
send, and the ``hue_gradient_mode`` config round-trip through the store.
"""
from __future__ import annotations
import struct
import pytest
from ledgrab.core.devices.hue_client import (
HEADER_SIZE,
PROTOCOL_NAME,
HueClient,
_build_entertainment_frame,
parse_entertainment_channels,
)
from ledgrab.storage.device_store import Device
class _FakeSock:
def __init__(self) -> None:
self.sent: list[bytes] = []
def sendto(self, data: bytes, addr) -> None:
self.sent.append(bytes(data))
class TestParseChannels:
def test_orders_by_position_x_then_y(self):
cfg = {
"data": [
{
"channels": [
{"channel_id": 2, "position": {"x": 0.5, "y": 0.0}},
{"channel_id": 0, "position": {"x": -0.5, "y": 0.0}},
{"channel_id": 1, "position": {"x": 0.0, "y": 0.0}},
]
}
]
}
assert parse_entertainment_channels(cfg) == [0, 1, 2]
def test_gradient_strip_contributes_multiple_channels(self):
# A 5-segment gradient strip → five channels, all from one light.
channels = [{"channel_id": i, "position": {"x": i * 0.1, "y": 0.0}} for i in range(5)]
cfg = {"data": [{"channels": channels}]}
assert parse_entertainment_channels(cfg) == [0, 1, 2, 3, 4]
def test_empty_payload_is_empty(self):
assert parse_entertainment_channels({}) == []
assert parse_entertainment_channels({"data": []}) == []
class TestFrameBuilder:
def test_header_is_huestream_v2(self):
frame = _build_entertainment_frame([(255, 0, 0)], sequence=7)
assert frame[0:9] == PROTOCOL_NAME
assert frame[9] == 2 and frame[10] == 0 # version 2.0
assert frame[11] == 7 # sequence
def test_record_is_seven_bytes_keyed_by_channel(self):
colors = [(255, 0, 0), (0, 255, 0)]
frame = _build_entertainment_frame(colors, channel_ids=[3, 1])
assert len(frame) == HEADER_SIZE + 7 * 2
cid0, r0, g0, b0 = struct.unpack_from(">BHHH", frame, HEADER_SIZE)
assert (cid0, r0, g0, b0) == (3, 255 * 257, 0, 0)
cid1, r1, g1, b1 = struct.unpack_from(">BHHH", frame, HEADER_SIZE + 7)
assert (cid1, r1, g1, b1) == (1, 0, 255 * 257, 0)
def test_falls_back_to_index_without_channel_map(self):
frame = _build_entertainment_frame([(1, 2, 3), (4, 5, 6)])
cid0 = struct.unpack_from(">B", frame, HEADER_SIZE)[0]
cid1 = struct.unpack_from(">B", frame, HEADER_SIZE + 7)[0]
assert (cid0, cid1) == (0, 1)
class TestSendAndCount:
def _client(self, *, channel_ids, led_count, gradient_mode=True) -> HueClient:
c = HueClient("hue://1.2.3.4", led_count=led_count, gradient_mode=gradient_mode)
c._connected = True
c._sock = _FakeSock() # type: ignore[assignment]
c._dtls_sock = None
c._channel_ids = channel_ids
return c
def test_resamples_strip_across_channels(self):
c = self._client(channel_ids=[0, 1, 2, 3, 4], led_count=1)
c.send_pixels_fast([(10, 0, 0)] * 20)
frame = c._sock.sent[0] # type: ignore[attr-defined]
# 5 channels → 5 records.
assert len(frame) == HEADER_SIZE + 7 * 5
def test_legacy_path_uses_led_count(self):
c = self._client(channel_ids=[], led_count=3)
c.send_pixels_fast([(5, 5, 5)] * 8)
frame = c._sock.sent[0] # type: ignore[attr-defined]
assert len(frame) == HEADER_SIZE + 7 * 3
def test_device_led_count_reflects_channels(self):
assert self._client(channel_ids=[0, 1, 2, 3, 4], led_count=1).device_led_count == 5
assert self._client(channel_ids=[], led_count=3).device_led_count == 3
# Gradient mode off → channel count ignored even if present.
off = self._client(channel_ids=[0, 1], led_count=2, gradient_mode=False)
assert off.device_led_count == 2
class TestConfigRoundTrip:
def _device(self, gradient_mode: bool) -> Device:
return Device(
device_id="d1",
name="Gradient strip",
url="hue://1.2.3.4",
led_count=5,
device_type="hue",
hue_gradient_mode=gradient_mode,
)
def test_default_on_is_omitted(self):
d = self._device(True)
assert "hue_gradient_mode" not in d.to_dict()
back = Device.from_dict(d.to_dict())
assert back.hue_gradient_mode is True
assert back.to_config().hue_gradient_mode is True
def test_opt_out_round_trips(self):
d = self._device(False)
assert d.to_dict().get("hue_gradient_mode") is False
back = Device.from_dict(d.to_dict())
assert back.hue_gradient_mode is False
assert back.to_config().hue_gradient_mode is False
if __name__ == "__main__":
pytest.main([__file__, "-v"])