Refine wb-rules examples and align player/TTS API
This commit is contained in:
67
yastation.py
67
yastation.py
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user