"""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