#!/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 # HA Light entity registered with LVA via register_light. The same object_id # is used to route incoming light_command events back to this script. LIGHT_OBJECT_ID = "led_ring" LIGHT_NAME = "LED Ring" LIGHT_ICON = "mdi:circle-outline" EFFECT_VOICE_ASSISTANT = "Voice Assistant" # 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) # LED that lights up solid red while the media player (speaker) output is # muted at volume 0 — the `volume_muted` peripheral event. LED 9 is the # board's bottom-middle position (DOA 270°), sitting right between the # micro-USB connector and the 3.5 mm AUX jack on the board edge — the same # "bottom" position already used by the mic indicator mapping below. VOLUME_MUTED_LED_INDEX = 9 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" VOLUME_MUTED = "volume_muted" # --------------------------------------------------------------------------- # 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 # mic mute self.volume: float = 1.0 self.volume_muted: bool = False # media player volume zero self.timer_total_seconds: int = 0 self.timer_seconds_left: int = 0 # Light entity state, driven by HA via light_command events. # Defaults match the LEDLightEntity in LVA core so the script # behaves sensibly before the first light_command arrives: off by # default, so idle stays dark until the user turns the light on. self.light_is_on: bool = False self.light_brightness: float = 0.66 self.light_red: float = 0.094 self.light_green: float = 0.733 self.light_blue: float = 0.949 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, "volume_muted": self.volume_muted, "timer_total_seconds": self.timer_total_seconds, "timer_seconds_left": self.timer_seconds_left, "light_is_on": self.light_is_on, "light_brightness": self.light_brightness, "light_red": self.light_red, "light_green": self.light_green, "light_blue": self.light_blue, } # =========================================================================== # USB LED driver # # The ReSpeaker Mic Array v2.0 LED firmware exposes a vendor-command USB # interface. The copied pixel_ring reference uses command 0x06 for custom LED # frames and command 0x20 for brightness, both addressed to wIndex 0x1C. # =========================================================================== 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 LEDs on the ReSpeaker Mic Array v2.0 over USB. Uses the same vendor-command protocol as Seeed's pixel_ring library. That protocol talks to the device recipient directly and does not require detaching the kernel audio driver. """ _CMD_SHOW = 0x06 _CMD_BRIGHTNESS = 0x20 _CTRL_TIMEOUT = 8000 # ms _CTRL_REQUEST = 0 _CTRL_INDEX = 0x1C _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() self._last_brightness: Optional[int] = None 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 self._dev = dev self._last_brightness = None _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 _write(self, command: int, data: list[int]) -> None: self._dev.ctrl_transfer( self._CTRL_TYPE_OUT, self._CTRL_REQUEST, command, self._CTRL_INDEX, data, self._CTRL_TIMEOUT, ) def show(self, brightness: float = 1.0) -> None: """ Push the current pixel buffer to the device. The USB firmware expects command 0x06 with 4 bytes per LED in RGB0 order, plus command 0x20 for device brightness. Sending raw APA102 frames here causes the firmware to misinterpret the payload. """ bright5 = max(0, min(31, int(brightness * LED_BRIGHTNESS))) frame: list[int] = [] 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 += [br, bg, bb, 0] 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: if self._last_brightness != bright5: self._write(self._CMD_BRIGHTNESS, [bright5]) self._last_brightness = bright5 self._write(self._CMD_SHOW, frame) except usb.core.USBError as exc: _LOGGER.warning("USB write error: %s – attempting reconnect", exc) self._dev = None self._last_brightness = 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_IDLE = "idle" 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: """Return the current user-configured ring colour from light entity state.""" snap = self._state.snapshot r = int(max(0.0, min(1.0, snap["light_red"])) * 255) g = int(max(0.0, min(1.0, snap["light_green"])) * 255) b = int(max(0.0, min(1.0, snap["light_blue"])) * 255) return (r, g, b) 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)) def _apply_volume_muted_indicator(self) -> None: """ Overlay a solid red dot on the bottom-middle LED (index ``VOLUME_MUTED_LED_INDEX``, DOA 270°) when the media player output is muted at volume 0. Sits right between the board's micro-USB connector and 3.5 mm AUX jack. Applied after the current pipeline animation has already rendered its frame, so the indicator shows up consistently no matter which animation is active — mirrors how ``_apply_mic_indicators`` marks the microphone cardinal points, but for the distinct speaker-mute state. """ self._leds.set(VOLUME_MUTED_LED_INDEX, RED) self._leds.show() # ------------------------------------------------------------------ # Animations — each returns sleep time in seconds # ------------------------------------------------------------------ def _anim_off(self) -> float: self._leds.off() return 0.1 def _anim_idle(self) -> float: """Resting state with user color when light is on, else dark. Matches the HA Voice PE LED Ring behavior where the light defaults off, so idle stays dark until the user turns the light on in HA. Pipeline animations always run regardless. """ snap = self._state.snapshot if snap["light_is_on"]: color = self._color() brightness = snap["light_brightness"] for i in range(LED_COUNT): self._leds.set(i, _scale(color, brightness)) self._leds.show() else: 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"] volume_muted = snap["volume_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_IDLE: sleep = self._anim_idle() 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() if volume_muted: self._apply_volume_muted_indicator() 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_IDLE # =========================================================================== # 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") # Register the LED Light entity with LVA so HA can control it. # LVA treats repeat registrations for the same object_id as a # no-op, so it's safe to send this on every connect. await ws.send(json.dumps({ "command": "register_light", "data": { "name": LIGHT_NAME, "object_id": LIGHT_OBJECT_ID, "icon": LIGHT_ICON, "effects": [EFFECT_VOICE_ASSISTANT], "supports_rgb": True, "supports_brightness": True, }, })) 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": muted = data.get("muted", False) ha_connected = data.get("ha_connected", False) self._state.update( muted=muted, volume=data.get("volume", 1.0), ha_connected=ha_connected, ) if muted: self._state.update(assist_state=AssistState.MUTED) elif ha_connected: self._state.update(assist_state=AssistState.IDLE) else: self._state.update(assist_state=AssistState.NOT_READY) 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": muted = data.get("muted", True) self._state.update(muted=muted) if muted: self._state.update(assist_state=AssistState.MUTED) elif self._state.assist_state == AssistState.MUTED: self._state.update(assist_state=AssistState.IDLE) 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": Volume_muted = data.get("muted", True) self._state.update(volume_muted=Volume_muted) if Volume_muted: self._state.update(assist_state=AssistState.VOLUME_MUTED) elif self._state.assist_state == AssistState.VOLUME_MUTED: self._state.update(assist_state=AssistState.VOLUME_MUTED) elif event == "zeroconf": status = data.get("status", "") if status == "connected": self._state.update(ha_connected=True) if self._state.assist_state == AssistState.NOT_READY: self._state.update( assist_state=( AssistState.MUTED if self._state.muted else AssistState.IDLE ), ) _LOGGER.info("Home Assistant connected") elif status == "getting_started": _LOGGER.info("LVA starting up, waiting for HA …") # --- Light command events ------------------------------------------ elif event == "light_command": # LVA broadcasts to every connected peripheral; only act on # commands targeting our registered Light. The Light exposes a # single "Voice Assistant" effect, so there is no effect to # switch on: we apply on/off, brightness, and color and let the # pipeline animations run. if data.get("object_id") != LIGHT_OBJECT_ID: return self._state.update( light_is_on=bool(data.get("state", True)), light_brightness=float(data.get("brightness", 0.66)), light_red=float(data.get("red", 0.094)), light_green=float(data.get("green", 0.733)), light_blue=float(data.get("blue", 0.949)), ) # Force re-render animation with new light state snap = self._state.snapshot anim = state_to_animation( snap["assist_state"], snap["ha_connected"], snap["muted"] ) self._leds.set_animation(anim) return # 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()