797b806972
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.
171 lines
5.3 KiB
Python
171 lines
5.3 KiB
Python
"""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)
|