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:
2026-05-20 13:50:16 +03:00
parent 75ba5481b6
commit c62203b220
9 changed files with 2267 additions and 152 deletions
+789
View File
@@ -0,0 +1,789 @@
#!/usr/bin/env python3
"""
ReSpeaker Mic Array v2.0 (USB) – Linux Voice Assistant peripheral controller.
Connects to the device via USB HID and drives its 12 APA102 LEDs with
animations that mirror the Home Assistant Voice PE LED ring behaviour.
Hardware
--------
Connection : USB (Vendor 0x2886, Product 0x0018)
LED ring : 12 × APA102 RGB LEDs (controlled via USB HID)
Microphones: 4 × MEMS mics at DOA 0°, 90°, 180°, 270° (XMOS XVF3000, USB audio)
LED animations mirror the ESPHome home-assistant-voice.yaml effects exactly.
Muted indicator: LEDs at the 4 mic positions light red.
The v2.0 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.
Install dependencies
--------------------
pip install websockets pyusb pixel-ring
Host udev rule (run once on the Pi/host, then reboot or run udevadm trigger):
echo 'SUBSYSTEM=="usb", ATTR{idVendor}=="2886", ATTR{idProduct}=="0018", \
MODE="0666", GROUP="plugdev"' | sudo tee /etc/udev/rules.d/99-respeaker.rules
sudo udevadm trigger
Run
---
python3 respeaker_usb_mic_array.py
python3 respeaker_usb_mic_array.py --host 127.0.0.1 --port 6055 --debug
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import math
import random
import signal
import sys
import threading
import time
from enum import Enum
from typing import Optional, Tuple
# ---------------------------------------------------------------------------
# Hardware dependencies — gracefully stubbed when not on real hardware
# ---------------------------------------------------------------------------
try:
import usb.core # type: ignore[import]
import usb.util # type: ignore[import]
_HAS_USB = True
except ImportError:
_HAS_USB = False
logging.warning("pyusb not found – LED output will be simulated")
try:
import websockets # type: ignore[import]
except ImportError:
sys.exit("websockets not installed. Run: pip install websockets")
# ===========================================================================
# Configuration
# ===========================================================================
DEFAULT_LVA_HOST = "localhost"
DEFAULT_LVA_PORT = 6055
# USB device identifiers
USB_VENDOR_ID = 0x2886
USB_PRODUCT_ID = 0x0018
LED_COUNT = 12
LED_BRIGHTNESS = 10 # 0–31 (APA102 5-bit global brightness register)
RECONNECT_DELAY_S = 3.0
# ===========================================================================
# Logging
# ===========================================================================
_LOGGER = logging.getLogger("respeaker-usb")
# ===========================================================================
# Assistant state
# ===========================================================================
class AssistState(str, Enum):
NOT_READY = "not_ready"
IDLE = "idle"
WAKE_WORD = "wake_word_detected"
LISTENING = "listening"
THINKING = "thinking"
SPEAKING = "tts_speaking"
TTS_FINISHED = "tts_finished"
ERROR = "error"
MUTED = "muted"
TIMER_TICKING = "timer_ticking"
TIMER_RINGING = "timer_ringing"
MEDIA_PLAYING = "media_player_playing"
# ---------------------------------------------------------------------------
# Shared state
# ---------------------------------------------------------------------------
class SharedState:
def __init__(self) -> None:
self._lock = threading.Lock()
self.assist_state: AssistState = AssistState.NOT_READY
self.ha_connected: bool = False
self.muted: bool = False
self.volume: float = 1.0
self.timer_total_seconds: int = 0
self.timer_seconds_left: int = 0
def update(self, **kwargs) -> None:
with self._lock:
for key, val in kwargs.items():
setattr(self, key, val)
@property
def snapshot(self) -> dict:
with self._lock:
return {
"assist_state": self.assist_state,
"ha_connected": self.ha_connected,
"muted": self.muted,
"volume": self.volume,
"timer_total_seconds": self.timer_total_seconds,
"timer_seconds_left": self.timer_seconds_left,
}
# ===========================================================================
# USB LED driver
#
# The ReSpeaker Mic Array v2.0 accepts LED data via a USB vendor control
# transfer. Each LED is set as an APA102 frame: 0xFF start, then per-LED
# bytes in the order [start_frame | brightness, B, G, R].
# ===========================================================================
RGB = Tuple[int, int, int]
BLACK: RGB = (0, 0, 0)
RED: RGB = (255, 0, 0)
BLUE: RGB = (0, 0, 255)
CYAN: RGB = (0, 200, 200)
GREEN: RGB = (0, 200, 50)
YELLOW: RGB = (220, 180, 0)
def _scale(color: RGB, factor: float) -> RGB:
f = max(0.0, min(1.0, factor))
return (int(color[0] * f), int(color[1] * f), int(color[2] * f))
class USBLEDRing:
"""
Controls the 12 APA102 LEDs on the ReSpeaker Mic Array v2.0 over USB.
Uses a USB vendor control transfer (bmRequestType=0x40, bRequest=0,
wValue=0x0001, wIndex=0x8001) to push raw LED data to the XMOS firmware.
This is the same protocol used by Seeed's pixel_ring library.
"""
# USB control transfer parameters (from XMOS firmware)
_CTRL_TIMEOUT = 100 # ms
_CTRL_REQUEST = 0
_CTRL_VALUE = 0x0001
_CTRL_INDEX = 0x8001
_CTRL_TYPE_OUT = 0x40 # vendor, device, host-to-device
def __init__(self) -> None:
self._pixels: list[RGB] = [BLACK] * LED_COUNT
self._dev = None
self._dev_lock = threading.Lock()
if _HAS_USB:
self._find_device()
def _find_device(self) -> None:
dev = usb.core.find(idVendor=USB_VENDOR_ID, idProduct=USB_PRODUCT_ID)
if dev is None:
_LOGGER.error(
"ReSpeaker Mic Array v2.0 not found (USB %04x:%04x). "
"Check connection and udev rules.",
USB_VENDOR_ID, USB_PRODUCT_ID,
)
return
try:
if dev.is_kernel_driver_active(0):
dev.detach_kernel_driver(0)
except Exception: # pylint: disable=broad-except
pass
self._dev = dev
_LOGGER.info(
"ReSpeaker Mic Array v2.0 found (USB %04x:%04x, bus %d, addr %d)",
USB_VENDOR_ID, USB_PRODUCT_ID, dev.bus, dev.address,
)
def set(self, index: int, color: RGB) -> None:
self._pixels[index % LED_COUNT] = color
def set_all(self, color: RGB) -> None:
self._pixels = [color] * LED_COUNT
def show(self, brightness: float = 1.0) -> None:
"""
Push the current pixel buffer to the device.
Frame layout (matches pixel_ring / APA102 USB protocol):
[0xFF, 0xFF, 0xFF, 0xFF] ← start frame
[0xE0|bright5, B, G, R] × LED_COUNT ← one word per LED
[0xFF, ...] ← end frame (⌈n/2⌉ bytes)
"""
bright5 = max(0, min(31, int(brightness * LED_BRIGHTNESS)))
frame = [0xFF, 0xFF, 0xFF, 0xFF] # start frame
for r, g, b in self._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)))
frame += [0xE0 | bright5, bb, bg, br]
frame += [0xFF] * math.ceil(LED_COUNT / 2) # end frame
with self._dev_lock:
if self._dev is None:
pixels = ", ".join(f"rgb{p}" for p in self._pixels)
_LOGGER.debug("LEDs [simulated]: %s", pixels)
return
try:
self._dev.ctrl_transfer(
self._CTRL_TYPE_OUT,
self._CTRL_REQUEST,
self._CTRL_VALUE,
self._CTRL_INDEX,
frame,
self._CTRL_TIMEOUT,
)
except usb.core.USBError as exc:
_LOGGER.warning("USB write error: %s – attempting reconnect", exc)
self._dev = None
# Attempt to re-find the device on next show() call
threading.Thread(
target=self._reconnect, daemon=True
).start()
def _reconnect(self) -> None:
time.sleep(2)
_LOGGER.info("Attempting to reconnect to ReSpeaker USB device …")
self._find_device()
def off(self) -> None:
self._pixels = [BLACK] * LED_COUNT
self.show(0)
def close(self) -> None:
self.off()
with self._dev_lock:
if self._dev is not None:
usb.util.dispose_resources(self._dev)
self._dev = None
# ===========================================================================
# LED ring animator
#
# All animations mirror the ESPHome home-assistant-voice.yaml effects.
# Muted indicator uses LEDs 0, 2, 4, 6, 8, 10 — the 6 outer mic positions
# on the v2.0 (0°, 60°, 120°, 180°, 240°, 300° on the ring).
# ===========================================================================
# 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)
class LEDRing:
"""
Manages the 12-LED ring animations for the ReSpeaker Mic Array v2.0.
All animations run in a dedicated daemon thread.
"""
ANIM_OFF = "off"
ANIM_TWINKLE = "twinkle"
ANIM_TWINKLE_BLUE = "twinkle_blue"
ANIM_WAIT_CMD = "waiting"
ANIM_LISTENING = "listening"
ANIM_THINKING = "thinking"
ANIM_REPLYING = "replying"
ANIM_MUTED = "muted"
ANIM_ERROR = "error"
ANIM_TIMER_TICK = "timer_tick"
ANIM_TIMER_RING = "timer_ring"
def __init__(self, state: SharedState) -> None:
self._state = state
self._leds = USBLEDRing()
self._animation = self.ANIM_OFF
self._anim_lock = threading.Lock()
self._stop_event = threading.Event()
self._thread = threading.Thread(
target=self._loop, daemon=True, name="led-ring"
)
self._index: int = 0
self._brightness_step: int = 0
self._brightness_dec: bool = True
self._twinkle_state: list[float] = [0.0] * LED_COUNT
def start(self) -> None:
self._thread.start()
def stop(self) -> None:
self._stop_event.set()
self._thread.join(timeout=2)
self._leds.off()
self._leds.close()
def set_animation(self, name: str) -> None:
with self._anim_lock:
if self._animation != name:
_LOGGER.debug("LED animation → %s", name)
self._animation = name
self._index = 0
self._brightness_step = 0
self._brightness_dec = True
self._twinkle_state = [random.random() for _ in range(LED_COUNT)]
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _color(self) -> RGB:
"""Default ring colour matching HA Voice PE."""
return (24, 187, 242)
def _pulse_step(self, steps: int = 10) -> float:
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) -> None:
"""
Mark all 4 mic positions red on the already-rendered frame.
ReSpeaker Mic Array v2.0 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.
"""
self._leds.set(11, BLACK); self._leds.set(0, RED); self._leds.set(1, BLACK)
self._leds.set(2, BLACK); self._leds.set(3, RED); self._leds.set(4, BLACK)
self._leds.set(5, BLACK); self._leds.set(6, RED); self._leds.set(7, BLACK)
self._leds.set(8, BLACK); self._leds.set(9, RED); self._leds.set(10, BLACK)
def _apply_mic_indicators_pulsed(self, factor: float) -> None:
"""Pulsed version for timer_ring — keeps indicators in sync with ring brightness."""
self._leds.set(0, _scale(RED, factor))
self._leds.set(3, _scale(RED, factor))
self._leds.set(6, _scale(RED, factor))
self._leds.set(9, _scale(RED, factor))
# ------------------------------------------------------------------
# Animations — each returns sleep time in seconds
# ------------------------------------------------------------------
def _anim_off(self) -> float:
self._leds.off()
return 0.1
def _anim_twinkle(self, color: RGB) -> float:
FADE = 0.85
SPARK_PROB = 0.15
for i in range(LED_COUNT):
if random.random() < SPARK_PROB:
self._twinkle_state[i] = 1.0
else:
self._twinkle_state[i] *= FADE
self._leds.set(i, _scale(color, self._twinkle_state[i]))
self._leds.show()
return 0.05
def _anim_spin(self, color: RGB, interval: float, reverse: bool = False) -> float:
"""
Shared spin for Waiting / Listening / Replying.
Mirrors ESPHome "Waiting for Command", "Listening For Command",
and "Replying" effects.
"""
if reverse:
self._index = (LED_COUNT + self._index - 1) % LED_COUNT
offsets = [(0, 1.0), (1, 192/255), (2, 128/255),
(6, 1.0), (7, 192/255), (8, 128/255)]
else:
offsets = [(0, 1.0), (11, 192/255), (10, 128/255),
(6, 1.0), (5, 192/255), (4, 128/255)]
for i in range(LED_COUNT):
self._leds.set(i, BLACK)
for offset, brightness in offsets:
self._leds.set((self._index + offset) % LED_COUNT, _scale(color, brightness))
if not reverse:
self._index = (self._index + 1) % LED_COUNT
self._leds.show()
return interval
def _anim_thinking(self, color: RGB) -> float:
"""Two opposing LEDs pulsing. Mirrors ESPHome "Thinking" effect."""
factor = self._pulse_step(10)
for i in range(LED_COUNT):
if i == self._index % LED_COUNT or i == (self._index + 6) % LED_COUNT:
self._leds.set(i, _scale(color, factor))
else:
self._leds.set(i, BLACK)
self._leds.show()
return 0.01
def _anim_muted(self, color: RGB) -> float:
"""
Solid ring with red at all 4 mic positions (cardinal points).
DOA 0°/90°/180°/270° → LEDs 0, 3, 6, 9.
Neighbours are blanked so each indicator stands out clearly.
Mirrors ESPHome "Muted or Silent" effect.
"""
for i in range(LED_COUNT):
self._leds.set(i, color)
self._apply_mic_indicators()
self._leds.show()
return 0.016
def _anim_error(self) -> float:
"""All LEDs red, pulsing. Mirrors ESPHome "Error" effect."""
factor = self._pulse_step(10)
for i in range(LED_COUNT):
self._leds.set(i, _scale(RED, factor))
self._leds.show()
return 0.01
def _anim_timer_ring(self, color: RGB, muted: bool) -> float:
"""
Full ring pulsing; red at all 6 mic positions if muted.
Mirrors ESPHome "Timer Ring" effect.
"""
factor = self._pulse_step(10)
for i in range(LED_COUNT):
self._leds.set(i, _scale(color, factor))
if muted:
self._apply_mic_indicators_pulsed(factor)
self._leds.show()
return 0.01
def _anim_timer_tick(
self, color: RGB, muted: bool,
seconds_left: int, total_seconds: int,
) -> float:
"""
Arc proportional to remaining time with sweep marker dip.
Mirrors ESPHome "Timer Tick" effect exactly.
"""
total = max(total_seconds, 1)
timer_ratio = LED_COUNT * seconds_left / total
last_led_on = max(0, math.ceil(timer_ratio) - 1)
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)
self._leds.set(i, _scale(color, brightness))
else:
self._leds.set(i, BLACK)
if muted:
self._apply_mic_indicators()
self._index = (LED_COUNT + self._index - 1) % LED_COUNT
self._leds.show()
return 0.1
# ------------------------------------------------------------------
# Animation loop
# ------------------------------------------------------------------
def _loop(self) -> None:
while not self._stop_event.is_set():
with self._anim_lock:
anim = self._animation
snap = self._state.snapshot
color = self._color()
muted = snap["muted"]
t_total = snap["timer_total_seconds"]
t_left = snap["timer_seconds_left"]
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(color)
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, muted)
elif anim == self.ANIM_TIMER_TICK:
sleep = self._anim_timer_tick(color, muted, t_left, t_total)
else:
sleep = self._anim_off()
time.sleep(sleep)
# ===========================================================================
# State → animation mapping (identical to 4-Mic HAT)
# ===========================================================================
def state_to_animation(
state: AssistState, ha_connected: bool, muted: bool
) -> str:
if not ha_connected:
return LEDRing.ANIM_TWINKLE
if state == AssistState.NOT_READY:
return LEDRing.ANIM_TWINKLE
if state == AssistState.TIMER_RINGING:
return LEDRing.ANIM_TIMER_RING
if state == AssistState.WAKE_WORD:
return LEDRing.ANIM_WAIT_CMD
if state == AssistState.LISTENING:
return LEDRing.ANIM_LISTENING
if state == AssistState.THINKING:
return LEDRing.ANIM_THINKING
if state == AssistState.SPEAKING:
return LEDRing.ANIM_REPLYING
if state == AssistState.ERROR:
return LEDRing.ANIM_ERROR
if state == AssistState.MUTED or muted:
return LEDRing.ANIM_MUTED
if state == AssistState.TIMER_TICKING:
return LEDRing.ANIM_TIMER_TICK
return LEDRing.ANIM_OFF
# ===========================================================================
# WebSocket client
# ===========================================================================
class LVAClient:
def __init__(
self,
host: str,
port: int,
state: SharedState,
leds: LEDRing,
command_queue: asyncio.Queue,
) -> None:
self._uri = f"ws://{host}:{port}"
self._state = state
self._leds = leds
self._queue = command_queue
async def run_forever(self) -> None:
while True:
try:
await self._connect()
except Exception as exc: # pylint: disable=broad-except
_LOGGER.warning(
"LVA connection lost (%s) – retrying in %.0fs",
exc, RECONNECT_DELAY_S,
)
self._state.update(
ha_connected=False,
assist_state=AssistState.NOT_READY,
)
self._leds.set_animation(LEDRing.ANIM_TWINKLE)
await asyncio.sleep(RECONNECT_DELAY_S)
async def _connect(self) -> None:
_LOGGER.info("Connecting to LVA at %s …", self._uri)
async with websockets.connect(self._uri) as ws:
_LOGGER.info("Connected to LVA peripheral API")
recv_task = asyncio.create_task(self._recv_loop(ws))
send_task = asyncio.create_task(self._send_loop(ws))
done, pending = await asyncio.wait(
[recv_task, send_task],
return_when=asyncio.FIRST_EXCEPTION,
)
for task in pending:
task.cancel()
for task in done:
if task.exception():
raise task.exception()
async def _recv_loop(self, ws) -> None:
async for raw in ws:
try:
msg = json.loads(raw)
except json.JSONDecodeError:
continue
await self._handle_event(msg)
async def _send_loop(self, ws) -> None:
while True:
command = await self._queue.get()
try:
await ws.send(json.dumps({"command": command}))
except Exception as exc: # pylint: disable=broad-except
_LOGGER.warning("Failed to send command %s: %s", command, exc)
self._queue.put_nowait(command)
raise
async def _handle_event(self, msg: dict) -> None:
event = msg.get("event", "")
data = msg.get("data") or {}
_LOGGER.debug("Event: %s data=%s", event, data)
if event == "snapshot":
self._state.update(
muted=data.get("muted", False),
volume=data.get("volume", 1.0),
ha_connected=data.get("ha_connected", False),
)
elif event == "wake_word_detected":
self._state.update(assist_state=AssistState.WAKE_WORD)
elif event == "listening":
self._state.update(assist_state=AssistState.LISTENING)
elif event == "thinking":
self._state.update(assist_state=AssistState.THINKING)
elif event == "tts_speaking":
self._state.update(assist_state=AssistState.SPEAKING)
elif event in ("tts_finished", "idle"):
self._state.update(assist_state=AssistState.IDLE)
elif event == "muted":
self._state.update(assist_state=AssistState.MUTED, muted=True)
elif event == "pipeline_error":
_LOGGER.warning("LVA pipeline error: %s", data.get("reason", ""))
self._state.update(assist_state=AssistState.ERROR)
elif event == "disconnected":
_LOGGER.warning("Home Assistant disconnected")
self._state.update(
ha_connected=False,
assist_state=AssistState.NOT_READY,
)
elif event == "timer_ticking":
self._state.update(
assist_state=AssistState.TIMER_TICKING,
timer_total_seconds=data.get("total_seconds", 0),
timer_seconds_left=data.get("seconds_left", 0),
)
elif event == "timer_updated":
self._state.update(
timer_total_seconds=data.get("total_seconds", 0),
timer_seconds_left=data.get("seconds_left", 0),
)
elif event == "timer_ringing":
self._state.update(
assist_state=AssistState.TIMER_RINGING,
timer_total_seconds=data.get("total_seconds", 0),
timer_seconds_left=data.get("seconds_left", 0),
)
elif event == "media_player_playing":
self._state.update(assist_state=AssistState.MEDIA_PLAYING)
elif event == "volume_changed":
self._state.update(volume=data.get("volume", 1.0))
elif event == "volume_muted":
self._state.update(muted=data.get("muted", False))
elif event == "zeroconf":
status = data.get("status", "")
if status == "connected":
self._state.update(ha_connected=True)
_LOGGER.info("Home Assistant connected")
elif status == "getting_started":
_LOGGER.info("LVA starting up, waiting for HA …")
# Recompute LED animation after every event
snap = self._state.snapshot
anim = state_to_animation(
snap["assist_state"], snap["ha_connected"], snap["muted"]
)
self._leds.set_animation(anim)
# ===========================================================================
# Main
# ===========================================================================
async def async_main(host: str, port: int) -> None:
state = SharedState()
command_queue: asyncio.Queue = asyncio.Queue()
leds = LEDRing(state)
client = LVAClient(host, port, state, leds, command_queue)
leds.start()
leds.set_animation(LEDRing.ANIM_TWINKLE)
def _shutdown(signum, frame) -> None:
_LOGGER.info("Shutdown requested")
leds.stop()
sys.exit(0)
signal.signal(signal.SIGINT, _shutdown)
signal.signal(signal.SIGTERM, _shutdown)
_LOGGER.info(
"ReSpeaker Mic Array v2.0 controller started – connecting to ws://%s:%d",
host, port,
)
try:
await client.run_forever()
finally:
leds.stop()
def main() -> None:
parser = argparse.ArgumentParser(
description="ReSpeaker Mic Array v2.0 (USB) controller for Linux Voice Assistant"
)
parser.add_argument(
"--host", default=DEFAULT_LVA_HOST,
help=f"LVA container hostname or IP (default: {DEFAULT_LVA_HOST})",
)
parser.add_argument(
"--port", type=int, default=DEFAULT_LVA_PORT,
help=f"LVA peripheral API port (default: {DEFAULT_LVA_PORT})",
)
parser.add_argument(
"--debug", action="store_true",
help="Enable DEBUG logging",
)
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.debug else logging.INFO,
format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
)
asyncio.run(async_main(args.host, args.port))
if __name__ == "__main__":
main()
+82 -52
View File
@@ -7,7 +7,6 @@ Wyoming LED Control with MQTT Discovery for Home Assistant
import argparse
import asyncio
import logging
import subprocess
import sys
import signal
import json
@@ -22,6 +21,13 @@ from wyoming.snd import Played
from wyoming.vad import VoiceStarted, VoiceStopped
from wyoming.wake import Detection
# Импорт USB контроллера
from wyoming_satellite.xvf_usb import (
XVF3800Controller,
EFFECT_OFF, EFFECT_BREATH, EFFECT_RAINBOW, EFFECT_DOA,
rgb_to_bgr_int, scale_color
)
# ====================== НАСТРОЙКИ ======================
logging.basicConfig(
level=logging.INFO,
@@ -31,7 +37,6 @@ logging.basicConfig(
logger = logging.getLogger("XVF3800 LED Service")
SCRIPT_DIR = Path(__file__).resolve().parent
XVF_HOST_PATH = str(SCRIPT_DIR / "xvf_host.py")
CONFIG_FILE = SCRIPT_DIR / "led_brightness_config.json"
MQTT_BROKER = "192.168.200.100"
@@ -68,6 +73,7 @@ mqtt_client = None
current_state = "idle"
last_mqtt_brightness = None
ha_disabled = False
led_controller: XVF3800Controller | None = None
# ====================== CONFIG ======================
@@ -94,15 +100,19 @@ def save_brightness_config():
# ====================== LED ======================
def run_xvf_command(command: str, values=None):
args = [sys.executable, XVF_HOST_PATH, command]
if values:
args.extend(["--values"] + [str(v) for v in values])
def init_leds() -> bool:
"""Инициализация USB контроллера."""
global led_controller
try:
subprocess.run(args, capture_output=True, text=True, check=True, timeout=5)
return True
led_controller = XVF3800Controller()
if led_controller.is_connected():
logger.info("✅ XVF3800 USB подключён")
return True
else:
logger.warning("⚠️ XVF3800 не найден (USB)")
return False
except Exception as e:
logger.error(f"❌ LED command error {command}: {e}")
logger.error(f"❌ Ошибка инициализации LED: {e}")
return False
@@ -113,58 +123,74 @@ def get_effective_brightness(state: str) -> int:
def set_led_state(state: str, skip_publish=False):
"""Установка LED состояния через USB контроллер."""
global current_state
current_state = state
brightness = get_effective_brightness(state)
if led_controller is None:
logger.warning("⚠️ LED контроллер не инициализирован")
if not skip_publish:
publish_state(state)
publish_satellite_state(state)
return
if state == "listening":
run_xvf_command("LED_EFFECT", [1])
run_xvf_command("LED_COLOR", ["0x00BCD4"])
run_xvf_command("LED_BRIGHTNESS", [brightness])
run_xvf_command("LED_SPEED", [2])
# Бирюзовое дыхание
led_controller.set_led_effect(EFFECT_BREATH)
led_controller.set_led_color(rgb_to_bgr_int(0, 188, 212)) # 0x00BCD4
led_controller.set_led_brightness(brightness)
led_controller.set_led_speed(2)
logger.info(f"🔵 Listening: бирюзовое дыхание ({brightness})")
elif state == "wake":
run_xvf_command("LED_EFFECT", [1])
run_xvf_command("LED_COLOR", ["0xFF0000"])
run_xvf_command("LED_BRIGHTNESS", [brightness])
# Красное дыхание
led_controller.set_led_effect(EFFECT_BREATH)
led_controller.set_led_color(rgb_to_bgr_int(255, 0, 0)) # 0xFF0000
led_controller.set_led_brightness(brightness)
logger.info(f"🔴 Wake: красный ({brightness})")
elif state == "thinking":
run_xvf_command("LED_EFFECT", [2])
run_xvf_command("LED_BRIGHTNESS", [brightness])
run_xvf_command("LED_SPEED", [4])
# Радуга
led_controller.set_led_effect(EFFECT_RAINBOW)
led_controller.set_led_brightness(brightness)
led_controller.set_led_speed(4)
logger.info(f"🌈 Thinking: радуга ({brightness})")
elif state == "speaking":
run_xvf_command("LED_EFFECT", [1])
run_xvf_command("LED_COLOR", ["0x4CAF50"])
run_xvf_command("LED_BRIGHTNESS", [brightness])
# Зелёное дыхание
led_controller.set_led_effect(EFFECT_BREATH)
led_controller.set_led_color(rgb_to_bgr_int(76, 175, 80)) # 0x4CAF50
led_controller.set_led_brightness(brightness)
logger.info(f"🟢 Speaking: зелёный ({brightness})")
elif state == "idle":
# DoA эффект: яркость управляется через значения цвета, а не LED_BRIGHTNESS
# Масштабируем цвета по яркости (0-255)
base_r = int(0x30 * brightness / 255)
base_g = int(0x30 * brightness / 255)
base_b = int(0x30 * brightness / 255)
doa_r = int(0x00 * brightness / 255)
doa_g = int(0xBC * brightness / 255)
doa_b = int(0xD4 * brightness / 255)
base_color = f"0x{base_b:02X}{base_g:02X}{base_r:02X}"
doa_color = f"0x{doa_b:02X}{doa_g:02X}{doa_r:02X}"
run_xvf_command("LED_EFFECT", [4])
run_xvf_command("LED_DOA_COLOR", [base_color, doa_color])
logger.info(f"⚪ Idle: DoA (brightness={brightness}, base={base_color}, doa={doa_color})")
# DoA эффект: яркость управляется через значения цвета
base_rgb = scale_color((0x30, 0x30, 0x30), brightness)
doa_rgb = scale_color((0x00, 0xBC, 0xD4), brightness)
base_color = rgb_to_bgr_int(*base_rgb)
doa_color = rgb_to_bgr_int(*doa_rgb)
led_controller.set_led_effect(EFFECT_DOA)
led_controller.set_led_doa_color(base_color, doa_color)
logger.info(f"⚪ Idle: DoA (brightness={brightness}, base=0x{base_color:06X}, doa=0x{doa_color:06X})")
elif state == "paused":
run_xvf_command("LED_EFFECT", [1])
run_xvf_command("LED_COLOR", ["0x404040"])
run_xvf_command("LED_BRIGHTNESS", [brightness])
# Серое дыхание
led_controller.set_led_effect(EFFECT_BREATH)
led_controller.set_led_color(rgb_to_bgr_int(64, 64, 64)) # 0x404040
led_controller.set_led_brightness(brightness)
logger.info(f"⚫ Paused: серый ({brightness})")
elif state == "error":
run_xvf_command("LED_EFFECT", [1])
run_xvf_command("LED_COLOR", ["0xFF00FF"])
run_xvf_command("LED_BRIGHTNESS", [brightness])
# Маджента дыхание
led_controller.set_led_effect(EFFECT_BREATH)
led_controller.set_led_color(rgb_to_bgr_int(255, 0, 255)) # 0xFF00FF
led_controller.set_led_brightness(brightness)
logger.info(f"🟣 Error: маджента ({brightness})")
elif state == "off":
run_xvf_command("LED_EFFECT", [0])
run_xvf_command("LED_BRIGHTNESS", [0])
# Выключить
led_controller.set_led_effect(EFFECT_OFF)
logger.info("⬛ Off: выключено")
if not skip_publish:
@@ -350,8 +376,8 @@ def on_mqtt_message(client, userdata, msg):
ha_state = payload.upper()
if ha_state == "OFF":
ha_disabled = True
run_xvf_command("LED_EFFECT", [0])
run_xvf_command("LED_BRIGHTNESS", [0])
if led_controller:
led_controller.set_led_effect(EFFECT_OFF)
publish_state("off")
logger.info("⬛ LED OFF")
elif ha_state == "ON":
@@ -413,20 +439,24 @@ class LedEventHandler(AsyncEventHandler):
def cleanup_leds():
"""Очистка: выключаем LED и закрываем USB."""
logger.info("🧹 Очистка: выключаем LED...")
try:
run_xvf_command("LED_EFFECT", [0])
run_xvf_command("LED_BRIGHTNESS", [0])
logger.info("✅ LED выключены")
except Exception as e:
logger.error(f"❌ Ошибка очистки: {e}")
if led_controller:
try:
led_controller.set_led_effect(EFFECT_OFF)
led_controller.close()
logger.info("✅ LED выключены, USB закрыт")
except Exception as e:
logger.error(f"❌ Ошибка очистки: {e}")
else:
logger.info("ℹ️ LED контроллер не был инициализирован")
async def main(args):
logger.info(f"🚀 Запуск на {args.host}:{args.port}")
load_brightness_config()
init_leds()
start_mqtt_client()
run_xvf_command("VERSION")
set_led_state("idle")
uri = f"tcp://{args.host}:{args.port}"