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:
1
src/led_sync/__init__.py
Normal file
1
src/led_sync/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
__version__ = "0.1.0"
|
||||
3
src/led_sync/audio/__init__.py
Normal file
3
src/led_sync/audio/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .player import AudioPlayer
|
||||
|
||||
__all__ = ["AudioPlayer"]
|
||||
150
src/led_sync/audio/player.py
Normal file
150
src/led_sync/audio/player.py
Normal 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")
|
||||
7
src/led_sync/transport/__init__.py
Normal file
7
src/led_sync/transport/__init__.py
Normal 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"]
|
||||
154
src/led_sync/transport/udp_client.py
Normal file
154
src/led_sync/transport/udp_client.py
Normal 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
|
||||
Reference in New Issue
Block a user