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
+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}"