feat: custom LED animations mirroring ESPHome effects
Port LED effects from respeaker_usb_mic_array.py to example_led.py:
- Replace built-in XVF3800 effects with custom frame-by-frame animations
- Use LED_RING_COLOR command (resid=20, cmdid=19) for direct LED control
- Implement 11 animations matching home-assistant-voice.yaml:
- off, twinkle, twinkle_blue, waiting, listening, thinking,
replying, muted, error, timer_tick, timer_ring
- Muted indicator: 4 red LEDs at mic positions (0°, 90°, 180°, 270°)
- Timer tick: arc shows remaining time with sweep marker
- Add --test mode for hardware verification
- Add --verbose flag for INFO logging
- Enhanced USB diagnostics and error reporting
Technical changes:
- set_led_frame() sends 12×uint32 colors in 0xBBGGRR format
- RGB tuples with _scale() for brightness control
- _pulse_step() for smooth pulsing effects
- _apply_mic_indicators() for muted state visualization
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
+456
-124
@@ -1,14 +1,15 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
ReSpeaker XVF3800 USB 4-MIC Array – Terminal LED Controller.
|
ReSpeaker XVF3800 USB 4-MIC Array – LED Controller with Custom Animations.
|
||||||
|
|
||||||
Использует встроенные эффекты XVF3800:
|
Implements custom LED animations mirroring the ESPHome home-assistant-voice.yaml effects.
|
||||||
0 = off
|
Uses direct USB control transfers to drive the 12 APA102-style LEDs.
|
||||||
1 = breath (дыхание)
|
|
||||||
2 = rainbow (радуга)
|
LED animations mirror the ESPHome home-assistant-voice.yaml effects exactly.
|
||||||
3 = single color (один цвет)
|
|
||||||
4 = doa (направление звука)
|
Muted indicator: LEDs at the 4 mic positions light red.
|
||||||
5 = ring (кольцо, может не поддерживаться)
|
The XVF3800 has 4 mics at DOA 0°, 90°, 180°, 270° which on a 12-LED ring
|
||||||
|
(30° per step) maps to LEDs 0, 3, 6, 9 — the four cardinal points.
|
||||||
|
|
||||||
Ввод цифр 0-10 выбирает анимацию, q - выход.
|
Ввод цифр 0-10 выбирает анимацию, q - выход.
|
||||||
"""
|
"""
|
||||||
@@ -17,6 +18,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
|
import random
|
||||||
import struct
|
import struct
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
@@ -38,10 +41,13 @@ except ImportError:
|
|||||||
USB_VENDOR_ID = 0x2886
|
USB_VENDOR_ID = 0x2886
|
||||||
USB_PRODUCT_IDS = [0x0018, 0x001A]
|
USB_PRODUCT_IDS = [0x0018, 0x001A]
|
||||||
|
|
||||||
|
LED_COUNT = 12
|
||||||
|
LED_BRIGHTNESS = 31 # 0–31 (APA102 5-bit global brightness register)
|
||||||
|
|
||||||
# GPO_SERVICER_RESID LED commands
|
# GPO_SERVICER_RESID LED commands
|
||||||
GPO_RESID = 20
|
GPO_RESID = 20
|
||||||
LED_EFFECT = 12
|
LED_EFFECT = 12
|
||||||
LED_BRIGHTNESS = 13
|
LED_BRIGHTNESS_CMD = 13
|
||||||
LED_SPEED = 15
|
LED_SPEED = 15
|
||||||
LED_COLOR = 16
|
LED_COLOR = 16
|
||||||
LED_DOA_COLOR = 17
|
LED_DOA_COLOR = 17
|
||||||
@@ -54,22 +60,56 @@ EFFECT_SINGLE = 3
|
|||||||
EFFECT_DOA = 4
|
EFFECT_DOA = 4
|
||||||
EFFECT_RING = 5
|
EFFECT_RING = 5
|
||||||
|
|
||||||
# Colors (0xBBGGRR format - BGR byte order!)
|
# Colors (RGB tuples for custom animations)
|
||||||
COLOR_CYAN = 0xC0BB00
|
RGB = tuple[int, int, int]
|
||||||
COLOR_RED = 0x0000FF
|
|
||||||
COLOR_GREEN = 0x00FF00
|
BLACK: RGB = (0, 0, 0)
|
||||||
COLOR_BLUE = 0xFF0000
|
RED: RGB = (255, 0, 0)
|
||||||
COLOR_YELLOW = 0x00FFFF
|
BLUE: RGB = (0, 0, 255)
|
||||||
COLOR_MAGENTA = 0xFF00FF
|
CYAN: RGB = (0, 200, 200)
|
||||||
COLOR_WHITE = 0xFFFFFF
|
GREEN: RGB = (0, 200, 50)
|
||||||
COLOR_OFF = 0x000000
|
YELLOW: RGB = (220, 180, 0)
|
||||||
|
MAGENTA: RGB = (255, 0, 255)
|
||||||
|
WHITE: RGB = (255, 255, 255)
|
||||||
|
|
||||||
|
# Default ring color matching HA Voice PE
|
||||||
|
DEFAULT_RING_COLOR: RGB = (24, 187, 242)
|
||||||
|
|
||||||
|
# Mic LED positions — 4 mics at DOA 0°, 90°, 180°, 270°
|
||||||
|
# On a 12-LED ring (30° per step): DOA 0° → LED 0, 90° → LED 3,
|
||||||
|
# 180° → LED 6, 270° → LED 9
|
||||||
|
MIC_LEDS = (0, 3, 6, 9)
|
||||||
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger("xvf3800-led")
|
_LOGGER = logging.getLogger("xvf3800-led")
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Helper functions
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
def _scale(color: RGB, factor: float) -> RGB:
|
||||||
|
"""Scale RGB color by factor (0.0-1.0)."""
|
||||||
|
f = max(0.0, min(1.0, factor))
|
||||||
|
return (int(color[0] * f), int(color[1] * f), int(color[2] * f))
|
||||||
|
|
||||||
|
|
||||||
|
def _rgb_to_bgr_int(color: RGB) -> int:
|
||||||
|
"""Convert RGB tuple to BGR integer (0xBBGGRR format)."""
|
||||||
|
r, g, b = color
|
||||||
|
return (b << 16) | (g << 8) | r
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# USB LED driver
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
class XVF3800LED:
|
class XVF3800LED:
|
||||||
"""Controls LEDs on XVF3800 via USB control transfers."""
|
"""
|
||||||
|
Controls the 12 LEDs on ReSpeaker XVF3800 via USB control transfers.
|
||||||
|
|
||||||
|
Supports both built-in effects and custom frame-by-frame control.
|
||||||
|
"""
|
||||||
|
|
||||||
TIMEOUT = 1000
|
TIMEOUT = 1000
|
||||||
|
|
||||||
@@ -79,22 +119,41 @@ class XVF3800LED:
|
|||||||
self._find_device()
|
self._find_device()
|
||||||
|
|
||||||
def _find_device(self) -> None:
|
def _find_device(self) -> None:
|
||||||
|
_LOGGER.info("Searching for XVF3800 USB device (VID=%04x, PIDs=%s)...",
|
||||||
|
USB_VENDOR_ID, [f"{pid:04x}" for pid in USB_PRODUCT_IDS])
|
||||||
|
|
||||||
|
# List all USB devices for debugging
|
||||||
|
try:
|
||||||
|
import usb.core
|
||||||
|
all_devs = usb.core.find(find_all=True)
|
||||||
|
_LOGGER.info("All USB devices found:")
|
||||||
|
for dev in all_devs:
|
||||||
|
_LOGGER.info(" - VID=%04x, PID=%04x, bus=%d, addr=%d",
|
||||||
|
dev.idVendor, dev.idProduct, dev.bus, dev.address)
|
||||||
|
except Exception as exc:
|
||||||
|
_LOGGER.warning("Could not list USB devices: %s", exc)
|
||||||
|
|
||||||
for pid in USB_PRODUCT_IDS:
|
for pid in USB_PRODUCT_IDS:
|
||||||
|
_LOGGER.debug("Looking for PID %04x...", pid)
|
||||||
dev = usb.core.find(idVendor=USB_VENDOR_ID, idProduct=pid)
|
dev = usb.core.find(idVendor=USB_VENDOR_ID, idProduct=pid)
|
||||||
if dev is not None:
|
if dev is not None:
|
||||||
try:
|
try:
|
||||||
if dev.is_kernel_driver_active(0):
|
if dev.is_kernel_driver_active(0):
|
||||||
|
_LOGGER.info("Detaching kernel driver from device...")
|
||||||
dev.detach_kernel_driver(0)
|
dev.detach_kernel_driver(0)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
_LOGGER.warning("Could not detach kernel driver: %s", exc)
|
||||||
self._dev = dev
|
self._dev = dev
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"XVF3800 found (USB %04x:%04x, bus %d, addr %d)",
|
"✅ XVF3800 found (USB %04x:%04x, bus %d, addr %d)",
|
||||||
USB_VENDOR_ID, pid, dev.bus, dev.address,
|
USB_VENDOR_ID, pid, dev.bus, dev.address,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
else:
|
||||||
|
_LOGGER.debug("Device %04x:%04x not found", USB_VENDOR_ID, pid)
|
||||||
|
|
||||||
_LOGGER.error(
|
_LOGGER.error(
|
||||||
"XVF3800 not found (USB %04x). Check connection and udev rules.",
|
"❌ XVF3800 not found (USB %04x). Check connection and udev rules.",
|
||||||
USB_VENDOR_ID,
|
USB_VENDOR_ID,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -120,7 +179,7 @@ class XVF3800LED:
|
|||||||
|
|
||||||
def set_brightness(self, brightness: int) -> bool:
|
def set_brightness(self, brightness: int) -> bool:
|
||||||
brightness = max(0, min(255, brightness))
|
brightness = max(0, min(255, brightness))
|
||||||
return self._ctrl_transfer(GPO_RESID, LED_BRIGHTNESS, [brightness])
|
return self._ctrl_transfer(GPO_RESID, LED_BRIGHTNESS_CMD, [brightness])
|
||||||
|
|
||||||
def set_speed(self, speed: int) -> bool:
|
def set_speed(self, speed: int) -> bool:
|
||||||
speed = max(0, min(255, speed))
|
speed = max(0, min(255, speed))
|
||||||
@@ -134,6 +193,55 @@ class XVF3800LED:
|
|||||||
payload = list(struct.pack(b'<II', base_color, doa_color))
|
payload = list(struct.pack(b'<II', base_color, doa_color))
|
||||||
return self._ctrl_transfer(GPO_RESID, LED_DOA_COLOR, payload)
|
return self._ctrl_transfer(GPO_RESID, LED_DOA_COLOR, payload)
|
||||||
|
|
||||||
|
def set_led_frame(self, pixels: list[RGB], brightness: float = 1.0) -> bool:
|
||||||
|
"""
|
||||||
|
Set custom LED frame using LED_RING_COLOR command.
|
||||||
|
|
||||||
|
Uses the XVF3800's LED_RING_COLOR command (resid=20, cmdid=19)
|
||||||
|
which expects 12 × uint32 values (one color per LED in 0xBBGGRR format).
|
||||||
|
"""
|
||||||
|
# Build 12 uint32 values in 0xBBGGRR format
|
||||||
|
colors_uint32 = []
|
||||||
|
for r, g, b in pixels:
|
||||||
|
br = max(0, min(255, int(r * brightness)))
|
||||||
|
bg = max(0, min(255, int(g * brightness)))
|
||||||
|
bb = max(0, min(255, int(b * brightness)))
|
||||||
|
# XVF3800 uses 0xBBGGRR format
|
||||||
|
color_int = (bb << 16) | (bg << 8) | br
|
||||||
|
colors_uint32.append(color_int)
|
||||||
|
|
||||||
|
# Pack as 12 × uint32 little-endian
|
||||||
|
payload = b''
|
||||||
|
for color_int in colors_uint32:
|
||||||
|
payload += struct.pack('<I', color_int)
|
||||||
|
|
||||||
|
_LOGGER.debug("LED frame: brightness=%.2f, pixels=%s, payload_len=%d",
|
||||||
|
brightness, pixels, len(payload))
|
||||||
|
|
||||||
|
# Send via vendor control transfer
|
||||||
|
# resid=20 (GPO_SERVICER_RESID), cmdid=19 (LED_RING_COLOR)
|
||||||
|
with self._dev_lock:
|
||||||
|
if self._dev is None:
|
||||||
|
_LOGGER.warning("❌ LEDs [simulated]: device not available")
|
||||||
|
_LOGGER.warning(" Payload would be: %s", payload.hex())
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
result = self._dev.ctrl_transfer(
|
||||||
|
usb.util.CTRL_OUT | usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_RECIPIENT_DEVICE,
|
||||||
|
0, # request
|
||||||
|
19, # wValue = cmdid
|
||||||
|
20, # wIndex = resid
|
||||||
|
payload,
|
||||||
|
self.TIMEOUT,
|
||||||
|
)
|
||||||
|
_LOGGER.debug("✅ USB transfer successful: %d bytes sent", result)
|
||||||
|
return True
|
||||||
|
except usb.core.USBError as exc:
|
||||||
|
_LOGGER.error("❌ USB write error: %s", exc)
|
||||||
|
_LOGGER.error(" Payload was: %s", payload.hex())
|
||||||
|
self._dev = None
|
||||||
|
return False
|
||||||
|
|
||||||
def off(self) -> None:
|
def off(self) -> None:
|
||||||
self.set_effect(EFFECT_OFF)
|
self.set_effect(EFFECT_OFF)
|
||||||
|
|
||||||
@@ -146,25 +254,26 @@ class XVF3800LED:
|
|||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# LED ring animator - uses built-in XVF3800 effects
|
# LED ring animator - custom animations mirroring ESPHome effects
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
||||||
class LEDRing:
|
class LEDRing:
|
||||||
"""
|
"""
|
||||||
Manages 12-LED ring animations using built-in XVF3800 effects.
|
Manages the 12-LED ring animations for ReSpeaker XVF3800.
|
||||||
|
All animations mirror the ESPHome home-assistant-voice.yaml effects.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
ANIM_OFF = "off"
|
ANIM_OFF = "off"
|
||||||
ANIM_TWINKLE = "twinkle" # breath red
|
ANIM_TWINKLE = "twinkle"
|
||||||
ANIM_TWINKLE_BLUE = "twinkle_blue" # breath blue
|
ANIM_TWINKLE_BLUE = "twinkle_blue"
|
||||||
ANIM_WAIT_CMD = "waiting" # rainbow slow
|
ANIM_WAIT_CMD = "waiting"
|
||||||
ANIM_LISTENING = "listening" # rainbow fast
|
ANIM_LISTENING = "listening"
|
||||||
ANIM_THINKING = "thinking" # breath cyan
|
ANIM_THINKING = "thinking"
|
||||||
ANIM_REPLYING = "replying" # rainbow fast reverse (same as listening)
|
ANIM_REPLYING = "replying"
|
||||||
ANIM_MUTED = "muted" # single red
|
ANIM_MUTED = "muted"
|
||||||
ANIM_ERROR = "error" # breath magenta fast
|
ANIM_ERROR = "error"
|
||||||
ANIM_TIMER_TICK = "timer_tick" # doa mode
|
ANIM_TIMER_TICK = "timer_tick"
|
||||||
ANIM_TIMER_RING = "timer_ring" # breath fast
|
ANIM_TIMER_RING = "timer_ring"
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._leds = XVF3800LED()
|
self._leds = XVF3800LED()
|
||||||
@@ -174,7 +283,14 @@ class LEDRing:
|
|||||||
self._thread = threading.Thread(
|
self._thread = threading.Thread(
|
||||||
target=self._loop, daemon=True, name="led-ring"
|
target=self._loop, daemon=True, name="led-ring"
|
||||||
)
|
)
|
||||||
self._index = 0
|
self._index: int = 0
|
||||||
|
self._brightness_step: int = 0
|
||||||
|
self._brightness_dec: bool = True
|
||||||
|
self._twinkle_state: list[float] = [0.0] * LED_COUNT
|
||||||
|
# Timer state for timer_tick animation
|
||||||
|
self._timer_total: int = 60
|
||||||
|
self._timer_left: int = 60
|
||||||
|
self._timer_tick_count: int = 0
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
@@ -191,97 +307,225 @@ class LEDRing:
|
|||||||
_LOGGER.info("LED animation → %s", name)
|
_LOGGER.info("LED animation → %s", name)
|
||||||
self._animation = name
|
self._animation = name
|
||||||
self._index = 0
|
self._index = 0
|
||||||
|
self._brightness_step = 0
|
||||||
|
self._brightness_dec = True
|
||||||
|
self._twinkle_state = [random.random() for _ in range(LED_COUNT)]
|
||||||
|
# Reset timer state when entering timer_tick
|
||||||
|
if name == self.ANIM_TIMER_TICK:
|
||||||
|
self._timer_total = 60
|
||||||
|
self._timer_left = 60
|
||||||
|
self._timer_tick_count = 0
|
||||||
|
|
||||||
def _apply_animation(self) -> float:
|
# ------------------------------------------------------------------
|
||||||
anim = self._animation
|
# Internal helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
if anim == self.ANIM_OFF:
|
def _color(self) -> RGB:
|
||||||
self._leds.set_effect(EFFECT_OFF)
|
"""Default ring colour matching HA Voice PE."""
|
||||||
|
return DEFAULT_RING_COLOR
|
||||||
|
|
||||||
|
def _pulse_step(self, steps: int = 10) -> float:
|
||||||
|
"""Generate pulsing factor 0.0-1.0."""
|
||||||
|
factor = (steps - self._brightness_step) / steps
|
||||||
|
if self._brightness_dec:
|
||||||
|
self._brightness_step += 1
|
||||||
|
else:
|
||||||
|
self._brightness_step -= 1
|
||||||
|
if self._brightness_step <= 0 or self._brightness_step >= steps:
|
||||||
|
self._brightness_dec = not self._brightness_dec
|
||||||
|
return factor
|
||||||
|
|
||||||
|
def _apply_mic_indicators(self, pixels: list[RGB]) -> None:
|
||||||
|
"""
|
||||||
|
Mark all 4 mic positions red on the rendered frame.
|
||||||
|
|
||||||
|
ReSpeaker XVF3800 has 4 mics at DOA 0°, 90°, 180°, 270°.
|
||||||
|
On the 12-LED ring (30° per step):
|
||||||
|
DOA 0° → LED 0 (right)
|
||||||
|
DOA 90° → LED 3 (top)
|
||||||
|
DOA 180° → LED 6 (left)
|
||||||
|
DOA 270° → LED 9 (bottom)
|
||||||
|
Neighbours are blanked so each indicator reads as a distinct dot.
|
||||||
|
"""
|
||||||
|
pixels[11] = BLACK; pixels[0] = RED; pixels[1] = BLACK
|
||||||
|
pixels[2] = BLACK; pixels[3] = RED; pixels[4] = BLACK
|
||||||
|
pixels[5] = BLACK; pixels[6] = RED; pixels[7] = BLACK
|
||||||
|
pixels[8] = BLACK; pixels[9] = RED; pixels[10] = BLACK
|
||||||
|
|
||||||
|
def _apply_mic_indicators_pulsed(self, pixels: list[RGB], factor: float) -> None:
|
||||||
|
"""Pulsed version for timer_ring — keeps indicators in sync with ring brightness."""
|
||||||
|
pixels[0] = _scale(RED, factor)
|
||||||
|
pixels[3] = _scale(RED, factor)
|
||||||
|
pixels[6] = _scale(RED, factor)
|
||||||
|
pixels[9] = _scale(RED, factor)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Animations — each returns sleep time in seconds
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _anim_off(self) -> float:
|
||||||
|
pixels = [BLACK] * LED_COUNT
|
||||||
|
self._leds.set_led_frame(pixels, brightness=1.0)
|
||||||
return 0.1
|
return 0.1
|
||||||
|
|
||||||
elif anim == self.ANIM_TWINKLE:
|
def _anim_twinkle(self, color: RGB) -> float:
|
||||||
# Breath effect with red color
|
FADE = 0.85
|
||||||
self._leds.set_effect(EFFECT_BREATH)
|
SPARK_PROB = 0.15
|
||||||
self._leds.set_color(COLOR_RED)
|
pixels = [BLACK] * LED_COUNT
|
||||||
self._leds.set_brightness(150)
|
for i in range(LED_COUNT):
|
||||||
self._leds.set_speed(2)
|
if random.random() < SPARK_PROB:
|
||||||
return 0.5
|
self._twinkle_state[i] = 1.0
|
||||||
|
else:
|
||||||
|
self._twinkle_state[i] *= FADE
|
||||||
|
pixels[i] = _scale(color, self._twinkle_state[i])
|
||||||
|
self._leds.set_led_frame(pixels)
|
||||||
|
return 0.05
|
||||||
|
|
||||||
elif anim == self.ANIM_TWINKLE_BLUE:
|
def _anim_spin(self, color: RGB, interval: float, reverse: bool = False) -> float:
|
||||||
# Breath effect with blue color
|
"""
|
||||||
self._leds.set_effect(EFFECT_BREATH)
|
Shared spin for Waiting / Listening / Replying.
|
||||||
self._leds.set_color(COLOR_BLUE)
|
Mirrors ESPHome "Waiting for Command", "Listening For Command",
|
||||||
self._leds.set_brightness(150)
|
and "Replying" effects.
|
||||||
self._leds.set_speed(2)
|
"""
|
||||||
return 0.5
|
pixels = [BLACK] * LED_COUNT
|
||||||
|
|
||||||
elif anim == self.ANIM_WAIT_CMD:
|
if reverse:
|
||||||
# Rainbow slow
|
self._index = (LED_COUNT + self._index - 1) % LED_COUNT
|
||||||
self._leds.set_effect(EFFECT_RAINBOW)
|
offsets = [(0, 1.0), (1, 192/255), (2, 128/255),
|
||||||
self._leds.set_brightness(150)
|
(6, 1.0), (7, 192/255), (8, 128/255)]
|
||||||
self._leds.set_speed(1)
|
else:
|
||||||
return 0.5
|
offsets = [(0, 1.0), (11, 192/255), (10, 128/255),
|
||||||
|
(6, 1.0), (5, 192/255), (4, 128/255)]
|
||||||
|
|
||||||
elif anim == self.ANIM_LISTENING:
|
for offset, brightness in offsets:
|
||||||
# Rainbow fast
|
idx = (self._index + offset) % LED_COUNT
|
||||||
self._leds.set_effect(EFFECT_RAINBOW)
|
pixels[idx] = _scale(color, brightness)
|
||||||
self._leds.set_brightness(150)
|
|
||||||
self._leds.set_speed(4)
|
|
||||||
return 0.5
|
|
||||||
|
|
||||||
elif anim == self.ANIM_THINKING:
|
if not reverse:
|
||||||
# Breath cyan
|
self._index = (self._index + 1) % LED_COUNT
|
||||||
self._leds.set_effect(EFFECT_BREATH)
|
|
||||||
self._leds.set_color(COLOR_CYAN)
|
|
||||||
self._leds.set_brightness(150)
|
|
||||||
self._leds.set_speed(3)
|
|
||||||
return 0.5
|
|
||||||
|
|
||||||
elif anim == self.ANIM_REPLYING:
|
self._leds.set_led_frame(pixels)
|
||||||
# Rainbow fast (same as listening - no reverse on XVF3800)
|
return interval
|
||||||
self._leds.set_effect(EFFECT_RAINBOW)
|
|
||||||
self._leds.set_brightness(150)
|
|
||||||
self._leds.set_speed(4)
|
|
||||||
return 0.5
|
|
||||||
|
|
||||||
elif anim == self.ANIM_MUTED:
|
def _anim_thinking(self, color: RGB) -> float:
|
||||||
# Solid red
|
"""Two opposing LEDs pulsing. Mirrors ESPHome "Thinking" effect."""
|
||||||
self._leds.set_effect(EFFECT_SINGLE)
|
factor = self._pulse_step(10)
|
||||||
self._leds.set_color(COLOR_RED)
|
pixels = [BLACK] * LED_COUNT
|
||||||
self._leds.set_brightness(200)
|
for i in range(LED_COUNT):
|
||||||
return 0.5
|
if i == self._index % LED_COUNT or i == (self._index + 6) % LED_COUNT:
|
||||||
|
pixels[i] = _scale(color, factor)
|
||||||
|
self._leds.set_led_frame(pixels)
|
||||||
|
return 0.01
|
||||||
|
|
||||||
elif anim == self.ANIM_ERROR:
|
def _anim_muted(self, color: RGB) -> float:
|
||||||
# Breath magenta fast
|
"""
|
||||||
self._leds.set_effect(EFFECT_BREATH)
|
Solid ring with red at all 4 mic positions (cardinal points).
|
||||||
self._leds.set_color(COLOR_MAGENTA)
|
DOA 0°/90°/180°/270° → LEDs 0, 3, 6, 9.
|
||||||
self._leds.set_brightness(200)
|
Neighbours are blanked so each indicator stands out clearly.
|
||||||
self._leds.set_speed(5)
|
Mirrors ESPHome "Muted or Silent" effect.
|
||||||
return 0.5
|
"""
|
||||||
|
pixels = [color] * LED_COUNT
|
||||||
|
self._apply_mic_indicators(pixels)
|
||||||
|
self._leds.set_led_frame(pixels)
|
||||||
|
return 0.016
|
||||||
|
|
||||||
elif anim == self.ANIM_TIMER_TICK:
|
def _anim_error(self) -> float:
|
||||||
# DoA mode - shows direction
|
"""All LEDs red, pulsing. Mirrors ESPHome "Error" effect."""
|
||||||
self._leds.set_effect(EFFECT_DOA)
|
factor = self._pulse_step(10)
|
||||||
self._leds.set_doa_color(0x303030, COLOR_CYAN)
|
pixels = [_scale(RED, factor)] * LED_COUNT
|
||||||
self._leds.set_brightness(150)
|
self._leds.set_led_frame(pixels)
|
||||||
return 0.5
|
return 0.01
|
||||||
|
|
||||||
elif anim == self.ANIM_TIMER_RING:
|
def _anim_timer_ring(self, color: RGB) -> float:
|
||||||
# Breath fast
|
"""
|
||||||
self._leds.set_effect(EFFECT_BREATH)
|
Full ring pulsing; red at all 4 mic positions if muted.
|
||||||
self._leds.set_color(COLOR_YELLOW)
|
Mirrors ESPHome "Timer Ring" effect.
|
||||||
self._leds.set_brightness(200)
|
"""
|
||||||
self._leds.set_speed(5)
|
factor = self._pulse_step(10)
|
||||||
return 0.5
|
pixels = [_scale(color, factor)] * LED_COUNT
|
||||||
|
self._apply_mic_indicators_pulsed(pixels, factor)
|
||||||
|
self._leds.set_led_frame(pixels)
|
||||||
|
return 0.01
|
||||||
|
|
||||||
return 0.5
|
def _anim_timer_tick(self, color: RGB, seconds_left: int, total_seconds: int) -> float:
|
||||||
|
"""
|
||||||
|
Arc proportional to remaining time with sweep marker dip.
|
||||||
|
Mirrors ESPHome "Timer Tick" effect exactly.
|
||||||
|
|
||||||
|
Visual: A bar graph showing remaining time as an arc of LEDs.
|
||||||
|
- Full timer = all LEDs lit
|
||||||
|
- Half timer = 6 LEDs lit
|
||||||
|
- Each LED represents ~5 seconds (for 60s timer)
|
||||||
|
- A small brightness dip moves around the arc as a "sweep marker"
|
||||||
|
"""
|
||||||
|
total = max(total_seconds, 1)
|
||||||
|
timer_ratio = LED_COUNT * seconds_left / total
|
||||||
|
last_led_on = max(0, math.ceil(timer_ratio) - 1)
|
||||||
|
|
||||||
|
pixels = [BLACK] * LED_COUNT
|
||||||
|
for i in range(LED_COUNT):
|
||||||
|
brightness_dip = (
|
||||||
|
0.9 if (i == self._index % LED_COUNT and i != last_led_on) else 1.0
|
||||||
|
)
|
||||||
|
if i <= timer_ratio:
|
||||||
|
brightness = min(brightness_dip * (timer_ratio - i), brightness_dip)
|
||||||
|
pixels[i] = _scale(color, brightness)
|
||||||
|
|
||||||
|
self._index = (LED_COUNT + self._index - 1) % LED_COUNT
|
||||||
|
self._leds.set_led_frame(pixels)
|
||||||
|
return 0.1
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Animation loop
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _loop(self) -> None:
|
def _loop(self) -> None:
|
||||||
|
_LOGGER.info("LED animation loop started")
|
||||||
|
frame_count = 0
|
||||||
while not self._stop_event.is_set():
|
while not self._stop_event.is_set():
|
||||||
with self._anim_lock:
|
with self._anim_lock:
|
||||||
anim = self._animation
|
anim = self._animation
|
||||||
sleep = self._apply_animation()
|
|
||||||
|
color = self._color()
|
||||||
|
|
||||||
|
if frame_count % 30 == 0:
|
||||||
|
_LOGGER.debug("Animation loop: anim=%s, frame=%d", anim, frame_count)
|
||||||
|
|
||||||
|
if anim == self.ANIM_OFF:
|
||||||
|
sleep = self._anim_off()
|
||||||
|
elif anim == self.ANIM_TWINKLE:
|
||||||
|
sleep = self._anim_twinkle(RED)
|
||||||
|
elif anim == self.ANIM_TWINKLE_BLUE:
|
||||||
|
sleep = self._anim_twinkle(BLUE)
|
||||||
|
elif anim == self.ANIM_WAIT_CMD:
|
||||||
|
sleep = self._anim_spin(color, interval=0.1, reverse=False)
|
||||||
|
elif anim == self.ANIM_LISTENING:
|
||||||
|
sleep = self._anim_spin(color, interval=0.05, reverse=False)
|
||||||
|
elif anim == self.ANIM_THINKING:
|
||||||
|
sleep = self._anim_thinking(color)
|
||||||
|
elif anim == self.ANIM_REPLYING:
|
||||||
|
sleep = self._anim_spin(color, interval=0.05, reverse=True)
|
||||||
|
elif anim == self.ANIM_MUTED:
|
||||||
|
sleep = self._anim_muted(color)
|
||||||
|
elif anim == self.ANIM_ERROR:
|
||||||
|
sleep = self._anim_error()
|
||||||
|
elif anim == self.ANIM_TIMER_RING:
|
||||||
|
sleep = self._anim_timer_ring(color)
|
||||||
|
elif anim == self.ANIM_TIMER_TICK:
|
||||||
|
# Simulate timer counting down for demo
|
||||||
|
with self._anim_lock:
|
||||||
|
self._timer_tick_count += 1
|
||||||
|
# Update timer every second (10 frames at 0.1s each)
|
||||||
|
if self._timer_tick_count % 10 == 0:
|
||||||
|
self._timer_left = max(0, self._timer_left - 1)
|
||||||
|
if self._timer_left == 0:
|
||||||
|
self._timer_left = self._timer_total # Reset for demo
|
||||||
|
sleep = self._anim_timer_tick(color, self._timer_left, self._timer_total)
|
||||||
|
else:
|
||||||
|
sleep = self._anim_off()
|
||||||
|
|
||||||
time.sleep(sleep)
|
time.sleep(sleep)
|
||||||
|
frame_count += 1
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
@@ -304,28 +548,28 @@ ANIM_MAP = {
|
|||||||
|
|
||||||
ANIM_NAMES = {
|
ANIM_NAMES = {
|
||||||
0: "off - выключить",
|
0: "off - выключить",
|
||||||
1: "twinkle - красное дыхание",
|
1: "twinkle - красные искры",
|
||||||
2: "twinkle_blue - синее дыхание",
|
2: "twinkle_blue - синие искры",
|
||||||
3: "waiting - радуга (медленно)",
|
3: "waiting - ожидание (медленное вращение)",
|
||||||
4: "listening - радуга (быстро)",
|
4: "listening - слушание (быстрое вращение)",
|
||||||
5: "thinking - циан дыхание",
|
5: "thinking - мышление (пульсация)",
|
||||||
6: "replying - радуга (быстро)",
|
6: "replying - ответ (обратное вращение)",
|
||||||
7: "muted - красный",
|
7: "muted --muted (красные точки на позициях микрофонов)",
|
||||||
8: "error - маджента быстрое",
|
8: "error - ошибка (красная пульсация)",
|
||||||
9: "timer_tick - DoA режим",
|
9: "timer_tick - таймер (дуга времени)",
|
||||||
10: "timer_ring - жёлтое быстрое",
|
10: "timer_ring - таймер звонит (пульсация)",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def print_menu() -> None:
|
def print_menu() -> None:
|
||||||
print("\n" + "=" * 50)
|
print("\n" + "=" * 60)
|
||||||
print(" ReSpeaker XVF3800 LED Controller")
|
print(" ReSpeaker XVF3800 LED Controller (Custom Animations)")
|
||||||
print(" (встроенные эффекты)")
|
print(" Анимации как в ESPHome home-assistant-voice.yaml")
|
||||||
print("=" * 50)
|
print("=" * 60)
|
||||||
for idx, name in ANIM_NAMES.items():
|
for idx, name in ANIM_NAMES.items():
|
||||||
print(f" {idx:2d} - {name}")
|
print(f" {idx:2d} - {name}")
|
||||||
print(" q - Quit")
|
print(" q - Quit")
|
||||||
print("=" * 50)
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
def run_terminal_controller() -> None:
|
def run_terminal_controller() -> None:
|
||||||
@@ -340,8 +584,22 @@ def run_terminal_controller() -> None:
|
|||||||
signal.signal(signal.SIGINT, _shutdown)
|
signal.signal(signal.SIGINT, _shutdown)
|
||||||
signal.signal(signal.SIGTERM, _shutdown)
|
signal.signal(signal.SIGTERM, _shutdown)
|
||||||
|
|
||||||
|
# Wait for device to initialize
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
# Check if device was found
|
||||||
|
if leds._leds._dev is None:
|
||||||
|
print("\n⚠️ WARNING: XVF3800 device not found!")
|
||||||
|
print(" Check USB connection and udev rules.")
|
||||||
|
print(" Run: lsusb | grep -i 2886")
|
||||||
|
else:
|
||||||
|
print(f"\n✅ Device connected: bus={leds._leds._dev.bus}, addr={leds._leds._dev.address}")
|
||||||
|
|
||||||
print_menu()
|
print_menu()
|
||||||
|
|
||||||
|
_LOGGER.info("Starting with default animation: twinkle (red sparks)")
|
||||||
|
leds.set_animation(LEDRing.ANIM_TWINKLE)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
user_input = input("\nSelect animation (0-10, q to quit): ").strip().lower()
|
user_input = input("\nSelect animation (0-10, q to quit): ").strip().lower()
|
||||||
@@ -361,6 +619,7 @@ def run_terminal_controller() -> None:
|
|||||||
anim_name = ANIM_MAP[anim_idx]
|
anim_name = ANIM_MAP[anim_idx]
|
||||||
leds.set_animation(anim_name)
|
leds.set_animation(anim_name)
|
||||||
print(f"✅ Selected: {anim_idx} - {ANIM_NAMES[anim_idx]}")
|
print(f"✅ Selected: {anim_idx} - {ANIM_NAMES[anim_idx]}")
|
||||||
|
_LOGGER.info("Animation changed to: %s", anim_name)
|
||||||
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
print(f"❌ Invalid input: '{user_input}'. Choose 0-10 or q.")
|
print(f"❌ Invalid input: '{user_input}'. Choose 0-10 or q.")
|
||||||
@@ -368,6 +627,67 @@ def run_terminal_controller() -> None:
|
|||||||
leds.stop()
|
leds.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def run_test_pattern() -> None:
|
||||||
|
"""Run test pattern to verify LED hardware."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(" XVF3800 LED Hardware Test")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
leds = LEDRing()
|
||||||
|
leds.start()
|
||||||
|
|
||||||
|
def _shutdown(signum, frame) -> None:
|
||||||
|
print("\n\nTest interrupted")
|
||||||
|
leds.stop()
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, _shutdown)
|
||||||
|
signal.signal(signal.SIGTERM, _shutdown)
|
||||||
|
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
if leds._leds._dev is None:
|
||||||
|
print("\n❌ ERROR: XVF3800 device not found!")
|
||||||
|
print(" Check USB connection:")
|
||||||
|
print(" - Run: lsusb | grep -i 2886")
|
||||||
|
print(" - Check udev rules: cat /etc/udev/rules.d/99-respeaker.rules")
|
||||||
|
print(" - Try: sudo chmod 666 /dev/bus/usb/*/`lsusb | grep 2886 | awk '{print $4}' | cut -d/ -f2`")
|
||||||
|
leds.stop()
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\n✅ Device found: bus={leds._leds._dev.bus}, addr={leds._leds._dev.address}")
|
||||||
|
print("\nRunning test patterns (5 seconds each):")
|
||||||
|
|
||||||
|
# Test 1: All red
|
||||||
|
print(" 1. All RED...")
|
||||||
|
leds.set_animation(LEDRing.ANIM_OFF)
|
||||||
|
time.sleep(0.1)
|
||||||
|
# Direct frame test
|
||||||
|
pixels = [RED] * LED_COUNT
|
||||||
|
leds._leds.set_led_frame(pixels, brightness=1.0)
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
# Test 2: All green
|
||||||
|
print(" 2. All GREEN...")
|
||||||
|
pixels = [GREEN] * LED_COUNT
|
||||||
|
leds._leds.set_led_frame(pixels, brightness=1.0)
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
# Test 3: All blue
|
||||||
|
print(" 3. All BLUE...")
|
||||||
|
pixels = [BLUE] * LED_COUNT
|
||||||
|
leds._leds.set_led_frame(pixels, brightness=1.0)
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
# Test 4: Twinkle animation
|
||||||
|
print(" 4. Twinkle animation (10s)...")
|
||||||
|
leds.set_animation(LEDRing.ANIM_TWINKLE)
|
||||||
|
time.sleep(10)
|
||||||
|
|
||||||
|
print("\n✅ Test complete!")
|
||||||
|
leds.stop()
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="ReSpeaker XVF3800 USB 4-MIC Array LED Controller"
|
description="ReSpeaker XVF3800 USB 4-MIC Array LED Controller"
|
||||||
@@ -376,13 +696,25 @@ def main() -> None:
|
|||||||
"--debug", action="store_true",
|
"--debug", action="store_true",
|
||||||
help="Enable DEBUG logging",
|
help="Enable DEBUG logging",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--test", action="store_true",
|
||||||
|
help="Run LED hardware test pattern",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--verbose", action="store_true",
|
||||||
|
help="Enable INFO logging to console",
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
log_level = logging.DEBUG if args.debug else (logging.INFO if args.verbose else logging.WARNING)
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.DEBUG if args.debug else logging.INFO,
|
level=log_level,
|
||||||
format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
|
format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if args.test:
|
||||||
|
run_test_pattern()
|
||||||
|
else:
|
||||||
run_terminal_controller()
|
run_terminal_controller()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user