6745e25b20
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.
90 lines
3.3 KiB
Python
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
|