Files
satellite/examples/wyoming-led.py
T
2026-05-14 10:19:01 +03:00

520 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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("👋 Завершено")