""" test_led.py — WS2812B NeoPixel LED driver tests (Issue #193) Verifies in Python: - State transitions: boot → armed, error, low_battery, charging, e_stop - Animation timing: chase speed, blink/strobe frequency, pulse duration - LED color encoding: RGB to GRB byte order, MSB-first bit encoding - PWM duty values: bit "0" (~40%) and bit "1" (~56%) detection - Animation sequencing: smooth transitions between states - Sine wave lookup: breathing and pulse envelopes """ import pytest # ── Constants ───────────────────────────────────────────────────────────── NUM_LEDS = 8 BITS_PER_LED = 24 # RGB = 8 bits each TOTAL_BITS = NUM_LEDS * BITS_PER_LED PWM_PERIOD = 270 # 216 MHz / 800 kHz ≈ 270 (integer approximation) BIT_0_DUTY = int(PWM_PERIOD * 40 / 100) # ~108 (40%) BIT_1_DUTY = int(PWM_PERIOD * 56 / 100) # ~151 (56%) # Animation periods (ms) BOOT_CHASE_MS = 100 # ms per LED rotation ERROR_BLINK_MS = 250 ESTOP_STROBE_MS = 125 PULSE_PERIOD_MS = 5120 # ── RGB Color Utility ───────────────────────────────────────────────────── class RGBColor: def __init__(self, r=0, g=0, b=0): self.r = r self.g = g self.b = b def __eq__(self, other): return self.r == other.r and self.g == other.g and self.b == other.b def __repr__(self): return f"RGB({self.r},{self.g},{self.b})" # ── WS2812B Encoding Utilities ──────────────────────────────────────────── def rgb_to_pwm_buffer(colors): """Encode LED colors into PWM duty values (GRB byte order, MSB first).""" pwm_buf = [] for color in colors: # GRB byte order (WS2812 standard) bytes_grb = [color.g, color.r, color.b] for byte in bytes_grb: for bit in range(7, -1, -1): bit_val = (byte >> bit) & 1 pwm_buf.append(BIT_1_DUTY if bit_val else BIT_0_DUTY) return pwm_buf def pwm_buffer_to_rgb(pwm_buf): """Decode PWM duty values back to RGB colors (for verification).""" colors = [] for led_idx in range(NUM_LEDS): base = led_idx * BITS_PER_LED # GRB byte order g = bytes_from_bits(pwm_buf[base : base + 8]) r = bytes_from_bits(pwm_buf[base + 8 : base + 16]) b = bytes_from_bits(pwm_buf[base + 16 : base + 24]) colors.append(RGBColor(r, g, b)) return colors def bytes_from_bits(pwm_values): """Reconstruct a byte from PWM duty values.""" byte = 0 for pwm in pwm_values: byte = (byte << 1) | (1 if pwm > (BIT_0_DUTY + BIT_1_DUTY) // 2 else 0) return byte # ── Sine Lookup ─────────────────────────────────────────────────────────── def sin_u8(phase): """Approximate sine wave (0-255) from phase (0-255).""" # Simplified lookup (matching C implementation) sine_lut = [ 128, 131, 134, 137, 140, 143, 146, 149, 152, 155, 158, 161, 164, 167, 170, 173, 176, 179, 182, 185, 188, 191, 193, 196, 199, 201, 204, 206, 209, 211, 214, 216, 218, 221, 223, 225, 227, 229, 231, 233, 235, 236, 238, 240, 241, 243, 244, 245, 247, 248, 249, 250, 251, 252, 252, 253, 254, 254, 255, 255, 255, 255, 255, 254, 254, 253, 252, 252, 251, 250, 249, 248, 247, 245, 244, 243, 241, 240, 238, 236, 235, 233, 231, 229, 227, 225, 223, 221, 218, 216, 214, 211, 209, 206, 204, 201, 199, 196, 193, 191, 188, 185, 182, 179, 176, 173, 170, 167, 164, 161, 158, 155, 152, 149, 146, 143, 140, 137, 134, 131, 128, 125, 122, 119, 116, 113, 110, 107, 104, 101, 98, 95, 92, 89, 86, 83, 80, 77, 74, 71, 68, 65, 62, 59, 56, 53, 50, 47, 44, 41, 39, 36, 33, 31, 28, 26, 23, 21, 18, 16, 14, 11, 9, 7, 5, 3, 1, 0, ] return sine_lut[phase % 256] if phase < len(sine_lut) else sine_lut[255] # ── LED State Machine Simulator ─────────────────────────────────────────── class LEDSimulator: def __init__(self): self.leds = [RGBColor() for _ in range(NUM_LEDS)] self.pwm_buf = [0] * TOTAL_BITS self.current_state = 'BOOT' self.next_state = 'BOOT' self.state_start_ms = 0 def set_state(self, state): self.next_state = state def tick(self, now_ms): # State transition if self.next_state != self.current_state: self.current_state = self.next_state self.state_start_ms = now_ms elapsed = now_ms - self.state_start_ms # Run animation if self.current_state == 'BOOT': self._animate_boot(elapsed) elif self.current_state == 'ARMED': self._animate_armed() elif self.current_state == 'ERROR': self._animate_error(elapsed) elif self.current_state == 'LOW_BATT': self._animate_low_battery(elapsed) elif self.current_state == 'CHARGING': self._animate_charging(elapsed) elif self.current_state == 'ESTOP': self._animate_estop(elapsed) # Encode to PWM buffer self.pwm_buf = rgb_to_pwm_buffer(self.leds) def _animate_boot(self, elapsed): for i in range(NUM_LEDS): self.leds[i] = RGBColor() led_idx = (elapsed // BOOT_CHASE_MS) % NUM_LEDS self.leds[led_idx] = RGBColor(b=255) def _animate_armed(self): for i in range(NUM_LEDS): self.leds[i] = RGBColor(g=200) def _animate_error(self, elapsed): on = ((elapsed // ERROR_BLINK_MS) % 2) == 0 for i in range(NUM_LEDS): self.leds[i] = RGBColor(r=255 if on else 0) def _animate_low_battery(self, elapsed): phase = (elapsed // 20) & 0xFF brightness = sin_u8(phase) val = (brightness * 255) >> 8 for i in range(NUM_LEDS): self.leds[i] = RGBColor(r=val, g=val) def _animate_charging(self, elapsed): phase = (elapsed // 20) & 0xFF brightness = sin_u8(phase) val = (brightness * 255) >> 8 for i in range(NUM_LEDS): self.leds[i] = RGBColor(g=val) def _animate_estop(self, elapsed): on = ((elapsed // ESTOP_STROBE_MS) % 2) == 0 for i in range(NUM_LEDS): self.leds[i] = RGBColor(r=255 if on else 0) # ── Tests ────────────────────────────────────────────────────────────────── def test_state_transitions(): """LED state should transition correctly.""" sim = LEDSimulator() assert sim.current_state == 'BOOT' sim.set_state('ARMED') sim.tick(0) assert sim.current_state == 'ARMED' sim.set_state('ERROR') sim.tick(1) assert sim.current_state == 'ERROR' def test_boot_chase_timing(): """Boot state: LED should rotate every 100 ms.""" sim = LEDSimulator() sim.set_state('BOOT') # t=0: LED 0 should be blue sim.tick(0) assert sim.leds[0].b > 0 for i in range(1, NUM_LEDS): assert sim.leds[i].b == 0 # t=100: LED 1 should be blue sim.tick(100) assert sim.leds[1].b > 0 for i in range(NUM_LEDS): if i != 1: assert sim.leds[i].b == 0 def test_armed_solid_green(): """Armed state: all LEDs should be solid green.""" sim = LEDSimulator() sim.set_state('ARMED') sim.tick(0) for led in sim.leds: assert led.g > 0 assert led.r == 0 assert led.b == 0 def test_error_blinking(): """Error state: LEDs should blink red every 250 ms.""" sim = LEDSimulator() sim.set_state('ERROR') # t=0-249: red on sim.tick(0) for led in sim.leds: assert led.r > 0 # t=250-499: red off sim.tick(250) for led in sim.leds: assert led.r == 0 # t=500-749: red on again sim.tick(500) for led in sim.leds: assert led.r > 0 def test_low_battery_pulsing(): """Low battery: LEDs should pulse yellow with sine envelope.""" sim = LEDSimulator() sim.set_state('LOW_BATT') # Sample at different points sim.tick(0) v0 = sim.leds[0].r sim.tick(1280) # Quarter period v1 = sim.leds[0].r assert v1 > v0 # Should increase from bottom of sine def test_charging_breathing(): """Charging: LEDs should breathe green smoothly.""" sim = LEDSimulator() sim.set_state('CHARGING') # Sample at different points sim.tick(0) v0 = sim.leds[0].g sim.tick(1280) # Quarter period v1 = sim.leds[0].g assert v1 > v0 # Should increase def test_estop_strobe(): """E-stop: LEDs should strobe red at 8 Hz (125 ms on/off).""" sim = LEDSimulator() sim.set_state('ESTOP') # t=0-124: strobe on sim.tick(0) for led in sim.leds: assert led.r > 0 # t=125-249: strobe off sim.tick(125) for led in sim.leds: assert led.r == 0 def test_pwm_duty_encoding(): """PWM duty values should encode RGB correctly (GRB, MSB-first).""" colors = [ RGBColor(255, 0, 0), # Red RGBColor(0, 255, 0), # Green RGBColor(0, 0, 255), # Blue RGBColor(255, 255, 255), # White ] # Encode to PWM pwm_buf = rgb_to_pwm_buffer(colors + [RGBColor()] * (NUM_LEDS - 4)) # Verify PWM buffer has correct length assert len(pwm_buf) == TOTAL_BITS # Verify bit values are either 0-duty or 1-duty for pwm in pwm_buf: assert pwm == BIT_0_DUTY or pwm == BIT_1_DUTY def test_color_roundtrip(): """Colors should survive encode/decode roundtrip.""" original = [ RGBColor(100, 150, 200), RGBColor(0, 255, 0), RGBColor(255, 0, 0), ] + [RGBColor()] * (NUM_LEDS - 3) pwm_buf = rgb_to_pwm_buffer(original) decoded = pwm_buffer_to_rgb(pwm_buf) for i in range(NUM_LEDS): assert decoded[i] == original[i] def test_multiple_state_transitions(): """Simulate state transitions over time.""" sim = LEDSimulator() states = ['BOOT', 'ARMED', 'ERROR', 'LOW_BATT', 'CHARGING', 'ESTOP'] for state_name in states: sim.set_state(state_name) sim.tick(0) assert sim.current_state == state_name if __name__ == '__main__': pytest.main([__file__, '-v'])