Files

282 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""Controls the LEDs on the ReSpeaker 2mic HAT, inspired by smart speakers like Alexa and Alice."""
import argparse
import asyncio
import logging
import time
from functools import partial
from math import ceil
from typing import Tuple, List, Optional
import gpiozero
import spidev
from wyoming.asr import Transcript
from wyoming.event import Event
from wyoming.satellite import (
RunSatellite,
SatelliteConnected,
SatelliteDisconnected,
StreamingStarted,
StreamingStopped,
)
from wyoming.server import AsyncEventHandler, AsyncServer
from wyoming.vad import VoiceStarted, VoiceStopped
from wyoming.wake import Detection
_LOGGER = logging.getLogger()
NUM_LEDS = 3
LEDS_GPIO = 12
RGB_MAP = {
"rgb": [3, 2, 1],
"rbg": [3, 1, 2],
"grb": [2, 3, 1],
"gbr": [2, 1, 3],
"brg": [1, 3, 2],
"bgr": [1, 2, 3],
}
async def main() -> None:
"""Main entry point."""
parser = argparse.ArgumentParser()
parser.add_argument("--uri", required=True, help="unix:// or tcp://")
parser.add_argument("--debug", action="store_true", help="Log DEBUG messages")
parser.add_argument(
"--led-brightness",
type=int,
choices=range(1, 32),
default=31,
help="LED brightness (integer from 1 to 31)",
)
args = parser.parse_args()
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
_LOGGER.debug(args)
_LOGGER.info("Ready")
# Turn on power to LEDs
led_power = gpiozero.LED(LEDS_GPIO, active_high=False)
led_power.on()
leds = APA102(num_led=NUM_LEDS, global_brightness=args.led_brightness)
# Start server
server = AsyncServer.from_uri(args.uri)
try:
await server.run(partial(LEDsEventHandler, args, leds))
except KeyboardInterrupt:
pass
finally:
leds.cleanup()
led_power.off()
# -----------------------------------------------------------------------------
_BLACK = (0, 0, 0)
_WHITE = (255, 255, 255)
_RED = (255, 0, 0)
_YELLOW = (255, 255, 0)
_BLUE = (0, 0, 255)
_GREEN = (0, 255, 0)
_PURPLE = (128, 0, 128)
_ORANGE = (255, 165, 0)
_CYAN = (0, 255, 255)
class LEDsEventHandler(AsyncEventHandler):
"""Event handler for clients, with animations inspired by Alexa and Alice."""
def __init__(
self,
cli_args: argparse.Namespace,
leds: "APA102",
*args,
**kwargs,
) -> None:
super().__init__(*args, **kwargs)
self.cli_args = cli_args
self.client_id = str(time.monotonic_ns())
self.leds = leds
self.voice_active = False
self.voice_animation_task: Optional[asyncio.Task] = None
self.streaming_active = False
self.streaming_animation_task: Optional[asyncio.Task] = None
_LOGGER.debug("Client connected: %s", self.client_id)
async def handle_event(self, event: Event) -> bool:
_LOGGER.debug(event)
if StreamingStarted.is_type(event.type):
self.streaming_active = True
if self.streaming_animation_task is None or self.streaming_animation_task.done():
self.streaming_animation_task = asyncio.create_task(self.streaming_animation_loop())
elif Detection.is_type(event.type):
# Как Alexa: directional blue spotlight for wake/listening
await self.rotate_colors([_BLUE, _CYAN, _BLACK], rotations=5, delay=0.2) # Вращение для "просыпания"
self.color(_BLACK)
elif VoiceStarted.is_type(event.type):
self.voice_active = True
if self.voice_animation_task is None or self.voice_animation_task.done():
self.voice_animation_task = asyncio.create_task(self.voice_animation_loop())
elif Transcript.is_type(event.type):
# Как Alexa/Alice: Зеленый успех или эмоция (мигание зеленым для подтверждения)
await self.pulse_color(_GREEN, pulses=2, duration=0.8)
self.color(_BLACK)
elif VoiceStopped.is_type(event.type):
self.voice_active = False
if self.voice_animation_task:
self.voice_animation_task.cancel()
self.voice_animation_task = None
await self.fade_out(_BLUE, steps=10, delay=0.15) # Затухание синего, как конец обработки
elif StreamingStopped.is_type(event.type):
self.streaming_active = False
if self.streaming_animation_task:
self.streaming_animation_task.cancel()
self.streaming_animation_task = None
await self.fade_out(_BLUE, steps=10, delay=0.15)
elif RunSatellite.is_type(event.type):
self.color(_BLACK)
elif SatelliteConnected.is_type(event.type):
# Разовая анимация при подключении, без loop, чтобы в idle не светилось
await self.pulse_color(_GREEN, pulses=2, duration=1.5)
self.color(_BLACK)
elif SatelliteDisconnected.is_type(event.type):
await self.pulse_color(_RED, pulses=3, duration=0.5) # Красный пульс для ошибки, как Alexa purple/red
self.color(_BLACK)
return True
async def voice_animation_loop(self) -> None:
"""Loop анимации для VoiceStarted: как Alexa listening/thinking - alternating blue/cyan."""
try:
while self.voice_active:
await self.gradient([_BLUE, _CYAN, _BLUE], duration=1.5) # Градиент для "thinking"
await self.rotate_colors([_CYAN, _BLUE, _BLACK], rotations=1, delay=0.3) # Вращение для активности
await asyncio.sleep(0.2)
except asyncio.CancelledError:
self.color(_BLACK)
async def streaming_animation_loop(self) -> None:
"""Loop для StreamingStarted: pulsing blue как Alexa processing."""
try:
while self.streaming_active:
await self.pulse_color(_BLUE, pulses=1, duration=0.8)
await asyncio.sleep(0.2)
except asyncio.CancelledError:
self.color(_BLACK)
def color(self, rgb: Tuple[int, int, int]) -> None:
for i in range(NUM_LEDS):
self.leds.set_pixel(i, rgb[0], rgb[1], rgb[2])
self.leds.show()
def set_colors(self, colors: List[Tuple[int, int, int]]) -> None:
for i in range(min(NUM_LEDS, len(colors))):
self.leds.set_pixel(i, colors[i][0], colors[i][1], colors[i][2])
self.leds.show()
async def pulse_color(self, rgb: Tuple[int, int, int], pulses: int = 3, duration: float = 0.5) -> None:
steps = 10
for _ in range(pulses):
for bright in range(steps):
self.set_pixel_bright(rgb, (bright + 1) / steps * 100)
await asyncio.sleep(duration / (2 * steps))
for bright in range(steps, 0, -1):
self.set_pixel_bright(rgb, (bright - 1) / steps * 100)
await asyncio.sleep(duration / (2 * steps))
def set_pixel_bright(self, rgb: Tuple[int, int, int], bright_percent: float) -> None:
for i in range(NUM_LEDS):
self.leds.set_pixel(i, rgb[0], rgb[1], rgb[2], bright_percent)
self.leds.show()
async def rainbow_cycle(self, steps: int = 20, delay: float = 0.05) -> None:
for step in range(steps):
colors = []
for i in range(NUM_LEDS):
r = int((step + i * 85) % 255)
g = int((step + i * 85 + 85) % 255)
b = int((step + i * 85 + 170) % 255)
colors.append((r, g, b))
self.set_colors(colors)
await asyncio.sleep(delay)
self.color(_BLACK)
async def gradient(self, colors: List[Tuple[int, int, int]], duration: float = 1.0) -> None:
steps = 10
for step in range(steps):
blended_colors = []
for i in range(NUM_LEDS):
idx = i % len(colors)
next_idx = (idx + 1) % len(colors)
r = int(colors[idx][0] + (colors[next_idx][0] - colors[idx][0]) * step / steps)
g = int(colors[idx][1] + (colors[next_idx][1] - colors[idx][1]) * step / steps)
b = int(colors[idx][2] + (colors[next_idx][2] - colors[idx][2]) * step / steps)
blended_colors.append((r, g, b))
self.set_colors(blended_colors)
await asyncio.sleep(duration / steps)
async def fade_out(self, start_rgb: Tuple[int, int, int], steps: int = 10, delay: float = 0.1) -> None:
for step in range(steps, 0, -1):
r = int(start_rgb[0] * step / steps)
g = int(start_rgb[1] * step / steps)
b = int(start_rgb[2] * step / steps)
self.color((r, g, b))
await asyncio.sleep(delay)
self.color(_BLACK)
async def rotate_colors(self, colors: List[Tuple[int, int, int]], rotations: int = 3, delay: float = 0.2) -> None:
current_colors = colors[:NUM_LEDS]
for _ in range(rotations):
self.set_colors(current_colors)
await asyncio.sleep(delay)
current_colors = current_colors[-1:] + current_colors[:-1]
# -----------------------------------------------------------------------------
class APA102:
"""
Driver for APA102 LEDS (aka "DotStar").
(c) Martin Erzberger 2016-2017
"""
# Constants
MAX_BRIGHTNESS = 0b11111 # Safeguard: Set to a value appropriate for your setup
LED_START = 0b11100000 # Three "1" bits, followed by 5 brightness bits
def __init__(
self,
num_led,
global_brightness,
order="rgb",
bus=0,
device=1,
max_speed_hz=8000000,
):
self.num_led = num_led # The number of LEDs in the Strip
order = order.lower()
self.rgb = RGB_MAP.get(order, RGB_MAP["rgb"])
# Limit the brightness to the maximum if it's set higher
if global_brightness > self.MAX_BRIGHTNESS:
self.global_brightness = self.MAX_BRIGHTNESS
else:
self.global_brightness = global_brightness
_LOGGER.debug("LED brightness: %d", self.global_brightness)
self.leds = [self.LED_START, 0, 0, 0] * self.num_led # Pixel buffer
self.spi = spidev.SpiDev() # Init the SPI device
self.spi.open(bus, device) # Open SPI port 0, slave device (CS) 1
# Up the speed a bit, so that the LEDs are painted faster
if max_speed_hz:
self.spi.max_speed_hz = max_speed_hz
def clock_start_frame(self):
self.spi.xfer2([0] * 4) # Start frame, 32 zero bits
def clock_end_frame(self):
self.spi.xfer2([0xFF] * 4)
def set_pixel(self, led_num, red, green, blue, bright_percent=100):
if led_num < 0:
return # Pixel is invisible, so ignore
if led_num >= self.num_led:
return # again, invisible
brightness = int(ceil(bright_percent * self.global_brightness / 100.0))
ledstart = (brightness & 0b00011111) | self.LED_START
start_index = 4 * led_num
self.leds[start_index] = ledstart
self.leds[start_index + self.rgb[0]] = red
self.leds[start_index + self.rgb[1]] = green
self.leds[start_index + self.rgb[2]] = blue
def set_pixel_rgb(self, led_num, rgb_color, bright_percent=100):
self.set_pixel(
led_num,
(rgb_color & 0xFF0000) >> 16,
(rgb_color & 0x00FF00) >> 8,
rgb_color & 0x0000FF,
bright_percent,
)
def rotate(self, positions=1):
cutoff = 4 * (positions % self.num_led)
self.leds = self.leds[cutoff:] + self.leds[:cutoff]
def show(self):
self.clock_start_frame()
data = list(self.leds)
while data:
self.spi.xfer2(data[:32])
data = data[32:]
self.clock_end_frame()
def cleanup(self):
self.spi.close() # Close SPI port
# -----------------------------------------------------------------------------
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass