Refine wb-rules examples and align player/TTS API

This commit is contained in:
2026-04-02 11:16:25 +03:00
parent 50961eb3fc
commit 2c8f9f32b9
5 changed files with 290 additions and 19 deletions

View File

@@ -1,8 +1,16 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Демон управления Яндекс Станциями + TCP/HTTP API.
Модуль отвечает за runtime-команды:
- поиск станций в локальной сети через mDNS;
- открытие websocket к станции и отправку payload-команд;
- публикацию API `/api/exec` и `/api/status` для внешней интеграции.
"""
import argparse
import asyncio
import html
import json
import os
import ssl
@@ -30,6 +38,7 @@ class Station:
class _Listener:
"""Слушатель mDNS-событий и сборщик найденных станций."""
def __init__(self):
self.stations: Dict[str, Station] = {}
@@ -43,6 +52,8 @@ class _Listener:
self._handle(zeroconf, service_type, name)
def _handle(self, zeroconf: Zeroconf, service_type: str, name: str) -> None:
# Zeroconf возвращает "сырые" свойства как bytes: нормализуем в str,
# чтобы дальше всё работало единообразно и без проблем сериализации.
info = zeroconf.get_service_info(service_type, name)
if not info:
return
@@ -83,6 +94,7 @@ class _Listener:
async def discover(timeout_sec: float = 2.5) -> List[Station]:
"""Ищет станции по mDNS в течение `timeout_sec` и возвращает снимок."""
zc = Zeroconf()
listener = _Listener()
browser = ServiceBrowser(zc, MDNS_SERVICE, listener)
@@ -113,7 +125,23 @@ def _volume01_from_0_100(v: float) -> float:
return round(v / 100.0, 3)
def _speaker_value(text: str, voice: str = "", effect: str = "", audio: str = "") -> str:
attrs: List[str] = []
if voice:
attrs.append(f"voice='{html.escape(str(voice), quote=True)}'")
if effect:
attrs.append(f"effect='{html.escape(str(effect), quote=True)}'")
if audio:
attrs.append(f"audio='{html.escape(str(audio), quote=True)}'")
attr_str = (" " + " ".join(attrs)) if attrs else ""
return f"<speaker{attr_str}>{text or ''}"
def pick_station(stations: List[Station], selector: str) -> Station:
"""Выбирает станцию по наиболее частым форматам селектора.
Поддерживаем device_id, host, host:port и частичное совпадение имени.
"""
selector = (selector or "").strip()
if not selector:
raise RuntimeError("empty selector")
@@ -215,6 +243,13 @@ async def do_stations_cloud(args) -> int:
class StationConn:
"""Долгоживущее соединение с одной станцией.
Что хранит:
- websocket и conversation token;
- кэш состояния (playing/volume) для быстрых status-ответов;
- lock, чтобы параллельные команды не перемешивались в одном ws.
"""
def __init__(self, station: Station, oauth_token: str, wait_sec: float):
self.station = station
self.oauth_token = oauth_token
@@ -259,7 +294,7 @@ class StationConn:
low = value.strip().lower()
if low in {"playing", "play", "started", "start", "on", "true", "yes"}:
return 1
if low in {"paused", "pause", "stopped", "stop", "idle", "off", "false", "no"}:
if low in {"paused", "stopped", "stop", "idle", "off", "false", "no"}:
return 0
try:
vv = int(float(low))
@@ -353,6 +388,8 @@ class StationConn:
self._session = None
async def _ensure_conv_token(self):
# Токен короткоживущий: обновляем заранее, чтобы не ловить отказ
# в середине отправки команды.
now = time.time()
if self._conv_token and self._conv_exp_ts > now:
return
@@ -376,6 +413,7 @@ class StationConn:
)
async def send(self, payload: dict) -> dict:
"""Отправляет команду в станцию и ждёт ответ с тем же request id."""
async with self._lock:
await self._ensure_ws()
@@ -398,6 +436,8 @@ class StationConn:
assert self._ws is not None
await self._ws.send_json(msg)
except Exception:
# WS мог оборваться между командами: делаем один
# контролируемый реконнект и повтор отправки.
try:
if self._ws is not None:
await self._ws.close()
@@ -425,7 +465,7 @@ class StationConn:
self._playing = inferred
elif cmd == "play":
self._playing = 1
elif cmd in {"pause", "stop"}:
elif cmd == "stop":
self._playing = 0
inferred_volume = self._infer_volume(data)
if inferred_volume is not None:
@@ -441,6 +481,7 @@ class StationConn:
return {"status": "timeout", "id": req_id}
async def probe_state(self) -> None:
"""Пытается обновить playing/volume через набор безопасных команд статуса."""
now = time.time()
if now - self._last_probe_ts < 5.0:
return
@@ -462,6 +503,7 @@ class StationConn:
async def serve_daemon(args) -> int:
"""Запускает основной daemon: TCP-сервер + HTTP API."""
oauth = args.token or os.environ.get("YANDEX_TOKEN", "").strip()
if not oauth:
raise RuntimeError("No token. Use --token or env YANDEX_TOKEN")
@@ -495,6 +537,7 @@ async def serve_daemon(args) -> int:
raise
async def resolve_conn(station_sel: str) -> Optional[StationConn]:
"""Находит активное соединение станции по селектору пользователя."""
station_sel = (station_sel or "").strip()
if not station_sel:
return None
@@ -512,6 +555,7 @@ async def serve_daemon(args) -> int:
return None
async def execute_action(req: Dict[str, Any]) -> Dict[str, Any]:
"""Валидация входного запроса и преобразование в payload станции."""
station_sel = str(req.get("station") or "").strip()
if not station_sel:
return {"ok": False, "error": "station is required"}
@@ -524,12 +568,18 @@ async def serve_daemon(args) -> int:
try:
if action == "tts":
text = str(req.get("text") or "").strip()
if not text:
raise RuntimeError("text is required")
tts_voice = str(req.get("tts_voice") or "").strip()
tts_effect = str(req.get("tts_effect") or "").strip()
tts_audio = str(req.get("tts_audio") or "").strip()
if tts_audio:
tts_audio = os.path.basename(tts_audio.replace("\\", "/"))
if not text and not tts_audio:
raise RuntimeError("text or tts_audio is required")
volume = req.get("volume")
if volume is not None and str(volume).strip() != "":
vol_payload = {"command": "setVolume", "volume": _volume01_from_0_100(float(volume))}
await conn.send(vol_payload)
speaker_value = _speaker_value(text, voice=tts_voice, effect=tts_effect, audio=tts_audio)
payload = {
"command": "serverAction",
"serverActionEventPayload": {
@@ -538,7 +588,7 @@ async def serve_daemon(args) -> int:
"payload": {
"form_update": {
"name": "personal_assistant.scenarios.repeat_after_me",
"slots": [{"type": "string", "name": "request", "value": text}],
"slots": [{"type": "string", "name": "request", "value": speaker_value}],
},
"resubmit": True,
},
@@ -551,8 +601,8 @@ async def serve_daemon(args) -> int:
payload = {"command": "sendText", "text": text}
elif action == "player":
cmd = str(req.get("cmd") or "").strip().lower()
if cmd not in {"play", "pause", "stop", "next", "prev"}:
raise RuntimeError("cmd must be play|pause|stop|next|prev")
if cmd not in {"play", "stop", "next", "prev"}:
raise RuntimeError("cmd must be play|stop|next|prev")
payload = {"command": cmd}
elif action == "volume":
lvl = float(req.get("level"))
@@ -579,6 +629,7 @@ async def serve_daemon(args) -> int:
return {"ok": False, "error": str(e)}
async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
# TCP-протокол простой: одна JSON-строка на запрос.
try:
line = await reader.readline()
if not line:
@@ -603,6 +654,7 @@ async def serve_daemon(args) -> int:
app = web.Application()
async def http_exec(request: web.Request) -> web.Response:
"""HTTP-обёртка над execute_action: query/form/json -> единый dict."""
data: Dict[str, Any] = {}
if request.method == "POST":
if request.content_type and "application/json" in request.content_type:
@@ -626,6 +678,7 @@ async def serve_daemon(args) -> int:
return web.json_response(result, status=200 if result.get("ok") else 422)
async def http_status(request: web.Request) -> web.Response:
"""Возвращает текущее состояние соединения и кэш плеера/громкости."""
station_sel = str(request.query.get("station") or "").strip()
conn = await resolve_conn(station_sel)
if conn is None: