From 7a8651931422ee0286bf25e2775c5687207aa9de Mon Sep 17 00:00:00 2001 From: Mark Kats Date: Sat, 7 Mar 2026 22:43:08 +0300 Subject: [PATCH] release --- README.md | 244 ++++++++- alice_plugin.py | 847 +++++++++++++++++++++++++++++++ cmd_1_get_columns.sh | 4 + cmd_2_get_loxone_templates.sh | 4 + cmd_3_get_wb_rules_templates.sh | 4 + data/.gitkeep | 0 install.sh | 39 ++ loxone/.gitkeep | 0 requirements.txt | 2 + restart.sh | 8 + start.sh | 36 ++ start_plugin_web.sh | 20 + stop.sh | 7 + systemd/shd-alice-plugin.service | 15 + systemd/shd-alice.service | 15 + web/index.html | 335 ++++++++++++ yastation.py | 737 +++++++++++++++++++++++++++ 17 files changed, 2316 insertions(+), 1 deletion(-) create mode 100644 alice_plugin.py create mode 100644 cmd_1_get_columns.sh create mode 100644 cmd_2_get_loxone_templates.sh create mode 100644 cmd_3_get_wb_rules_templates.sh create mode 100644 data/.gitkeep create mode 100644 install.sh create mode 100644 loxone/.gitkeep create mode 100644 requirements.txt create mode 100644 restart.sh create mode 100644 start.sh create mode 100644 start_plugin_web.sh create mode 100644 stop.sh create mode 100644 systemd/shd-alice-plugin.service create mode 100644 systemd/shd-alice.service create mode 100644 web/index.html create mode 100644 yastation.py diff --git a/README.md b/README.md index ea8fd22..e4a60bd 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,244 @@ -# local-yandex-station +# Alice Stations Plugin +Standalone-плагин для управления Яндекс Станциями, генерации шаблонов Loxone / wb-rules и web UI. + +## Содержимое репозитория + +- `yastation.py` - демон управления колонками (локальный API: `127.0.0.1:9123`, HTTP API: `:9124`). +- `alice_plugin.py` - CLI и web API для списка колонок, генерации шаблонов и установки wb-rules. +- `web/index.html` - web-интерфейс. +- `loxone/` - выходные файлы шаблонов и ZIP-архивы. +- `data/` - рабочие данные (`stations.json`, `config.json`). +- `systemd/*.service` - юниты сервисов. +- `cmd_*.sh` - готовые команды для типовых действий. + +## Требования + +Минимум для Debian/Ubuntu, Wiren Board, Raspberry Pi OS: + +```bash +sudo apt update +sudo apt install -y python3 python3-venv python3-pip +``` + +Важно: +- команды установки и запуска нужно выполнять из каталога проекта; +- если нет `python3-venv`, `install.sh` завершится ошибкой и сервисы не установятся. + +## Рекомендуемая структура на контроллере + +```text +/home/shd/scripts/alice +``` + +Если репозиторий скопирован в другое место, нужно скорректировать пути в `systemd/*.service`. + +## Установка (пошагово) + +1. Скопировать проект: + +```bash +mkdir -p /home/shd/scripts +cd /home/shd/scripts +cp -a alice_station_plugin_repo alice +``` + +2. Установить зависимости Python и systemd-сервисы: + +```bash +cd /home/shd/scripts/alice +chmod +x *.sh +./install.sh +``` + +3. Сохранить OAuth-токен: + +```bash +printf %s 'YANDEX_OAUTH_TOKEN' > /home/shd/scripts/alice/token.txt +chmod 600 /home/shd/scripts/alice/token.txt +``` + +4. Включить и запустить сервисы: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now shd-alice.service shd-alice-plugin.service +``` + +## Проверка после установки + +Проверка сервисов: + +```bash +sudo systemctl status shd-alice.service --no-pager +sudo systemctl status shd-alice-plugin.service --no-pager +``` + +Проверка web API: + +```bash +curl -s http://127.0.0.1:9140/api/status +``` + +Web UI: + +```text +http://:9140/ +``` + +## Обязательные команды (CLI) + +| Команда | Что делает | Входы | Выходы | +|---|---|---|---| +| `./cmd_1_get_columns.sh` | Обновляет список колонок (`stations-refresh`) | OAuth-токен: `token.txt` или `YANDEX_TOKEN` | `data/stations.json`, JSON в stdout (`ok`, `stations`, `cloud_total`, `local_total`) | +| `./cmd_2_get_loxone_templates.sh` | Генерирует шаблоны Loxone (`templates-loxone`) | `data/stations.json` (должен быть заполнен), `ALICE_CONTROLLER_HOST` (опц.) | Файлы в `loxone/`, архив `loxone/loxone_templates.zip`, JSON в stdout | +| `./cmd_3_get_wb_rules_templates.sh` | Генерирует шаблоны wb-rules (`templates-wb-rules`) | `data/stations.json` (должен быть заполнен), `ALICE_CONTROLLER_HOST` (опц.) | Файлы в `loxone/`, архив `loxone/wb_rules_templates.zip`, JSON в stdout | + +Примеры запуска: + +```bash +cd /home/shd/scripts/alice +./cmd_1_get_columns.sh +./cmd_2_get_loxone_templates.sh +./cmd_3_get_wb_rules_templates.sh +``` + +### Команды `yastation.py` (daemon/client) + +Примеры: + +```bash +# локальные станции (mDNS) +/home/shd/scripts/alice/.venv/bin/python3 /home/shd/scripts/alice/yastation.py --timeout 3 list + +# запуск daemon +/home/shd/scripts/alice/.venv/bin/python3 /home/shd/scripts/alice/yastation.py --token "$YANDEX_TOKEN" serve --host 127.0.0.1 --port 9123 --http-host 0.0.0.0 --http-port 9124 + +# tts +/home/shd/scripts/alice/.venv/bin/python3 /home/shd/scripts/alice/yastation.py client --host 127.0.0.1 --port 9123 --station "M00..." tts --text "Проверка" + +# command +/home/shd/scripts/alice/.venv/bin/python3 /home/shd/scripts/alice/yastation.py client --host 127.0.0.1 --port 9123 --station "M00..." command --text "включи музыку" + +# player pause +/home/shd/scripts/alice/.venv/bin/python3 /home/shd/scripts/alice/yastation.py client --host 127.0.0.1 --port 9123 --station "M00..." player --cmd pause + +# volume +/home/shd/scripts/alice/.venv/bin/python3 /home/shd/scripts/alice/yastation.py client --host 127.0.0.1 --port 9123 --station "M00..." volume --level 20 + +# raw +/home/shd/scripts/alice/.venv/bin/python3 /home/shd/scripts/alice/yastation.py client --host 127.0.0.1 --port 9123 --station "M00..." raw --payload '{"command":"setVolume","volume":0.2}' +``` + +Для станции в шаблоне есть: +- `VirtualOut` команды: `TTS`, `Command`, `Play`, `Pause`, `Stop`, `Next`, `Prev`, `Volume`, `Raw`. +- `VirtualIn` статусы: `Daemon OK`, `WS Running`, `Playing`, `Volume`. + +## Работа через web UI + +На странице можно: +- сохранить OAuth-токен; +- найти/обновить список колонок; +- скачать ZIP для Loxone; +- скачать ZIP для wb-rules; +- на Wiren Board доступны кнопки установки wb-rules в `/etc/wb-rules`. + +## Установка wb-rules из CLI + +Установить все станции: + +```bash +cd /home/shd/scripts/alice +.venv/bin/python3 alice_plugin.py wb-install +``` + +Установить только одну станцию: + +```bash +cd /home/shd/scripts/alice +.venv/bin/python3 alice_plugin.py wb-install --station-id +``` + +## Сервисы и управление + +- Демон Alice: `shd-alice.service` +- Web UI: `shd-alice-plugin.service` + +Перезапуск: + +```bash +sudo systemctl restart shd-alice.service shd-alice-plugin.service +``` + +Остановка: + +```bash +sudo systemctl stop shd-alice.service shd-alice-plugin.service +``` + +## Типовые ошибки и решения + +### `Unit shd-alice.service not found` + +Причина: `install.sh` не завершился успешно, поэтому юниты не были скопированы в `/etc/systemd/system`. + +Что делать: +1. Исправить ошибку установки (часто это отсутствие `python3-venv`). +2. Снова запустить: + +```bash +cd /home/shd/scripts/alice +./install.sh +sudo systemctl daemon-reload +sudo systemctl enable --now shd-alice.service shd-alice-plugin.service +``` + +### `The virtual environment was not created successfully because ensurepip is not available` + +Причина: отсутствует пакет `python3-venv`. + +Решение: + +```bash +sudo apt update +sudo apt install -y python3-venv python3-pip +cd /home/shd/scripts/alice +./install.sh +``` + +### `./install.sh: No such file or directory` + +Причина: команда выполняется не из каталога проекта. + +Решение: + +```bash +cd /home/shd/scripts/alice +./install.sh +``` + +### Токен не подхватывается + +Проверь, что файл существует именно по пути: + +```text +/home/shd/scripts/alice/token.txt +``` + +А не в `/home/shd/scripts/token.txt`. + +## Переменные окружения (опционально) + +- `YANDEX_TOKEN` - OAuth токен (если не используется `token.txt`). +- `ALICE_CONTROLLER_HOST` - host/IP, который вставляется в шаблоны. +- `ALICE_PLUGIN_WEB_PORT` - порт web UI (по умолчанию `9140`). + +## Удаление с контроллера + +```bash +sudo systemctl stop shd-alice.service shd-alice-plugin.service +sudo systemctl disable shd-alice.service shd-alice-plugin.service +sudo rm -f /etc/systemd/system/shd-alice.service /etc/systemd/system/shd-alice-plugin.service +sudo systemctl daemon-reload +sudo rm -rf /home/shd/scripts/alice +``` diff --git a/alice_plugin.py b/alice_plugin.py new file mode 100644 index 0000000..0b4e98b --- /dev/null +++ b/alice_plugin.py @@ -0,0 +1,847 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import argparse +import asyncio +import html +import ipaddress +import json +import os +import platform +import shutil +import socket +import subprocess +import sys +import zipfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List, Optional + +import aiohttp +from aiohttp import web + +import yastation + +BASE_DIR = Path(__file__).resolve().parent +DATA_DIR = BASE_DIR / "data" +LOXONE_DIR = BASE_DIR / "loxone" +STATIONS_PATH = DATA_DIR / "stations.json" +CONFIG_PATH = DATA_DIR / "config.json" +TOKEN_PATH = BASE_DIR / "token.txt" + +YANDEX_INFO_URL = "https://api.iot.yandex.net/v1.0/user/info" + + +@dataclass +class StationRecord: + name: str + station_id: str + home: str + room: str + model: str + con_url: str + local_id: str + + def selector(self) -> str: + host = "" + if self.con_url: + value = self.con_url.strip() + if not value.startswith("http://") and not value.startswith("https://"): + value = f"http://{value}" + try: + from urllib.parse import urlparse + + host = (urlparse(value).hostname or "").strip() + except Exception: + host = "" + return host or self.local_id or self.station_id + + +def ensure_dirs() -> None: + DATA_DIR.mkdir(parents=True, exist_ok=True) + LOXONE_DIR.mkdir(parents=True, exist_ok=True) + + +def safe_name(value: str, fallback: str) -> str: + out = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in (value or "").strip()) + out = out.strip("._-") + return out or fallback + + +def load_json(path: Path, default: Any) -> Any: + if not path.exists(): + return default + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return default + + +def save_json(path: Path, payload: Any) -> None: + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def read_token(cli_token: str) -> str: + token = (cli_token or "").strip() + if token: + return token + env_token = os.environ.get("YANDEX_TOKEN", "").strip() + if env_token: + return env_token + if TOKEN_PATH.exists(): + return TOKEN_PATH.read_text(encoding="utf-8").strip() + cfg = load_json(CONFIG_PATH, {}) + return str(cfg.get("token") or "").strip() + + +def save_token(token: str) -> None: + token = token.strip() + TOKEN_PATH.write_text(token + "\n", encoding="utf-8") + os.chmod(TOKEN_PATH, 0o600) + cfg = load_json(CONFIG_PATH, {}) + cfg["token"] = token + save_json(CONFIG_PATH, cfg) + + +def save_controller_host(host: str) -> None: + cfg = load_json(CONFIG_PATH, {}) + cfg["controller_host"] = host.strip() + save_json(CONFIG_PATH, cfg) + + +def _is_valid_local_ip(value: str) -> bool: + try: + ip = ipaddress.ip_address(value) + except Exception: + return False + return bool(ip.version == 4 and not ip.is_loopback and not ip.is_link_local and not ip.is_unspecified) + + +def _is_loopback_host(value: str) -> bool: + host = (value or "").strip().lower() + if not host: + return False + if host == "localhost": + return True + return host.startswith("127.") + + +def _detect_local_ip() -> str: + # Best-effort detection of the primary LAN address. + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: + s.connect(("1.1.1.1", 80)) + ip = str(s.getsockname()[0] or "").strip() + if _is_valid_local_ip(ip): + return ip + except Exception: + pass + + try: + host = socket.gethostname() + for family, _, _, _, sockaddr in socket.getaddrinfo(host, None, socket.AF_INET): + if family != socket.AF_INET or not sockaddr: + continue + ip = str(sockaddr[0] or "").strip() + if _is_valid_local_ip(ip): + return ip + except Exception: + pass + + try: + _, _, ips = socket.gethostbyname_ex(socket.gethostname()) + for ip in ips: + if _is_valid_local_ip(str(ip).strip()): + return str(ip).strip() + except Exception: + pass + + return "127.0.0.1" + + +def detect_controller_host() -> str: + cfg = load_json(CONFIG_PATH, {}) + cfg_host = str(cfg.get("controller_host") or "").strip() + if cfg_host and not _is_loopback_host(cfg_host): + return cfg_host + env_host = os.environ.get("ALICE_CONTROLLER_HOST", "").strip() + if env_host and not _is_loopback_host(env_host): + return env_host + return _detect_local_ip() + + +def normalize_model(model: str) -> str: + value = (model or "").strip() + prefix = "devices.types.smart_speaker." + if value.lower().startswith(prefix): + return value[len(prefix) :] + return value + + +async def fetch_cloud_stations(token: str) -> List[Dict[str, Any]]: + headers = {"Authorization": f"OAuth {token}"} + timeout = aiohttp.ClientTimeout(total=20) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(YANDEX_INFO_URL, headers=headers) as resp: + text = await resp.text() + if resp.status >= 400: + raise RuntimeError(f"Yandex API error {resp.status}: {text[:220]}") + try: + data = json.loads(text) + except Exception as exc: + raise RuntimeError(f"Bad Yandex API JSON: {exc}") from exc + + homes: Dict[str, str] = {} + for home in data.get("homes") or []: + hid = str(home.get("id") or "").strip() + if hid: + homes[hid] = str(home.get("name") or "").strip() + for home in data.get("households") or []: + hid = str(home.get("id") or "").strip() + if hid and hid not in homes: + homes[hid] = str(home.get("name") or "").strip() + + rooms: Dict[str, str] = {} + room_home: Dict[str, str] = {} + for room in data.get("rooms") or []: + rid = str(room.get("id") or "").strip() + if not rid: + continue + rooms[rid] = str(room.get("name") or "").strip() + home_id = str(room.get("home_id") or room.get("household_id") or room.get("household") or "").strip() + if home_id and home_id in homes: + room_home[rid] = homes[home_id] + + def nrm_id(value: str) -> str: + return value.strip().lower() + + def extract_match_ids(device: Dict[str, Any]) -> List[str]: + candidates = [ + str(device.get("id") or ""), + str(device.get("device_id") or ""), + str(device.get("external_id") or ""), + str((device.get("quasar_info") or {}).get("device_id") or ""), + str((device.get("quasar_info") or {}).get("deviceId") or ""), + str((device.get("quasar_info") or {}).get("external_id") or ""), + str((device.get("quasar_info") or {}).get("serial") or ""), + str(device.get("serial") or ""), + ] + out: Dict[str, bool] = {} + for c in candidates: + n = nrm_id(c) + if n: + out[n] = True + return list(out.keys()) + + result: List[Dict[str, Any]] = [] + for dev in data.get("devices") or []: + dtype = str(dev.get("type") or "").lower() + if "speaker" not in dtype and "smart_speaker" not in dtype: + continue + room_id = str(dev.get("room") or "").strip() + home_id = str(dev.get("home_id") or dev.get("household_id") or dev.get("household") or "").strip() + home_name = homes.get(home_id, room_home.get(room_id, "")) + result.append( + { + "name": str(dev.get("name") or "").strip(), + "id": str(dev.get("id") or "").strip(), + "home": home_name, + "room": rooms.get(room_id, ""), + "model": normalize_model(str(dev.get("model") or dev.get("type") or "")), + "_match_ids": extract_match_ids(dev), + } + ) + return result + + +async def discover_local(timeout: float = 3.0) -> List[Dict[str, str]]: + items = await yastation.discover(timeout) + out: List[Dict[str, str]] = [] + for st in items: + out.append({"id": st.device_id, "name": st.name, "ip": st.host, "platform": st.platform}) + return out + + +def merge_cloud_with_local(cloud: List[Dict[str, Any]], local: List[Dict[str, str]]) -> List[StationRecord]: + def nrm(value: str) -> str: + return " ".join((value or "").split()).strip().lower() + + local_by_id: Dict[str, Dict[str, str]] = {} + local_by_name: Dict[str, Dict[str, str]] = {} + local_name_count: Dict[str, int] = {} + + for row in local: + lid = (row.get("id") or "").strip() + if lid: + local_by_id[lid.lower()] = row + name_key = nrm(row.get("name") or "") + if name_key: + local_name_count[name_key] = local_name_count.get(name_key, 0) + 1 + local_by_name[name_key] = row + + merged: List[StationRecord] = [] + for st in cloud: + ip = "" + local_id = "" + + for candidate in st.get("_match_ids") or []: + item = local_by_id.get(str(candidate).strip().lower()) + if item: + ip = str(item.get("ip") or "").strip() + local_id = str(item.get("id") or "").strip() + break + + if not ip: + name_key = nrm(str(st.get("name") or "")) + if name_key and local_name_count.get(name_key) == 1: + item = local_by_name.get(name_key) or {} + 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( + name=str(st.get("name") or "").strip(), + station_id=str(st.get("id") or "").strip(), + home=str(st.get("home") or "").strip(), + room=str(st.get("room") or "").strip(), + model=normalize_model(str(st.get("model") or "")), + con_url=con_url, + local_id=local_id, + ) + ) + + return merged + + +def serialize_stations(stations: List[StationRecord]) -> List[Dict[str, str]]: + return [ + { + "name": s.name, + "id": s.station_id, + "home": s.home, + "room": s.room, + "model": s.model, + "con_url": s.con_url, + "ip": s.con_url, + "local_id": s.local_id, + } + for s in stations + ] + + +def load_stations_records() -> List[StationRecord]: + rows = load_json(STATIONS_PATH, []) + out: List[StationRecord] = [] + for row in rows: + out.append( + StationRecord( + name=str(row.get("name") or "").strip(), + station_id=str(row.get("id") or row.get("station_id") or "").strip(), + home=str(row.get("home") or "").strip(), + room=str(row.get("room") or "").strip(), + model=str(row.get("model") or "").strip(), + con_url=str(row.get("con_url") or row.get("ip") or "").strip(), + local_id=str(row.get("local_id") or "").strip(), + ) + ) + return [s for s in out if s.station_id] + + +def build_vo_xml(controller_host: str, station_name: str, selector: str) -> str: + base = f"http://{controller_host}:9124" + from urllib.parse import quote + + qs = quote(selector, safe="") + + def mk(title: str, path: str, analog: bool = False) -> str: + analog_val = "true" if analog else "false" + return ( + f'' + ) + + rows = [ + 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"), + mk("Volume", f"/api/exec?action=volume&station={qs}&level=", True), + mk("Raw", f"/api/exec?action=raw&station={qs}&payload=", True), + ] + + xml = [''] + xml.append( + f'' + ) + xml.append('\t') + xml.extend(["\t" + row for row in rows]) + xml.append("") + return "\n".join(xml) + "\n" + + +def build_vi_xml(controller_host: str, station_name: str, selector: str) -> str: + from urllib.parse import quote + + addr = f"http://{controller_host}:9124/api/status?station={quote(selector, safe='')}" + title = html.escape(f"Alice status - {station_name}") + lines = [ + '', + f'', + '\t', + '\t', + '\t', + '\t', + '\t', + "", + ] + return "\n".join(lines) + "\n" + + +def build_wb_rules_js(controller_host: str, station_name: str, selector: str) -> str: + device_id = safe_name(f"alice_station_{station_name}".lower().replace("-", "_"), "alice_station") + station_json = json.dumps(selector, ensure_ascii=False) + title_json = json.dumps(f"Alice - {station_name}", ensure_ascii=False) + base_json = json.dumps(f"http://{controller_host}:9124", ensure_ascii=False) + device_json = json.dumps(device_id, ensure_ascii=False) + return f"""// Auto-generated template for wb-rules +// Place this file to /etc/wb-rules/ and restart wb-rules. + +var STATION_SELECTOR = {station_json}; +var API_BASE = {base_json}; +var DEVICE_ID = {device_json}; +var DEVICE_TITLE = {title_json}; + +function _apiGet(path, cb) {{ + runShellCommand("curl -s '" + API_BASE + path + "'", {{ + captureOutput: true, + exitCallback: function (exitCode, capturedOutput) {{ + if (exitCode !== 0) return cb({{ ok: false, error: "curl_failed" }}); + try {{ + return cb(JSON.parse(capturedOutput || "{{}}")); + }} catch (e) {{ + return cb({{ ok: false, error: "bad_json", raw: capturedOutput }}); + }} + }} + }}); +}} + +function _enc(v) {{ + return encodeURIComponent(String(v === undefined || v === null ? "" : v)); +}} + +defineVirtualDevice(DEVICE_ID, {{ + title: DEVICE_TITLE, + cells: {{ + daemon_ok: {{ title: {{ en: "Daemon OK", ru: "Демон OK" }}, type: "switch", value: false, readonly: true, order: 10 }}, + ws_running: {{ title: {{ en: "WS Running", ru: "WS к колонке" }}, type: "switch", value: false, readonly: true, order: 20 }}, + playing: {{ title: {{ en: "Playing", ru: "Воспроизведение" }}, type: "switch", value: false, readonly: true, order: 30 }}, + current_volume: {{ title: {{ en: "Current Volume", ru: "Текущая громкость" }}, type: "value", value: 0, readonly: true, order: 40 }}, + tts_text: {{ title: {{ en: "TTS Text", ru: "Текст TTS" }}, type: "text", value: "", readonly: false, order: 100 }}, + tts_send: {{ title: {{ en: "Send TTS", ru: "Отправить TTS" }}, type: "pushbutton", value: false, order: 110 }}, + command_text: {{ title: {{ en: "Command Text", ru: "Текст команды" }}, type: "text", value: "", readonly: false, order: 120 }}, + command_send: {{ title: {{ en: "Send Command", ru: "Отправить команду" }}, type: "pushbutton", value: false, order: 130 }}, + volume: {{ title: {{ en: "Volume", ru: "Громкость" }}, type: "range", value: 35, min: 0, max: 100, order: 200 }}, + 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 }}, + status_json: {{ title: {{ en: "Status JSON", ru: "Статус JSON" }}, type: "text", value: "{{}}", readonly: true, order: 400 }} + }} +}}); + +defineRule(DEVICE_ID + "_tts_send", {{ + whenChanged: DEVICE_ID + "/tts_send", + then: function (newValue) {{ + if (!newValue) return; + var txt = String(dev[DEVICE_ID + "/tts_text"] || "").trim(); + if (!txt) return; + _apiGet("/api/exec?action=tts&station=" + _enc(STATION_SELECTOR) + "&text=" + _enc(txt) + "&volume=35", function(){{}}); + }} +}}); + +defineRule(DEVICE_ID + "_command_send", {{ + whenChanged: DEVICE_ID + "/command_send", + then: function (newValue) {{ + if (!newValue) return; + var txt = String(dev[DEVICE_ID + "/command_text"] || "").trim(); + if (!txt) return; + _apiGet("/api/exec?action=command&station=" + _enc(STATION_SELECTOR) + "&text=" + _enc(txt), function(){{}}); + }} +}}); + +defineRule(DEVICE_ID + "_volume_control", {{ + whenChanged: DEVICE_ID + "/volume", + then: function () {{ + var level = parseInt(dev[DEVICE_ID + "/volume"], 10); + if (isNaN(level)) level = 35; + if (level < 0) level = 0; + if (level > 100) level = 100; + _apiGet("/api/exec?action=volume&station=" + _enc(STATION_SELECTOR) + "&level=" + _enc(level), function(){{}}); + }} +}}); + +function _player(cmd) {{ + return function (newValue) {{ + if (!newValue) return; + _apiGet("/api/exec?action=player&station=" + _enc(STATION_SELECTOR) + "&cmd=" + _enc(cmd), function(){{}}); + }}; +}} + +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") }}); + +defineRule(DEVICE_ID + "_raw_send", {{ + whenChanged: DEVICE_ID + "/raw_send", + then: function (newValue) {{ + if (!newValue) return; + var raw = String(dev[DEVICE_ID + "/raw_json"] || "").trim(); + if (!raw) return; + _apiGet("/api/exec?action=raw&station=" + _enc(STATION_SELECTOR) + "&payload=" + _enc(raw), function(){{}}); + }} +}}); + +defineRule(DEVICE_ID + "_status_poll", {{ + when: cron("@every 10s"), + then: function () {{ + _apiGet("/api/status?station=" + _enc(STATION_SELECTOR), function (j) {{ + var ok = Number(j && j.ok ? 1 : 0); + var running = Number(j && j.running ? 1 : 0); + dev[DEVICE_ID + "/daemon_ok"] = !!ok; + dev[DEVICE_ID + "/ws_running"] = !!running; + if (j && j.playing !== undefined && j.playing !== null) dev[DEVICE_ID + "/playing"] = !!Number(j.playing ? 1 : 0); + if (j && j.volume !== undefined && j.volume !== null) dev[DEVICE_ID + "/current_volume"] = Number(j.volume) || 0; + dev[DEVICE_ID + "/status_json"] = JSON.stringify(j || {{}}); + }}); + }} +}}); +""" + + +def write_station_templates(station: StationRecord, controller_host: str, target_dir: Path) -> Dict[str, str]: + station_folder = target_dir / safe_name(station.name or station.station_id, station.station_id or "station") + station_folder.mkdir(parents=True, exist_ok=True) + selector = station.selector() + + vo_path = station_folder / "VO.xml" + vi_path = station_folder / "VI.xml" + wb_path = station_folder / "wb-rules-station.js" + readme_path = station_folder / "README.md" + + vo_path.write_text(build_vo_xml(controller_host, station.name or station.station_id, selector), encoding="utf-8") + vi_path.write_text(build_vi_xml(controller_host, station.name or station.station_id, selector), encoding="utf-8") + wb_path.write_text(build_wb_rules_js(controller_host, station.name or station.station_id, selector), encoding="utf-8") + readme_path.write_text( + "# Alice templates\n\n" + f"- Station: {station.name}\n" + f"- Station ID: {station.station_id}\n" + f"- Selector: {selector}\n" + f"- API: http://{controller_host}:9124\n", + encoding="utf-8", + ) + + return { + "station": station.station_id, + "name": station.name, + "folder": str(station_folder.relative_to(BASE_DIR)), + "vo": str(vo_path.relative_to(BASE_DIR)), + "vi": str(vi_path.relative_to(BASE_DIR)), + "wb": str(wb_path.relative_to(BASE_DIR)), + } + + +def generate_templates(template_kind: str, controller_host: str) -> Dict[str, Any]: + stations = load_stations_records() + if not stations: + raise RuntimeError("stations.json is empty, run station refresh first") + + ensure_dirs() + result: List[Dict[str, str]] = [] + + for station in stations: + files = write_station_templates(station, controller_host, LOXONE_DIR) + if template_kind == "loxone": + files = {k: v for k, v in files.items() if k in {"station", "name", "folder", "vo", "vi"}} + elif template_kind == "wb-rules": + files = {k: v for k, v in files.items() if k in {"station", "name", "folder", "wb"}} + result.append(files) + + zip_name = "loxone_templates.zip" if template_kind == "loxone" else "wb_rules_templates.zip" + zip_path = LOXONE_DIR / zip_name + with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for station in stations: + folder = LOXONE_DIR / safe_name(station.name or station.station_id, station.station_id or "station") + if template_kind in ("loxone", "all"): + for fn in ["VO.xml", "VI.xml", "README.md"]: + p = folder / fn + if p.exists(): + zf.write(p, arcname=f"{folder.name}/{fn}") + if template_kind in ("wb-rules", "all"): + p = folder / "wb-rules-station.js" + if p.exists(): + zf.write(p, arcname=f"{folder.name}/wb-rules-station.js") + + return {"ok": True, "templates": result, "zip": str(zip_path.relative_to(BASE_DIR))} + + +def is_wiren_board() -> bool: + return Path("/etc/wb-release").exists() or Path("/etc/wb-rules").exists() + + +def install_wb_rules(station_id: Optional[str] = None) -> Dict[str, Any]: + target_dir = Path("/etc/wb-rules") + if not target_dir.exists(): + raise RuntimeError("/etc/wb-rules not found") + + stations = load_stations_records() + if station_id: + stations = [s for s in stations if s.station_id == station_id] + if not stations: + raise RuntimeError(f"station not found: {station_id}") + + installed: List[str] = [] + for station in stations: + folder = LOXONE_DIR / safe_name(station.name or station.station_id, station.station_id or "station") + src = folder / "wb-rules-station.js" + if not src.exists(): + write_station_templates(station, detect_controller_host(), LOXONE_DIR) + src = folder / "wb-rules-station.js" + dst = target_dir / f"alice_station_{safe_name(station.station_id, 'station')}.js" + + try: + shutil.copy2(src, dst) + except PermissionError: + subprocess.run(["sudo", "-n", "install", "-m", "0644", str(src), str(dst)], check=True) + + installed.append(str(dst)) + + try: + subprocess.run(["systemctl", "restart", "wb-rules"], check=True) + except Exception: + subprocess.run(["sudo", "-n", "systemctl", "restart", "wb-rules"], check=True) + + return {"ok": True, "installed": installed} + + +async def refresh_stations(token: str, timeout: float = 3.0) -> Dict[str, Any]: + token = token.strip() + if not token: + raise RuntimeError("token is required") + + cloud = await fetch_cloud_stations(token) + local = await discover_local(timeout=timeout) + merged = merge_cloud_with_local(cloud, local) + + payload = serialize_stations(merged) + save_json(STATIONS_PATH, payload) + + return {"ok": True, "stations": payload, "cloud_total": len(cloud), "local_total": len(local)} + + +async def handle_index(_: web.Request) -> web.Response: + return web.FileResponse(BASE_DIR / "web" / "index.html") + + +async def handle_static(request: web.Request) -> web.Response: + name = request.match_info.get("name", "") + p = BASE_DIR / "web" / name + if not p.exists() or not p.is_file(): + raise web.HTTPNotFound() + return web.FileResponse(p) + + +async def api_status(_: web.Request) -> web.Response: + stations = load_json(STATIONS_PATH, []) + cfg = load_json(CONFIG_PATH, {}) + return web.json_response( + { + "ok": True, + "token_set": bool(read_token("")), + "controller_host": detect_controller_host(), + "stations": stations, + "is_wiren_board": is_wiren_board(), + "platform": platform.platform(), + "config": {"controller_host": str(cfg.get("controller_host") or "")}, + } + ) + + +async def api_set_token(request: web.Request) -> web.Response: + body = await request.json() + token = str(body.get("token") or "").strip() + if not token: + return web.json_response({"ok": False, "error": "token is required"}, status=422) + save_token(token) + return web.json_response({"ok": True}) + + +async def api_set_host(request: web.Request) -> web.Response: + body = await request.json() + host = str(body.get("controller_host") or "").strip() + if not host: + return web.json_response({"ok": False, "error": "controller_host is required"}, status=422) + save_controller_host(host) + return web.json_response({"ok": True, "controller_host": detect_controller_host()}) + + +async def api_refresh(_: web.Request) -> web.Response: + try: + out = await refresh_stations(read_token("")) + return web.json_response(out) + except Exception as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=422) + + +async def api_templates_loxone(_: web.Request) -> web.Response: + try: + out = generate_templates("loxone", detect_controller_host()) + return web.json_response(out) + except Exception as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=422) + + +async def api_templates_wb(_: web.Request) -> web.Response: + try: + out = generate_templates("wb-rules", detect_controller_host()) + return web.json_response(out) + except Exception as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=422) + + +async def api_download(request: web.Request) -> web.StreamResponse: + kind = request.match_info.get("kind", "") + if kind not in {"loxone", "wb-rules"}: + raise web.HTTPNotFound() + zip_name = "loxone_templates.zip" if kind == "loxone" else "wb_rules_templates.zip" + zip_path = LOXONE_DIR / zip_name + if not zip_path.exists(): + generate_templates(kind, detect_controller_host()) + return web.FileResponse(zip_path) + + +async def api_install_wb(request: web.Request) -> web.Response: + if not is_wiren_board(): + return web.json_response({"ok": False, "error": "not a Wiren Board environment"}, status=422) + try: + body = await request.json() + except Exception: + body = {} + station_id = str(body.get("station_id") or "").strip() or None + try: + out = install_wb_rules(station_id) + return web.json_response(out) + except Exception as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=422) + + +async def run_web(host: str, port: int) -> int: + app = web.Application() + app.router.add_get("/", handle_index) + app.router.add_get("/web/{name}", handle_static) + + app.router.add_get("/api/status", api_status) + app.router.add_post("/api/token", api_set_token) + app.router.add_post("/api/controller-host", api_set_host) + app.router.add_post("/api/stations/refresh", api_refresh) + app.router.add_post("/api/templates/loxone", api_templates_loxone) + app.router.add_post("/api/templates/wb-rules", api_templates_wb) + app.router.add_get("/api/download/{kind}", api_download) + app.router.add_post("/api/wb-rules/install", api_install_wb) + + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, host=host, port=port) + await site.start() + + print(f"alice plugin web listening on http://{host}:{port}") + while True: + await asyncio.sleep(3600) + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(description="Standalone Alice stations plugin") + p.add_argument("--token", default="", help="Yandex OAuth token") + + sub = p.add_subparsers(dest="cmd", required=True) + + sp = sub.add_parser("stations-refresh", help="1) get stations list") + sp.add_argument("--timeout", type=float, default=3.0) + + sp = sub.add_parser("templates-loxone", help="2) generate Loxone templates in ./loxone") + sp.add_argument("--controller-host", default="") + + sp = sub.add_parser("templates-wb-rules", help="3) generate wb-rules templates in ./loxone") + sp.add_argument("--controller-host", default="") + + sp = sub.add_parser("wb-install", help="install wb-rules templates to /etc/wb-rules") + sp.add_argument("--station-id", default="") + + sp = sub.add_parser("web", help="run web UI") + sp.add_argument("--host", default="0.0.0.0") + sp.add_argument("--port", type=int, default=9140) + + return p + + +async def main_async() -> int: + ensure_dirs() + parser = build_parser() + args = parser.parse_args() + + if args.cmd == "stations-refresh": + token = read_token(args.token) + out = await refresh_stations(token, timeout=args.timeout) + print(json.dumps(out, ensure_ascii=False, indent=2)) + return 0 + + if args.cmd == "templates-loxone": + host = (args.controller_host or "").strip() or detect_controller_host() + out = generate_templates("loxone", host) + print(json.dumps(out, ensure_ascii=False, indent=2)) + return 0 + + if args.cmd == "templates-wb-rules": + host = (args.controller_host or "").strip() or detect_controller_host() + out = generate_templates("wb-rules", host) + print(json.dumps(out, ensure_ascii=False, indent=2)) + return 0 + + if args.cmd == "wb-install": + out = install_wb_rules(args.station_id.strip() or None) + print(json.dumps(out, ensure_ascii=False, indent=2)) + return 0 + + if args.cmd == "web": + token = (args.token or "").strip() + if token: + save_token(token) + return await run_web(args.host, args.port) + + parser.print_help() + return 1 + + +def main() -> int: + try: + return asyncio.run(main_async()) + except KeyboardInterrupt: + return 130 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cmd_1_get_columns.sh b/cmd_1_get_columns.sh new file mode 100644 index 0000000..9f2034a --- /dev/null +++ b/cmd_1_get_columns.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +BASE_DIR="$(cd "$(dirname "$0")" && pwd)" +exec "$BASE_DIR/.venv/bin/python3" "$BASE_DIR/alice_plugin.py" stations-refresh "$@" diff --git a/cmd_2_get_loxone_templates.sh b/cmd_2_get_loxone_templates.sh new file mode 100644 index 0000000..5d57e20 --- /dev/null +++ b/cmd_2_get_loxone_templates.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +BASE_DIR="$(cd "$(dirname "$0")" && pwd)" +exec "$BASE_DIR/.venv/bin/python3" "$BASE_DIR/alice_plugin.py" templates-loxone "$@" diff --git a/cmd_3_get_wb_rules_templates.sh b/cmd_3_get_wb_rules_templates.sh new file mode 100644 index 0000000..5ea9101 --- /dev/null +++ b/cmd_3_get_wb_rules_templates.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +BASE_DIR="$(cd "$(dirname "$0")" && pwd)" +exec "$BASE_DIR/.venv/bin/python3" "$BASE_DIR/alice_plugin.py" templates-wb-rules "$@" diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..b8b1f1d --- /dev/null +++ b/install.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail +BASE_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$BASE_DIR" + +chmod +x *.sh + +if [[ -d .venv ]]; then + rm -rf .venv 2>/dev/null || sudo rm -rf .venv +fi + +python3 -m venv .venv +# shellcheck disable=SC1091 +source .venv/bin/activate +python3 -m pip install --upgrade pip +python3 -m pip install -r requirements.txt +python3 -c "import aiohttp, zeroconf" + +mkdir -p "$BASE_DIR/loxone" "$BASE_DIR/data" + +if command -v systemctl >/dev/null 2>&1; then + if [[ -w /etc/systemd/system ]]; then + cp -f "$BASE_DIR/systemd/shd-alice.service" /etc/systemd/system/shd-alice.service + cp -f "$BASE_DIR/systemd/shd-alice-plugin.service" /etc/systemd/system/shd-alice-plugin.service + systemctl daemon-reload + systemctl enable shd-alice.service || true + systemctl enable shd-alice-plugin.service || true + systemctl restart shd-alice-plugin.service || true + elif command -v sudo >/dev/null 2>&1; then + sudo cp -f "$BASE_DIR/systemd/shd-alice.service" /etc/systemd/system/shd-alice.service || true + sudo cp -f "$BASE_DIR/systemd/shd-alice-plugin.service" /etc/systemd/system/shd-alice-plugin.service || true + sudo systemctl daemon-reload || true + sudo systemctl enable shd-alice.service || true + sudo systemctl enable shd-alice-plugin.service || true + sudo systemctl restart shd-alice-plugin.service || true + fi +fi + +echo "Install done." diff --git a/loxone/.gitkeep b/loxone/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..95a205e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +aiohttp>=3.9,<4 +zeroconf>=0.132,<1 diff --git a/restart.sh b/restart.sh new file mode 100644 index 0000000..71cddbd --- /dev/null +++ b/restart.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +if command -v systemctl >/dev/null 2>&1 && systemctl list-unit-files | grep -q '^shd-alice.service'; then + systemctl restart shd-alice.service || sudo systemctl restart shd-alice.service || true +else + "$(cd "$(dirname "$0")" && pwd)/stop.sh" + nohup "$(cd "$(dirname "$0")" && pwd)/start.sh" >/tmp/shd-alice.log 2>&1 & +fi diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..40f95db --- /dev/null +++ b/start.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail +BASE_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$BASE_DIR" + +if [[ ! -d .venv ]]; then + python3 -m venv .venv +fi + +# shellcheck disable=SC1091 +source .venv/bin/activate +python3 -c "import aiohttp, zeroconf" >/dev/null 2>&1 || { + echo "Missing deps in .venv. Run install.sh" + exit 3 +} + +TOKEN="${YANDEX_TOKEN:-}" +if [[ -z "$TOKEN" && -f "$BASE_DIR/token.txt" ]]; then + TOKEN="$(tr -d '\r' < "$BASE_DIR/token.txt")" +fi + +if [[ -z "$TOKEN" ]]; then + echo "YANDEX_TOKEN is empty and token.txt not found" + exit 2 +fi + +BIND_HOST="${YASTATION_BIND_HOST:-127.0.0.1}" +BIND_PORT="${YASTATION_BIND_PORT:-9123}" +HTTP_HOST="${YASTATION_HTTP_HOST:-0.0.0.0}" +HTTP_PORT="${YASTATION_HTTP_PORT:-9124}" + +exec "$BASE_DIR/.venv/bin/python3" "$BASE_DIR/yastation.py" --token "$TOKEN" serve \ + --host "$BIND_HOST" \ + --port "$BIND_PORT" \ + --http-host "$HTTP_HOST" \ + --http-port "$HTTP_PORT" diff --git a/start_plugin_web.sh b/start_plugin_web.sh new file mode 100644 index 0000000..86e5c44 --- /dev/null +++ b/start_plugin_web.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail +BASE_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$BASE_DIR" + +if [[ ! -d .venv ]]; then + python3 -m venv .venv +fi + +# shellcheck disable=SC1091 +source .venv/bin/activate +python3 -c "import aiohttp, zeroconf" >/dev/null 2>&1 || { + echo "Missing deps in .venv. Run install.sh" + exit 3 +} + +WEB_HOST="${ALICE_PLUGIN_WEB_HOST:-0.0.0.0}" +WEB_PORT="${ALICE_PLUGIN_WEB_PORT:-9140}" + +exec "$BASE_DIR/.venv/bin/python3" "$BASE_DIR/alice_plugin.py" web --host "$WEB_HOST" --port "$WEB_PORT" diff --git a/stop.sh b/stop.sh new file mode 100644 index 0000000..d487ed8 --- /dev/null +++ b/stop.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail +if command -v systemctl >/dev/null 2>&1 && systemctl list-unit-files | grep -q '^shd-alice.service'; then + systemctl stop shd-alice.service || sudo systemctl stop shd-alice.service || true +else + pkill -f '/home/shd/scripts/alice/yastation.py' || true +fi diff --git a/systemd/shd-alice-plugin.service b/systemd/shd-alice-plugin.service new file mode 100644 index 0000000..24f4f1b --- /dev/null +++ b/systemd/shd-alice-plugin.service @@ -0,0 +1,15 @@ +[Unit] +Description=SHD Alice Plugin Web UI +After=network-online.target shd-alice.service +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/home/shd/scripts/alice +EnvironmentFile=-/home/shd/scripts/alice/.env +ExecStart=/bin/bash -lc '/home/shd/scripts/alice/start_plugin_web.sh' +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/systemd/shd-alice.service b/systemd/shd-alice.service new file mode 100644 index 0000000..bcb599b --- /dev/null +++ b/systemd/shd-alice.service @@ -0,0 +1,15 @@ +[Unit] +Description=SHD Alice Station Service +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/home/shd/scripts/alice +EnvironmentFile=-/home/shd/scripts/alice/.env +ExecStart=/bin/bash -lc '/home/shd/scripts/alice/start.sh' +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..9ff2a27 --- /dev/null +++ b/web/index.html @@ -0,0 +1,335 @@ + + + + + + Alice Stations Plugin + + + +
+
+

Alice Stations Plugin

+
+ +
+
+ + + + +
+
+ +
+
+ + + +
+
+
+ +
+
+ + + + + + + + + + + + + +
ИмяIDLocal IDКомнатаМодельIP
+
+
+
+ + + + diff --git a/yastation.py b/yastation.py new file mode 100644 index 0000000..12340de --- /dev/null +++ b/yastation.py @@ -0,0 +1,737 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import argparse +import asyncio +import json +import os +import ssl +import time +import uuid +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +import aiohttp +from aiohttp import web +from zeroconf import ServiceBrowser, Zeroconf + +MDNS_SERVICE = "_yandexio._tcp.local." +QUASAR_TOKEN_URL = "https://quasar.yandex.net/glagol/token" +YANDEX_INFO_URL = "https://api.iot.yandex.net/v1.0/user/info" + + +@dataclass +class Station: + device_id: str + platform: str + host: str + port: int + name: str = "" + + +class _Listener: + def __init__(self): + self.stations: Dict[str, Station] = {} + + def remove_service(self, zeroconf: Zeroconf, service_type: str, name: str) -> None: + pass + + def add_service(self, zeroconf: Zeroconf, service_type: str, name: str) -> None: + self._handle(zeroconf, service_type, name) + + def update_service(self, zeroconf: Zeroconf, service_type: str, name: str) -> None: + self._handle(zeroconf, service_type, name) + + def _handle(self, zeroconf: Zeroconf, service_type: str, name: str) -> None: + info = zeroconf.get_service_info(service_type, name) + if not info: + return + + props: Dict[str, Any] = {} + for k, v in (info.properties or {}).items(): + try: + kk = k.decode("utf-8") if isinstance(k, (bytes, bytearray)) else str(k) + vv = v.decode("utf-8", errors="ignore") if isinstance(v, (bytes, bytearray)) else v + props[kk] = vv + except Exception: + continue + + device_id = str(props.get("deviceId") or props.get("device_id") or "").strip() + platform = str(props.get("platform") or "").strip() + if not device_id or not platform: + return + + try: + addr = info.addresses[0] + host = ".".join(str(b) for b in addr) + except Exception: + return + + port = int(info.port or 0) + if not port: + return + + name_friendly = str(props.get("name") or props.get("deviceName") or name).strip() + + self.stations[device_id] = Station( + device_id=device_id, + platform=platform, + host=host, + port=port, + name=name_friendly, + ) + + +async def discover(timeout_sec: float = 2.5) -> List[Station]: + zc = Zeroconf() + listener = _Listener() + browser = ServiceBrowser(zc, MDNS_SERVICE, listener) + try: + await asyncio.sleep(timeout_sec) + finally: + try: + browser.cancel() + except Exception: + pass + try: + zc.close() + except Exception: + pass + return list(listener.stations.values()) + + +def _ssl_ctx() -> ssl.SSLContext: + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx + + +def _volume01_from_0_100(v: float) -> float: + if v < 0 or v > 100: + raise RuntimeError("volume must be 0..100") + return round(v / 100.0, 3) + + +def pick_station(stations: List[Station], selector: str) -> Station: + selector = (selector or "").strip() + if not selector: + raise RuntimeError("empty selector") + + for s in stations: + if s.device_id == selector: + return s + for s in stations: + if s.host == selector or f"{s.host}:{s.port}" == selector: + return s + low = selector.lower() + for s in stations: + if low in (s.name or "").lower(): + return s + raise RuntimeError(f"station not found by selector: {selector}") + + +async def get_conversation_token(session: aiohttp.ClientSession, oauth_token: str, station: Station) -> str: + params = {"device_id": station.device_id, "platform": station.platform} + headers = {"Authorization": f"oauth {oauth_token}"} + async with session.get(QUASAR_TOKEN_URL, params=params, headers=headers, timeout=aiohttp.ClientTimeout(total=10)) as r: + text = await r.text() + try: + data = json.loads(text) + except Exception: + raise RuntimeError(f"Quasar token: bad json: {text[:200]}") + if data.get("status") != "ok" or not data.get("token"): + raise RuntimeError(f"Quasar token: unexpected response: {data}") + return str(data["token"]) + + +async def ws_send(session: aiohttp.ClientSession, station: Station, conv_token: str, payload: dict, wait_sec: float = 5.0) -> dict: + url = f"wss://{station.host}:{station.port}" + req_id = str(uuid.uuid4()) + msg = { + "conversationToken": conv_token, + "id": req_id, + "payload": payload, + "sentTime": int(round(time.time() * 1000)), + } + + async with session.ws_connect(url, ssl=_ssl_ctx(), heartbeat=55, timeout=aiohttp.ClientTimeout(total=10)) as ws: + await ws.send_json(msg) + + deadline = time.time() + wait_sec + while time.time() < deadline: + in_msg = await ws.receive(timeout=wait_sec) + if in_msg.type == aiohttp.WSMsgType.TEXT: + try: + data = json.loads(in_msg.data) + except Exception: + continue + if data.get("requestId") == req_id or data.get("id") == req_id: + return data + elif in_msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR): + break + + return {"status": "timeout"} + + +async def do_list(args) -> int: + stations = await discover(args.timeout) + for s in stations: + print(f"{s.name}\tdevice_id={s.device_id}\tplatform={s.platform}\t{s.host}:{s.port}") + return 0 + + +async def do_stations_cloud(args) -> int: + oauth = args.token or os.environ.get("YANDEX_TOKEN", "").strip() + if not oauth: + raise RuntimeError("No token. Use --token or env YANDEX_TOKEN") + + async with aiohttp.ClientSession() as session: + headers = {"Authorization": f"OAuth {oauth}"} + async with session.get(YANDEX_INFO_URL, headers=headers, timeout=aiohttp.ClientTimeout(total=15)) as r: + data = await r.json(content_type=None) + + rooms = {} + for rm in (data.get("rooms") or []): + rid = str(rm.get("id") or "") + if rid: + rooms[rid] = str(rm.get("name") or "") + + out = [] + for d in (data.get("devices") or []): + tp = str(d.get("type") or "").lower() + if "speaker" not in tp and "smart_speaker" not in tp: + continue + rid = str(d.get("room") or "") + out.append({ + "name": str(d.get("name") or ""), + "id": str(d.get("id") or ""), + "room": rooms.get(rid, ""), + "model": str(d.get("model") or d.get("type") or ""), + }) + + print(json.dumps(out, ensure_ascii=False, indent=2)) + return 0 + + +class StationConn: + def __init__(self, station: Station, oauth_token: str, wait_sec: float): + self.station = station + self.oauth_token = oauth_token + self.wait_sec = wait_sec + self._session: Optional[aiohttp.ClientSession] = None + self._ws: Optional[aiohttp.ClientWebSocketResponse] = None + self._conv_token: str = "" + self._conv_exp_ts: float = 0.0 + self._lock = asyncio.Lock() + self._playing: Optional[int] = None + self._volume: Optional[int] = None + self._last_probe_ts: float = 0.0 + + @property + def playing(self) -> Optional[int]: + if self._playing is None: + return None + return 1 if self._playing else 0 + + @property + def volume(self) -> Optional[int]: + if self._volume is None: + return None + v = int(self._volume) + if v < 0: + return 0 + if v > 100: + return 100 + return v + + @staticmethod + def _to_playing_flag(value: Any) -> Optional[int]: + if isinstance(value, bool): + return 1 if value else 0 + if isinstance(value, (int, float)): + v = int(round(float(value))) + if v == 2: + return 1 + if v in (0, 1): + return v + if isinstance(value, str): + 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"}: + return 0 + try: + vv = int(float(low)) + if vv == 2: + return 1 + if vv in (0, 1): + return vv + except Exception: + return None + return None + + @classmethod + def _infer_playing(cls, obj: Any) -> Optional[int]: + if isinstance(obj, dict): + for k in ("playing", "isPlaying", "playState", "playerState", "state"): + if k in obj: + out = cls._to_playing_flag(obj.get(k)) + if out is not None: + return out + for v in obj.values(): + out = cls._infer_playing(v) + if out is not None: + return out + elif isinstance(obj, list): + for v in obj: + out = cls._infer_playing(v) + if out is not None: + return out + return None + + @staticmethod + def _to_volume_percent(value: Any) -> Optional[int]: + if isinstance(value, bool): + return 100 if value else 0 + if isinstance(value, (int, float)): + v = float(value) + if 0.0 <= v <= 1.0: + v = v * 100.0 + iv = int(round(v)) + if iv < 0: + iv = 0 + if iv > 100: + iv = 100 + return iv + if isinstance(value, str): + s = value.strip().replace(",", ".") + if not s: + return None + try: + return StationConn._to_volume_percent(float(s)) + except Exception: + return None + return None + + @classmethod + def _infer_volume(cls, obj: Any) -> Optional[int]: + if isinstance(obj, dict): + for k in ("volume", "currentVolume", "volumeLevel", "level"): + if k in obj: + out = cls._to_volume_percent(obj.get(k)) + if out is not None: + return out + for v in obj.values(): + out = cls._infer_volume(v) + if out is not None: + return out + elif isinstance(obj, list): + for v in obj: + out = cls._infer_volume(v) + if out is not None: + return out + return None + + async def start(self): + if self._session is None: + self._session = aiohttp.ClientSession() + await self._ensure_ws() + + async def close(self): + try: + if self._ws is not None: + await self._ws.close() + except Exception: + pass + self._ws = None + try: + if self._session is not None: + await self._session.close() + except Exception: + pass + self._session = None + + async def _ensure_conv_token(self): + now = time.time() + if self._conv_token and self._conv_exp_ts > now: + return + assert self._session is not None + self._conv_token = await get_conversation_token(self._session, self.oauth_token, self.station) + self._conv_exp_ts = now + 60.0 + + async def _ensure_ws(self): + assert self._session is not None + await self._ensure_conv_token() + + if self._ws is not None and not self._ws.closed: + return + + url = f"wss://{self.station.host}:{self.station.port}" + self._ws = await self._session.ws_connect( + url, + ssl=_ssl_ctx(), + heartbeat=55, + timeout=aiohttp.ClientTimeout(total=10), + ) + + async def send(self, payload: dict) -> dict: + async with self._lock: + await self._ensure_ws() + + req_id = str(uuid.uuid4()) + cmd = str(payload.get("command") or "").strip().lower() + sent_volume = None + if cmd == "setvolume": + sent_volume = self._to_volume_percent(payload.get("volume")) + if sent_volume is not None: + # Optimistic update: status endpoint can immediately expose requested level. + self._volume = sent_volume + msg = { + "conversationToken": self._conv_token, + "id": req_id, + "payload": payload, + "sentTime": int(round(time.time() * 1000)), + } + + try: + assert self._ws is not None + await self._ws.send_json(msg) + except Exception: + try: + if self._ws is not None: + await self._ws.close() + except Exception: + pass + self._ws = None + await self._ensure_ws() + assert self._ws is not None + await self._ws.send_json(msg) + + deadline = time.time() + self.wait_sec + while time.time() < deadline: + try: + in_msg = await self._ws.receive(timeout=self.wait_sec) + except Exception: + break + if in_msg.type == aiohttp.WSMsgType.TEXT: + try: + data = json.loads(in_msg.data) + except Exception: + continue + if data.get("requestId") == req_id or data.get("id") == req_id: + inferred = self._infer_playing(data) + if inferred is not None: + self._playing = inferred + elif cmd == "play": + self._playing = 1 + elif cmd in {"pause", "stop"}: + self._playing = 0 + inferred_volume = self._infer_volume(data) + if inferred_volume is not None: + self._volume = inferred_volume + elif sent_volume is not None: + self._volume = sent_volume + return data + elif in_msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR): + break + + if sent_volume is not None: + self._volume = sent_volume + return {"status": "timeout", "id": req_id} + + async def probe_state(self) -> None: + now = time.time() + if now - self._last_probe_ts < 5.0: + return + self._last_probe_ts = now + + candidates = [ + {"command": "getState"}, + {"command": "getStatus"}, + {"command": "status"}, + {"command": "playerState"}, + ] + for payload in candidates: + try: + data = await self.send(payload) + except Exception: + continue + if self._infer_playing(data) is not None or self._infer_volume(data) is not None: + break + + +async def serve_daemon(args) -> int: + oauth = args.token or os.environ.get("YANDEX_TOKEN", "").strip() + if not oauth: + raise RuntimeError("No token. Use --token or env YANDEX_TOKEN") + + stations = await discover(args.timeout) + selected: List[Station] = [] + + if args.stations: + for sel in args.stations.split(","): + selected.append(pick_station(stations, sel.strip())) + else: + selected = stations + + if not selected: + raise RuntimeError("No stations found") + + conns: Dict[str, StationConn] = {} + for st in selected: + conns[st.device_id] = StationConn(st, oauth, args.wait) + await conns[st.device_id].start() + + async def resolve_conn(station_sel: str) -> Optional[StationConn]: + station_sel = (station_sel or "").strip() + if not station_sel: + return None + + conn = conns.get(station_sel) + if conn is not None: + return conn + + low = station_sel.lower() + for c in conns.values(): + if c.station.device_id == station_sel or low in (c.station.name or "").lower(): + return c + if c.station.host == station_sel or f"{c.station.host}:{c.station.port}" == station_sel: + return c + return None + + async def execute_action(req: Dict[str, Any]) -> Dict[str, Any]: + station_sel = str(req.get("station") or "").strip() + if not station_sel: + return {"ok": False, "error": "station is required"} + + action = str(req.get("action") or "").strip().lower() + conn = await resolve_conn(station_sel) + if conn is None: + return {"ok": False, "error": "station not found"} + + try: + if action == "tts": + text = str(req.get("text") or "").strip() + if not text: + raise RuntimeError("text 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) + payload = { + "command": "serverAction", + "serverActionEventPayload": { + "type": "server_action", + "name": "update_form", + "payload": { + "form_update": { + "name": "personal_assistant.scenarios.repeat_after_me", + "slots": [{"type": "string", "name": "request", "value": text}], + }, + "resubmit": True, + }, + }, + } + elif action == "command": + text = str(req.get("text") or "").strip() + if not text: + raise RuntimeError("text is required") + 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") + payload = {"command": cmd} + elif action == "volume": + lvl = float(req.get("level")) + payload = {"command": "setVolume", "volume": _volume01_from_0_100(lvl)} + elif action == "raw": + payload = req.get("payload") + if isinstance(payload, list): + if not payload: + raise RuntimeError("payload list is empty") + results = [] + for idx, p in enumerate(payload): + if not isinstance(p, dict): + raise RuntimeError(f"payload[{idx}] must be object") + results.append(await conn.send(p)) + return {"ok": True, "result": results} + if not isinstance(payload, dict): + raise RuntimeError("payload must be object or array of objects") + else: + raise RuntimeError("action must be tts|command|player|volume|raw") + + result = await conn.send(payload) + return {"ok": True, "result": result} + except Exception as e: + return {"ok": False, "error": str(e)} + + async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter): + try: + line = await reader.readline() + if not line: + writer.close() + return + req = json.loads(line.decode("utf-8", errors="ignore")) + except Exception as e: + writer.write((json.dumps({"ok": False, "error": f"bad request: {e}"}, ensure_ascii=False) + "\n").encode("utf-8")) + await writer.drain() + writer.close() + return + resp = await execute_action(req) + + writer.write((json.dumps(resp, ensure_ascii=False) + "\n").encode("utf-8")) + await writer.drain() + writer.close() + + server = await asyncio.start_server(handle_client, host=args.host, port=args.port) + addrs = ", ".join(str(sock.getsockname()) for sock in server.sockets or []) + print(f"tcp server listening on {addrs}") + + app = web.Application() + + async def http_exec(request: web.Request) -> web.Response: + data: Dict[str, Any] = {} + if request.method == "POST": + if request.content_type and "application/json" in request.content_type: + body = await request.json() + if isinstance(body, dict): + data.update(body) + else: + body = await request.post() + data.update({k: v for k, v in body.items()}) + data.update({k: v for k, v in request.query.items()}) + + if "payload" in data and isinstance(data["payload"], str): + payload_raw = str(data["payload"]).strip() + if payload_raw: + try: + data["payload"] = json.loads(payload_raw) + except Exception: + return web.json_response({"ok": False, "error": "bad payload json"}, status=400) + + result = await execute_action(data) + 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: + return web.json_response({"ok": False, "error": "station not found"}, status=404) + # Keep persistent WS connection: try to restore it on each status poll. + try: + await conn.start() + except Exception: + pass + try: + await conn.probe_state() + except Exception: + pass + running = 1 if (conn._ws is not None and not conn._ws.closed) else 0 + return web.json_response({ + "ok": 1, + "running": running, + "playing": conn.playing, + "volume": conn.volume, + "station": conn.station.device_id, + }) + + async def http_health(_request: web.Request) -> web.Response: + return web.json_response({"ok": 1}) + + app.router.add_get("/health", http_health) + app.router.add_get("/api/status", http_status) + app.router.add_get("/api/exec", http_exec) + app.router.add_post("/api/exec", http_exec) + + http_runner = web.AppRunner(app) + await http_runner.setup() + http_site = web.TCPSite(http_runner, host=args.http_host, port=args.http_port) + await http_site.start() + print(f"http server listening on {(args.http_host, args.http_port)}") + + try: + async with server: + await server.serve_forever() + finally: + try: + await http_runner.cleanup() + except Exception: + pass + for c in conns.values(): + await c.close() + + +async def client_call(args) -> int: + req = {"station": args.station, "action": args.action} + if args.action in {"tts", "command"}: + req["text"] = args.text + elif args.action == "player": + req["cmd"] = args.cmd + elif args.action == "volume": + req["level"] = float(args.level) + elif args.action == "raw": + req["payload"] = json.loads(args.payload) + + reader, writer = await asyncio.open_connection(args.host, args.port) + writer.write((json.dumps(req, ensure_ascii=False) + "\n").encode("utf-8")) + await writer.drain() + line = await reader.readline() + writer.close() + + if not line: + print(json.dumps({"ok": False, "error": "no response"}, ensure_ascii=False)) + return 2 + + print(line.decode("utf-8", errors="ignore").rstrip("\n")) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="yastation.py", description="Yandex station helper") + p.add_argument("--timeout", type=float, default=2.5) + p.add_argument("--token", type=str, default="") + p.add_argument("--wait", type=float, default=5.0) + + sub = p.add_subparsers(dest="action", required=True) + + sp = sub.add_parser("list") + sp.set_defaults(fn=do_list) + + sp = sub.add_parser("stations-cloud") + sp.set_defaults(fn=do_stations_cloud) + + sp = sub.add_parser("serve") + sp.add_argument("--host", default="127.0.0.1") + sp.add_argument("--port", type=int, default=9123) + sp.add_argument("--http-host", default="0.0.0.0") + sp.add_argument("--http-port", type=int, default=9124) + sp.add_argument("--stations", default="") + sp.set_defaults(fn=serve_daemon) + + sp = sub.add_parser("client") + sp.add_argument("--host", default="127.0.0.1") + sp.add_argument("--port", type=int, default=9123) + sp.add_argument("--station", required=True) + sp.add_argument("action", help="tts|command|player|volume|raw") + sp.add_argument("--text", default="") + sp.add_argument("--cmd", default="") + sp.add_argument("--level", default="0") + sp.add_argument("--payload", default="{}") + sp.set_defaults(fn=client_call) + + return p + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + try: + return asyncio.run(args.fn(args)) + except KeyboardInterrupt: + return 130 + + +if __name__ == "__main__": + raise SystemExit(main())