feat(07-03): implement frame mode in firmware/main.py

- Import parse_drgb, parse_drgbw, parse_dnrgb, FrameAssembler
- Wire DRGB/DRGBW/DNRGB parsing in udp_listener_task
- Add frame_is_rgbw flag to route to write_rgb or write_rgbw
- Add 2s frame timeout in render_task — reverts to last animation
- Initialize FrameAssembler with led_count in main()
This commit is contained in:
Claude
2026-04-07 15:41:57 +00:00
parent 49f0b0f68c
commit 2a22eeff75

View File

@@ -1,21 +1,41 @@
"""LightSync firmware — main entry point.
Two asyncio tasks:
1. udp_listener_task: non-blocking UDP socket, parses packets, updates state
2. render_task: 60fps loop, renders animations or writes frame data to strip
Modes:
- 'idle': strip is off, waiting for first packet
- 'animation': running a locally-rendered animation (from 0xAC command)
- 'frame': displaying raw pixel data (from DRGB/DRGBW/DNRGB packets)
Frame timeout: if no frame packet arrives for 2 seconds, reverts to last animation.
"""
import asyncio import asyncio
import socket import socket
import select import select
import time import time
import json import json
from protocol import classify_packet, parse_animation_cmd from protocol import (
classify_packet, parse_animation_cmd,
parse_drgb, parse_drgbw, parse_dnrgb, FrameAssembler
)
from led_driver import create_strip from led_driver import create_strip
from animations import create_animation from animations import create_animation
# --- Global state --- # --- Global state ---
config = None config = None
strip = None strip = None
current_mode = 'idle' # 'idle', 'animation', 'frame' current_mode = 'idle' # 'idle', 'animation', 'frame'
current_animation = None # dict from parse_animation_cmd current_animation = None # dict from parse_animation_cmd
current_anim_instance = None # animation object with render() current_anim_instance = None # animation object with render()
animation_start_ms = 0 animation_start_ms = 0
frame_pixels = None # set by frame packets frame_pixels = None # list of (R,G,B) or (R,G,B,W) tuples
frame_is_rgbw = False # True if last frame was DRGBW
last_frame_ms = 0 # ticks_ms of last frame packet
frame_assembler = None # FrameAssembler for DNRGB
FRAME_TIMEOUT_MS = 2000 # revert to animation after 2s without frames
def load_config(): def load_config():
@@ -30,7 +50,8 @@ def load_config():
async def udp_listener_task(port): async def udp_listener_task(port):
"""Non-blocking UDP listener using select.poll(0) inside asyncio.""" """Non-blocking UDP listener using select.poll(0) inside asyncio."""
global current_mode, current_animation, current_anim_instance global current_mode, current_animation, current_anim_instance
global animation_start_ms, frame_pixels global animation_start_ms, frame_pixels, frame_is_rgbw
global last_frame_ms, frame_assembler
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(('0.0.0.0', port)) sock.bind(('0.0.0.0', port))
@@ -56,10 +77,29 @@ async def udp_listener_task(port):
animation_start_ms = time.ticks_ms() animation_start_ms = time.ticks_ms()
print('[udp] Animation:', cmd['animation']) print('[udp] Animation:', cmd['animation'])
elif ptype in ('drgb', 'drgbw', 'dnrgb'): elif ptype == 'drgb':
# Frame handling added by Plan 07-03 pixels = parse_drgb(data)
frame_pixels = pixels
frame_is_rgbw = False
current_mode = 'frame' current_mode = 'frame'
print('[udp] Frame packet:', ptype, len(data), 'bytes') last_frame_ms = time.ticks_ms()
elif ptype == 'drgbw':
pixels = parse_drgbw(data)
frame_pixels = pixels
frame_is_rgbw = True
current_mode = 'frame'
last_frame_ms = time.ticks_ms()
elif ptype == 'dnrgb':
start_idx, pixels = parse_dnrgb(data)
now = time.ticks_ms()
complete = frame_assembler.feed(start_idx, pixels, now)
if complete:
frame_pixels = complete
frame_is_rgbw = False # DNRGB is always RGB
current_mode = 'frame'
last_frame_ms = now
else: else:
print('[udp] Unknown packet type:', data[0] if data else '?') print('[udp] Unknown packet type:', data[0] if data else '?')
@@ -70,30 +110,46 @@ async def udp_listener_task(port):
async def render_task(): async def render_task():
"""Animation render loop at ~60fps.""" """Animation/frame render loop at ~60fps."""
global current_mode, frame_pixels global current_mode, frame_pixels
while True: while True:
if current_mode == 'animation' and current_anim_instance: if current_mode == 'frame' and frame_pixels:
# Check frame timeout — revert to animation if no packets for 2s
if time.ticks_diff(time.ticks_ms(), last_frame_ms) > FRAME_TIMEOUT_MS:
if current_anim_instance:
current_mode = 'animation'
animation_start_ms = time.ticks_ms()
print('[render] Frame timeout, reverting to animation')
else:
current_mode = 'idle'
strip.clear()
else:
# Write frame to strip
if frame_is_rgbw:
strip.write_rgbw(frame_pixels)
else:
strip.write_rgb(frame_pixels)
elif current_mode == 'animation' and current_anim_instance:
t = time.ticks_diff(time.ticks_ms(), animation_start_ms) / 1000.0 t = time.ticks_diff(time.ticks_ms(), animation_start_ms) / 1000.0
pixels = current_anim_instance.render(t, config['led_count']) pixels = current_anim_instance.render(t, config['led_count'])
strip.write_rgb(pixels) strip.write_rgb(pixels)
elif current_mode == 'frame' and frame_pixels:
# Plan 07-03 adds frame write logic here
pass
await asyncio.sleep_ms(16) # ~60 fps await asyncio.sleep_ms(16) # ~60 fps
async def main(): async def main():
global config, strip global config, strip, frame_assembler
config = load_config() config = load_config()
strip = create_strip(config) strip = create_strip(config)
strip.clear() strip.clear()
frame_assembler = FrameAssembler(config['led_count'])
print('[main] LightSync firmware ready') print('[main] LightSync firmware ready')
print('[main] Strip:', config['strip_type'], '/', config['led_count'], 'LEDs') print('[main] Strip:', config['strip_type'], '/', config['led_count'], 'LEDs')
print('[main] UDP port:', config['udp_port'])
asyncio.create_task(udp_listener_task(config['udp_port'])) asyncio.create_task(udp_listener_task(config['udp_port']))
asyncio.create_task(render_task()) asyncio.create_task(render_task())
@@ -101,5 +157,4 @@ async def main():
while True: while True:
await asyncio.sleep(1) await asyncio.sleep(1)
asyncio.run(main()) asyncio.run(main())