Implements TIM3_CH1 PWM driver for 8-LED NeoPixel ring with: - 6 state-based animations: boot (blue chase), armed (solid green), error (red blink), low battery (yellow pulse), charging (green breathe), e_stop (red strobe) - Non-blocking via 1 ms tick callback - GRB byte order encoding (WS2812B standard) - PWM duty values for "0" (~40%) and "1" (~56%) bit encoding - 10 unit tests covering state transitions, animations, color encoding Driver integrated into main.c initialization and main loop tick. Includes buzzer driver (Issue #189) integration. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
345 lines
10 KiB
Python
345 lines
10 KiB
Python
"""
|
|
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'])
|