переделан MQTT
This commit is contained in:
+144
-301
@@ -1,13 +1,12 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Controls the LEDs on the ReSpeaker Mic Array via xvf_host.py
|
Wyoming LED Control - Простая надёжная версия для HA
|
||||||
С поддержкой MQTT Discovery для Home Assistant (без эхо-петли)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
import signal
|
import signal
|
||||||
import json
|
import json
|
||||||
@@ -16,467 +15,314 @@ from pathlib import Path
|
|||||||
|
|
||||||
import paho.mqtt.client as mqtt
|
import paho.mqtt.client as mqtt
|
||||||
from wyoming.event import Event
|
from wyoming.event import Event
|
||||||
from wyoming.satellite import (
|
from wyoming.satellite import SatelliteConnected
|
||||||
SatelliteConnected,
|
|
||||||
SatelliteDisconnected,
|
|
||||||
StreamingStarted,
|
|
||||||
StreamingStopped,
|
|
||||||
)
|
|
||||||
from wyoming.server import AsyncEventHandler, AsyncServer
|
from wyoming.server import AsyncEventHandler, AsyncServer
|
||||||
from wyoming.snd import Played
|
from wyoming.snd import Played
|
||||||
from wyoming.vad import VoiceStarted, VoiceStopped
|
from wyoming.vad import VoiceStarted, VoiceStopped
|
||||||
from wyoming.wake import Detection
|
from wyoming.wake import Detection
|
||||||
|
|
||||||
# 🔹 Настройка логирования
|
# ====================== НАСТРОЙКИ ======================
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
datefmt="%Y-%m-%d %H:%M:%S"
|
datefmt="%Y-%m-%d %H:%M:%S"
|
||||||
)
|
)
|
||||||
logger = logging.getLogger("XVF3800 LED Service via xvf_host")
|
logger = logging.getLogger("XVF3800 LED Service")
|
||||||
|
|
||||||
# 🔹 Пути
|
|
||||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
XVF_HOST_PATH = str(SCRIPT_DIR / "xvf_host.py")
|
XVF_HOST_PATH = str(SCRIPT_DIR / "xvf_host.py")
|
||||||
CONFIG_FILE = SCRIPT_DIR / "led_brightness_config.json"
|
CONFIG_FILE = SCRIPT_DIR / "led_brightness_config.json"
|
||||||
|
|
||||||
# 🔹 MQTT конфигурация
|
|
||||||
MQTT_BROKER = "192.168.200.100"
|
MQTT_BROKER = "192.168.200.100"
|
||||||
MQTT_PORT = 1883
|
MQTT_PORT = 1883
|
||||||
MQTT_USER = "mqtt"
|
MQTT_USER = "mqtt"
|
||||||
MQTT_PASS = "mqtt"
|
MQTT_PASS = "mqtt"
|
||||||
|
|
||||||
# 🔹 Разделённые топики (команды ≠ состояния)
|
# Топики
|
||||||
MQTT_TOPIC_COMMAND = "wyoming/led/command" # HA → Скрипт (команды)
|
MQTT_TOPIC_COMMAND = "wyoming/led/command"
|
||||||
MQTT_TOPIC_BRIGHTNESS_SET = "wyoming/led/brightness/set" # HA → Скрипт (установка)
|
MQTT_TOPIC_BRIGHTNESS_SET = "wyoming/led/brightness/set"
|
||||||
MQTT_TOPIC_BRIGHTNESS_STATE = "wyoming/led/brightness/state" # Скрипт → HA (отчёт)
|
MQTT_TOPIC_STATE = "wyoming/led/state" # ON/OFF
|
||||||
MQTT_TOPIC_STATE = "wyoming/led/state" # Скрипт → HA (состояние)
|
MQTT_TOPIC_BRIGHTNESS_STATE = "wyoming/led/brightness/state" # яркость
|
||||||
MQTT_TOPIC_AVAILABILITY = "wyoming/led/availability" # Статус онлайн/офлайн
|
MQTT_TOPIC_SATELLITE_STATE = "wyoming/satellite/state"
|
||||||
|
MQTT_TOPIC_AVAILABILITY = "wyoming/led/availability"
|
||||||
|
|
||||||
|
MQTT_DISCOVERY_PREFIX = "homeassistant"
|
||||||
MQTT_CLIENT_ID = "wyoming-led-service"
|
MQTT_CLIENT_ID = "wyoming-led-service"
|
||||||
|
|
||||||
# 🔹 MQTT Discovery
|
|
||||||
MQTT_DISCOVERY_PREFIX = "homeassistant"
|
|
||||||
MQTT_DISCOVERY_DEVICE = {
|
MQTT_DISCOVERY_DEVICE = {
|
||||||
"identifiers": ["wyoming-led-repeaker"],
|
"identifiers": ["wyoming-led-repeaker"],
|
||||||
"name": "Wyoming LED ReSpeaker",
|
"name": "Wyoming LED ReSpeaker",
|
||||||
"manufacturer": "ReSpeaker",
|
"manufacturer": "ReSpeaker",
|
||||||
"model": "XVF3800 USB 4-MIC Array",
|
"model": "XVF3800",
|
||||||
"sw_version": "1.0",
|
|
||||||
"connections": [["mac", "unknown"]]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# 🔹 Глобальные настройки
|
|
||||||
BRIGHTNESS_CONFIG = {
|
BRIGHTNESS_CONFIG = {
|
||||||
"listening": 120, "wake": 200, "thinking": 150,
|
"listening": 120, "wake": 200, "thinking": 150,
|
||||||
"speaking": 140, "idle": 80, "paused": 40, "error": 255,
|
"speaking": 140, "idle": 80, "paused": 40, "error": 255,
|
||||||
"global_multiplier": 1.0
|
"global_multiplier": 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
# 🔹 Глобальные переменные
|
|
||||||
shutdown_event = asyncio.Event()
|
|
||||||
mqtt_client = None
|
mqtt_client = None
|
||||||
current_state = "idle"
|
current_state = "idle"
|
||||||
last_mqtt_brightness = None # 🔹 Защита от эха: последнее значение из MQTT
|
last_mqtt_brightness = None
|
||||||
|
ha_disabled = False
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ====================== CONFIG ======================
|
||||||
# 💾 Настройки яркости
|
|
||||||
# ============================================================
|
|
||||||
def load_brightness_config():
|
def load_brightness_config():
|
||||||
global BRIGHTNESS_CONFIG
|
global BRIGHTNESS_CONFIG
|
||||||
if CONFIG_FILE.exists():
|
if CONFIG_FILE.exists():
|
||||||
try:
|
try:
|
||||||
with open(CONFIG_FILE, "r") as f:
|
with open(CONFIG_FILE) as f:
|
||||||
saved = json.load(f)
|
BRIGHTNESS_CONFIG.update(json.load(f))
|
||||||
BRIGHTNESS_CONFIG.update(saved)
|
logger.info("✅ Загружены настройки яркости")
|
||||||
logger.info(f"📂 Загружены настройки из {CONFIG_FILE}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"⚠️ Ошибка загрузки конфига: {e}")
|
logger.warning(f"Ошибка загрузки config: {e}")
|
||||||
else:
|
else:
|
||||||
save_brightness_config()
|
save_brightness_config()
|
||||||
|
|
||||||
|
|
||||||
def save_brightness_config():
|
def save_brightness_config():
|
||||||
try:
|
try:
|
||||||
with open(CONFIG_FILE, "w") as f:
|
with open(CONFIG_FILE, "w") as f:
|
||||||
json.dump(BRIGHTNESS_CONFIG, f, indent=2)
|
json.dump(BRIGHTNESS_CONFIG, f, indent=2)
|
||||||
logger.debug(f"💾 Сохранены настройки яркости")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Ошибка сохранения: {e}")
|
logger.error(f"Ошибка сохранения config: {e}")
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ====================== LED ======================
|
||||||
# 🎛️ Управление LED
|
|
||||||
# ============================================================
|
|
||||||
def run_xvf_command(command: str, values=None):
|
def run_xvf_command(command: str, values=None):
|
||||||
args = [sys.executable, XVF_HOST_PATH, command]
|
args = [sys.executable, XVF_HOST_PATH, command]
|
||||||
if values:
|
if values:
|
||||||
args.extend(["--values"] + [str(v) for v in values])
|
args.extend(["--values"] + [str(v) for v in values])
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(args, capture_output=True, text=True, check=True, timeout=5)
|
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
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Ошибка {command}: {e}")
|
logger.error(f"LED error {command}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def get_effective_brightness(state: str) -> int:
|
def get_effective_brightness(state: str) -> int:
|
||||||
base = BRIGHTNESS_CONFIG.get(state, 100)
|
base = BRIGHTNESS_CONFIG.get(state, 100)
|
||||||
multiplier = BRIGHTNESS_CONFIG.get("global_multiplier", 1.0)
|
return int(base * BRIGHTNESS_CONFIG.get("global_multiplier", 1.0))
|
||||||
return int(base * multiplier)
|
|
||||||
|
|
||||||
|
|
||||||
def set_led_state(state: str, skip_publish=False):
|
def set_led_state(state: str, skip_publish=False):
|
||||||
"""Устанавливает состояние LED. Если skip_publish=True — не публикуем в MQTT (избегаем эха)"""
|
|
||||||
global current_state
|
global current_state
|
||||||
current_state = state
|
current_state = state
|
||||||
|
|
||||||
brightness = get_effective_brightness(state)
|
brightness = get_effective_brightness(state)
|
||||||
|
|
||||||
if state == "listening":
|
if state == "listening":
|
||||||
run_xvf_command("LED_EFFECT", [0])
|
|
||||||
run_xvf_command("LED_EFFECT", [1])
|
run_xvf_command("LED_EFFECT", [1])
|
||||||
run_xvf_command("LED_COLOR", ["0x00BCD4"])
|
run_xvf_command("LED_COLOR", ["0x00BCD4"])
|
||||||
run_xvf_command("LED_BRIGHTNESS", [brightness])
|
run_xvf_command("LED_BRIGHTNESS", [brightness])
|
||||||
run_xvf_command("LED_SPEED", [2])
|
run_xvf_command("LED_SPEED", [2])
|
||||||
logger.info(f"🔵 Listening: бирюзовое дыхание ({brightness})")
|
|
||||||
|
|
||||||
elif state == "wake":
|
elif state == "wake":
|
||||||
run_xvf_command("LED_EFFECT", [1])
|
run_xvf_command("LED_EFFECT", [1])
|
||||||
run_xvf_command("LED_COLOR", ["0xFF0000"])
|
run_xvf_command("LED_COLOR", ["0xFF0000"])
|
||||||
run_xvf_command("LED_BRIGHTNESS", [brightness])
|
run_xvf_command("LED_BRIGHTNESS", [brightness])
|
||||||
logger.info(f"🔴 Wake: красный ({brightness})")
|
|
||||||
|
|
||||||
elif state == "thinking":
|
elif state == "thinking":
|
||||||
run_xvf_command("LED_EFFECT", [2])
|
run_xvf_command("LED_EFFECT", [2])
|
||||||
run_xvf_command("LED_BRIGHTNESS", [brightness])
|
run_xvf_command("LED_BRIGHTNESS", [brightness])
|
||||||
run_xvf_command("LED_SPEED", [4])
|
run_xvf_command("LED_SPEED", [4])
|
||||||
logger.info(f"🌈 Thinking: радуга ({brightness})")
|
|
||||||
|
|
||||||
elif state == "speaking":
|
elif state == "speaking":
|
||||||
run_xvf_command("LED_EFFECT", [1])
|
run_xvf_command("LED_EFFECT", [1])
|
||||||
run_xvf_command("LED_COLOR", ["0x4CAF50"])
|
run_xvf_command("LED_COLOR", ["0x4CAF50"])
|
||||||
run_xvf_command("LED_BRIGHTNESS", [brightness])
|
run_xvf_command("LED_BRIGHTNESS", [brightness])
|
||||||
run_xvf_command("LED_SPEED", [2])
|
|
||||||
logger.info(f"🟢 Speaking: зелёный ({brightness})")
|
|
||||||
|
|
||||||
elif state == "idle":
|
elif state == "idle":
|
||||||
run_xvf_command("LED_EFFECT", [4])
|
run_xvf_command("LED_EFFECT", [4])
|
||||||
run_xvf_command("LED_DOA_COLOR", ["0x303030", "0x00BCD4"])
|
run_xvf_command("LED_DOA_COLOR", ["0x303030", "0x00BCD4"])
|
||||||
run_xvf_command("LED_BRIGHTNESS", [brightness])
|
run_xvf_command("LED_BRIGHTNESS", [brightness])
|
||||||
logger.info(f"⚪ Idle: DoA ({brightness})")
|
|
||||||
|
|
||||||
elif state == "paused":
|
elif state == "paused":
|
||||||
run_xvf_command("LED_EFFECT", [1])
|
run_xvf_command("LED_EFFECT", [1])
|
||||||
run_xvf_command("LED_COLOR", ["0x404040"])
|
run_xvf_command("LED_COLOR", ["0x404040"])
|
||||||
run_xvf_command("LED_BRIGHTNESS", [brightness])
|
run_xvf_command("LED_BRIGHTNESS", [brightness])
|
||||||
logger.info(f"⚫ Paused: серый ({brightness})")
|
|
||||||
|
|
||||||
elif state == "error":
|
elif state == "error":
|
||||||
run_xvf_command("LED_EFFECT", [1])
|
run_xvf_command("LED_EFFECT", [1])
|
||||||
run_xvf_command("LED_COLOR", ["0xFF00FF"])
|
run_xvf_command("LED_COLOR", ["0xFF00FF"])
|
||||||
run_xvf_command("LED_BRIGHTNESS", [brightness])
|
run_xvf_command("LED_BRIGHTNESS", [brightness])
|
||||||
logger.info(f"🟣 Error: маджента ({brightness})")
|
|
||||||
|
|
||||||
elif state == "off":
|
elif state == "off":
|
||||||
run_xvf_command("LED_EFFECT", [0])
|
run_xvf_command("LED_EFFECT", [0])
|
||||||
run_xvf_command("LED_BRIGHTNESS", [0])
|
run_xvf_command("LED_BRIGHTNESS", [0])
|
||||||
logger.info("⬛ Off: выключено")
|
|
||||||
else:
|
|
||||||
run_xvf_command("LED_EFFECT", [0])
|
|
||||||
logger.info("⬛ Unknown")
|
|
||||||
|
|
||||||
# 🔹 Публикуем состояние только если не скипаем
|
|
||||||
if not skip_publish:
|
if not skip_publish:
|
||||||
publish_state(state, brightness)
|
publish_state(state, brightness)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ====================== MQTT ======================
|
||||||
# 🔍 MQTT Discovery
|
|
||||||
# ============================================================
|
|
||||||
def publish_discovery_config():
|
def publish_discovery_config():
|
||||||
if not mqtt_client:
|
if not mqtt_client:
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 🔹 Number (слайдер) — читаем из STATE, пишем в SET
|
discovery = {
|
||||||
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",
|
"name": "Wyoming LED",
|
||||||
"unique_id": "wyoming_led_light",
|
"unique_id": "wyoming_led_light",
|
||||||
"state_topic": MQTT_TOPIC_STATE,
|
"state_topic": MQTT_TOPIC_STATE,
|
||||||
"command_topic": MQTT_TOPIC_COMMAND,
|
"command_topic": MQTT_TOPIC_COMMAND,
|
||||||
"brightness_state_topic": MQTT_TOPIC_BRIGHTNESS_STATE,
|
|
||||||
"brightness_command_topic": MQTT_TOPIC_BRIGHTNESS_SET,
|
"brightness_command_topic": MQTT_TOPIC_BRIGHTNESS_SET,
|
||||||
"brightness_scale": 255,
|
"brightness_state_topic": MQTT_TOPIC_BRIGHTNESS_STATE,
|
||||||
"availability_topic": MQTT_TOPIC_AVAILABILITY,
|
"availability_topic": MQTT_TOPIC_AVAILABILITY,
|
||||||
"payload_available": "online",
|
"payload_available": "online",
|
||||||
"payload_not_available": "offline",
|
"payload_not_available": "offline",
|
||||||
"payload_on": '{"type": "set_state", "state": "speaking"}',
|
"payload_on": "ON",
|
||||||
"payload_off": '{"type": "set_state", "state": "idle"}',
|
"payload_off": "OFF",
|
||||||
|
"brightness_scale": 100, # ← ИЗМЕНИЛИ НА 100
|
||||||
|
"optimistic": False,
|
||||||
|
"qos": 1,
|
||||||
"device": MQTT_DISCOVERY_DEVICE,
|
"device": MQTT_DISCOVERY_DEVICE,
|
||||||
"object_id": "wyoming_led",
|
|
||||||
"schema": "json"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Публикуем с retain=True для автообнаружения
|
mqtt_client.publish(
|
||||||
mqtt_client.publish(f"{MQTT_DISCOVERY_PREFIX}/number/wyoming_led/config",
|
f"{MQTT_DISCOVERY_PREFIX}/light/wyoming_led/config",
|
||||||
json.dumps(discovery_number), retain=True)
|
json.dumps(discovery),
|
||||||
mqtt_client.publish(f"{MQTT_DISCOVERY_PREFIX}/sensor/wyoming_led/config",
|
retain=True,
|
||||||
json.dumps(discovery_sensor), retain=True)
|
qos=1
|
||||||
mqtt_client.publish(f"{MQTT_DISCOVERY_PREFIX}/light/wyoming_led/config",
|
)
|
||||||
json.dumps(discovery_light), retain=True)
|
logger.info("✅ Discovery опубликован (scale = 100)")
|
||||||
|
|
||||||
logger.info("🔍 MQTT Discovery: конфигурации опубликованы")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Ошибка Discovery: {e}")
|
logger.error(f"Discovery error: {e}")
|
||||||
|
|
||||||
|
def publish_state(state: str, brightness: int = None):
|
||||||
|
"""Публикуем в HA именно global_multiplier, а не effective brightness"""
|
||||||
|
if not mqtt_client:
|
||||||
|
return
|
||||||
|
|
||||||
|
ha_state = "OFF" if (ha_disabled or state == "off") else "ON"
|
||||||
|
|
||||||
|
# Главное: публикуем текущий global_multiplier в процентах (0-100)
|
||||||
|
multiplier = BRIGHTNESS_CONFIG.get("global_multiplier", 1.0)
|
||||||
|
ha_brightness = int(multiplier * 100) # 0..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)
|
||||||
|
|
||||||
|
logger.info(f"📤 HA ← {ha_state} | Яркость: {ha_brightness}% (multiplier: {multiplier:.2f})")
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 📡 MQTT функции
|
|
||||||
# ============================================================
|
|
||||||
def on_mqtt_connect(client, userdata, flags, rc, properties=None):
|
def on_mqtt_connect(client, userdata, flags, rc, properties=None):
|
||||||
if rc == 0:
|
if rc == 0:
|
||||||
logger.info(f"✅ MQTT подключён к {MQTT_BROKER}:{MQTT_PORT}")
|
logger.info("✅ MQTT подключён")
|
||||||
client.publish(MQTT_TOPIC_AVAILABILITY, "online", retain=True)
|
client.publish(MQTT_TOPIC_AVAILABILITY, "online", retain=True, qos=1)
|
||||||
|
|
||||||
# 🔹 Подписываемся ТОЛЬКО на командные топики (не на state!)
|
|
||||||
client.subscribe(MQTT_TOPIC_COMMAND)
|
client.subscribe(MQTT_TOPIC_COMMAND)
|
||||||
client.subscribe(MQTT_TOPIC_BRIGHTNESS_SET)
|
client.subscribe(MQTT_TOPIC_BRIGHTNESS_SET)
|
||||||
|
|
||||||
publish_discovery_config()
|
publish_discovery_config()
|
||||||
logger.info(f"📡 Подписки: {MQTT_TOPIC_COMMAND}, {MQTT_TOPIC_BRIGHTNESS_SET}")
|
|
||||||
|
# Отправляем текущее состояние
|
||||||
|
brightness = get_effective_brightness(current_state)
|
||||||
|
publish_state(current_state, brightness)
|
||||||
else:
|
else:
|
||||||
logger.error(f"❌ MQTT ошибка подключения: {rc}")
|
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):
|
def on_mqtt_message(client, userdata, msg):
|
||||||
"""Обработчик входящих MQTT сообщений"""
|
global ha_disabled, last_mqtt_brightness, current_state
|
||||||
global last_mqtt_brightness
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
topic = msg.topic
|
payload = msg.payload.decode('utf-8').strip()
|
||||||
payload = msg.payload.decode('utf-8')
|
logger.info(f"📩 {msg.topic}: {payload}")
|
||||||
logger.debug(f"📩 MQTT {topic}: {payload}")
|
|
||||||
|
|
||||||
# 🔹 Обработка яркости (только из _SET топика!)
|
# ==================== ЯРКОСТЬ ====================
|
||||||
if topic == MQTT_TOPIC_BRIGHTNESS_SET:
|
if msg.topic == MQTT_TOPIC_BRIGHTNESS_SET:
|
||||||
brightness = int(payload)
|
try:
|
||||||
brightness = max(0, min(255, brightness))
|
ha_value = max(0, min(100, int(payload))) # 0-100
|
||||||
|
new_multiplier = ha_value / 100.0
|
||||||
|
|
||||||
# 🔹 Защита от эха: если значение не изменилось — игнорируем
|
last_mqtt_brightness = ha_value
|
||||||
if brightness == last_mqtt_brightness:
|
BRIGHTNESS_CONFIG["global_multiplier"] = new_multiplier
|
||||||
logger.debug(f"⏭️ Эхо: яркость {brightness} уже обработана")
|
|
||||||
return
|
|
||||||
|
|
||||||
last_mqtt_brightness = brightness
|
|
||||||
BRIGHTNESS_CONFIG["global_multiplier"] = brightness / 255.0
|
|
||||||
save_brightness_config()
|
save_brightness_config()
|
||||||
|
|
||||||
logger.info(f"🔆 Яркость: {brightness} (×{BRIGHTNESS_CONFIG['global_multiplier']:.2f})")
|
logger.info(f"🔆 HA установил яркость: {ha_value}% → multiplier = {new_multiplier:.2f}")
|
||||||
|
|
||||||
# 🔹 Применяем к текущему состоянию, но НЕ публикуем обратно (skip_publish=True)
|
if not ha_disabled:
|
||||||
set_led_state(current_state, skip_publish=True)
|
# Применяем новую яркость к текущему состоянию
|
||||||
|
set_led_state(current_state, skip_publish=False)
|
||||||
|
|
||||||
# 🔹 Публикуем отчёт в отдельный _STATE топик (без эха)
|
except ValueError:
|
||||||
publish_brightness(brightness)
|
logger.warning(f"Неверное значение яркости: {payload}")
|
||||||
|
# ==================== КОМАНДА (ON / OFF) ====================
|
||||||
|
elif msg.topic == MQTT_TOPIC_COMMAND:
|
||||||
|
ha_state = payload.upper()
|
||||||
|
|
||||||
# 🔹 Обработка команд
|
if ha_state in ["ON", "OFF"]:
|
||||||
elif topic == MQTT_TOPIC_COMMAND:
|
if ha_state == "OFF":
|
||||||
command = json.loads(payload)
|
logger.info("🔴 HA → OFF")
|
||||||
cmd_type = command.get("type", "")
|
ha_disabled = True
|
||||||
|
run_xvf_command("LED_EFFECT", [0])
|
||||||
|
run_xvf_command("LED_BRIGHTNESS", [0])
|
||||||
|
publish_state("off", 0)
|
||||||
|
else: # ON
|
||||||
|
logger.info("🟢 HA → ON")
|
||||||
|
ha_disabled = False
|
||||||
|
set_led_state(current_state)
|
||||||
|
else:
|
||||||
|
# Если вдруг пришёл JSON (редкий случай)
|
||||||
|
try:
|
||||||
|
cmd = json.loads(payload)
|
||||||
|
ha_state = str(cmd.get("state", "")).upper()
|
||||||
|
bright = cmd.get("brightness")
|
||||||
|
|
||||||
if cmd_type == "set_brightness":
|
if ha_state == "OFF":
|
||||||
brightness = max(0, min(255, command.get("value", 100)))
|
ha_disabled = True
|
||||||
if brightness != last_mqtt_brightness:
|
run_xvf_command("LED_EFFECT", [0])
|
||||||
last_mqtt_brightness = brightness
|
run_xvf_command("LED_BRIGHTNESS", [0])
|
||||||
BRIGHTNESS_CONFIG["global_multiplier"] = brightness / 255.0
|
publish_state("off", 0)
|
||||||
|
elif ha_state == "ON":
|
||||||
|
ha_disabled = False
|
||||||
|
if bright is not None:
|
||||||
|
bright = max(0, min(255, int(bright)))
|
||||||
|
last_mqtt_brightness = bright
|
||||||
|
BRIGHTNESS_CONFIG["global_multiplier"] = bright / 255.0
|
||||||
save_brightness_config()
|
save_brightness_config()
|
||||||
publish_brightness(brightness)
|
set_led_state(current_state)
|
||||||
set_led_state(current_state, skip_publish=True)
|
except:
|
||||||
logger.info(f"🔆 Яркость из команды: {brightness}")
|
logger.warning(f"Неизвестная команда: {payload}")
|
||||||
|
|
||||||
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:
|
except Exception as e:
|
||||||
logger.error(f"❌ Ошибка MQTT: {e}")
|
logger.error(f"❌ Ошибка обработки MQTT: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
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}")
|
|
||||||
|
|
||||||
|
|
||||||
|
# ====================== START ======================
|
||||||
def start_mqtt_client():
|
def start_mqtt_client():
|
||||||
global mqtt_client
|
global mqtt_client
|
||||||
|
mqtt_client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2, client_id=MQTT_CLIENT_ID)
|
||||||
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.username_pw_set(MQTT_USER, MQTT_PASS)
|
||||||
mqtt_client.on_connect = on_mqtt_connect
|
mqtt_client.on_connect = on_mqtt_connect
|
||||||
mqtt_client.on_disconnect = on_mqtt_disconnect
|
|
||||||
mqtt_client.on_message = on_mqtt_message
|
mqtt_client.on_message = on_mqtt_message
|
||||||
mqtt_client.will_set(MQTT_TOPIC_AVAILABILITY, "offline", retain=True, qos=1)
|
mqtt_client.will_set(MQTT_TOPIC_AVAILABILITY, "offline", retain=True, qos=1)
|
||||||
|
|
||||||
try:
|
|
||||||
mqtt_client.connect(MQTT_BROKER, MQTT_PORT, keepalive=60)
|
mqtt_client.connect(MQTT_BROKER, MQTT_PORT, keepalive=60)
|
||||||
mqtt_client.loop_start()
|
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):
|
class LedEventHandler(AsyncEventHandler):
|
||||||
async def handle_event(self, event: Event) -> bool:
|
async def handle_event(self, event: Event) -> bool:
|
||||||
event_type = event.type
|
if ha_disabled:
|
||||||
logger.debug(f"🔍 Событие: {event_type}")
|
return True
|
||||||
|
et = event.type
|
||||||
if Detection.is_type(event_type):
|
if Detection.is_type(et):
|
||||||
logger.info("🎯 Detection → wake")
|
|
||||||
set_led_state("wake")
|
set_led_state("wake")
|
||||||
elif VoiceStarted.is_type(event_type):
|
elif VoiceStarted.is_type(et):
|
||||||
logger.info("🎤 VoiceStarted → listening")
|
|
||||||
set_led_state("listening")
|
set_led_state("listening")
|
||||||
elif VoiceStopped.is_type(event_type):
|
elif VoiceStopped.is_type(et):
|
||||||
logger.info("💭 VoiceStopped → thinking")
|
|
||||||
set_led_state("thinking")
|
set_led_state("thinking")
|
||||||
elif event_type == "audio-start":
|
elif et == "audio-start":
|
||||||
logger.info("🔊 audio-start → speaking")
|
|
||||||
set_led_state("speaking")
|
set_led_state("speaking")
|
||||||
elif Played.is_type(event_type):
|
elif Played.is_type(et):
|
||||||
logger.info("✅ Played → idle")
|
|
||||||
set_led_state("idle")
|
set_led_state("idle")
|
||||||
elif event_type in ["transcribe", "transcript", "synthesize", "intent"]:
|
elif et == "pause-satellite":
|
||||||
logger.debug(f"🧠 {event_type} → thinking")
|
|
||||||
elif event_type == "pause-satellite":
|
|
||||||
logger.info("⏸️ pause → paused")
|
|
||||||
set_led_state("paused")
|
set_led_state("paused")
|
||||||
elif SatelliteConnected.is_type(event_type) or event_type == "satellite-connected":
|
elif SatelliteConnected.is_type(et):
|
||||||
logger.info("🔌 connected → idle")
|
|
||||||
set_led_state("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
|
return True
|
||||||
|
|
||||||
async def on_connected(self) -> None:
|
|
||||||
logger.info("✅ TCP подключён")
|
def cleanup_leds():
|
||||||
async def on_disconnected(self) -> None:
|
logger.info("🧹 Выключаем LED")
|
||||||
logger.info("❌ TCP отключён")
|
run_xvf_command("LED_EFFECT", [0])
|
||||||
set_led_state("idle")
|
run_xvf_command("LED_BRIGHTNESS", [0])
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 🚀 Main
|
|
||||||
# ============================================================
|
|
||||||
async def main(args):
|
async def main(args):
|
||||||
logger.info(f"🚀 Запуск на {args.host}:{args.port}")
|
logger.info("🚀 Запуск Wyoming LED Service")
|
||||||
load_brightness_config()
|
load_brightness_config()
|
||||||
start_mqtt_client()
|
start_mqtt_client()
|
||||||
run_xvf_command("VERSION")
|
run_xvf_command("VERSION")
|
||||||
@@ -484,19 +330,24 @@ async def main(args):
|
|||||||
|
|
||||||
uri = f"tcp://{args.host}:{args.port}"
|
uri = f"tcp://{args.host}:{args.port}"
|
||||||
server = AsyncServer.from_uri(uri)
|
server = AsyncServer.from_uri(uri)
|
||||||
logger.info(f"📡 Слушаем {uri}")
|
|
||||||
await server.run(partial(LedEventHandler))
|
await server.run(partial(LedEventHandler))
|
||||||
|
|
||||||
|
|
||||||
def signal_handler(sig, frame):
|
def signal_handler(sig, frame):
|
||||||
logger.info(f"\n🛑 Сигнал {sig}")
|
logger.info(f"🛑 Сигнал {sig}")
|
||||||
cleanup_leds()
|
cleanup_leds()
|
||||||
stop_mqtt_client()
|
if mqtt_client:
|
||||||
|
try:
|
||||||
|
mqtt_client.publish(MQTT_TOPIC_AVAILABILITY, "offline", retain=True)
|
||||||
|
mqtt_client.loop_stop()
|
||||||
|
mqtt_client.disconnect()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
parser = argparse.ArgumentParser(description="LED service with MQTT Discovery (no echo)")
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("--host", default="127.0.0.1")
|
parser.add_argument("--host", default="127.0.0.1")
|
||||||
parser.add_argument("--port", type=int, default=10701)
|
parser.add_argument("--port", type=int, default=10701)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
@@ -505,16 +356,8 @@ if __name__ == "__main__":
|
|||||||
signal.signal(signal.SIGTERM, signal_handler)
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info("💡 Ctrl+C для остановки")
|
|
||||||
asyncio.run(main(args))
|
asyncio.run(main(args))
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
logger.info("⌨️ KeyboardInterrupt")
|
pass
|
||||||
cleanup_leds()
|
|
||||||
stop_mqtt_client()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Ошибка: {e}")
|
|
||||||
cleanup_leds()
|
|
||||||
stop_mqtt_client()
|
|
||||||
sys.exit(1)
|
|
||||||
finally:
|
finally:
|
||||||
logger.info("👋 Завершено")
|
cleanup_leds()
|
||||||
Reference in New Issue
Block a user