feat: Multi-event server support + USB LED controller
New features: - wyoming_satellite/xvf_usb.py — прямой USB доступ к XVF3800 без subprocess - docs/development/multi-event-server-plan.md — план разработки event server - examples/respeaker_usb_mic_array.py — исходный файл с LED анимациями Documentation updates: - DEPLOY.md — добавлена секция про LED управление и event server план - docs/integration/mqtt-led.md — обновлена документация по USB и event server - docs/deployment/systemd-services.md — добавлен event server раздел Refactoring: - examples/wyoming-led.py — замена subprocess на XVF3800Controller - install_service.sh — удалён (устарел, используется install_systemd_services.sh) Bug fixes: - wyoming-led.py — яркость DoA эффекта через масштабирование цвета - systemd сервис — добавлена поддержка wyoming_satellite.xvf_usb модуля
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
XVF3800 USB LED Control Module.
|
||||
|
||||
Прямой доступ к ReSpeaker XVF3800 через USB control transfers.
|
||||
Использует pyusb для отправки команд без subprocess.
|
||||
|
||||
Пример использования:
|
||||
from wyoming_satellite.xvf_usb import XVF3800Controller
|
||||
|
||||
controller = XVF3800Controller()
|
||||
controller.set_led_effect(1) # breath effect
|
||||
controller.set_led_color(0x00BCD4) # бирюзовый
|
||||
controller.set_led_brightness(150)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import struct
|
||||
import threading
|
||||
from typing import Optional, Tuple
|
||||
|
||||
try:
|
||||
import usb.core
|
||||
import usb.util
|
||||
_HAS_USB = True
|
||||
except ImportError:
|
||||
_HAS_USB = False
|
||||
logging.warning("pyusb not installed – XVF3800 control disabled")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Configuration
|
||||
# ===========================================================================
|
||||
|
||||
USB_VENDOR_ID = 0x2886
|
||||
USB_PRODUCT_IDS = [0x0018, 0x001A]
|
||||
|
||||
# GPO_SERVICER_RESID LED commands
|
||||
GPO_RESID = 20
|
||||
LED_EFFECT_CMD = 12
|
||||
LED_BRIGHTNESS_CMD = 13
|
||||
LED_SPEED_CMD = 15
|
||||
LED_COLOR_CMD = 16
|
||||
LED_DOA_COLOR_CMD = 17
|
||||
LED_RING_COLOR_CMD = 19
|
||||
|
||||
# Effects
|
||||
EFFECT_OFF = 0
|
||||
EFFECT_BREATH = 1
|
||||
EFFECT_RAINBOW = 2
|
||||
EFFECT_SINGLE = 3
|
||||
EFFECT_DOA = 4
|
||||
EFFECT_RING = 5
|
||||
|
||||
# Color format: RGB tuple
|
||||
RGB = Tuple[int, int, int]
|
||||
|
||||
_LOGGER = logging.getLogger("xvf_usb")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Helper functions
|
||||
# ===========================================================================
|
||||
|
||||
def rgb_to_bgr_int(r: int, g: int, b: int) -> int:
|
||||
"""Convert RGB (0-255) to 0xBBGGRR format."""
|
||||
return (b << 16) | (g << 8) | r
|
||||
|
||||
|
||||
def bgr_int_to_rgb(color_int: int) -> RGB:
|
||||
"""Convert 0xBBGGRR to RGB tuple."""
|
||||
b = (color_int >> 16) & 0xFF
|
||||
g = (color_int >> 8) & 0xFF
|
||||
r = color_int & 0xFF
|
||||
return (r, g, b)
|
||||
|
||||
|
||||
def scale_color(color: RGB, brightness: int) -> RGB:
|
||||
"""
|
||||
Scale RGB color by brightness (0-255).
|
||||
|
||||
Args:
|
||||
color: RGB tuple (r, g, b)
|
||||
brightness: Brightness value 0-255
|
||||
|
||||
Returns:
|
||||
Scaled RGB tuple
|
||||
"""
|
||||
factor = brightness / 255.0
|
||||
return (
|
||||
int(color[0] * factor),
|
||||
int(color[1] * factor),
|
||||
int(color[2] * factor)
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# XVF3800 Controller
|
||||
# ===========================================================================
|
||||
|
||||
class XVF3800Controller:
|
||||
"""
|
||||
Controller for ReSpeaker XVF3800 via USB.
|
||||
|
||||
Provides direct USB control transfer access to LED commands.
|
||||
Thread-safe with automatic device reconnection.
|
||||
|
||||
Example:
|
||||
controller = XVF3800Controller()
|
||||
controller.set_led_effect(EFFECT_BREATH)
|
||||
controller.set_led_color(rgb_to_bgr_int(0, 188, 212))
|
||||
controller.set_led_brightness(150)
|
||||
"""
|
||||
|
||||
TIMEOUT = 1000 # USB transfer timeout in ms
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
vendor_id: int = USB_VENDOR_ID,
|
||||
product_ids: list[int] | None = None
|
||||
) -> None:
|
||||
self._vendor_id = vendor_id
|
||||
self._product_ids = product_ids or USB_PRODUCT_IDS
|
||||
self._dev: Optional[usb.core.Device] = None
|
||||
self._dev_lock = threading.Lock()
|
||||
self._find_device()
|
||||
|
||||
def _find_device(self) -> None:
|
||||
"""Find and initialize USB device."""
|
||||
_LOGGER.info(
|
||||
"Searching for XVF3800 USB device (VID=%04x, PIDs=%s)...",
|
||||
self._vendor_id,
|
||||
[f"{pid:04x}" for pid in self._product_ids]
|
||||
)
|
||||
|
||||
# List all USB devices for debugging
|
||||
if _HAS_USB:
|
||||
try:
|
||||
all_devs = usb.core.find(find_all=True)
|
||||
_LOGGER.debug("All USB devices found:")
|
||||
for dev in all_devs:
|
||||
_LOGGER.debug(
|
||||
" - VID=%04x, PID=%04x, bus=%d, addr=%d",
|
||||
dev.idVendor, dev.idProduct, dev.bus, dev.address
|
||||
)
|
||||
except Exception as exc:
|
||||
_LOGGER.debug("Could not list USB devices: %s", exc)
|
||||
|
||||
for pid in self._product_ids:
|
||||
_LOGGER.debug("Looking for PID %04x...", pid)
|
||||
dev = usb.core.find(idVendor=self._vendor_id, idProduct=pid)
|
||||
if dev is not None:
|
||||
try:
|
||||
if dev.is_kernel_driver_active(0):
|
||||
_LOGGER.info("Detaching kernel driver from device...")
|
||||
dev.detach_kernel_driver(0)
|
||||
except Exception as exc:
|
||||
_LOGGER.warning("Could not detach kernel driver: %s", exc)
|
||||
|
||||
self._dev = dev
|
||||
_LOGGER.info(
|
||||
"✅ XVF3800 found (USB %04x:%04x, bus %d, addr %d)",
|
||||
self._vendor_id, pid, dev.bus, dev.address
|
||||
)
|
||||
return
|
||||
else:
|
||||
_LOGGER.debug("Device %04x:%04x not found", self._vendor_id, pid)
|
||||
|
||||
_LOGGER.error(
|
||||
"❌ XVF3800 not found (USB %04x). Check connection and udev rules.",
|
||||
self._vendor_id
|
||||
)
|
||||
|
||||
def _ctrl_transfer(
|
||||
self,
|
||||
resid: int,
|
||||
cmdid: int,
|
||||
payload: list | bytes
|
||||
) -> bool:
|
||||
"""
|
||||
Send USB control transfer.
|
||||
|
||||
Args:
|
||||
resid: Resource ID (e.g., GPO_RESID=20)
|
||||
cmdid: Command ID within the resource
|
||||
payload: Data to send (list of int or bytes)
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
with self._dev_lock:
|
||||
if self._dev is None:
|
||||
_LOGGER.warning("USB device not available")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Convert list to bytes if needed
|
||||
if isinstance(payload, list):
|
||||
payload = bytes(payload)
|
||||
|
||||
_LOGGER.debug(
|
||||
"CTRL: resid=%d, cmdid=%d, payload_len=%d",
|
||||
resid, cmdid, len(payload)
|
||||
)
|
||||
|
||||
result = self._dev.ctrl_transfer(
|
||||
usb.util.CTRL_OUT | usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_RECIPIENT_DEVICE,
|
||||
0, # request
|
||||
cmdid, # wValue
|
||||
resid, # wIndex
|
||||
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)
|
||||
self._dev = None
|
||||
return False
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if USB device is available."""
|
||||
with self._dev_lock:
|
||||
return self._dev is not None
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release USB resources."""
|
||||
self.set_led_effect(EFFECT_OFF)
|
||||
with self._dev_lock:
|
||||
if self._dev is not None:
|
||||
usb.util.dispose_resources(self._dev)
|
||||
self._dev = None
|
||||
|
||||
# =======================================================================
|
||||
# LED Commands
|
||||
# =======================================================================
|
||||
|
||||
def set_led_effect(self, effect: int) -> bool:
|
||||
"""
|
||||
Set LED effect mode.
|
||||
|
||||
Args:
|
||||
effect: 0=off, 1=breath, 2=rainbow, 3=single, 4=doa, 5=ring
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
return self._ctrl_transfer(GPO_RESID, LED_EFFECT_CMD, [effect])
|
||||
|
||||
def set_led_brightness(self, brightness: int) -> bool:
|
||||
"""
|
||||
Set LED brightness (for breath and rainbow effects only).
|
||||
|
||||
Args:
|
||||
brightness: 0-255
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
brightness = max(0, min(255, brightness))
|
||||
return self._ctrl_transfer(GPO_RESID, LED_BRIGHTNESS_CMD, [brightness])
|
||||
|
||||
def set_led_speed(self, speed: int) -> bool:
|
||||
"""
|
||||
Set LED effect speed (for breath and rainbow effects).
|
||||
|
||||
Args:
|
||||
speed: 0-255
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
speed = max(0, min(255, speed))
|
||||
return self._ctrl_transfer(GPO_RESID, LED_SPEED_CMD, [speed])
|
||||
|
||||
def set_led_color(self, color_bgr: int) -> bool:
|
||||
"""
|
||||
Set LED color for breath or single color mode.
|
||||
|
||||
Args:
|
||||
color_bgr: Color in 0xBBGGRR format
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
payload = list(struct.pack('<I', color_bgr))
|
||||
return self._ctrl_transfer(GPO_RESID, LED_COLOR_CMD, payload)
|
||||
|
||||
def set_led_doa_color(self, base_color_bgr: int, doa_color_bgr: int) -> bool:
|
||||
"""
|
||||
Set LED colors for DoA (Direction of Arrival) mode.
|
||||
|
||||
Args:
|
||||
base_color_bgr: Base color in 0xBBGGRR format
|
||||
doa_color_bgr: DoA color in 0xBBGGRR format
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
payload = list(struct.pack('<II', base_color_bgr, doa_color_bgr))
|
||||
return self._ctrl_transfer(GPO_RESID, LED_DOA_COLOR_CMD, payload)
|
||||
|
||||
def set_led_ring_color(self, pixels: list[RGB], brightness: float = 1.0) -> bool:
|
||||
"""
|
||||
Set custom LED frame for ring mode (12 individual LEDs).
|
||||
|
||||
Args:
|
||||
pixels: List of 12 RGB tuples
|
||||
brightness: Brightness multiplier 0.0-1.0
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
if len(pixels) != 12:
|
||||
_LOGGER.error("Expected 12 pixels, got %d", len(pixels))
|
||||
return False
|
||||
|
||||
# Build 12 uint32 values in 0xBBGGRR format
|
||||
payload = b''
|
||||
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)))
|
||||
color_int = (bb << 16) | (bg << 8) | br
|
||||
payload += struct.pack('<I', color_int)
|
||||
|
||||
return self._ctrl_transfer(GPO_RESID, LED_RING_COLOR_CMD, payload)
|
||||
|
||||
# =======================================================================
|
||||
# Convenience Methods
|
||||
# =======================================================================
|
||||
|
||||
def set_led_state(
|
||||
self,
|
||||
effect: int,
|
||||
color_bgr: Optional[int] = None,
|
||||
brightness: Optional[int] = None,
|
||||
speed: Optional[int] = None
|
||||
) -> None:
|
||||
"""
|
||||
Set complete LED state in one call.
|
||||
|
||||
Args:
|
||||
effect: Effect mode (0-5)
|
||||
color_bgr: Color in 0xBBGGRR format (for effects 1, 3)
|
||||
brightness: Brightness 0-255 (for effects 1, 2)
|
||||
speed: Speed 0-255 (for effects 1, 2)
|
||||
"""
|
||||
self.set_led_effect(effect)
|
||||
|
||||
if color_bgr is not None:
|
||||
self.set_led_color(color_bgr)
|
||||
|
||||
if brightness is not None:
|
||||
self.set_led_brightness(brightness)
|
||||
|
||||
if speed is not None:
|
||||
self.set_led_speed(speed)
|
||||
Reference in New Issue
Block a user