Files
ledgrab/server/tests/test_nanoleaf_extcontrol.py
T
alexei.dolgolyov 6745e25b20 feat: roadmap batch (2026-06-19) — solar/linear-light/dither/nanoleaf + integrations
Eight roadmap features from the 2026-06-19 review, each a full vertical
(backend + tests + frontend + i18n en/ru/zh); ~67 new unit tests:

- automations: SolarRule sunrise/sunset trigger (new utils/solar.py, shared
  with the daylight cycle; window logic mirrors TimeOfDayRule)
- ci: best-effort arm64 multi-arch Docker manifest via QEMU + docker manifest
  (release.yml; amd64 path untouched, continue-on-error)
- game-integration: wire the orphaned LoLPoller via a LoLPollManager + a shared
  runtime_state module (poll lifecycle on enable/CRUD/startup/shutdown)
- ui: color-harmony gradient generator (complementary/analogous/triadic/...)
- effects: audio-reactive palette modulation (new audio_energy_tap; brightness/
  saturation modulation across all 12 procedural effects)
- capture: linear-light blending + spatio-temporal dithering, opt-in per
  calibration (new utils/linear_light.py, utils/dither.py)
- devices: Nanoleaf extControl v2 per-panel UDP streaming (per_panel mode)

Also bundles the pending 2026-06-18 production-review fixes and other
in-progress work already in the working tree (manual-trigger rule, etc.),
since they share files and could not be cleanly separated.

Gate: ruff + tsc clean; pytest 2654 passed / 2 skipped. The single failing
test (automation manual_trigger handler coverage) is a separate in-progress
item owned elsewhere, intentionally left as-is.
2026-06-22 23:21:24 +03:00

90 lines
3.3 KiB
Python

"""Tests for Nanoleaf extControl v2 per-panel streaming.
The device-side (panelLayout fetch, extControl enable, UDP send) needs a real
controller to validate; here we lock down the parts that DON'T: panel
ordering, strip→panel resampling, the exact UDP packet framing, and the
``nanoleaf_per_panel`` config round-trip through the device store.
"""
from __future__ import annotations
import struct
from ledgrab.core.devices.nanoleaf_client import (
build_extcontrol_v2_packet,
map_pixels_to_panels,
order_panels,
)
from ledgrab.storage.device_store import Device
class TestOrderPanels:
def test_sorts_by_x_then_y_and_drops_controller(self):
position = [
{"panelId": 5, "x": 100, "y": 0},
{"panelId": 3, "x": 0, "y": 0},
{"panelId": 0, "x": 50, "y": 50}, # controller / rhythm → dropped
{"panelId": 7, "x": 0, "y": 100},
]
assert order_panels(position) == [3, 7, 5]
def test_ignores_non_integer_panel_ids(self):
assert order_panels([{"panelId": "x", "x": 0, "y": 0}, {"x": 1, "y": 1}]) == []
class TestMapPixelsToPanels:
def test_nearest_neighbour_resample(self):
pixels = [[10, 10, 10], [20, 20, 20], [30, 30, 30], [40, 40, 40]]
out = map_pixels_to_panels(pixels, [1, 2])
assert out == [(1, 10, 10, 10), (2, 30, 30, 30)]
def test_empty_strip_is_black(self):
assert map_pixels_to_panels([], [9, 8]) == [(9, 0, 0, 0), (8, 0, 0, 0)]
def test_more_panels_than_pixels_repeats(self):
out = map_pixels_to_panels([[255, 0, 0]], [1, 2, 3])
assert out == [(1, 255, 0, 0), (2, 255, 0, 0), (3, 255, 0, 0)]
class TestPacket:
def test_framing_is_byte_exact(self):
panels = [(100, 10, 20, 30), (200, 40, 50, 60)]
pkt = build_extcontrol_v2_packet(panels)
assert len(pkt) == 2 + 2 * 8 # uint16 header + 8 bytes/panel
assert struct.unpack(">H", pkt[0:2])[0] == 2
pid, r, g, b, w, trans = struct.unpack(">HBBBBH", pkt[2:10])
assert (pid, r, g, b, w, trans) == (100, 10, 20, 30, 0, 1)
pid2, r2, g2, b2, w2, trans2 = struct.unpack(">HBBBBH", pkt[10:18])
assert (pid2, r2, g2, b2, w2, trans2) == (200, 40, 50, 60, 0, 1)
def test_values_are_masked_to_byte_range(self):
pkt = build_extcontrol_v2_packet([(70000, 300, -5, 256)])
pid, r, g, b, w, trans = struct.unpack(">HBBBBH", pkt[2:10])
assert pid == 70000 & 0xFFFF
assert r == 300 & 0xFF and b == 256 & 0xFF
class TestConfigRoundTrip:
def _device(self, per_panel: bool) -> Device:
return Device(
device_id="d1",
name="Shapes",
url="nanoleaf://1.2.3.4",
led_count=10,
device_type="nanoleaf",
nanoleaf_token="tok",
nanoleaf_per_panel=per_panel,
)
def test_per_panel_round_trips_through_store(self):
d = self._device(True)
assert d.to_dict().get("nanoleaf_per_panel") is True
back = Device.from_dict(d.to_dict())
assert back.nanoleaf_per_panel is True
assert back.to_config().nanoleaf_per_panel is True
def test_default_off_is_omitted(self):
d = self._device(False)
assert "nanoleaf_per_panel" not in d.to_dict()
assert d.to_config().nanoleaf_per_panel is False