From d86884306f3af77ab32a927679fd78cf7f5a3891 Mon Sep 17 00:00:00 2001 From: Aronnaxx Date: Sat, 16 Aug 2025 14:57:38 -0700 Subject: [PATCH] Tests + figuring out the weird eye crap for why adding MORE makes it work --- src/open_duck_mini_runtime/eyes.py | 12 +- src/open_duck_mini_runtime/led_controller.py | 41 +++---- tests/light_test.py | 24 ++-- tests/neopixel_numpad.py | 113 +++++++++++++++++++ 4 files changed, 157 insertions(+), 33 deletions(-) create mode 100644 tests/neopixel_numpad.py diff --git a/src/open_duck_mini_runtime/eyes.py b/src/open_duck_mini_runtime/eyes.py index eff400c..adf7106 100644 --- a/src/open_duck_mini_runtime/eyes.py +++ b/src/open_duck_mini_runtime/eyes.py @@ -14,6 +14,9 @@ class Eyes: self.max_interval = max_interval # Ensure eyes start ON to mimic previous behavior + + self.ctrl.set_eyes_color("white") + self.ctrl.set_eyes(True) self._stop_event = Event() @@ -27,10 +30,12 @@ class Eyes: try: while not self._stop_event.is_set(): self._set_eyes(False) - time.sleep(self.blink_duration) + if self._stop_event.wait(self.blink_duration): + break self._set_eyes(True) next_blink = random.uniform(self.min_interval, self.max_interval) - time.sleep(next_blink) + if self._stop_event.wait(next_blink): + break except Exception as err: print(f"Error in eye thread: {err}") self._stop_event.set() @@ -39,7 +44,8 @@ class Eyes: self._stop_event.set() self._thread.join() self._set_eyes(False) - # Do not deinit controller here; projector may also use it. + # deinit controller + self.ctrl.deinit() if __name__ == "__main__": diff --git a/src/open_duck_mini_runtime/led_controller.py b/src/open_duck_mini_runtime/led_controller.py index da36a62..68f9c8c 100644 --- a/src/open_duck_mini_runtime/led_controller.py +++ b/src/open_duck_mini_runtime/led_controller.py @@ -10,7 +10,6 @@ from __future__ import annotations import os import atexit -import signal from threading import Lock from typing import Tuple, Optional, Union @@ -20,7 +19,7 @@ import neopixel # Pin and pixel configuration PIXEL_PIN = board.D10 -NUM_PIXELS = 3 +NUM_PIXELS = 10 # Allow configuration of pixel order. # Default to GRBW (common on many RGBW strips). You can override via: @@ -31,12 +30,21 @@ try: 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) +_ORDER_NAME = os.getenv("ODUCK_LED_ORDER", _CFG_LED_ORDER or "RGBW").upper() +ORDER = getattr(neopixel, _ORDER_NAME, neopixel.RGBW) # Brightness can be tuned via env BRIGHTNESS = float(os.getenv("ODUCK_LED_BRIGHTNESS", "1.0")) +# White rendering mode: "W" uses the dedicated white channel (RGBW strips), +# "RGB" mixes white from RGB. Useful if a particular LED's W phosphor has tint. +try: + from open_duck_mini_runtime.duck_config import LED_WHITE_MODE as _CFG_WHITE_MODE # type: ignore +except Exception: + _CFG_WHITE_MODE = None + +WHITE_MODE = os.getenv("ODUCK_LED_WHITE_MODE", _CFG_WHITE_MODE or "W").upper() + class LedController: def __init__(self) -> None: @@ -46,14 +54,18 @@ class LedController: # Lazily create the NeoPixel instance (avoid creating it at import-time) self._pixels = neopixel.NeoPixel( - PIXEL_PIN, NUM_PIXELS, brightness=BRIGHTNESS, auto_write=False, pixel_order=ORDER + PIXEL_PIN, NUM_PIXELS, brightness=BRIGHTNESS, auto_write=True, pixel_order=ORDER ) # Cache simple color tuples (use 4-tuple for RGBW strips) # Colors are stored in logical (R, G, B, W) regardless of ORDER. self.OFF = (0, 0, 0, 0) - # On RGBW strips, "white" uses the W channel for best white. - self.WHITE = (0, 0, 0, 255) + # White color can be sourced from W channel or mixed from RGB. + if WHITE_MODE == "RGB": + self.WHITE = (255, 255, 255, 0) + else: + # Default: use dedicated W channel on RGBW strips + self.WHITE = (0, 0, 0, 255) self.RED = (255, 0, 0, 0) self.GREEN = (0, 255, 0, 0) self.BLUE = (0, 0, 255, 0) @@ -226,15 +238,6 @@ def get_controller() -> LedController: 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 \ No newline at end of file +# Note: We intentionally avoid installing signal handlers here. +# Libraries should not override application-level signal behavior. +# atexit cleanup above is sufficient in most cases. \ No newline at end of file diff --git a/tests/light_test.py b/tests/light_test.py index cddb625..189a95c 100644 --- a/tests/light_test.py +++ b/tests/light_test.py @@ -1,6 +1,3 @@ -# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries -# SPDX-License-Identifier: MIT - # Simple test for NeoPixels on Raspberry Pi import time @@ -17,7 +14,7 @@ num_pixels = 3 # The order of the pixel colors - RGB or GRB. Some NeoPixels have red and green reversed! # For RGBW NeoPixels, simply change the ORDER to RGBW or GRBW. -ORDER = neopixel.GRB +ORDER = neopixel.RGBW pixels = neopixel.NeoPixel( pixel_pin, num_pixels, brightness=0.2, auto_write=False, pixel_order=ORDER @@ -57,24 +54,29 @@ def rainbow_cycle(wait): while True: # Comment this line out if you have RGBW/GRBW NeoPixels - pixels.fill((255, 0, 0)) + # pixels.fill((255, 0, 0)) # Uncomment this line if you have RGBW/GRBW NeoPixels - # pixels.fill((255, 0, 0, 0)) + pixels.fill((255, 0, 0, 0)) pixels.show() time.sleep(1) # Comment this line out if you have RGBW/GRBW NeoPixels - pixels.fill((0, 255, 0)) + # pixels.fill((0, 255, 0)) # Uncomment this line if you have RGBW/GRBW NeoPixels - # pixels.fill((0, 255, 0, 0)) + pixels.fill((0, 255, 0, 0)) pixels.show() time.sleep(1) # Comment this line out if you have RGBW/GRBW NeoPixels - pixels.fill((0, 0, 255)) + # pixels.fill((0, 0, 255)) # Uncomment this line if you have RGBW/GRBW NeoPixels - # pixels.fill((0, 0, 255, 0)) + pixels.fill((0, 0, 255, 0)) pixels.show() time.sleep(1) - rainbow_cycle(0.001) # rainbow cycle with 1ms delay per step + pixels.fill((0, 0, 255, 0)) + pixels.show() + time.sleep(1) + + + # rainbow_cycle(0.001) # rainbow cycle with 1ms delay per step diff --git a/tests/neopixel_numpad.py b/tests/neopixel_numpad.py new file mode 100644 index 0000000..e4ba1a4 --- /dev/null +++ b/tests/neopixel_numpad.py @@ -0,0 +1,113 @@ +import time +import board +import neopixel +import threading +import curses +import math + +# --- NeoPixel setup --- +pixel_pin = board.D10 +num_pixels = 10 # Ok so for some unknown reason this works with 10 but NOT with 3? TODO figure this out I guess +ORDER = neopixel.RGBW # <-- flip to GRBW if colors look wrong + +pixels = neopixel.NeoPixel( + pixel_pin, num_pixels, brightness=0.3, auto_write=False, pixel_order=ORDER +) + +# --- Colors (R, G, B, W) --- +colors = { + '1': (255, 0, 0, 0), # Red + '2': (0, 255, 0, 0), # Green + '3': (0, 0, 255, 0), # Blue + '4': (0, 0, 0, 255), # White + '5': (255, 255, 0, 0), # Yellow + '6': (0, 255, 255, 0), # Cyan + '7': (255, 0, 255, 0), # Magenta + '8': (128, 128, 128, 0), # Gray + '9': (0, 0, 0, 0), # Off +} + +# --- Globals --- +rainbow_active = False +brightness = 0.3 + +# --- Gradient rainbow (wave across strip) --- +def smooth_rainbow(wait=0.02): + global rainbow_active + hue = 0 + while rainbow_active: + for i in range(num_pixels): + # Offset hue per pixel for gradient effect + offset = (hue + (i * 360 / num_pixels)) % 360 + r, g, b = hsv_to_rgb(offset / 360.0, 1, 1) + pixels[i] = (int(r * 255), int(g * 255), int(b * 255), 0) + pixels.show() + hue = (hue + 1) % 360 + time.sleep(wait) + +def hsv_to_rgb(h, s, v): + if s == 0.0: + return v, v, v + i = int(h * 6.0) + f = (h * 6.0) - i + p = v * (1.0 - s) + q = v * (1.0 - s * f) + t = v * (1.0 - s * (1.0 - f)) + i = i % 6 + if i == 0: return v, t, p + if i == 1: return q, v, p + if i == 2: return p, v, t + if i == 3: return p, q, v + if i == 4: return t, p, v + if i == 5: return v, p, q + +# --- Main with curses --- +def main(stdscr): + global rainbow_active, brightness + curses.curs_set(0) # Hide cursor + stdscr.nodelay(True) + stdscr.addstr(0, 0, "Press 1–9 for colors, 0 for rainbow, ←/→ for brightness, q to quit.") + + while True: + key = stdscr.getch() + if key == -1: + time.sleep(0.05) + continue + + if key in range(49, 58): # Keys '1'–'9' + ch = chr(key) + rainbow_active = False + pixels.fill(colors[ch]) + pixels.show() + stdscr.addstr(2, 0, f"Set color {ch}: {colors[ch]} ") + + elif key == ord('0'): # Rainbow toggle + if not rainbow_active: + rainbow_active = True + threading.Thread(target=smooth_rainbow, daemon=True).start() + stdscr.addstr(2, 0, "🌈 Gradient rainbow started ") + else: + rainbow_active = False + stdscr.addstr(2, 0, "Rainbow stopped ") + + elif key == curses.KEY_RIGHT: # Brightness up + brightness = min(1.0, brightness + 0.1) + pixels.brightness = brightness + pixels.show() + stdscr.addstr(3, 0, f"Brightness: {brightness:.1f} ") + + elif key == curses.KEY_LEFT: # Brightness down + brightness = max(0.0, brightness - 0.1) + pixels.brightness = brightness + pixels.show() + stdscr.addstr(3, 0, f"Brightness: {brightness:.1f} ") + + elif key in (ord('q'), ord('Q')): + rainbow_active = False + pixels.fill((0, 0, 0, 0)) + pixels.show() + stdscr.addstr(2, 0, "Goodbye! ") + time.sleep(0.5) + break + +curses.wrapper(main)