feat: LED hot-path perf, tutorials expansion, modal markup polish
Performance (LED hot path, allocation-free per-frame): - Adalight: dedicated single-worker tx executor (avoids asyncio.to_thread overhead), pre-allocated wire buffer + uint8 scratch, header struct precomputed. New tests cover header format, buffer reuse, non-contiguous input, and brightness scaling. - DDP: pre-built struct.Struct for the 10-byte header, allocation-free send buffer + memoryview emit path. New tests cover RGB/RGBW packets, sequence/PUSH semantics, and multi-packet fragmentation. - Calibration: precomputed Phase 3 skip-LED resampling (floor/ceil indices, fractional weights, take/blend scratch buffers) — per-frame work is now np.take + in-place blend, no allocations. - WLED target processor: matching hot-path tightening. Tutorials: - Sub-tab switching, breadcrumb header, and prepare/switchSubTab hooks so a tour can open/close the dashboard customize panel and resolve targets behind sub-tabs. - New steps for integrations tab, dashboard customize panel (presets, global, sections, perf cells), targets, scenes, sync-clocks. - en/ru/zh locales updated with the new tour strings. Dashboard layout: - Structural deep-equal so the "modified" indicator reflects truth after a user edits then reverts, instead of a stale flag. UI polish: - Mod-card / modal markup pass across ~33 modal templates and the tutorial overlay partial. - appearance.css, modal.css, tutorials.css refresh. Tooling: - Add .mcp.json with code-review-graph MCP server config so the graph tools are available to the team out of the box.
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
"""Tests for DDP client packet construction (the LED hot path)."""
|
||||
|
||||
import struct
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from ledgrab.core.devices.ddp_client import DDPClient
|
||||
|
||||
|
||||
def _make_client(rgbw: bool = False) -> DDPClient:
|
||||
"""Build a DDPClient with a mocked transport that captures sendto bytes."""
|
||||
client = DDPClient(host="127.0.0.1", rgbw=rgbw)
|
||||
transport = MagicMock()
|
||||
captured: list[bytes] = []
|
||||
|
||||
def _sendto(data, addr=None): # noqa: ARG001
|
||||
# asyncio's sendto accepts bytes-like; copy to bytes for assertion stability
|
||||
captured.append(bytes(data))
|
||||
|
||||
transport.sendto.side_effect = _sendto
|
||||
client._transport = transport
|
||||
client._captured = captured # type: ignore[attr-defined]
|
||||
return client
|
||||
|
||||
|
||||
def _parse_header(packet: bytes) -> dict:
|
||||
"""Parse a 10-byte DDP header back into named fields."""
|
||||
flags, seq, dtype, src, offset, dlen = struct.unpack("!BBB B I H", packet[:10])
|
||||
return {
|
||||
"flags": flags,
|
||||
"seq": seq,
|
||||
"type": dtype,
|
||||
"src": src,
|
||||
"offset": offset,
|
||||
"data_len": dlen,
|
||||
"payload": packet[10:],
|
||||
}
|
||||
|
||||
|
||||
def test_send_pixels_numpy_single_packet_rgb():
|
||||
client = _make_client(rgbw=False)
|
||||
pixels = np.array(
|
||||
[[10, 20, 30], [40, 50, 60], [70, 80, 90]],
|
||||
dtype=np.uint8,
|
||||
)
|
||||
|
||||
client.send_pixels_numpy(pixels)
|
||||
|
||||
assert len(client._captured) == 1
|
||||
parsed = _parse_header(client._captured[0])
|
||||
# PUSH on the only packet, VER=1
|
||||
assert parsed["flags"] == 0x40 | 0x01
|
||||
assert parsed["type"] == 0x01
|
||||
assert parsed["offset"] == 0
|
||||
assert parsed["data_len"] == 9
|
||||
assert parsed["payload"] == bytes([10, 20, 30, 40, 50, 60, 70, 80, 90])
|
||||
|
||||
|
||||
def test_send_pixels_numpy_increments_sequence():
|
||||
client = _make_client(rgbw=False)
|
||||
pixels = np.array([[1, 2, 3]], dtype=np.uint8)
|
||||
|
||||
client.send_pixels_numpy(pixels)
|
||||
client.send_pixels_numpy(pixels)
|
||||
client.send_pixels_numpy(pixels)
|
||||
|
||||
seqs = [_parse_header(p)["seq"] for p in client._captured]
|
||||
assert seqs == [1, 2, 3]
|
||||
|
||||
|
||||
def test_send_pixels_numpy_chunks_large_payload():
|
||||
"""A 1000-LED payload (3000 bytes) splits into multiple packets."""
|
||||
client = _make_client(rgbw=False)
|
||||
n = 1000
|
||||
pixels = np.arange(n * 3, dtype=np.uint8).reshape(n, 3)
|
||||
|
||||
client.send_pixels_numpy(pixels, max_packet_size=1400)
|
||||
|
||||
# max_payload = 1390, bytes_per_packet = 1389 (multiple of 3) → 3 packets
|
||||
assert len(client._captured) >= 2
|
||||
headers = [_parse_header(p) for p in client._captured]
|
||||
|
||||
# Only the last packet has PUSH
|
||||
assert (headers[-1]["flags"] & 0x01) == 0x01
|
||||
for h in headers[:-1]:
|
||||
assert (h["flags"] & 0x01) == 0
|
||||
|
||||
# Offsets are contiguous and lengths sum to total
|
||||
total = sum(h["data_len"] for h in headers)
|
||||
assert total == n * 3
|
||||
expected_offset = 0
|
||||
for h in headers:
|
||||
assert h["offset"] == expected_offset
|
||||
expected_offset += h["data_len"]
|
||||
|
||||
|
||||
def test_send_pixels_numpy_rgbw_pads_alpha():
|
||||
client = _make_client(rgbw=True)
|
||||
pixels = np.array([[1, 2, 3], [4, 5, 6]], dtype=np.uint8)
|
||||
|
||||
client.send_pixels_numpy(pixels)
|
||||
|
||||
parsed = _parse_header(client._captured[0])
|
||||
# bpp=4 → payload is 8 bytes, alpha=0
|
||||
assert parsed["data_len"] == 8
|
||||
expected = bytes([1, 2, 3, 0, 4, 5, 6, 0])
|
||||
assert parsed["payload"] == expected
|
||||
|
||||
|
||||
def test_send_pixels_numpy_reuses_send_buffer():
|
||||
"""The internal send_buf must be allocated lazily and reused — no fresh
|
||||
bytearray per call."""
|
||||
client = _make_client(rgbw=False)
|
||||
pixels = np.array([[1, 2, 3]], dtype=np.uint8)
|
||||
|
||||
assert client._send_buf is None
|
||||
client.send_pixels_numpy(pixels)
|
||||
first_buf_id = id(client._send_buf)
|
||||
assert first_buf_id is not None
|
||||
|
||||
client.send_pixels_numpy(pixels)
|
||||
assert id(client._send_buf) == first_buf_id
|
||||
|
||||
|
||||
def test_send_pixels_numpy_growing_payload_grows_buffer():
|
||||
client = _make_client(rgbw=False)
|
||||
small = np.array([[1, 2, 3]], dtype=np.uint8)
|
||||
big = np.zeros((600, 3), dtype=np.uint8)
|
||||
|
||||
client.send_pixels_numpy(small)
|
||||
small_capacity = len(client._send_buf)
|
||||
|
||||
client.send_pixels_numpy(big, max_packet_size=2048)
|
||||
big_capacity = len(client._send_buf)
|
||||
|
||||
assert big_capacity >= small_capacity
|
||||
|
||||
|
||||
def test_send_pixels_numpy_handles_non_contiguous():
|
||||
"""Slicing a wider array yields a non-contiguous view; payload must
|
||||
still serialise to the same bytes as a contiguous copy."""
|
||||
client = _make_client(rgbw=False)
|
||||
wide = np.arange(60, dtype=np.uint8).reshape(5, 4, 3)
|
||||
# take the middle pixel column → (5, 3) but possibly non-contiguous
|
||||
pixels = wide[:, 1, :]
|
||||
|
||||
client.send_pixels_numpy(pixels)
|
||||
payload = _parse_header(client._captured[0])["payload"]
|
||||
assert payload == np.ascontiguousarray(pixels).tobytes()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_pixels_async_delegates_to_numpy():
|
||||
client = _make_client(rgbw=False)
|
||||
pixels = [(10, 20, 30), (40, 50, 60)]
|
||||
|
||||
result = await client.send_pixels(pixels)
|
||||
assert result is True
|
||||
|
||||
payload = _parse_header(client._captured[0])["payload"]
|
||||
assert payload == bytes([10, 20, 30, 40, 50, 60])
|
||||
|
||||
|
||||
def test_send_pixels_numpy_raises_when_disconnected():
|
||||
client = DDPClient(host="127.0.0.1")
|
||||
pixels = np.array([[1, 2, 3]], dtype=np.uint8)
|
||||
with pytest.raises(RuntimeError):
|
||||
client.send_pixels_numpy(pixels)
|
||||
Reference in New Issue
Block a user