#!/usr/bin/env python3 """ Controls the LEDs on the ReSpeaker Mic Array via xvf_host.py С поддержкой MQTT Discovery для Home Assistant (без эхо-петли) """ import argparse import asyncio import logging import subprocess import os 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, SatelliteDisconnected, StreamingStarted, StreamingStopped, ) from wyoming.server import AsyncEventHandler, AsyncServer from wyoming.snd import Played from wyoming.vad import VoiceStarted, VoiceStopped from wyoming.wake import Detection # 🔹 Настройка логирования 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 via xvf_host") # 🔹 Пути SCRIPT_DIR = Path(__file__).resolve().parent XVF_HOST_PATH = str(SCRIPT_DIR / "xvf_host.py") CONFIG_FILE = SCRIPT_DIR / "led_brightness_config.json" # 🔹 MQTT конфигурация MQTT_BROKER = "192.168.200.100" MQTT_PORT = 1883 MQTT_USER = "mqtt" MQTT_PASS = "mqtt" # 🔹 Разделённые топики (команды ≠ состояния) MQTT_TOPIC_COMMAND = "wyoming/led/command" # HA → Скрипт (команды) MQTT_TOPIC_BRIGHTNESS_SET = "wyoming/led/brightness/set" # HA → Скрипт (установка) MQTT_TOPIC_BRIGHTNESS_STATE = "wyoming/led/brightness/state" # Скрипт → HA (отчёт) MQTT_TOPIC_STATE = "wyoming/led/state" # Скрипт → HA (состояние) MQTT_TOPIC_AVAILABILITY = "wyoming/led/availability" # Статус онлайн/офлайн MQTT_CLIENT_ID = "wyoming-led-service" # 🔹 MQTT Discovery MQTT_DISCOVERY_PREFIX = "homeassistant" MQTT_DISCOVERY_DEVICE = { "identifiers": ["wyoming-led-repeaker"], "name": "Wyoming LED ReSpeaker", "manufacturer": "ReSpeaker", "model": "XVF3800 USB 4-MIC Array", "sw_version": "1.0", "connections": [["mac", "unknown"]] } # 🔹 Глобальные настройки BRIGHTNESS_CONFIG = { "listening": 120, "wake": 200, "thinking": 150, "speaking": 140, "idle": 80, "paused": 40, "error": 255, "global_multiplier": 1.0 } # 🔹 Глобальные переменные shutdown_event = asyncio.Event() mqtt_client = None current_state = "idle" last_mqtt_brightness = None # 🔹 Защита от эха: последнее значение из MQTT # ============================================================ # 💾 Настройки яркости # ============================================================ def load_brightness_config(): global BRIGHTNESS_CONFIG if CONFIG_FILE.exists(): try: with open(CONFIG_FILE, "r") as f: saved = json.load(f) BRIGHTNESS_CONFIG.update(saved) logger.info(f"📂 Загружены настройки из {CONFIG_FILE}") except Exception as e: logger.warning(f"⚠️ Ошибка загрузки конфига: {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.debug(f"💾 Сохранены настройки яркости") except Exception as e: logger.error(f"❌ Ошибка сохранения: {e}") # ============================================================ # 🎛️ Управление 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]) try: result = subprocess.run(args, capture_output=True, text=True, check=True, timeout=5) if result.stdout.strip(): logger.debug(f"{command} output: {result.stdout.strip()}") return True except Exception as e: logger.error(f"❌ Ошибка {command}: {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. Если skip_publish=True — не публикуем в MQTT (избегаем эха)""" global current_state current_state = state brightness = get_effective_brightness(state) if state == "listening": run_xvf_command("LED_EFFECT", [0]) run_xvf_command("LED_EFFECT", [1]) run_xvf_command("LED_COLOR", ["0x00BCD4"]) run_xvf_command("LED_BRIGHTNESS", [brightness]) run_xvf_command("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]) 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]) 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]) run_xvf_command("LED_SPEED", [2]) logger.info(f"🟢 Speaking: зелёный ({brightness})") elif state == "idle": run_xvf_command("LED_EFFECT", [4]) run_xvf_command("LED_DOA_COLOR", ["0x303030", "0x00BCD4"]) run_xvf_command("LED_BRIGHTNESS", [brightness]) logger.info(f"⚪ Idle: DoA ({brightness})") elif state == "paused": run_xvf_command("LED_EFFECT", [1]) run_xvf_command("LED_COLOR", ["0x404040"]) run_xvf_command("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]) logger.info(f"🟣 Error: маджента ({brightness})") elif state == "off": run_xvf_command("LED_EFFECT", [0]) run_xvf_command("LED_BRIGHTNESS", [0]) logger.info("⬛ Off: выключено") else: run_xvf_command("LED_EFFECT", [0]) logger.info("⬛ Unknown") # 🔹 Публикуем состояние только если не скипаем if not skip_publish: publish_state(state, brightness) # ============================================================ # 🔍 MQTT Discovery # ============================================================ def publish_discovery_config(): if not mqtt_client: return try: # 🔹 Number (слайдер) — читаем из STATE, пишем в SET discovery_number = { "name": "Wyoming LED Brightness", "unique_id": "wyoming_led_brightness", "state_topic": MQTT_TOPIC_BRIGHTNESS_STATE, # ← Читаем отчёт "command_topic": MQTT_TOPIC_BRIGHTNESS_SET, # ← Пишем команду "min": 0, "max": 255, "icon": "mdi:brightness-6", "availability_topic": MQTT_TOPIC_AVAILABILITY, "payload_available": "online", "payload_not_available": "offline", "device": MQTT_DISCOVERY_DEVICE, "object_id": "wyoming_led_brightness" } # 🔹 Sensor (состояние) discovery_sensor = { "name": "Wyoming LED State", "unique_id": "wyoming_led_state", "state_topic": MQTT_TOPIC_STATE, "value_template": "{{ value_json.state }}", "icon": "mdi:led-strip", "availability_topic": MQTT_TOPIC_AVAILABILITY, "payload_available": "online", "payload_not_available": "offline", "device": MQTT_DISCOVERY_DEVICE, "json_attributes_topic": MQTT_TOPIC_STATE, "object_id": "wyoming_led_state" } # 🔹 Light (полноценная лампа) discovery_light = { "name": "Wyoming LED", "unique_id": "wyoming_led_light", "state_topic": MQTT_TOPIC_STATE, "command_topic": MQTT_TOPIC_COMMAND, "brightness_state_topic": MQTT_TOPIC_BRIGHTNESS_STATE, "brightness_command_topic": MQTT_TOPIC_BRIGHTNESS_SET, "brightness_scale": 255, "availability_topic": MQTT_TOPIC_AVAILABILITY, "payload_available": "online", "payload_not_available": "offline", "payload_on": '{"type": "set_state", "state": "speaking"}', "payload_off": '{"type": "set_state", "state": "idle"}', "device": MQTT_DISCOVERY_DEVICE, "object_id": "wyoming_led", "schema": "json" } # Публикуем с retain=True для автообнаружения mqtt_client.publish(f"{MQTT_DISCOVERY_PREFIX}/number/wyoming_led/config", json.dumps(discovery_number), retain=True) mqtt_client.publish(f"{MQTT_DISCOVERY_PREFIX}/sensor/wyoming_led/config", json.dumps(discovery_sensor), retain=True) mqtt_client.publish(f"{MQTT_DISCOVERY_PREFIX}/light/wyoming_led/config", json.dumps(discovery_light), retain=True) logger.info("🔍 MQTT Discovery: конфигурации опубликованы") except Exception as e: logger.error(f"❌ Ошибка Discovery: {e}") # ============================================================ # 📡 MQTT функции # ============================================================ 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) # 🔹 Подписываемся ТОЛЬКО на командные топики (не на state!) client.subscribe(MQTT_TOPIC_COMMAND) client.subscribe(MQTT_TOPIC_BRIGHTNESS_SET) publish_discovery_config() logger.info(f"📡 Подписки: {MQTT_TOPIC_COMMAND}, {MQTT_TOPIC_BRIGHTNESS_SET}") else: logger.error(f"❌ MQTT ошибка подключения: {rc}") def on_mqtt_disconnect(client, userdata, rc, properties=None): logger.warning(f"⚠️ MQTT отключён (код: {rc})") def on_mqtt_message(client, userdata, msg): """Обработчик входящих MQTT сообщений""" global last_mqtt_brightness try: topic = msg.topic payload = msg.payload.decode('utf-8') logger.debug(f"📩 MQTT {topic}: {payload}") # 🔹 Обработка яркости (только из _SET топика!) if topic == MQTT_TOPIC_BRIGHTNESS_SET: brightness = int(payload) brightness = max(0, min(255, brightness)) # 🔹 Защита от эха: если значение не изменилось — игнорируем if brightness == last_mqtt_brightness: logger.debug(f"⏭️ Эхо: яркость {brightness} уже обработана") return last_mqtt_brightness = brightness BRIGHTNESS_CONFIG["global_multiplier"] = brightness / 255.0 save_brightness_config() logger.info(f"🔆 Яркость: {brightness} (×{BRIGHTNESS_CONFIG['global_multiplier']:.2f})") # 🔹 Применяем к текущему состоянию, но НЕ публикуем обратно (skip_publish=True) set_led_state(current_state, skip_publish=True) # 🔹 Публикуем отчёт в отдельный _STATE топик (без эха) publish_brightness(brightness) # 🔹 Обработка команд elif topic == MQTT_TOPIC_COMMAND: command = json.loads(payload) cmd_type = command.get("type", "") if cmd_type == "set_brightness": brightness = max(0, min(255, command.get("value", 100))) if brightness != last_mqtt_brightness: last_mqtt_brightness = brightness BRIGHTNESS_CONFIG["global_multiplier"] = brightness / 255.0 save_brightness_config() publish_brightness(brightness) set_led_state(current_state, skip_publish=True) logger.info(f"🔆 Яркость из команды: {brightness}") elif cmd_type == "set_state": state = command.get("state", "idle") set_led_state(state, skip_publish=False) # Можно публиковать, это новое состояние logger.info(f"🎨 Состояние: {state}") elif cmd_type == "set_effect": run_xvf_command("LED_EFFECT", [command.get("effect", 0)]) run_xvf_command("LED_COLOR", [command.get("color", "0x000000")]) run_xvf_command("LED_BRIGHTNESS", [command.get("brightness", 100)]) logger.info(f"🎨 Эффект применён") except json.JSONDecodeError: logger.warning(f"⚠️ Неверный JSON: {payload}") except Exception as e: logger.error(f"❌ Ошибка MQTT: {e}") def publish_state(state: str, brightness: int): """Публикует состояние в топик состояния""" if mqtt_client: try: payload = json.dumps({ "state": state, "brightness": brightness, "timestamp": asyncio.get_event_loop().time() }) mqtt_client.publish(MQTT_TOPIC_STATE, payload, retain=False) # ← Не retain! except Exception as e: logger.debug(f"⚠️ Ошибка публикации state: {e}") def publish_brightness(brightness: int): """Публикует яркость в топик состояния (отчёт, не команда!)""" if mqtt_client: try: # 🔹 Публикуем в _STATE, а не в _SET! И без retain, чтобы не триггерить эхо mqtt_client.publish(MQTT_TOPIC_BRIGHTNESS_STATE, str(brightness), retain=False) except Exception as e: logger.debug(f"⚠️ Ошибка публикации brightness: {e}") def start_mqtt_client(): global mqtt_client mqtt_client = mqtt.Client( callback_api_version=mqtt.CallbackAPIVersion.VERSION2, client_id=MQTT_CLIENT_ID, protocol=mqtt.MQTTv311 ) mqtt_client.username_pw_set(MQTT_USER, MQTT_PASS) mqtt_client.on_connect = on_mqtt_connect mqtt_client.on_disconnect = on_mqtt_disconnect mqtt_client.on_message = on_mqtt_message mqtt_client.will_set(MQTT_TOPIC_AVAILABILITY, "offline", retain=True, qos=1) try: mqtt_client.connect(MQTT_BROKER, MQTT_PORT, keepalive=60) mqtt_client.loop_start() logger.info("📡 MQTT клиент запущен") except Exception as e: logger.error(f"❌ Ошибка подключения MQTT: {e}") return mqtt_client def stop_mqtt_client(): global mqtt_client if mqtt_client: try: mqtt_client.publish(MQTT_TOPIC_AVAILABILITY, "offline", retain=True) mqtt_client.loop_stop() mqtt_client.disconnect() logger.info("📡 MQTT остановлен") except Exception as e: logger.error(f"❌ Остановка MQTT: {e}") # ============================================================ # 🧹 Очистка # ============================================================ def cleanup_leds(): 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}") # ============================================================ # 🎯 Wyoming Handler # ============================================================ class LedEventHandler(AsyncEventHandler): async def handle_event(self, event: Event) -> bool: event_type = event.type logger.debug(f"🔍 Событие: {event_type}") if Detection.is_type(event_type): logger.info("🎯 Detection → wake") set_led_state("wake") elif VoiceStarted.is_type(event_type): logger.info("🎤 VoiceStarted → listening") set_led_state("listening") elif VoiceStopped.is_type(event_type): logger.info("💭 VoiceStopped → thinking") set_led_state("thinking") elif event_type == "audio-start": logger.info("🔊 audio-start → speaking") set_led_state("speaking") elif Played.is_type(event_type): logger.info("✅ Played → idle") set_led_state("idle") elif event_type in ["transcribe", "transcript", "synthesize", "intent"]: logger.debug(f"🧠 {event_type} → thinking") elif event_type == "pause-satellite": logger.info("⏸️ pause → paused") set_led_state("paused") elif SatelliteConnected.is_type(event_type) or event_type == "satellite-connected": logger.info("🔌 connected → idle") set_led_state("idle") elif event_type == "error": logger.warning("⚠️ error") set_led_state("error") elif event_type in ["run-pipeline", "run-satellite", "streaming-started", "streaming-stopped", "audio-stop", "satellite-disconnected"]: logger.debug(f"⏭️ {event_type} — игнор") else: logger.debug(f"❓ Неизвестно: {event_type}") return True async def on_connected(self) -> None: logger.info("✅ TCP подключён") async def on_disconnected(self) -> None: logger.info("❌ TCP отключён") set_led_state("idle") # ============================================================ # 🚀 Main # ============================================================ async def main(args): logger.info(f"🚀 Запуск на {args.host}:{args.port}") load_brightness_config() start_mqtt_client() run_xvf_command("VERSION") 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() stop_mqtt_client() sys.exit(0) if __name__ == "__main__": parser = argparse.ArgumentParser(description="LED service with MQTT Discovery (no echo)") 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: logger.info("💡 Ctrl+C для остановки") asyncio.run(main(args)) except KeyboardInterrupt: logger.info("⌨️ KeyboardInterrupt") cleanup_leds() stop_mqtt_client() except Exception as e: logger.error(f"❌ Ошибка: {e}") cleanup_leds() stop_mqtt_client() sys.exit(1) finally: logger.info("👋 Завершено")