diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f7bcbc5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd + +.venv/ +venv/ +env/ + +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +.DS_Store +.idea/ +.vscode/ diff --git a/README.md b/README.md index eaf638a..e0b27e9 100644 --- a/README.md +++ b/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. diff --git a/alice_plugin.py b/alice_plugin.py index de62b3f..7cec926 100644 --- a/alice_plugin.py +++ b/alice_plugin.py @@ -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=&volume=35", True), mk("Command", f"/api/exec?action=command&station={qs}&text=", 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() diff --git a/configs/wb-rules/wb-rules-examples.js b/configs/wb-rules/wb-rules-examples.js new file mode 100644 index 0000000..8968645 --- /dev/null +++ b/configs/wb-rules/wb-rules-examples.js @@ -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", ""); + } +}); diff --git a/yastation.py b/yastation.py index a8485be..8475ffd 100644 --- a/yastation.py +++ b/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"{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: