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 модуля
493 lines
18 KiB
Python
493 lines
18 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Wyoming LED Control with MQTT Discovery for Home Assistant
|
||
Полная версия: Light + Sensor + 7 Number sliders
|
||
"""
|
||
|
||
import argparse
|
||
import asyncio
|
||
import logging
|
||
import sys
|
||
import signal
|
||
import json
|
||
from functools import partial
|
||
from pathlib import Path
|
||
|
||
import paho.mqtt.client as mqtt
|
||
from wyoming.event import Event
|
||
from wyoming.satellite import SatelliteConnected
|
||
from wyoming.server import AsyncEventHandler, AsyncServer
|
||
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,
|
||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||
datefmt="%Y-%m-%d %H:%M:%S"
|
||
)
|
||
logger = logging.getLogger("XVF3800 LED Service")
|
||
|
||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||
CONFIG_FILE = SCRIPT_DIR / "led_brightness_config.json"
|
||
|
||
MQTT_BROKER = "192.168.200.100"
|
||
MQTT_PORT = 1883
|
||
MQTT_USER = "mqtt"
|
||
MQTT_PASS = "mqtt"
|
||
|
||
# Топики
|
||
MQTT_TOPIC_COMMAND = "wyoming/led/command"
|
||
MQTT_TOPIC_BRIGHTNESS_SET = "wyoming/led/brightness/set"
|
||
MQTT_TOPIC_STATE = "wyoming/led/state"
|
||
MQTT_TOPIC_BRIGHTNESS_STATE = "wyoming/led/brightness/state"
|
||
MQTT_TOPIC_SATELLITE_STATE = "wyoming/satellite/state"
|
||
MQTT_TOPIC_AVAILABILITY = "wyoming/led/availability"
|
||
|
||
MQTT_DISCOVERY_PREFIX = "homeassistant"
|
||
MQTT_CLIENT_ID = "wyoming-led-service"
|
||
|
||
MQTT_DISCOVERY_DEVICE = {
|
||
"identifiers": ["wyoming-led-repeaker"],
|
||
"name": "Wyoming LED ReSpeaker",
|
||
"manufacturer": "ReSpeaker",
|
||
"model": "XVF3800",
|
||
}
|
||
|
||
# Настройки яркости
|
||
BRIGHTNESS_CONFIG = {
|
||
"listening": 120, "wake": 200, "thinking": 150,
|
||
"speaking": 140, "idle": 80, "paused": 40, "error": 255,
|
||
"global_multiplier": 1.0
|
||
}
|
||
|
||
mqtt_client = None
|
||
current_state = "idle"
|
||
last_mqtt_brightness = None
|
||
ha_disabled = False
|
||
led_controller: XVF3800Controller | None = None
|
||
|
||
|
||
# ====================== CONFIG ======================
|
||
def load_brightness_config():
|
||
global BRIGHTNESS_CONFIG
|
||
if CONFIG_FILE.exists():
|
||
try:
|
||
with open(CONFIG_FILE) as f:
|
||
saved = json.load(f)
|
||
BRIGHTNESS_CONFIG.update(saved)
|
||
logger.info(f"📂 Загружены настройки яркости из {CONFIG_FILE}")
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ Ошибка загрузки config: {e}")
|
||
else:
|
||
save_brightness_config()
|
||
|
||
def save_brightness_config():
|
||
try:
|
||
with open(CONFIG_FILE, "w") as f:
|
||
json.dump(BRIGHTNESS_CONFIG, f, indent=2)
|
||
logger.info(f"💾 Сохранено в {CONFIG_FILE}: {BRIGHTNESS_CONFIG}")
|
||
except Exception as e:
|
||
logger.error(f"❌ Ошибка сохранения config: {e}")
|
||
|
||
|
||
# ====================== LED ======================
|
||
def init_leds() -> bool:
|
||
"""Инициализация USB контроллера."""
|
||
global led_controller
|
||
try:
|
||
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: {e}")
|
||
return False
|
||
|
||
|
||
def get_effective_brightness(state: str) -> int:
|
||
base = BRIGHTNESS_CONFIG.get(state, 100)
|
||
multiplier = BRIGHTNESS_CONFIG.get("global_multiplier", 1.0)
|
||
return int(base * multiplier)
|
||
|
||
|
||
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":
|
||
# Бирюзовое дыхание
|
||
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":
|
||
# Красное дыхание
|
||
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":
|
||
# Радуга
|
||
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":
|
||
# Зелёное дыхание
|
||
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 эффект: яркость управляется через значения цвета
|
||
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":
|
||
# Серое дыхание
|
||
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":
|
||
# Маджента дыхание
|
||
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":
|
||
# Выключить
|
||
led_controller.set_led_effect(EFFECT_OFF)
|
||
logger.info("⬛ Off: выключено")
|
||
|
||
if not skip_publish:
|
||
publish_state(state)
|
||
publish_satellite_state(state)
|
||
|
||
|
||
# ====================== MQTT PUBLISH ======================
|
||
def publish_discovery_config():
|
||
if not mqtt_client:
|
||
return
|
||
try:
|
||
logger.info("🔄 Публикация Discovery...")
|
||
|
||
# Light
|
||
light_disc = {
|
||
"name": "Wyoming LED",
|
||
"unique_id": "wyoming_led_light",
|
||
"state_topic": MQTT_TOPIC_STATE,
|
||
"command_topic": MQTT_TOPIC_COMMAND,
|
||
"brightness_command_topic": MQTT_TOPIC_BRIGHTNESS_SET,
|
||
"brightness_state_topic": MQTT_TOPIC_BRIGHTNESS_STATE,
|
||
"availability_topic": MQTT_TOPIC_AVAILABILITY,
|
||
"payload_available": "online",
|
||
"payload_not_available": "offline",
|
||
"payload_on": "ON",
|
||
"payload_off": "OFF",
|
||
"brightness_scale": 100,
|
||
"optimistic": False,
|
||
"qos": 1,
|
||
"device": MQTT_DISCOVERY_DEVICE,
|
||
}
|
||
mqtt_client.publish(f"{MQTT_DISCOVERY_PREFIX}/light/wyoming_led/config",
|
||
json.dumps(light_disc), retain=True, qos=1)
|
||
|
||
# Sensor
|
||
sensor_disc = {
|
||
"name": "Wyoming Satellite State",
|
||
"unique_id": "wyoming_satellite_state",
|
||
"state_topic": MQTT_TOPIC_SATELLITE_STATE,
|
||
"availability_topic": MQTT_TOPIC_AVAILABILITY,
|
||
"payload_available": "online",
|
||
"payload_not_available": "offline",
|
||
"device": MQTT_DISCOVERY_DEVICE,
|
||
}
|
||
mqtt_client.publish(f"{MQTT_DISCOVERY_PREFIX}/sensor/wyoming_satellite_state/config",
|
||
json.dumps(sensor_disc), retain=True, qos=1)
|
||
|
||
# Number entities
|
||
publish_number_discovery()
|
||
|
||
logger.info("✅ Discovery опубликован успешно (Light + Sensor + 7 Number)")
|
||
# Состояния будут опубликованы с задержкой в on_mqtt_connect
|
||
except Exception as e:
|
||
logger.error(f"❌ Ошибка Discovery: {e}", exc_info=True)
|
||
|
||
|
||
def publish_number_discovery():
|
||
if not mqtt_client:
|
||
return
|
||
try:
|
||
modes = {
|
||
"idle": "Idle", "listening": "Listening", "wake": "Wake",
|
||
"thinking": "Thinking", "speaking": "Speaking",
|
||
"paused": "Paused", "error": "Error"
|
||
}
|
||
for mode, name in modes.items():
|
||
disc = {
|
||
"name": f"{name}",
|
||
"unique_id": f"wyoming_led_{mode}_base",
|
||
"object_id": f"wyoming_led_{mode}_base",
|
||
"state_topic": f"wyoming/led/{mode}/state",
|
||
"command_topic": f"wyoming/led/{mode}/set",
|
||
"availability_topic": MQTT_TOPIC_AVAILABILITY,
|
||
"payload_available": "online",
|
||
"payload_not_available": "offline",
|
||
"min": 0, "max": 100, "step": 5,
|
||
"unit_of_measurement": "%",
|
||
|
||
"icon": "mdi:led-on",
|
||
"device": MQTT_DISCOVERY_DEVICE,
|
||
}
|
||
|
||
|
||
mqtt_client.publish(
|
||
f"{MQTT_DISCOVERY_PREFIX}/number/wyoming_led_{mode}/config",
|
||
json.dumps(disc), retain=True, qos=1
|
||
)
|
||
logger.info("✅ 7 Number entities опубликованы")
|
||
except Exception as e:
|
||
logger.error(f"❌ Ошибка Number discovery: {e}")
|
||
|
||
|
||
def publish_state(state: str):
|
||
if not mqtt_client: return
|
||
ha_state = "OFF" if (ha_disabled or state == "off") else "ON"
|
||
multiplier = BRIGHTNESS_CONFIG.get("global_multiplier", 1.0)
|
||
ha_brightness = int(multiplier * 100)
|
||
|
||
mqtt_client.publish(MQTT_TOPIC_STATE, ha_state, retain=True, qos=1)
|
||
mqtt_client.publish(MQTT_TOPIC_BRIGHTNESS_STATE, str(ha_brightness), retain=True, qos=1)
|
||
|
||
|
||
def publish_satellite_state(state: str):
|
||
if mqtt_client:
|
||
mqtt_client.publish(MQTT_TOPIC_SATELLITE_STATE, state, retain=True, qos=0)
|
||
|
||
|
||
def publish_base_brightness_states():
|
||
if not mqtt_client: return
|
||
try:
|
||
logger.debug(f"📤 Brightness states: {BRIGHTNESS_CONFIG}")
|
||
for mode in ["idle","listening","wake","thinking","speaking","paused","error"]:
|
||
if mode in BRIGHTNESS_CONFIG:
|
||
percent = min(100, int(BRIGHTNESS_CONFIG[mode] / 2.55))
|
||
mqtt_client.publish(f"wyoming/led/{mode}/state", str(percent), retain=True, qos=1)
|
||
logger.debug(f"📤 {mode}/state = {percent}% (value={BRIGHTNESS_CONFIG[mode]})")
|
||
logger.info("📊 Brightness states опубликованы")
|
||
except Exception as e:
|
||
logger.error(f"❌ Ошибка publish_base_brightness_states: {e}", exc_info=True)
|
||
|
||
|
||
# ====================== MQTT CALLBACKS ======================
|
||
def on_mqtt_connect(client, userdata, flags, rc, properties=None):
|
||
if rc == 0:
|
||
logger.info(f"✅ MQTT подключён к {MQTT_BROKER}:{MQTT_PORT}")
|
||
client.publish(MQTT_TOPIC_AVAILABILITY, "online", retain=True, qos=1)
|
||
client.subscribe(MQTT_TOPIC_COMMAND)
|
||
client.subscribe(MQTT_TOPIC_BRIGHTNESS_SET)
|
||
# Подписка на топики 7 режимов
|
||
for mode in ["idle","listening","wake","thinking","speaking","paused","error"]:
|
||
client.subscribe(f"wyoming/led/{mode}/set")
|
||
logger.debug(f"📡 Подписка на wyoming/led/{mode}/set")
|
||
publish_discovery_config()
|
||
publish_state(current_state)
|
||
publish_satellite_state(current_state)
|
||
publish_base_brightness_states()
|
||
logger.info(f"📡 Подписки: {MQTT_TOPIC_COMMAND}, {MQTT_TOPIC_BRIGHTNESS_SET}, 7 режимов")
|
||
else:
|
||
logger.error(f"❌ MQTT ошибка подключения: {rc}")
|
||
|
||
|
||
def on_mqtt_message(client, userdata, msg):
|
||
global ha_disabled, last_mqtt_brightness, current_state
|
||
try:
|
||
payload = msg.payload.decode('utf-8').strip()
|
||
topic = msg.topic
|
||
logger.info(f"📩 MQTT {topic}: {payload}")
|
||
|
||
# Глобальная яркость
|
||
if topic == MQTT_TOPIC_BRIGHTNESS_SET:
|
||
ha_value = max(0, min(100, int(payload)))
|
||
new_multiplier = ha_value / 100.0
|
||
last_mqtt_brightness = ha_value
|
||
BRIGHTNESS_CONFIG["global_multiplier"] = new_multiplier
|
||
save_brightness_config()
|
||
logger.info(f"🔆 Глобальная яркость: {ha_value}% (×{new_multiplier:.2f})")
|
||
if not ha_disabled:
|
||
set_led_state(current_state)
|
||
publish_base_brightness_states()
|
||
|
||
# Базовая яркость режима
|
||
elif topic.startswith("wyoming/led/") and topic.endswith("/set"):
|
||
mode = topic.split("/")[2]
|
||
logger.info(f"🎚️ Получена команда для режима: {mode}")
|
||
if mode in BRIGHTNESS_CONFIG:
|
||
percent = max(0, min(100, int(payload)))
|
||
real_value = int(percent * 2.55)
|
||
old_value = BRIGHTNESS_CONFIG[mode]
|
||
BRIGHTNESS_CONFIG[mode] = real_value
|
||
save_brightness_config()
|
||
logger.info(f"🎛️ {mode}: {old_value} → {real_value} ({percent}%)")
|
||
logger.info(f"📤 Публикация {mode}/state = {percent}")
|
||
publish_base_brightness_states()
|
||
if current_state == mode and not ha_disabled:
|
||
logger.info(f"🔄 Применяем новую яркость к текущему состоянию {mode}")
|
||
set_led_state(mode)
|
||
else:
|
||
logger.warning(f"⚠️ Неизвестный режим: {mode}")
|
||
|
||
# ON / OFF
|
||
elif topic == MQTT_TOPIC_COMMAND:
|
||
ha_state = payload.upper()
|
||
if ha_state == "OFF":
|
||
ha_disabled = True
|
||
if led_controller:
|
||
led_controller.set_led_effect(EFFECT_OFF)
|
||
publish_state("off")
|
||
logger.info("⬛ LED OFF")
|
||
elif ha_state == "ON":
|
||
ha_disabled = False
|
||
set_led_state(current_state)
|
||
logger.info("✅ LED ON")
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Ошибка MQTT: {e}", exc_info=True)
|
||
|
||
|
||
# ====================== MQTT CLIENT ======================
|
||
def start_mqtt_client():
|
||
global mqtt_client
|
||
mqtt_client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2, client_id=MQTT_CLIENT_ID)
|
||
mqtt_client.username_pw_set(MQTT_USER, MQTT_PASS)
|
||
mqtt_client.on_connect = on_mqtt_connect
|
||
mqtt_client.on_message = on_mqtt_message
|
||
mqtt_client.will_set(MQTT_TOPIC_AVAILABILITY, "offline", retain=True, qos=1)
|
||
mqtt_client.connect(MQTT_BROKER, MQTT_PORT, keepalive=60)
|
||
mqtt_client.loop_start()
|
||
|
||
|
||
# ====================== HANDLER & MAIN ======================
|
||
class LedEventHandler(AsyncEventHandler):
|
||
async def handle_event(self, event: Event) -> bool:
|
||
if ha_disabled:
|
||
return True
|
||
et = event.type
|
||
logger.debug(f"🔍 Событие: {et}")
|
||
if Detection.is_type(et):
|
||
logger.info("🎯 Detection → wake")
|
||
set_led_state("wake")
|
||
elif VoiceStarted.is_type(et):
|
||
logger.info("🎤 VoiceStarted → listening")
|
||
set_led_state("listening")
|
||
elif VoiceStopped.is_type(et):
|
||
logger.info("💭 VoiceStopped → thinking")
|
||
set_led_state("thinking")
|
||
elif et == "audio-start":
|
||
logger.info("🔊 audio-start → speaking")
|
||
set_led_state("speaking")
|
||
elif Played.is_type(et):
|
||
logger.info("✅ Played → idle")
|
||
set_led_state("idle")
|
||
elif et == "pause-satellite":
|
||
logger.info("⏸️ pause → paused")
|
||
set_led_state("paused")
|
||
elif SatelliteConnected.is_type(et):
|
||
logger.info("🔌 connected → idle")
|
||
set_led_state("idle")
|
||
return True
|
||
|
||
async def on_connected(self) -> None:
|
||
logger.info("✅ TCP подключён")
|
||
async def on_disconnected(self) -> None:
|
||
logger.info("❌ TCP отключён")
|
||
set_led_state("idle")
|
||
|
||
|
||
def cleanup_leds():
|
||
"""Очистка: выключаем LED и закрываем USB."""
|
||
logger.info("🧹 Очистка: выключаем LED...")
|
||
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()
|
||
set_led_state("idle")
|
||
|
||
uri = f"tcp://{args.host}:{args.port}"
|
||
server = AsyncServer.from_uri(uri)
|
||
logger.info(f"📡 Слушаем {uri}")
|
||
await server.run(partial(LedEventHandler))
|
||
|
||
|
||
def signal_handler(sig, frame):
|
||
logger.info(f"\n🛑 Сигнал {sig}")
|
||
cleanup_leds()
|
||
if mqtt_client:
|
||
mqtt_client.publish(MQTT_TOPIC_AVAILABILITY, "offline", retain=True)
|
||
mqtt_client.loop_stop()
|
||
mqtt_client.disconnect()
|
||
logger.info("📡 MQTT остановлен")
|
||
sys.exit(0)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
parser = argparse.ArgumentParser()
|
||
parser.add_argument("--host", default="127.0.0.1")
|
||
parser.add_argument("--port", type=int, default=10701)
|
||
args = parser.parse_args()
|
||
|
||
signal.signal(signal.SIGINT, signal_handler)
|
||
signal.signal(signal.SIGTERM, signal_handler)
|
||
|
||
try:
|
||
asyncio.run(main(args))
|
||
except KeyboardInterrupt:
|
||
pass
|
||
finally:
|
||
cleanup_leds() |