Refine wb-rules examples and align player/TTS API
This commit is contained in:
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
29
README.md
29
README.md
@@ -12,6 +12,7 @@
|
||||
- [Полная установка](#полная-установка)
|
||||
- [Токен](#токен)
|
||||
- [CLI](#cli)
|
||||
- [Примеры wb-rules](#примеры-wb-rules)
|
||||
- [Прямые Python-команды](#прямые-python-команды)
|
||||
- [Web UI](#web-ui)
|
||||
- [Systemd](#systemd)
|
||||
@@ -304,6 +305,32 @@ cd /opt/shd/plugins/alice
|
||||
./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-команды задокументированы ниже. Их можно использовать напрямую, но штатный способ — `cli.sh`.
|
||||
@@ -434,6 +461,7 @@ cd /opt/shd/plugins/alice
|
||||
├── loxone/
|
||||
│ └── loxone_templates.zip
|
||||
└── wb-rules/
|
||||
├── wb-rules-examples.js
|
||||
└── wb_rules_templates.zip
|
||||
```
|
||||
|
||||
@@ -442,6 +470,7 @@ cd /opt/shd/plugins/alice
|
||||
- `token.txt` — OAuth-токен;
|
||||
- `data/config.json` — сохранённый IP контроллера и служебные настройки;
|
||||
- `data/stations.json` — найденные станции;
|
||||
- `configs/wb-rules/wb-rules-examples.js` — примеры `player/tts/command/audio+tts` для одной станции;
|
||||
- `configs/loxone/loxone_templates.zip` — ZIP для Loxone;
|
||||
- `configs/wb-rules/wb_rules_templates.zip` — ZIP для wb-rules.
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Alice plugin: CLI + web UI для подготовки и эксплуатации интеграции.
|
||||
|
||||
Что делает модуль:
|
||||
- получает станции из облака Яндекса и из локального mDNS;
|
||||
- объединяет данные в единый `stations.json`;
|
||||
- генерирует шаблоны для Loxone и wb-rules;
|
||||
- отдаёт web API для первичной настройки.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
@@ -36,6 +44,7 @@ YANDEX_INFO_URL = "https://api.iot.yandex.net/v1.0/user/info"
|
||||
|
||||
@dataclass
|
||||
class StationRecord:
|
||||
"""Нормализованная запись станции для хранения и генерации шаблонов."""
|
||||
name: str
|
||||
station_id: str
|
||||
home: str
|
||||
@@ -45,6 +54,13 @@ class StationRecord:
|
||||
local_id: str
|
||||
|
||||
def selector(self) -> str:
|
||||
"""Выбирает лучший идентификатор станции для API-команд.
|
||||
|
||||
Приоритет:
|
||||
1) host из `con_url` (если есть) - самый надёжный для локального управления;
|
||||
2) `local_id` из mDNS;
|
||||
3) `station_id` из облака.
|
||||
"""
|
||||
host = ""
|
||||
if self.con_url:
|
||||
value = self.con_url.strip()
|
||||
@@ -60,6 +76,7 @@ class StationRecord:
|
||||
|
||||
|
||||
def ensure_dirs() -> None:
|
||||
"""Гарантирует базовую структуру директорий проекта."""
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
CONFIGS_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:
|
||||
"""Читает токен из источников по приоритету.
|
||||
|
||||
Зачем порядок важен:
|
||||
- CLI/ENV позволяют временно переопределить токен;
|
||||
- `token.txt` и `config.json` нужны для постоянного хранения.
|
||||
"""
|
||||
token = (cli_token or "").strip()
|
||||
if token:
|
||||
return token
|
||||
@@ -164,6 +187,12 @@ def _detect_local_ip() -> str:
|
||||
|
||||
|
||||
def detect_controller_host() -> str:
|
||||
"""Определяет адрес контроллера для ссылок в шаблонах.
|
||||
|
||||
Сначала используем явные настройки (config/env), затем авто-детект LAN IP.
|
||||
Loopback отбрасывается, чтобы избежать нерабочих ссылок
|
||||
при использовании шаблонов на внешних клиентах.
|
||||
"""
|
||||
cfg = load_json(CONFIG_PATH, {})
|
||||
cfg_host = str(cfg.get("controller_host") or "").strip()
|
||||
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]]:
|
||||
"""Получает станции из облачного API и нормализует структуру.
|
||||
|
||||
На выходе каждая станция содержит `_match_ids` - набор идентификаторов,
|
||||
по которым затем безопасно матчим локальное mDNS-обнаружение.
|
||||
"""
|
||||
headers = {"Authorization": f"OAuth {token}"}
|
||||
timeout = aiohttp.ClientTimeout(total=20)
|
||||
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]:
|
||||
"""Объединяет облачные и локальные данные станций.
|
||||
|
||||
Логика сопоставления:
|
||||
1) строго по идентификаторам (`_match_ids`);
|
||||
2) по имени, только если совпадение имени уникально;
|
||||
3) если совпадения нет - IP оставляем пустым (без опасных догадок).
|
||||
"""
|
||||
def nrm(value: str) -> str:
|
||||
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()
|
||||
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 ""
|
||||
merged.append(
|
||||
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("Command", f"/api/exec?action=command&station={qs}&text=<v>", True),
|
||||
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("Next", f"/api/exec?action=player&station={qs}&cmd=next"),
|
||||
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_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 }},
|
||||
pause: {{ title: {{ en: "Pause", ru: "Pause" }}, 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: 330 }},
|
||||
prev: {{ title: {{ en: "Prev", ru: "Prev" }}, type: "pushbutton", value: false, order: 340 }},
|
||||
stop: {{ title: {{ en: "Stop", ru: "Stop" }}, type: "pushbutton", value: false, order: 310 }},
|
||||
next: {{ title: {{ en: "Next", ru: "Next" }}, type: "pushbutton", value: false, order: 320 }},
|
||||
prev: {{ title: {{ en: "Prev", ru: "Prev" }}, type: "pushbutton", value: false, order: 330 }},
|
||||
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 + "_pause", {{ whenChanged: DEVICE_ID + "/pause", then: _player("pause") }});
|
||||
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 + "_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]:
|
||||
"""Генерирует шаблоны и zip-архив для выбранной платформы."""
|
||||
stations = load_stations_records()
|
||||
if not stations:
|
||||
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]:
|
||||
"""Копирует подготовленные wb-rules в системный каталог и перезапускает wb-rules.
|
||||
|
||||
Если прямой записи нет, используется `sudo -n` (без интерактива),
|
||||
чтобы функция была пригодна для автоматизации из web/CLI.
|
||||
"""
|
||||
target_dir = Path("/etc/wb-rules")
|
||||
if not target_dir.exists():
|
||||
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]:
|
||||
"""Обновляет `stations.json` на основе облака + локальной сети."""
|
||||
token = token.strip()
|
||||
if not token:
|
||||
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:
|
||||
"""Сводный статус для web UI: токен, станции, окружение, конфиг."""
|
||||
stations = load_json(STATIONS_PATH, [])
|
||||
cfg = load_json(CONFIG_PATH, {})
|
||||
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:
|
||||
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()
|
||||
if not token:
|
||||
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:
|
||||
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()
|
||||
if not host:
|
||||
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:
|
||||
"""Перестраивает список станций и возвращает диагностику объединения."""
|
||||
try:
|
||||
out = await refresh_stations(read_token(""))
|
||||
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:
|
||||
"""Запускает web UI и REST API плагина."""
|
||||
app = web.Application()
|
||||
app.router.add_get("/", handle_index)
|
||||
app.router.add_get("/web/{name}", handle_static)
|
||||
@@ -838,6 +888,12 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
|
||||
async def main_async() -> int:
|
||||
"""Точка маршрутизации CLI-команд.
|
||||
|
||||
Принцип:
|
||||
- каждая команда выполняет ровно один сценарий;
|
||||
- вывод в JSON удобен для автоматизации и диагностики.
|
||||
"""
|
||||
ensure_dirs()
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
117
configs/wb-rules/wb-rules-examples.js
Normal file
117
configs/wb-rules/wb-rules-examples.js
Normal 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", "");
|
||||
}
|
||||
});
|
||||
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