feat(02-02): implement ESP32Transport UDP client with asyncio DatagramProtocol

- ESP32Transport.send_command() injects v:1, fire-and-forget, silent drop if not ready
- 512-byte payload limit enforced with warning log
- datagram_received() parses STATUS ok → connected=True, stores last_status
- on_status callback support for status update notifications
- connection_lost() sets connected=False, clears transport ref
- create_esp32_transport() async factory sends initial STATUS ping (D-12)
- All protocol behaviors match docs/protocol.md exactly (D-10, D-11, D-12, D-14)
- Add pytest-asyncio to dev deps + asyncio_mode=auto for test suite
This commit is contained in:
Claude
2026-04-03 14:19:20 +02:00
parent f10fcf016e
commit 378620e0bb
11 changed files with 898 additions and 0 deletions

1
src/led_sync/__init__.py Normal file
View File

@@ -0,0 +1 @@
__version__ = "0.1.0"

View File

@@ -0,0 +1,3 @@
from .player import AudioPlayer
__all__ = ["AudioPlayer"]

View File

@@ -0,0 +1,150 @@
"""
AudioPlayer: miniaudio-based song playback with sample-accurate position tracking.
Implements AUD-01 (MP3/FLAC/WAV playback) and AUD-02 (pause/resume/seek).
Design (D-04): miniaudio runs in its own OS thread. No asyncio usage here.
Pause (D-09): device.stop() + record position; resume = play(seek_seconds=saved).
"""
import miniaudio
import threading
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
SAMPLE_RATE = 44100
CHANNELS = 2
OUTPUT_FORMAT = miniaudio.SampleFormat.SIGNED16
BYTES_PER_FRAME = CHANNELS * 2 # 2 channels * 2 bytes (SIGNED16)
class AudioPlayer:
def __init__(self):
self._device: miniaudio.PlaybackDevice | None = None
self._frames_played: int = 0
self._lock = threading.Lock()
self._current_file: str | None = None
self._paused_position: float | None = None # seconds at pause point
self._playing: bool = False
# --- Thread-safe properties ---
@property
def position_seconds(self) -> float:
"""Current playback position in seconds. Thread-safe."""
if self._paused_position is not None:
return self._paused_position
with self._lock:
return self._frames_played / SAMPLE_RATE
@property
def is_playing(self) -> bool:
return self._playing
# --- Private ---
def _make_stream(self, file_path: str, seek_frame: int = 0):
"""Generator: yields decoded audio chunks and tracks frame count."""
stream = miniaudio.stream_file(
file_path,
output_format=OUTPUT_FORMAT,
nchannels=CHANNELS,
sample_rate=SAMPLE_RATE,
seek_frame=seek_frame,
)
frame_count = seek_frame
required = yield b"" # prime the generator
for chunk in stream:
with self._lock:
self._frames_played = frame_count
frame_count += len(chunk) // BYTES_PER_FRAME
required = yield chunk
# Stream exhausted — song finished
with self._lock:
self._playing = False
def _start_device(self, file_path: str, seek_seconds: float = 0.0) -> None:
"""(Re)creates and starts the PlaybackDevice. Call from any thread."""
self._stop_device()
seek_frame = int(seek_seconds * SAMPLE_RATE)
with self._lock:
self._frames_played = seek_frame
self._playing = True
self._current_file = file_path
self._device = miniaudio.PlaybackDevice(
sample_rate=SAMPLE_RATE,
nchannels=CHANNELS,
output_format=OUTPUT_FORMAT,
)
stream = self._make_stream(file_path, seek_frame)
next(stream) # prime
self._device.start(stream)
def _stop_device(self) -> None:
"""Halt and close the device if running."""
if self._device is not None:
self._device.stop()
self._device.close()
self._device = None
with self._lock:
self._playing = False
# --- Public API ---
def play(self, file_path: str, seek_seconds: float = 0.0) -> None:
"""
Load and start playing file_path from seek_seconds.
Supports MP3, FLAC, WAV (miniaudio auto-detects format).
AUD-01.
"""
path = str(Path(file_path).resolve())
self._paused_position = None
logger.info("Playing %s from %.2fs", path, seek_seconds)
self._start_device(path, seek_seconds)
def pause(self) -> None:
"""
Pause playback. Records position for resume. AUD-02.
No-op if already paused or stopped.
"""
if not self._playing:
return
pos = self.position_seconds
self._stop_device()
self._paused_position = pos
logger.info("Paused at %.2fs", pos)
def resume(self) -> None:
"""
Resume from pause position. No-op if not paused. AUD-02.
"""
if self._paused_position is None or self._current_file is None:
return
pos = self._paused_position
self._paused_position = None
logger.info("Resuming from %.2fs", pos)
self._start_device(self._current_file, seek_seconds=pos)
def seek(self, seconds: float) -> None:
"""
Seek to arbitrary timestamp. Restarts playback from new position. AUD-02.
"""
if self._current_file is None:
return
self._paused_position = None
logger.info("Seeking to %.2fs", seconds)
was_paused = not self._playing
self._start_device(self._current_file, seek_seconds=seconds)
if was_paused:
# re-pause at new position
self._stop_device()
self._paused_position = seconds
def stop(self) -> None:
"""Halt playback and reset position to 0. AUD-02."""
self._stop_device()
self._paused_position = None
self._current_file = None
with self._lock:
self._frames_played = 0
logger.info("Stopped")

View File

@@ -0,0 +1,7 @@
"""
UDP transport package for ESP32 LED controller communication.
Protocol: docs/protocol.md — v1, port 4210, fire-and-forget JSON datagrams.
"""
from .udp_client import ESP32Transport, create_esp32_transport
__all__ = ["ESP32Transport", "create_esp32_transport"]

View File

@@ -0,0 +1,154 @@
"""
UDP transport client for ESP32 LED controller.
Protocol: docs/protocol.md — v1, port 4210, fire-and-forget JSON datagrams.
Decisions applied:
D-10: asyncio DatagramProtocol, fire-and-forget sendto()
D-11: Optional periodic heartbeat (status ping), non-blocking
D-12: connected/disconnected tracked via STATUS responses
D-14: Minimal error handling (fire-and-forget transport)
"""
import asyncio
import json
import logging
from typing import Callable
logger = logging.getLogger(__name__)
PROTOCOL_VERSION = 1
MAX_PAYLOAD_BYTES = 512
class ESP32Transport(asyncio.DatagramProtocol):
"""
asyncio DatagramProtocol client for the ESP32 LED controller.
Usage:
transport = await create_esp32_transport("192.168.1.100")
transport.send_command({"zone": "wand", "animation": "chase", "params": {"speed": 0.5}})
Public interface:
send_command(cmd: dict) -> None — fire-and-forget, non-blocking
connected: bool — True after first STATUS ok response
last_status: dict | None — most recent STATUS response from ESP32
"""
def __init__(self, on_status: Callable[[dict], None] | None = None):
self._transport: asyncio.DatagramTransport | None = None
self.connected: bool = False
self.last_status: dict | None = None
self._on_status = on_status # optional callback invoked on STATUS ok
# -------------------------------------------------------------------------
# asyncio DatagramProtocol callbacks
# -------------------------------------------------------------------------
def connection_made(self, transport: asyncio.DatagramTransport) -> None:
"""Called by asyncio when the UDP socket is bound to the remote address."""
self._transport = transport
logger.debug("UDP socket bound to ESP32 remote address")
def datagram_received(self, data: bytes, addr: tuple) -> None:
"""
Called when a UDP datagram arrives from the ESP32.
Parses STATUS ok responses to update connection state (D-12).
All other messages and malformed JSON are silently ignored (D-14).
"""
try:
msg = json.loads(data.decode("utf-8"))
except Exception:
logger.debug("Received non-JSON datagram from %s — ignored", addr)
return
if msg.get("status") == "ok":
self.connected = True
self.last_status = msg
logger.debug("ESP32 STATUS ok: %s", msg)
if self._on_status is not None:
self._on_status(msg)
def error_received(self, exc: Exception) -> None:
"""
Called on non-fatal UDP errors (e.g., ICMP port unreachable).
Fire-and-forget transport — errors are logged but not re-raised.
"""
logger.debug("UDP error (non-fatal): %s", exc)
def connection_lost(self, exc: Exception | None) -> None:
"""Called when the UDP socket is closed."""
self.connected = False
self._transport = None
logger.debug("UDP transport closed (exc=%s)", exc)
# -------------------------------------------------------------------------
# Public API
# -------------------------------------------------------------------------
def send_command(self, cmd: dict) -> None:
"""
Fire-and-forget: inject "v":1 and send cmd as a JSON UDP datagram.
Safe to call from the asyncio event loop thread at any time.
Silent drop (no exception) if:
- Transport not yet ready (pre-connection)
- Resulting payload exceeds MAX_PAYLOAD_BYTES (512)
Matches docs/protocol.md exactly — "v":1 is injected here so callers
do not need to include it.
"""
if self._transport is None:
logger.debug("send_command() called before transport ready — dropped")
return
payload = {"v": PROTOCOL_VERSION, **cmd}
data = json.dumps(payload).encode("utf-8")
if len(data) > MAX_PAYLOAD_BYTES:
logger.warning(
"Command payload %d bytes exceeds %d-byte limit — dropped",
len(data),
MAX_PAYLOAD_BYTES,
)
return
self._transport.sendto(data)
def send_status_ping(self) -> None:
"""
Send a STATUS query to the ESP32 (D-11 heartbeat).
Fire-and-forget — a STATUS ok response via datagram_received() will
update connected=True and last_status. Silent drop if not ready.
"""
self.send_command({"cmd": "status"})
async def create_esp32_transport(
host: str,
port: int = 4210,
on_status: Callable[[dict], None] | None = None,
) -> "ESP32Transport":
"""
Async factory: create and return an ESP32Transport bound to host:port.
Sends an initial STATUS ping to establish connection state (D-12).
The reply (if ESP32 is reachable) will set connected=True via
datagram_received().
Args:
host: ESP32 IP address (e.g., "192.168.1.100")
port: UDP port (default 4210 per docs/protocol.md)
on_status: optional callback invoked on every STATUS ok response
Returns:
ESP32Transport instance ready for send_command() calls
"""
loop = asyncio.get_running_loop()
_, protocol = await loop.create_datagram_endpoint(
lambda: ESP32Transport(on_status=on_status),
remote_addr=(host, port),
)
# Initial status ping — non-blocking, reply updates connected state (D-12)
protocol.send_status_ping()
return protocol