- DRGB/DRGBW/DNRGB encoder behavior tests (byte layout, MTU, truncation) - DNRGB start index big-endian byte order tests - Animation command 0xAC packet tests (all 7 types, roundtrip, flags) - Tests fail: lightsync.protocol.drgb and animation_cmd modules do not exist yet
258 lines
8.6 KiB
Python
258 lines
8.6 KiB
Python
"""Tests for lightsync.protocol package — DRGB/DRGBW/DNRGB encoders and animation command."""
|
|
import struct
|
|
import pytest
|
|
|
|
from lightsync.protocol.drgb import (
|
|
PROTOCOL_DRGB,
|
|
PROTOCOL_DRGBW,
|
|
PROTOCOL_DNRGB,
|
|
TIMEOUT_BYTE,
|
|
DRGB_MAX_PX,
|
|
DRGBW_MAX_PX,
|
|
DNRGB_MAX_PX,
|
|
encode_drgb,
|
|
encode_drgbw,
|
|
encode_dnrgb_packets,
|
|
)
|
|
from lightsync.protocol.animation_cmd import (
|
|
ANIM_CMD_MARKER,
|
|
ANIMATION_IDS,
|
|
ANIMATION_NAMES,
|
|
encode_animation_cmd,
|
|
decode_animation_cmd,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DRGB tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_encode_drgb_basic():
|
|
"""DRGB header = [0x02, timeout], body = 3 bytes per pixel."""
|
|
pixels = [(255, 0, 0), (0, 255, 0)]
|
|
result = encode_drgb(pixels)
|
|
expected = bytes([0x02, 2, 255, 0, 0, 0, 255, 0])
|
|
assert result == expected, f"Got: {result.hex()}"
|
|
|
|
|
|
def test_encode_drgb_max_pixels_exact():
|
|
"""490 pixels produces exactly 1472 bytes (2 header + 490*3)."""
|
|
pixels = [(255, 0, 0)] * 490
|
|
result = encode_drgb(pixels)
|
|
assert len(result) == 1472
|
|
|
|
|
|
def test_encode_drgb_truncates_over_max():
|
|
"""491 pixels truncated to 490 — stays under MTU."""
|
|
pixels = [(255, 0, 0)] * 491
|
|
result = encode_drgb(pixels)
|
|
assert len(result) == 1472 # same as 490 pixels
|
|
|
|
|
|
def test_encode_drgb_header_bytes():
|
|
"""DRGB header byte 0 == 0x02, byte 1 == TIMEOUT_BYTE."""
|
|
result = encode_drgb([(0, 0, 0)])
|
|
assert result[0] == PROTOCOL_DRGB == 2
|
|
assert result[1] == TIMEOUT_BYTE == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DRGBW tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_encode_drgbw_basic():
|
|
"""DRGBW header = [0x03, timeout], body = 4 bytes per pixel (R,G,B,W)."""
|
|
pixels = [(255, 0, 0, 128)]
|
|
result = encode_drgbw(pixels)
|
|
expected = bytes([0x03, 2, 255, 0, 0, 128])
|
|
assert result == expected, f"Got: {result.hex()}"
|
|
|
|
|
|
def test_encode_drgbw_max_pixels_exact():
|
|
"""367 pixels produces exactly 1470 bytes (2 header + 367*4)."""
|
|
pixels = [(255, 0, 0, 0)] * 367
|
|
result = encode_drgbw(pixels)
|
|
assert len(result) == 1470
|
|
|
|
|
|
def test_encode_drgbw_truncates_over_max():
|
|
"""368 pixels truncated to 367."""
|
|
pixels = [(255, 0, 0, 0)] * 368
|
|
result = encode_drgbw(pixels)
|
|
assert len(result) == 1470 # same as 367 pixels
|
|
|
|
|
|
def test_encode_drgbw_header_bytes():
|
|
"""DRGBW header byte 0 == 0x03."""
|
|
result = encode_drgbw([(0, 0, 0, 0)])
|
|
assert result[0] == PROTOCOL_DRGBW == 3
|
|
assert result[1] == TIMEOUT_BYTE == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DNRGB multi-packet tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_dnrgb_split_600_pixels():
|
|
"""600 pixels splits into 2 packets (489 + 111 LEDs)."""
|
|
pixels = [(100, 150, 200)] * 600
|
|
packets = encode_dnrgb_packets(pixels)
|
|
assert len(packets) == 2
|
|
# First packet: 4 header + 489*3 = 1471 bytes
|
|
assert len(packets[0]) == 4 + 489 * 3
|
|
# Second packet: 4 header + 111*3 = 337 bytes
|
|
assert len(packets[1]) == 4 + 111 * 3
|
|
|
|
|
|
def test_dnrgb_start_index_first_packet():
|
|
"""First packet start index at bytes 2-3 (big-endian) == 0."""
|
|
pixels = [(0, 0, 0)] * 600
|
|
packets = encode_dnrgb_packets(pixels)
|
|
start = struct.unpack_from(">H", packets[0], 2)[0]
|
|
assert start == 0
|
|
|
|
|
|
def test_dnrgb_start_index_second_packet():
|
|
"""Second packet start index == 489 (big-endian at bytes 2-3)."""
|
|
pixels = [(0, 0, 0)] * 600
|
|
packets = encode_dnrgb_packets(pixels)
|
|
start = struct.unpack_from(">H", packets[1], 2)[0]
|
|
assert start == 489
|
|
|
|
|
|
def test_dnrgb_packets_under_mtu():
|
|
"""All DNRGB packets must be <= 1472 bytes."""
|
|
pixels = [(255, 128, 0)] * 1000
|
|
packets = encode_dnrgb_packets(pixels)
|
|
for i, pkt in enumerate(packets):
|
|
assert len(pkt) <= 1472, f"Packet {i} exceeds MTU: {len(pkt)} bytes"
|
|
|
|
|
|
def test_dnrgb_header_type_byte():
|
|
"""DNRGB type byte == 0x04."""
|
|
packets = encode_dnrgb_packets([(0, 0, 0)] * 10)
|
|
assert packets[0][0] == PROTOCOL_DNRGB == 4
|
|
|
|
|
|
def test_dnrgb_with_start_offset():
|
|
"""start_index parameter shifts all packet indices."""
|
|
pixels = [(0, 0, 0)] * 600
|
|
packets = encode_dnrgb_packets(pixels, start_index=100)
|
|
start0 = struct.unpack_from(">H", packets[0], 2)[0]
|
|
start1 = struct.unpack_from(">H", packets[1], 2)[0]
|
|
assert start0 == 100
|
|
assert start1 == 100 + 489
|
|
|
|
|
|
def test_dnrgb_single_packet_under_max():
|
|
"""Under DNRGB_MAX_PX produces exactly 1 packet."""
|
|
pixels = [(0, 0, 0)] * 100
|
|
packets = encode_dnrgb_packets(pixels)
|
|
assert len(packets) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Animation command tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_animation_cmd_chase_basic():
|
|
"""encode_animation_cmd returns 16-byte packet starting with 0xAC, 0x01 (chase)."""
|
|
result = encode_animation_cmd("chase", {"color": [255, 0, 0], "white": 0, "speed": 0.5, "reverse": False})
|
|
assert len(result) == 16
|
|
assert result[0] == 0xAC
|
|
assert result[1] == 1 # version
|
|
assert result[2] == ANIMATION_IDS["chase"] == 1
|
|
|
|
|
|
def test_animation_cmd_all_type_ids():
|
|
"""All 7 animation types have correct IDs."""
|
|
expected = {
|
|
"solid_color": 0, "chase": 1, "pulse": 2, "rainbow": 3,
|
|
"strobe": 4, "color_wipe": 5, "fire": 6,
|
|
}
|
|
assert ANIMATION_IDS == expected
|
|
|
|
|
|
def test_animation_cmd_marker_byte():
|
|
"""Byte 0 == 0xAC for all animation types."""
|
|
for anim_name in ANIMATION_IDS:
|
|
result = encode_animation_cmd(anim_name, {})
|
|
assert result[0] == 0xAC, f"Failed for {anim_name}: {result.hex()}"
|
|
|
|
|
|
def test_animation_cmd_packet_length():
|
|
"""All animation commands produce exactly 16 bytes."""
|
|
for anim_name in ANIMATION_IDS:
|
|
result = encode_animation_cmd(anim_name, {})
|
|
assert len(result) == 16, f"Wrong length for {anim_name}: {len(result)}"
|
|
|
|
|
|
def test_animation_cmd_unknown_raises():
|
|
"""Unknown animation type raises ValueError."""
|
|
with pytest.raises(ValueError):
|
|
encode_animation_cmd("nonexistent_animation", {})
|
|
|
|
|
|
def test_animation_cmd_roundtrip_all_types():
|
|
"""decode_animation_cmd round-trips with encode_animation_cmd for all 7 types."""
|
|
for anim_name in ANIMATION_IDS:
|
|
params = {"color": [10, 20, 30], "bg_color": [5, 5, 5], "speed": 0.75, "white": 64, "reverse": True, "density": 100}
|
|
encoded = encode_animation_cmd(anim_name, params)
|
|
decoded = decode_animation_cmd(encoded)
|
|
assert decoded["animation"] == anim_name
|
|
assert decoded["params"]["color"] == [10, 20, 30]
|
|
assert decoded["params"]["bg_color"] == [5, 5, 5]
|
|
assert decoded["params"]["white"] == 64
|
|
assert decoded["params"]["reverse"] is True
|
|
assert decoded["params"]["density"] == 100
|
|
|
|
|
|
def test_animation_cmd_speed_encoding():
|
|
"""Speed is packed as big-endian float32 at bytes 4-7."""
|
|
result = encode_animation_cmd("chase", {"speed": 0.5})
|
|
speed = struct.unpack_from(">f", result, 4)[0]
|
|
assert abs(speed - 0.5) < 1e-6
|
|
|
|
|
|
def test_animation_cmd_reverse_flag():
|
|
"""Reverse flag sets bit 0 of flags byte (byte 3)."""
|
|
result_rev = encode_animation_cmd("chase", {"reverse": True})
|
|
result_nrev = encode_animation_cmd("chase", {"reverse": False})
|
|
assert result_rev[3] & 0x01 == 1
|
|
assert result_nrev[3] & 0x01 == 0
|
|
|
|
|
|
def test_animation_cmd_color_fields():
|
|
"""Primary color at bytes 8-10, secondary (bg_color) at bytes 11-13."""
|
|
result = encode_animation_cmd("chase", {"color": [100, 150, 200], "bg_color": [10, 20, 30]})
|
|
assert result[8] == 100
|
|
assert result[9] == 150
|
|
assert result[10] == 200
|
|
assert result[11] == 10
|
|
assert result[12] == 20
|
|
assert result[13] == 30
|
|
|
|
|
|
def test_animation_cmd_white_byte():
|
|
"""White channel at byte 14."""
|
|
result = encode_animation_cmd("solid_color", {"white": 200})
|
|
assert result[14] == 200
|
|
|
|
|
|
def test_animation_cmd_density_byte():
|
|
"""Density at byte 15 (checks density param, falls back to size/cooling)."""
|
|
result_density = encode_animation_cmd("fire", {"density": 55})
|
|
assert result_density[15] == 55
|
|
|
|
result_cooling = encode_animation_cmd("fire", {"cooling": 80})
|
|
assert result_cooling[15] == 80
|
|
|
|
|
|
def test_animation_cmd_defaults():
|
|
"""encode_animation_cmd works with empty params (uses defaults)."""
|
|
result = encode_animation_cmd("solid_color", {})
|
|
assert len(result) == 16
|
|
assert result[0] == 0xAC
|
|
assert result[8] == 0 # default color [0,0,0]
|
|
assert result[14] == 0 # default white
|