better controller

This commit is contained in:
Aronnaxx 2025-08-16 14:29:33 -07:00
parent cef6c0a2ea
commit 0002523b0c
2 changed files with 104 additions and 138 deletions

View file

@ -8,8 +8,11 @@ Design:
""" """
from __future__ import annotations from __future__ import annotations
import os
import atexit
import signal
from threading import Lock from threading import Lock
from typing import Tuple, Optional, Union, Dict from typing import Tuple, Optional, Union
# Direct hardware imports (we assume we're running on-device) # Direct hardware imports (we assume we're running on-device)
import board import board
@ -19,15 +22,21 @@ import neopixel
PIXEL_PIN = board.D10 PIXEL_PIN = board.D10
NUM_PIXELS = 3 NUM_PIXELS = 3
# The order of the pixel colors - RGB or GRB. Some NeoPixels have red and green reversed! # Allow configuration of pixel order.
# For RGBW NeoPixels, simply change the ORDER to RGBW or GRBW. # Default to GRBW (common on many RGBW strips). You can override via:
ORDER = neopixel.RGBW # - env var ODUCK_LED_ORDER, e.g. "RGB", "GRB", "RGBW", "GRBW"
# - duck_config.LED_ORDER (string matching neopixel constants)
try:
from open_duck_mini_runtime.duck_config import LED_ORDER as _CFG_LED_ORDER # type: ignore
except Exception:
_CFG_LED_ORDER = None
_ORDER_NAME = os.getenv("ODUCK_LED_ORDER", _CFG_LED_ORDER or "GRBW").upper()
ORDER = getattr(neopixel, _ORDER_NAME, neopixel.GRBW)
# Brightness can be tuned via env
BRIGHTNESS = float(os.getenv("ODUCK_LED_BRIGHTNESS", "1.0"))
# Brightness and a single shared NeoPixel instance
BRIGHTNESS = 1
pixels = neopixel.NeoPixel(
PIXEL_PIN, NUM_PIXELS, brightness=BRIGHTNESS, auto_write=False, pixel_order=ORDER
)
class LedController: class LedController:
def __init__(self) -> None: def __init__(self) -> None:
@ -35,12 +44,15 @@ class LedController:
self._pixels = None # type: ignore self._pixels = None # type: ignore
self._deinited = False self._deinited = False
# Use the module-level NeoPixel configured above # Lazily create the NeoPixel instance (avoid creating it at import-time)
self._pixels = pixels self._pixels = neopixel.NeoPixel(
PIXEL_PIN, NUM_PIXELS, brightness=BRIGHTNESS, auto_write=False, pixel_order=ORDER
)
# Cache simple color tuples (use 4-tuple for RGBW strips) # Cache simple color tuples (use 4-tuple for RGBW strips)
# Colors are stored in logical (R, G, B, W) regardless of ORDER. # Colors are stored in logical (R, G, B, W) regardless of ORDER.
self.OFF = (0, 0, 0, 0) self.OFF = (0, 0, 0, 0)
# On RGBW strips, "white" uses the W channel for best white.
self.WHITE = (0, 0, 0, 255) self.WHITE = (0, 0, 0, 255)
self.RED = (255, 0, 0, 0) self.RED = (255, 0, 0, 0)
self.GREEN = (0, 255, 0, 0) self.GREEN = (0, 255, 0, 0)
@ -67,6 +79,9 @@ class LedController:
# Ensure an initial known state # Ensure an initial known state
self._apply() self._apply()
# Ensure cleanup on interpreter shutdown
atexit.register(self.deinit)
# Internal helper to apply current states to pixels # Internal helper to apply current states to pixels
def _apply( def _apply(
self, self,
@ -98,8 +113,6 @@ class LedController:
""" """
r, g, b, w = color_rgba r, g, b, w = color_rgba
try: try:
# neopixel library generally accepts (r,g,b) or (r,g,b,w) depending on pixel type
# We map to the declared ORDER for clarity when direct indexing is used.
if ORDER in (neopixel.RGB, neopixel.GRB): if ORDER in (neopixel.RGB, neopixel.GRB):
mapping = { mapping = {
neopixel.RGB: (r, g, b), neopixel.RGB: (r, g, b),
@ -114,7 +127,7 @@ class LedController:
} }
return mapping.get(ORDER, (r, g, b, w)) return mapping.get(ORDER, (r, g, b, w))
except Exception: except Exception:
# Fallback: return RGBA or RGB as-is # Fallback
return (r, g, b, w) return (r, g, b, w)
# Eyes API # Eyes API
@ -185,16 +198,19 @@ class LedController:
self._apply() self._apply()
def deinit(self) -> None: def deinit(self) -> None:
if self._deinited or self._pixels is None: if self._deinited:
return return
with self._lock: with self._lock:
try: try:
self.all_off() # Turn everything off before releasing the driver
if self._pixels is not None:
self._pixels.fill(self._to_order(self.OFF))
self._pixels.show()
except Exception: except Exception:
# Ignore if showing fails on teardown
pass pass
try: try:
self._pixels.deinit() if self._pixels is not None:
self._pixels.deinit()
finally: finally:
self._deinited = True self._deinited = True
self._pixels = None self._pixels = None
@ -208,3 +224,17 @@ def get_controller() -> LedController:
if _controller is None: if _controller is None:
_controller = LedController() _controller = LedController()
return _controller return _controller
# Ensure graceful cleanup on Ctrl+C / SIGTERM without forcing controller creation.
def _shutdown_handler(signum, frame):
global _controller
if _controller is not None:
_controller.deinit()
try:
signal.signal(signal.SIGINT, _shutdown_handler)
signal.signal(signal.SIGTERM, _shutdown_handler)
except Exception:
# Not all environments allow setting signals (e.g., some threads)
pass

View file

@ -1,130 +1,66 @@
import pygame import argparse
import time import time
import os from pathlib import Path
import random
from threading import Thread, Lock
from open_duck_mini_runtime.led_controller import get_controller import pygame
class Sounds: def find_assets_dir() -> Path:
def __init__(self, volume=1.0, sound_directory="", default_color: str = "white", sound_color_map: dict | None = None): # assets/ is a sibling of the package directory
pygame.mixer.init() pkg_dir = Path(__file__).resolve().parent
pygame.mixer.music.set_volume(volume) src_dir = pkg_dir.parent
self.sounds = {} assets = src_dir / "assets"
self.ok = True if not assets.exists():
self._ctrl = get_controller() raise FileNotFoundError(f"Assets directory not found: {assets}")
self._default_color = default_color return assets
self._sound_color_map = sound_color_map or {}
self._color_token = 0
self._lock = Lock() def list_wavs(assets_dir: Path) -> list[Path]:
return sorted(assets_dir.glob("*.wav"))
def play_sound(path: Path, volume: float = 1.0) -> None:
snd = pygame.mixer.Sound(str(path))
snd.set_volume(max(0.0, min(1.0, volume)))
ch = snd.play()
while ch.get_busy():
time.sleep(0.05)
def main():
parser = argparse.ArgumentParser(description="Step through sounds in assets/")
parser.add_argument("--auto", action="store_true", help="Automatically advance without waiting for Enter")
parser.add_argument("--delay", type=float, default=0.5, help="Delay between sounds when --auto is set")
parser.add_argument("--volume", type=float, default=1.0, help="Playback volume (0.0 - 1.0)")
args = parser.parse_args()
assets = find_assets_dir()
files = list_wavs(assets)
if not files:
print(f"No .wav files found in {assets}")
return
pygame.mixer.init()
print(f"pygame {pygame.version.ver}")
print(f"Found {len(files)} wav files in {assets}")
try:
for i, wav in enumerate(files, 1):
print(f"[{i}/{len(files)}] Playing: {wav.name}")
play_sound(wav, volume=args.volume)
if args.auto:
time.sleep(max(0.0, args.delay))
else:
input("Press Enter for next...")
except KeyboardInterrupt:
print("\nInterrupted.")
finally:
try: try:
for file in os.listdir(sound_directory): pygame.mixer.stop()
if file.endswith(".wav"):
sound_path = os.path.join(sound_directory, file)
try:
self.sounds[file] = pygame.mixer.Sound(sound_path)
print(f"Loaded: {file}")
except pygame.error as e:
print(f"Failed to load {file}: {e}")
except FileNotFoundError:
print(f"Directory {sound_directory} not found.")
self.ok = False
if len(self.sounds) == 0:
print("No sound files found in the directory.")
self.ok = False
# Initialize color map defaults (cycle through a limited palette)
if self.ok and not self._sound_color_map:
palette = ["red", "green", "blue", "white"]
for idx, name in enumerate(sorted(self.sounds.keys())):
self._sound_color_map[name] = palette[idx % len(palette)]
# Ensure LEDs are default color initially (without changing on/off state)
try:
self._ctrl.set_all_color(self._default_color)
except Exception: except Exception:
pass pass
pygame.quit()
def play(self, sound_name):
if not self.ok:
print("Sounds not initialized properly.")
return
if sound_name in self.sounds:
chan = self.sounds[sound_name].play()
print(f"Playing: {sound_name}")
# Change LEDs to mapped color (supports string for all or dict per pixel)
mapping_val = self._sound_color_map.get(sound_name, self._default_color)
try:
if isinstance(mapping_val, dict):
left = mapping_val.get("left", self._default_color)
right = mapping_val.get("right", self._default_color)
proj = mapping_val.get("projector", self._default_color)
self._ctrl.set_left_eye_color(left)
self._ctrl.set_right_eye_color(right)
self._ctrl.set_projector_color(proj)
else:
self._ctrl.set_all_color(mapping_val)
except Exception as e:
print(f"LED color set failed: {e}")
# Schedule reset to default when the sound finishes
with self._lock:
self._color_token += 1
token = self._color_token
def _reset_when_done(channel, expected_token):
try:
# If no channel returned, fallback to sleep by length
if channel is None:
duration = self.sounds[sound_name].get_length()
time.sleep(duration)
else:
# Wait until the channel is no longer busy
while channel.get_busy():
time.sleep(0.02)
# Only reset if no newer sound changed the color
with self._lock:
if expected_token == self._color_token:
self._ctrl.set_all_color(self._default_color)
except Exception:
pass
Thread(target=_reset_when_done, args=(chan, token), daemon=True).start()
else:
print(f"Sound '{sound_name}' not found!")
def play_random_sound(self):
if not self.ok:
print("Sounds not initialized properly.")
return
sound_name = random.choice(list(self.sounds.keys()))
self.play(sound_name)
def play_happy(self):
self.play("happy1.wav")
# API helpers
def set_default_color(self, color: str):
self._default_color = color
try:
self._ctrl.set_all_color(self._default_color)
except Exception:
pass
def set_sound_color(self, sound_name: str, color: str):
self._sound_color_map[sound_name] = color
def set_sound_color_map(self, mapping: dict):
self._sound_color_map.update(mapping)
# Example usage
if __name__ == "__main__": if __name__ == "__main__":
sound_player = Sounds(1.0, "../assets/") main()
time.sleep(1)
while True:
sound_player.play_random_sound()
time.sleep(5)