This commit is contained in:
2026-03-07 22:43:08 +03:00
parent 61e7e3af56
commit 7a86519314
17 changed files with 2316 additions and 1 deletions

244
README.md
View File

@@ -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://<controller-ip>: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 <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
```

847
alice_plugin.py Normal file
View File

@@ -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'<VirtualOutCmd Title="{html.escape(title)}" Comment="" CmdOnMethod="GET" CmdOffMethod="GET" '
f'CmdOn="{html.escape(path)}" CmdOnHTTP="" CmdOnPost="" CmdOff="" CmdOffHTTP="" CmdOffPost="" '
f'CmdAnswer="" HintText="" Analog="{analog_val}" Repeat="0" RepeatRate="0"/>'
)
rows = [
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"),
mk("Volume", f"/api/exec?action=volume&station={qs}&level=<v>", True),
mk("Raw", f"/api/exec?action=raw&station={qs}&payload=<v>", True),
]
xml = ['<?xml version="1.0" encoding="utf-8"?>']
xml.append(
f'<VirtualOut Title="{html.escape("Alice - " + station_name)}" Comment="" Address="{html.escape(base)}" '
'CmdInit="" HintText="" CloseAfterSend="true" CmdSep=";">'
)
xml.append('\t<Info templateType="3" minVersion="16000610"/>')
xml.extend(["\t" + row for row in rows])
xml.append("</VirtualOut>")
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 = [
'<?xml version="1.0" encoding="utf-8"?>',
f'<VirtualInHttp Title="{title}" Comment="" Address="{html.escape(addr)}" HintText="" PollingTime="10">',
'\t<Info templateType="2" minVersion="16000610"/>',
'\t<VirtualInHttpCmd Title="Daemon OK" Comment="" Check="\\i&quot;ok&quot;:\\i\\v" Signed="false" Analog="true" SourceValLow="0" DestValLow="0" SourceValHigh="1" DestValHigh="1" DefVal="0" MinVal="0" MaxVal="1" Unit="&lt;v.0&gt;" HintText=""/>',
'\t<VirtualInHttpCmd Title="WS Running" Comment="" Check="\\i&quot;running&quot;:\\i\\v" Signed="false" Analog="true" SourceValLow="0" DestValLow="0" SourceValHigh="1" DestValHigh="1" DefVal="0" MinVal="0" MaxVal="1" Unit="&lt;v.0&gt;" HintText=""/>',
'\t<VirtualInHttpCmd Title="Playing" Comment="" Check="\\i&quot;playing&quot;:\\i\\v" Signed="false" Analog="true" SourceValLow="0" DestValLow="0" SourceValHigh="1" DestValHigh="1" DefVal="0" MinVal="0" MaxVal="1" Unit="&lt;v.0&gt;" HintText=""/>',
'\t<VirtualInHttpCmd Title="Volume" Comment="" Check="\\i&quot;volume&quot;:\\i\\v" Signed="false" Analog="true" SourceValLow="0" DestValLow="0" SourceValHigh="100" DestValHigh="100" DefVal="0" MinVal="0" MaxVal="100" Unit="&lt;v.0&gt;" HintText=""/>',
"</VirtualInHttp>",
]
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())

4
cmd_1_get_columns.sh Normal file
View File

@@ -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 "$@"

View File

@@ -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 "$@"

View File

@@ -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 "$@"

0
data/.gitkeep Normal file
View File

39
install.sh Normal file
View File

@@ -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."

0
loxone/.gitkeep Normal file
View File

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
aiohttp>=3.9,<4
zeroconf>=0.132,<1

8
restart.sh Normal file
View File

@@ -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

36
start.sh Normal file
View File

@@ -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"

20
start_plugin_web.sh Normal file
View File

@@ -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"

7
stop.sh Normal file
View File

@@ -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

View File

@@ -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

15
systemd/shd-alice.service Normal file
View File

@@ -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

335
web/index.html Normal file
View File

@@ -0,0 +1,335 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Alice Stations Plugin</title>
<style>
:root {
--bg-main: #000;
--bg-soft: #070b14;
--card: rgba(255, 255, 255, 0.06);
--card-strong: rgba(255, 255, 255, 0.08);
--accent: rgba(255, 255, 255, 0.14);
--text: rgb(228, 228, 228);
--muted: rgba(228, 228, 228, 0.78);
--ok: #2ad384;
--err: #ff6a6a;
--line: rgba(255, 255, 255, 0.18);
}
html, body {
margin: 0;
padding: 0;
height: 100%;
background: #000;
}
* { box-sizing: border-box; }
body {
min-height: 100svh;
position: relative;
overflow-x: hidden;
padding-top: 12px;
background: linear-gradient(160deg, var(--bg-main), var(--bg-soft));
color: var(--text);
font: 14px/1.35 "Artifakt Element", "IBM Plex Sans Condensed", "Segoe UI", "Noto Sans", sans-serif;
}
body::before,
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: -2;
}
body::before {
background:
radial-gradient(70vw 70vw at -10% -20%, rgba(255, 255, 255, 0.05), transparent 56%),
radial-gradient(60vw 60vw at 110% 20%, rgba(100, 120, 180, 0.10), transparent 60%),
radial-gradient(50vw 50vw at 50% 120%, rgba(80, 60, 120, 0.12), transparent 62%);
}
body::after {
z-index: -1;
background-image: linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
background-size: 24px 24px;
opacity: 0.24;
}
.wrap {
width: min(980px, 100%);
max-width: 980px;
margin: 0 auto;
padding: 0 12px;
}
.card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 10px;
backdrop-filter: blur(8px);
padding: 7px;
box-shadow: none;
margin-bottom: 8px;
overflow: hidden;
}
.card:last-child { margin-bottom: 0; }
.card:first-child {
background:
linear-gradient(130deg, rgba(255, 255, 255, 0.04), rgba(80, 60, 120, 0.18)),
var(--card-strong);
text-align: center;
}
h1 {
margin: 0;
font-size: 18px;
letter-spacing: .2px;
text-transform: none;
font-family: "IBM Plex Sans Condensed", "Artifakt Element", sans-serif;
}
.muted { color: var(--muted); }
.row { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
input {
border: 1px solid var(--line);
border-radius: 6px;
padding: 6px 9px;
height: 27px;
line-height: 27px;
font: inherit;
color: var(--text);
background: rgba(255, 255, 255, 0.08);
font-family: "Fira Mono", "Artifakt Element", monospace;
}
input::placeholder { color: rgba(228, 228, 228, 0.64); }
.btn {
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 6px;
padding: 0 10px;
height: 27px;
line-height: 27px;
cursor: pointer;
color: #fff;
background: var(--accent);
font: inherit;
font-weight: 500;
box-shadow: none;
transition: background .2s ease, transform .08s ease;
}
.btn:hover { background: rgba(255, 255, 255, 0.2); }
.btn:active { transform: translateY(1px); }
.btn.gray {
background: rgba(255, 255, 255, 0.08);
}
.btn.green {
background: rgba(42, 211, 132, 0.22);
border-color: rgba(42, 211, 132, 0.42);
height: 20px;
line-height: 20px;
padding: 0 8px;
font-size: 11px;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.field-input {
flex: 1 1 240px;
min-width: 180px;
}
.table-wrap {
width: 100%;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
}
table {
width: max-content;
min-width: 100%;
border-collapse: collapse;
font-size: 12px;
}
th, td {
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
text-align: left;
padding: 6px 5px;
vertical-align: middle;
white-space: nowrap;
}
th { color: #fff; font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .3px; }
#status { display: none; margin-top: 0; }
#status.show { display: block; margin-top: 8px; }
#status.ok { color: var(--ok); }
#status.err { color: var(--err); }
@media (max-width: 720px) {
h1 { font-size: 16px; }
.btn { width: 100%; }
.card { border-radius: 8px; }
}
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<h1>Alice Stations Plugin</h1>
</div>
<div class="card">
<div class="row">
<input class="field-input" id="token" placeholder="Yandex OAuth token">
<button class="btn" id="saveToken">Сохранить токен</button>
<input class="field-input" id="controllerHost" placeholder="IP для шаблонов (например 192.168.1.50)">
<button class="btn gray" id="saveHost">Сохранить IP</button>
</div>
</div>
<div class="card">
<div class="row">
<button class="btn" id="refresh">Найти колонки</button>
<button class="btn" id="dlLoxone">Скачать для Loxone</button>
<button class="btn" id="dlWb">Скачать для WB-rules</button>
</div>
<div id="status" class="muted"></div>
</div>
<div class="card">
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Имя</th>
<th>ID</th>
<th>Local ID</th>
<th>Комната</th>
<th>Модель</th>
<th>IP</th>
<th id="thInstall" style="display:none;">wb-rules</th>
</tr>
</thead>
<tbody id="stations"></tbody>
</table>
</div>
</div>
</div>
<script>
const q = (s) => document.querySelector(s);
const statusEl = q('#status');
let wbMode = false;
function setStatus(msg, ok = true) {
const text = String(msg || '').trim();
if (!text) {
statusEl.textContent = '';
statusEl.className = 'muted';
return;
}
statusEl.className = (ok ? 'ok' : 'err') + ' show';
statusEl.textContent = text;
}
async function api(path, method = 'GET', body = null) {
const r = await fetch(path, {
method,
headers: body ? {'Content-Type': 'application/json'} : undefined,
body: body ? JSON.stringify(body) : undefined,
});
const j = await r.json().catch(() => ({}));
if (!r.ok || j.ok === false) throw new Error(j.error || ('HTTP ' + r.status));
return j;
}
function stationRow(s) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${s.name || ''}</td>
<td>${s.id || ''}</td>
<td>${s.local_id || ''}</td>
<td>${s.room || ''}</td>
<td>${s.model || ''}</td>
<td>${s.ip || s.con_url || ''}</td>
<td class="wb-cell" style="display:${wbMode ? '' : 'none'}"></td>
`;
if (wbMode) {
const btn = document.createElement('button');
btn.className = 'btn green';
btn.textContent = 'Установить';
btn.onclick = async () => {
try {
setStatus('Устанавливаю wb-rules для ' + (s.name || s.id) + '...');
const out = await api('/api/wb-rules/install', 'POST', {station_id: s.id});
setStatus('Установлено: ' + (out.installed || []).join(', '));
} catch (e) {
setStatus(e.message, false);
}
};
tr.querySelector('.wb-cell').appendChild(btn);
}
return tr;
}
function renderStations(items) {
const tbody = q('#stations');
tbody.innerHTML = '';
(items || []).forEach((s) => tbody.appendChild(stationRow(s)));
}
async function refreshStatus() {
const st = await api('/api/status');
wbMode = !!st.is_wiren_board;
q('#thInstall').style.display = wbMode ? '' : 'none';
q('#controllerHost').value = (st.config && st.config.controller_host) || st.controller_host || '';
renderStations(st.stations || []);
}
q('#saveToken').onclick = async () => {
try {
await api('/api/token', 'POST', {token: q('#token').value});
setStatus('Токен сохранён');
q('#token').value = '';
await refreshStatus();
} catch (e) {
setStatus(e.message, false);
}
};
q('#saveHost').onclick = async () => {
try {
await api('/api/controller-host', 'POST', {controller_host: q('#controllerHost').value});
setStatus('IP сохранён');
await refreshStatus();
} catch (e) {
setStatus(e.message, false);
}
};
q('#refresh').onclick = async () => {
try {
setStatus('Обновляю список колонок...');
const out = await api('/api/stations/refresh', 'POST');
renderStations(out.stations || []);
setStatus('Готово, колонок: ' + (out.stations || []).length);
} catch (e) {
setStatus(e.message, false);
}
};
q('#dlLoxone').onclick = async () => {
try {
setStatus('Генерирую Loxone шаблоны и готовлю архив...');
await api('/api/templates/loxone', 'POST');
window.location.href = '/api/download/loxone';
setStatus('Скачивание Loxone ZIP запущено');
} catch (e) {
setStatus(e.message, false);
}
};
q('#dlWb').onclick = async () => {
try {
setStatus('Генерирую wb-rules шаблоны и готовлю архив...');
await api('/api/templates/wb-rules', 'POST');
window.location.href = '/api/download/wb-rules';
setStatus('Скачивание wb-rules ZIP запущено');
} catch (e) {
setStatus(e.message, false);
}
};
refreshStatus().catch((e) => setStatus(e.message, false));
</script>
</body>
</html>

737
yastation.py Normal file
View File

@@ -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())