release
This commit is contained in:
244
README.md
244
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://<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
847
alice_plugin.py
Normal 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"ok":\\i\\v" Signed="false" Analog="true" SourceValLow="0" DestValLow="0" SourceValHigh="1" DestValHigh="1" DefVal="0" MinVal="0" MaxVal="1" Unit="<v.0>" HintText=""/>',
|
||||
'\t<VirtualInHttpCmd Title="WS Running" Comment="" Check="\\i"running":\\i\\v" Signed="false" Analog="true" SourceValLow="0" DestValLow="0" SourceValHigh="1" DestValHigh="1" DefVal="0" MinVal="0" MaxVal="1" Unit="<v.0>" HintText=""/>',
|
||||
'\t<VirtualInHttpCmd Title="Playing" Comment="" Check="\\i"playing":\\i\\v" Signed="false" Analog="true" SourceValLow="0" DestValLow="0" SourceValHigh="1" DestValHigh="1" DefVal="0" MinVal="0" MaxVal="1" Unit="<v.0>" HintText=""/>',
|
||||
'\t<VirtualInHttpCmd Title="Volume" Comment="" Check="\\i"volume":\\i\\v" Signed="false" Analog="true" SourceValLow="0" DestValLow="0" SourceValHigh="100" DestValHigh="100" DefVal="0" MinVal="0" MaxVal="100" Unit="<v.0>" 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
4
cmd_1_get_columns.sh
Normal 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 "$@"
|
||||
4
cmd_2_get_loxone_templates.sh
Normal file
4
cmd_2_get_loxone_templates.sh
Normal 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 "$@"
|
||||
4
cmd_3_get_wb_rules_templates.sh
Normal file
4
cmd_3_get_wb_rules_templates.sh
Normal 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
0
data/.gitkeep
Normal file
39
install.sh
Normal file
39
install.sh
Normal 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
0
loxone/.gitkeep
Normal file
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
aiohttp>=3.9,<4
|
||||
zeroconf>=0.132,<1
|
||||
8
restart.sh
Normal file
8
restart.sh
Normal 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
36
start.sh
Normal 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
20
start_plugin_web.sh
Normal 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
7
stop.sh
Normal 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
|
||||
15
systemd/shd-alice-plugin.service
Normal file
15
systemd/shd-alice-plugin.service
Normal 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
15
systemd/shd-alice.service
Normal 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
335
web/index.html
Normal 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
737
yastation.py
Normal 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())
|
||||
Reference in New Issue
Block a user