Полная синхронизация

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
2026-05-19 19:40:42 +03:00
co-authored by Qwen-Coder
parent df5ce8a683
commit cae22004b1
17 changed files with 1139 additions and 138 deletions
+196 -105
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
"""
Wyoming LED Control - Простая надёжная версия для HA
Wyoming LED Control with MQTT Discovery for Home Assistant
Полная версия: Light + Sensor + 7 Number sliders
"""
import argparse
@@ -41,8 +42,8 @@ MQTT_PASS = "mqtt"
# Топики
MQTT_TOPIC_COMMAND = "wyoming/led/command"
MQTT_TOPIC_BRIGHTNESS_SET = "wyoming/led/brightness/set"
MQTT_TOPIC_STATE = "wyoming/led/state" # ON/OFF
MQTT_TOPIC_BRIGHTNESS_STATE = "wyoming/led/brightness/state" # яркость
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"
@@ -56,6 +57,7 @@ MQTT_DISCOVERY_DEVICE = {
"model": "XVF3800",
}
# Настройки яркости
BRIGHTNESS_CONFIG = {
"listening": 120, "wake": 200, "thinking": 150,
"speaking": 140, "idle": 80, "paused": 40, "error": 255,
@@ -74,10 +76,11 @@ def load_brightness_config():
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE) as f:
BRIGHTNESS_CONFIG.update(json.load(f))
logger.info("✅ Загружены настройки яркости")
saved = json.load(f)
BRIGHTNESS_CONFIG.update(saved)
logger.info(f"📂 Загружены настройки яркости из {CONFIG_FILE}")
except Exception as e:
logger.warning(f"Ошибка загрузки config: {e}")
logger.warning(f"⚠️ Ошибка загрузки config: {e}")
else:
save_brightness_config()
@@ -85,8 +88,9 @@ 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}")
logger.error(f"❌ Ошибка сохранения config: {e}")
# ====================== LED ======================
@@ -98,13 +102,14 @@ def run_xvf_command(command: str, values=None):
subprocess.run(args, capture_output=True, text=True, check=True, timeout=5)
return True
except Exception as e:
logger.error(f"LED error {command}: {e}")
logger.error(f"❌ LED command error {command}: {e}")
return False
def get_effective_brightness(state: str) -> int:
base = BRIGHTNESS_CONFIG.get(state, 100)
return int(base * BRIGHTNESS_CONFIG.get("global_multiplier", 1.0))
multiplier = BRIGHTNESS_CONFIG.get("global_multiplier", 1.0)
return int(base * multiplier)
def set_led_state(state: str, skip_publish=False):
@@ -117,44 +122,56 @@ def set_led_state(state: str, skip_publish=False):
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, brightness)
publish_state(state)
publish_satellite_state(state)
# ====================== MQTT ======================
# ====================== MQTT PUBLISH ======================
def publish_discovery_config():
if not mqtt_client:
if not mqtt_client:
return
try:
discovery = {
logger.info("🔄 Публикация Discovery...")
# Light
light_disc = {
"name": "Wyoming LED",
"unique_id": "wyoming_led_light",
"state_topic": MQTT_TOPIC_STATE,
@@ -166,121 +183,178 @@ def publish_discovery_config():
"payload_not_available": "offline",
"payload_on": "ON",
"payload_off": "OFF",
"brightness_scale": 100, # ← ИЗМЕНИЛИ НА 100
"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)
mqtt_client.publish(
f"{MQTT_DISCOVERY_PREFIX}/light/wyoming_led/config",
json.dumps(discovery),
retain=True,
qos=1
)
logger.info("✅ Discovery опубликован (scale = 100)")
# 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 error: {e}")
logger.error(f"❌ Ошибка Discovery: {e}", exc_info=True)
def publish_state(state: str, brightness: int = None):
"""Публикуем в HA именно global_multiplier, а не effective brightness"""
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"
# Главное: публикуем текущий global_multiplier в процентах (0-100)
multiplier = BRIGHTNESS_CONFIG.get("global_multiplier", 1.0)
ha_brightness = int(multiplier * 100) # 0..100
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)
logger.info(f"📤 HA ← {ha_state} | Яркость: {ha_brightness}% (multiplier: {multiplier:.2f})")
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("✅ MQTT подключён")
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()
# Отправляем текущее состояние
brightness = get_effective_brightness(current_state)
publish_state(current_state, brightness)
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}")
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()
logger.info(f"📩 {msg.topic}: {payload}")
topic = msg.topic
logger.info(f"📩 MQTT {topic}: {payload}")
# ==================== ЯРКОСТЬ ====================
if msg.topic == MQTT_TOPIC_BRIGHTNESS_SET:
try:
ha_value = max(0, min(100, int(payload))) # 0-100
new_multiplier = ha_value / 100.0
last_mqtt_brightness = ha_value
BRIGHTNESS_CONFIG["global_multiplier"] = new_multiplier
# Глобальная яркость
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"🔆 HA установил яркость: {ha_value}% → multiplier = {new_multiplier:.2f}")
if not ha_disabled:
# Применяем новую яркость к текущему состоянию
set_led_state(current_state, skip_publish=False)
except ValueError:
logger.warning(f"Неверное значение яркости: {payload}")
# ==================== КОМАНДА (ON / OFF) ====================
elif msg.topic == MQTT_TOPIC_COMMAND:
ha_state = payload.upper()
if ha_state in ["ON", "OFF"]:
if ha_state == "OFF":
logger.info("🔴 HA → OFF")
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)
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:
# Если вдруг пришёл JSON (редкий случай)
try:
cmd = json.loads(payload)
ha_state = str(cmd.get("state", "")).upper()
bright = cmd.get("brightness")
logger.warning(f"⚠️ Неизвестный режим: {mode}")
if ha_state == "OFF":
ha_disabled = True
run_xvf_command("LED_EFFECT", [0])
run_xvf_command("LED_BRIGHTNESS", [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()
set_led_state(current_state)
except:
logger.warning(f"Неизвестная команда: {payload}")
# 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)
logger.error(f"❌ Ошибка MQTT: {e}", exc_info=True)
# ====================== START ======================
# ====================== MQTT CLIENT ======================
def start_mqtt_client():
global mqtt_client
mqtt_client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2, client_id=MQTT_CLIENT_ID)
@@ -288,41 +362,59 @@ def start_mqtt_client():
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")
run_xvf_command("LED_EFFECT", [0])
run_xvf_command("LED_BRIGHTNESS", [0])
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("🚀 Запуск Wyoming LED Service")
logger.info(f"🚀 Запуск на {args.host}:{args.port}")
load_brightness_config()
start_mqtt_client()
run_xvf_command("VERSION")
@@ -330,19 +422,18 @@ async def main(args):
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"🛑 Сигнал {sig}")
logger.info(f"\n🛑 Сигнал {sig}")
cleanup_leds()
if mqtt_client:
try:
mqtt_client.publish(MQTT_TOPIC_AVAILABILITY, "offline", retain=True)
mqtt_client.loop_stop()
mqtt_client.disconnect()
except:
pass
mqtt_client.publish(MQTT_TOPIC_AVAILABILITY, "offline", retain=True)
mqtt_client.loop_stop()
mqtt_client.disconnect()
logger.info("📡 MQTT остановлен")
sys.exit(0)