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

16
.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
__pycache__/
*.pyc
*.pyo
*.pyd
.venv/
venv/
env/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.DS_Store
.idea/
.vscode/

View File

@@ -12,6 +12,7 @@
- [Полная установка](#полная-установка) - [Полная установка](#полная-установка)
- [Токен](#токен) - [Токен](#токен)
- [CLI](#cli) - [CLI](#cli)
- [Примеры wb-rules](#примеры-wb-rules)
- [Прямые Python-команды](#прямые-python-команды) - [Прямые Python-команды](#прямые-python-команды)
- [Web UI](#web-ui) - [Web UI](#web-ui)
- [Systemd](#systemd) - [Systemd](#systemd)
@@ -304,6 +305,32 @@ cd /opt/shd/plugins/alice
./cli.sh web --host 0.0.0.0 --port 9140 ./cli.sh web --host 0.0.0.0 --port 9140
``` ```
## Примеры wb-rules
Есть готовый файл:
```text
configs/wb-rules/wb-rules-examples.js
```
- `wb-rules-examples.js`:
- примеры действий для одной колонки:
- `player` (`play`, `stop`, `next`, `prev`);
- `tts`;
- `command`;
- `audio + tts` (через параметры `tts_audio` + `text`).
Как привязать к конкретной колонке:
1. возьми `id` нужной станции из `data/stations.json`;
2. подставь его в строку:
```js
var STATION = "PUT_EXACT_STATION_ID_HERE";
```
3. скопируй файл в `/etc/wb-rules/` и перезапусти `wb-rules`.
## Прямые Python-команды ## Прямые Python-команды
Все публичные Python-команды задокументированы ниже. Их можно использовать напрямую, но штатный способ — `cli.sh`. Все публичные Python-команды задокументированы ниже. Их можно использовать напрямую, но штатный способ — `cli.sh`.
@@ -434,6 +461,7 @@ cd /opt/shd/plugins/alice
├── loxone/ ├── loxone/
│ └── loxone_templates.zip │ └── loxone_templates.zip
└── wb-rules/ └── wb-rules/
├── wb-rules-examples.js
└── wb_rules_templates.zip └── wb_rules_templates.zip
``` ```
@@ -442,6 +470,7 @@ cd /opt/shd/plugins/alice
- `token.txt` — OAuth-токен; - `token.txt` — OAuth-токен;
- `data/config.json` — сохранённый IP контроллера и служебные настройки; - `data/config.json` — сохранённый IP контроллера и служебные настройки;
- `data/stations.json` — найденные станции; - `data/stations.json` — найденные станции;
- `configs/wb-rules/wb-rules-examples.js` — примеры `player/tts/command/audio+tts` для одной станции;
- `configs/loxone/loxone_templates.zip` — ZIP для Loxone; - `configs/loxone/loxone_templates.zip` — ZIP для Loxone;
- `configs/wb-rules/wb_rules_templates.zip` — ZIP для wb-rules. - `configs/wb-rules/wb_rules_templates.zip` — ZIP для wb-rules.

View File

@@ -1,5 +1,13 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Alice plugin: CLI + web UI для подготовки и эксплуатации интеграции.
Что делает модуль:
- получает станции из облака Яндекса и из локального mDNS;
- объединяет данные в единый `stations.json`;
- генерирует шаблоны для Loxone и wb-rules;
- отдаёт web API для первичной настройки.
"""
import argparse import argparse
import asyncio import asyncio
@@ -36,6 +44,7 @@ YANDEX_INFO_URL = "https://api.iot.yandex.net/v1.0/user/info"
@dataclass @dataclass
class StationRecord: class StationRecord:
"""Нормализованная запись станции для хранения и генерации шаблонов."""
name: str name: str
station_id: str station_id: str
home: str home: str
@@ -45,6 +54,13 @@ class StationRecord:
local_id: str local_id: str
def selector(self) -> str: def selector(self) -> str:
"""Выбирает лучший идентификатор станции для API-команд.
Приоритет:
1) host из `con_url` (если есть) - самый надёжный для локального управления;
2) `local_id` из mDNS;
3) `station_id` из облака.
"""
host = "" host = ""
if self.con_url: if self.con_url:
value = self.con_url.strip() value = self.con_url.strip()
@@ -60,6 +76,7 @@ class StationRecord:
def ensure_dirs() -> None: def ensure_dirs() -> None:
"""Гарантирует базовую структуру директорий проекта."""
DATA_DIR.mkdir(parents=True, exist_ok=True) DATA_DIR.mkdir(parents=True, exist_ok=True)
CONFIGS_DIR.mkdir(parents=True, exist_ok=True) CONFIGS_DIR.mkdir(parents=True, exist_ok=True)
LOXONE_DIR.mkdir(parents=True, exist_ok=True) LOXONE_DIR.mkdir(parents=True, exist_ok=True)
@@ -86,6 +103,12 @@ def save_json(path: Path, payload: Any) -> None:
def read_token(cli_token: str) -> str: def read_token(cli_token: str) -> str:
"""Читает токен из источников по приоритету.
Зачем порядок важен:
- CLI/ENV позволяют временно переопределить токен;
- `token.txt` и `config.json` нужны для постоянного хранения.
"""
token = (cli_token or "").strip() token = (cli_token or "").strip()
if token: if token:
return token return token
@@ -164,6 +187,12 @@ def _detect_local_ip() -> str:
def detect_controller_host() -> str: def detect_controller_host() -> str:
"""Определяет адрес контроллера для ссылок в шаблонах.
Сначала используем явные настройки (config/env), затем авто-детект LAN IP.
Loopback отбрасывается, чтобы избежать нерабочих ссылок
при использовании шаблонов на внешних клиентах.
"""
cfg = load_json(CONFIG_PATH, {}) cfg = load_json(CONFIG_PATH, {})
cfg_host = str(cfg.get("controller_host") or "").strip() cfg_host = str(cfg.get("controller_host") or "").strip()
if cfg_host and not _is_loopback_host(cfg_host): if cfg_host and not _is_loopback_host(cfg_host):
@@ -183,6 +212,11 @@ def normalize_model(model: str) -> str:
async def fetch_cloud_stations(token: str) -> List[Dict[str, Any]]: async def fetch_cloud_stations(token: str) -> List[Dict[str, Any]]:
"""Получает станции из облачного API и нормализует структуру.
На выходе каждая станция содержит `_match_ids` - набор идентификаторов,
по которым затем безопасно матчим локальное mDNS-обнаружение.
"""
headers = {"Authorization": f"OAuth {token}"} headers = {"Authorization": f"OAuth {token}"}
timeout = aiohttp.ClientTimeout(total=20) timeout = aiohttp.ClientTimeout(total=20)
async with aiohttp.ClientSession(timeout=timeout) as session: async with aiohttp.ClientSession(timeout=timeout) as session:
@@ -267,6 +301,13 @@ async def discover_local(timeout: float = 3.0) -> List[Dict[str, str]]:
def merge_cloud_with_local(cloud: List[Dict[str, Any]], local: List[Dict[str, str]]) -> List[StationRecord]: def merge_cloud_with_local(cloud: List[Dict[str, Any]], local: List[Dict[str, str]]) -> List[StationRecord]:
"""Объединяет облачные и локальные данные станций.
Логика сопоставления:
1) строго по идентификаторам (`_match_ids`);
2) по имени, только если совпадение имени уникально;
3) если совпадения нет - IP оставляем пустым (без опасных догадок).
"""
def nrm(value: str) -> str: def nrm(value: str) -> str:
return " ".join((value or "").split()).strip().lower() return " ".join((value or "").split()).strip().lower()
@@ -302,10 +343,6 @@ def merge_cloud_with_local(cloud: List[Dict[str, Any]], local: List[Dict[str, st
ip = str(item.get("ip") or "").strip() ip = str(item.get("ip") or "").strip()
local_id = str(item.get("id") or "").strip() local_id = str(item.get("id") or "").strip()
if not ip and len(local) == 1:
ip = str(local[0].get("ip") or "").strip()
local_id = str(local[0].get("id") or "").strip()
con_url = ip if ip else "" con_url = ip if ip else ""
merged.append( merged.append(
StationRecord( StationRecord(
@@ -374,7 +411,6 @@ def build_vo_xml(controller_host: str, station_name: str, selector: str) -> str:
mk("TTS", f"/api/exec?action=tts&station={qs}&text=<v>&volume=35", True), mk("TTS", f"/api/exec?action=tts&station={qs}&text=<v>&volume=35", True),
mk("Command", f"/api/exec?action=command&station={qs}&text=<v>", True), mk("Command", f"/api/exec?action=command&station={qs}&text=<v>", True),
mk("Play", f"/api/exec?action=player&station={qs}&cmd=play"), mk("Play", f"/api/exec?action=player&station={qs}&cmd=play"),
mk("Pause", f"/api/exec?action=player&station={qs}&cmd=pause"),
mk("Stop", f"/api/exec?action=player&station={qs}&cmd=stop"), mk("Stop", f"/api/exec?action=player&station={qs}&cmd=stop"),
mk("Next", f"/api/exec?action=player&station={qs}&cmd=next"), mk("Next", f"/api/exec?action=player&station={qs}&cmd=next"),
mk("Prev", f"/api/exec?action=player&station={qs}&cmd=prev"), mk("Prev", f"/api/exec?action=player&station={qs}&cmd=prev"),
@@ -458,10 +494,9 @@ defineVirtualDevice(DEVICE_ID, {{
raw_json: {{ title: {{ en: "Raw JSON", ru: "RAW JSON" }}, type: "text", value: "{{\\"command\\":\\"stop\\"}}", readonly: false, order: 220 }}, raw_json: {{ title: {{ en: "Raw JSON", ru: "RAW JSON" }}, type: "text", value: "{{\\"command\\":\\"stop\\"}}", readonly: false, order: 220 }},
raw_send: {{ title: {{ en: "Send RAW", ru: "Отправить RAW" }}, type: "pushbutton", value: false, order: 230 }}, raw_send: {{ title: {{ en: "Send RAW", ru: "Отправить RAW" }}, type: "pushbutton", value: false, order: 230 }},
play: {{ title: {{ en: "Play", ru: "Play" }}, type: "pushbutton", value: false, order: 300 }}, play: {{ title: {{ en: "Play", ru: "Play" }}, type: "pushbutton", value: false, order: 300 }},
pause: {{ title: {{ en: "Pause", ru: "Pause" }}, type: "pushbutton", value: false, order: 310 }}, stop: {{ title: {{ en: "Stop", ru: "Stop" }}, type: "pushbutton", value: false, order: 310 }},
stop: {{ title: {{ en: "Stop", ru: "Stop" }}, type: "pushbutton", value: false, order: 320 }}, next: {{ title: {{ en: "Next", ru: "Next" }}, type: "pushbutton", value: false, order: 320 }},
next: {{ title: {{ en: "Next", ru: "Next" }}, type: "pushbutton", value: false, order: 330 }}, prev: {{ title: {{ en: "Prev", ru: "Prev" }}, type: "pushbutton", value: false, order: 330 }},
prev: {{ title: {{ en: "Prev", ru: "Prev" }}, type: "pushbutton", value: false, order: 340 }},
status_json: {{ title: {{ en: "Status JSON", ru: "Статус JSON" }}, type: "text", value: "{{}}", readonly: true, order: 400 }} status_json: {{ title: {{ en: "Status JSON", ru: "Статус JSON" }}, type: "text", value: "{{}}", readonly: true, order: 400 }}
}} }}
}}); }});
@@ -505,7 +540,6 @@ function _player(cmd) {{
}} }}
defineRule(DEVICE_ID + "_play", {{ whenChanged: DEVICE_ID + "/play", then: _player("play") }}); defineRule(DEVICE_ID + "_play", {{ whenChanged: DEVICE_ID + "/play", then: _player("play") }});
defineRule(DEVICE_ID + "_pause", {{ whenChanged: DEVICE_ID + "/pause", then: _player("pause") }});
defineRule(DEVICE_ID + "_stop", {{ whenChanged: DEVICE_ID + "/stop", then: _player("stop") }}); defineRule(DEVICE_ID + "_stop", {{ whenChanged: DEVICE_ID + "/stop", then: _player("stop") }});
defineRule(DEVICE_ID + "_next", {{ whenChanged: DEVICE_ID + "/next", then: _player("next") }}); defineRule(DEVICE_ID + "_next", {{ whenChanged: DEVICE_ID + "/next", then: _player("next") }});
defineRule(DEVICE_ID + "_prev", {{ whenChanged: DEVICE_ID + "/prev", then: _player("prev") }}); defineRule(DEVICE_ID + "_prev", {{ whenChanged: DEVICE_ID + "/prev", then: _player("prev") }});
@@ -593,6 +627,7 @@ def write_wb_rules_templates(station: StationRecord, controller_host: str) -> Di
def generate_templates(template_kind: str, controller_host: str) -> Dict[str, Any]: def generate_templates(template_kind: str, controller_host: str) -> Dict[str, Any]:
"""Генерирует шаблоны и zip-архив для выбранной платформы."""
stations = load_stations_records() stations = load_stations_records()
if not stations: if not stations:
raise RuntimeError("stations.json is empty, run station refresh first") raise RuntimeError("stations.json is empty, run station refresh first")
@@ -636,6 +671,11 @@ def is_wiren_board() -> bool:
def install_wb_rules(station_id: Optional[str] = None) -> Dict[str, Any]: def install_wb_rules(station_id: Optional[str] = None) -> Dict[str, Any]:
"""Копирует подготовленные wb-rules в системный каталог и перезапускает wb-rules.
Если прямой записи нет, используется `sudo -n` (без интерактива),
чтобы функция была пригодна для автоматизации из web/CLI.
"""
target_dir = Path("/etc/wb-rules") target_dir = Path("/etc/wb-rules")
if not target_dir.exists(): if not target_dir.exists():
raise RuntimeError("/etc/wb-rules not found") raise RuntimeError("/etc/wb-rules not found")
@@ -671,6 +711,7 @@ def install_wb_rules(station_id: Optional[str] = None) -> Dict[str, Any]:
async def refresh_stations(token: str, timeout: float = 3.0) -> Dict[str, Any]: async def refresh_stations(token: str, timeout: float = 3.0) -> Dict[str, Any]:
"""Обновляет `stations.json` на основе облака + локальной сети."""
token = token.strip() token = token.strip()
if not token: if not token:
raise RuntimeError("token is required") raise RuntimeError("token is required")
@@ -705,6 +746,7 @@ async def handle_static(request: web.Request) -> web.Response:
async def api_status(_: web.Request) -> web.Response: async def api_status(_: web.Request) -> web.Response:
"""Сводный статус для web UI: токен, станции, окружение, конфиг."""
stations = load_json(STATIONS_PATH, []) stations = load_json(STATIONS_PATH, [])
cfg = load_json(CONFIG_PATH, {}) cfg = load_json(CONFIG_PATH, {})
return web.json_response( return web.json_response(
@@ -721,7 +763,10 @@ async def api_status(_: web.Request) -> web.Response:
async def api_set_token(request: web.Request) -> web.Response: async def api_set_token(request: web.Request) -> web.Response:
body = await request.json() try:
body = await request.json()
except Exception:
return web.json_response({"ok": False, "error": "invalid json body"}, status=400)
token = str(body.get("token") or "").strip() token = str(body.get("token") or "").strip()
if not token: if not token:
return web.json_response({"ok": False, "error": "token is required"}, status=422) return web.json_response({"ok": False, "error": "token is required"}, status=422)
@@ -730,7 +775,10 @@ async def api_set_token(request: web.Request) -> web.Response:
async def api_set_host(request: web.Request) -> web.Response: async def api_set_host(request: web.Request) -> web.Response:
body = await request.json() try:
body = await request.json()
except Exception:
return web.json_response({"ok": False, "error": "invalid json body"}, status=400)
host = str(body.get("controller_host") or "").strip() host = str(body.get("controller_host") or "").strip()
if not host: if not host:
return web.json_response({"ok": False, "error": "controller_host is required"}, status=422) return web.json_response({"ok": False, "error": "controller_host is required"}, status=422)
@@ -739,6 +787,7 @@ async def api_set_host(request: web.Request) -> web.Response:
async def api_refresh(_: web.Request) -> web.Response: async def api_refresh(_: web.Request) -> web.Response:
"""Перестраивает список станций и возвращает диагностику объединения."""
try: try:
out = await refresh_stations(read_token("")) out = await refresh_stations(read_token(""))
return web.json_response(out) return web.json_response(out)
@@ -789,6 +838,7 @@ async def api_install_wb(request: web.Request) -> web.Response:
async def run_web(host: str, port: int) -> int: async def run_web(host: str, port: int) -> int:
"""Запускает web UI и REST API плагина."""
app = web.Application() app = web.Application()
app.router.add_get("/", handle_index) app.router.add_get("/", handle_index)
app.router.add_get("/web/{name}", handle_static) app.router.add_get("/web/{name}", handle_static)
@@ -838,6 +888,12 @@ def build_parser() -> argparse.ArgumentParser:
async def main_async() -> int: async def main_async() -> int:
"""Точка маршрутизации CLI-команд.
Принцип:
- каждая команда выполняет ровно один сценарий;
- вывод в JSON удобен для автоматизации и диагностики.
"""
ensure_dirs() ensure_dirs()
parser = build_parser() parser = build_parser()
args = parser.parse_args() args = parser.parse_args()

View File

@@ -0,0 +1,117 @@
// Примеры wb-rules для ОДНОЙ выбранной станции.
//
// Как использовать:
// 1) подставь id станции из data/stations.json в переменную STATION;
// 2) скопируй файл в /etc/wb-rules/;
// 3) перезапусти сервис wb-rules.
var API_BASE = "http://127.0.0.1:9124";
var STATION = "PUT_EXACT_STATION_ID_HERE";
function enc(v) {
return encodeURIComponent(String(v === undefined || v === null ? "" : v));
}
function apiExec(query, cb) {
runShellCommand("curl -s '" + API_BASE + "/api/exec?" + query + "'", {
captureOutput: true,
exitCallback: function(exitCode, output) {
if (exitCode !== 0) return cb({ ok: false, error: "curl_failed" });
try { cb(JSON.parse(output || "{}")); } catch (e) { cb({ ok: false, error: "bad_json", raw: output }); }
}
});
}
function player(cmd) {
apiExec("action=player&station=" + enc(STATION) + "&cmd=" + enc(cmd), function() {});
}
function tts(text, volume) {
apiExec(
"action=tts&station=" + enc(STATION) + "&text=" + enc(text) + "&volume=" + enc(volume),
function() {}
);
}
function command(text) {
apiExec("action=command&station=" + enc(STATION) + "&text=" + enc(text), function() {});
}
function audioTts(text, audioId, volume, voice, effect) {
var v = String(voice || "");
var e = String(effect || "");
apiExec(
"action=tts&station=" + enc(STATION) +
"&text=" + enc(text) +
"&tts_audio=" + enc(audioId) +
"&tts_voice=" + enc(v) +
"&tts_effect=" + enc(e) +
"&volume=" + enc(volume),
function() {}
);
}
defineVirtualDevice("alice_one_station_examples", {
title: "Алиса: одна станция",
cells: {
play_btn: { type: "pushbutton", value: false, title: "Play" },
stop_btn: { type: "pushbutton", value: false, title: "Stop" },
next_btn: { type: "pushbutton", value: false, title: "Next" },
prev_btn: { type: "pushbutton", value: false, title: "Prev" },
tts_btn: { type: "pushbutton", value: false, title: "TTS" },
command_btn: { type: "pushbutton", value: false, title: "Command" },
audio_tts_btn:{ type: "pushbutton", value: false, title: "Audio + TTS" }
}
});
// Правило 1: команда player=play
defineRule("alice_example_play", {
whenChanged: "alice_one_station_examples/play_btn",
then: function(v) { if (v) player("play"); }
});
// Правило 2: команда player=stop
defineRule("alice_example_stop", {
whenChanged: "alice_one_station_examples/stop_btn",
then: function(v) { if (v) player("stop"); }
});
// Правило 3: команда player=next
defineRule("alice_example_next", {
whenChanged: "alice_one_station_examples/next_btn",
then: function(v) { if (v) player("next"); }
});
// Правило 4: команда player=prev
defineRule("alice_example_prev", {
whenChanged: "alice_one_station_examples/prev_btn",
then: function(v) { if (v) player("prev"); }
});
// Правило 5: обычный TTS-текст с громкостью 35
defineRule("alice_example_tts", {
whenChanged: "alice_one_station_examples/tts_btn",
then: function(v) {
if (!v) return;
tts("Тестовое голосовое сообщение", 35);
}
});
// Правило 6: обычная голосовая команда станции
defineRule("alice_example_command", {
whenChanged: "alice_one_station_examples/command_btn",
then: function(v) {
if (!v) return;
command("включи радио");
}
});
// Правило 7: звук + TTS в одном запросе (параметр tts_audio)
defineRule("alice_example_audio_tts", {
whenChanged: "alice_one_station_examples/audio_tts_btn",
then: function(v) {
if (!v) return;
// Тот же звук и голос, что в правиле "Дверной звонок" из `Новая папка/rules.json`.
audioTts("Кто-то ждёт у входной двери", "alice-sounds-things-bell-1.opus", 70, "anton_samokhvalov", "");
}
});

View File

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