#!/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 subprocess 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 # ====================== НАСТРОЙКИ ====================== 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 XVF_HOST_PATH = str(SCRIPT_DIR / "xvf_host.py") 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 # ====================== 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 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: subprocess.run(args, capture_output=True, text=True, check=True, timeout=5) return True except Exception as e: logger.error(f"❌ LED command error {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): global current_state current_state = state brightness = get_effective_brightness(state) 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]) 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]) 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: выключено") 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 run_xvf_command("LED_EFFECT", [0]) run_xvf_command("LED_BRIGHTNESS", [0]) 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(): 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}") 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() 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()