diff --git a/lightsync/protocol/__init__.py b/lightsync/protocol/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_protocol.py b/tests/test_protocol.py new file mode 100644 index 0000000..fc373b2 --- /dev/null +++ b/tests/test_protocol.py @@ -0,0 +1,257 @@ +"""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