Refine wb-rules examples and align player/TTS API

This commit is contained in:
2026-04-02 11:16:25 +03:00
parent 50961eb3fc
commit 2c8f9f32b9
5 changed files with 290 additions and 19 deletions

View File

@@ -1,5 +1,13 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Alice plugin: CLI + web UI для подготовки и эксплуатации интеграции.
Что делает модуль:
- получает станции из облака Яндекса и из локального mDNS;
- объединяет данные в единый `stations.json`;
- генерирует шаблоны для Loxone и wb-rules;
- отдаёт web API для первичной настройки.
"""
import argparse
import asyncio
@@ -36,6 +44,7 @@ YANDEX_INFO_URL = "https://api.iot.yandex.net/v1.0/user/info"
@dataclass
class StationRecord:
"""Нормализованная запись станции для хранения и генерации шаблонов."""
name: str
station_id: str
home: str
@@ -45,6 +54,13 @@ class StationRecord:
local_id: str
def selector(self) -> str:
"""Выбирает лучший идентификатор станции для API-команд.
Приоритет:
1) host из `con_url` (если есть) - самый надёжный для локального управления;
2) `local_id` из mDNS;
3) `station_id` из облака.
"""
host = ""
if self.con_url:
value = self.con_url.strip()
@@ -60,6 +76,7 @@ class StationRecord:
def ensure_dirs() -> None:
"""Гарантирует базовую структуру директорий проекта."""
DATA_DIR.mkdir(parents=True, exist_ok=True)
CONFIGS_DIR.mkdir(parents=True, exist_ok=True)
LOXONE_DIR.mkdir(parents=True, exist_ok=True)
@@ -86,6 +103,12 @@ def save_json(path: Path, payload: Any) -> None:
def read_token(cli_token: str) -> str:
"""Читает токен из источников по приоритету.
Зачем порядок важен:
- CLI/ENV позволяют временно переопределить токен;
- `token.txt` и `config.json` нужны для постоянного хранения.
"""
token = (cli_token or "").strip()
if token:
return token
@@ -164,6 +187,12 @@ def _detect_local_ip() -> str:
def detect_controller_host() -> str:
"""Определяет адрес контроллера для ссылок в шаблонах.
Сначала используем явные настройки (config/env), затем авто-детект LAN IP.
Loopback отбрасывается, чтобы избежать нерабочих ссылок
при использовании шаблонов на внешних клиентах.
"""
cfg = load_json(CONFIG_PATH, {})
cfg_host = str(cfg.get("controller_host") or "").strip()
if cfg_host and not _is_loopback_host(cfg_host):
@@ -183,6 +212,11 @@ def normalize_model(model: str) -> str:
async def fetch_cloud_stations(token: str) -> List[Dict[str, Any]]:
"""Получает станции из облачного API и нормализует структуру.
На выходе каждая станция содержит `_match_ids` - набор идентификаторов,
по которым затем безопасно матчим локальное mDNS-обнаружение.
"""
headers = {"Authorization": f"OAuth {token}"}
timeout = aiohttp.ClientTimeout(total=20)
async with aiohttp.ClientSession(timeout=timeout) as session:
@@ -267,6 +301,13 @@ async def discover_local(timeout: float = 3.0) -> List[Dict[str, str]]:
def merge_cloud_with_local(cloud: List[Dict[str, Any]], local: List[Dict[str, str]]) -> List[StationRecord]:
"""Объединяет облачные и локальные данные станций.
Логика сопоставления:
1) строго по идентификаторам (`_match_ids`);
2) по имени, только если совпадение имени уникально;
3) если совпадения нет - IP оставляем пустым (без опасных догадок).
"""
def nrm(value: str) -> str:
return " ".join((value or "").split()).strip().lower()
@@ -302,10 +343,6 @@ def merge_cloud_with_local(cloud: List[Dict[str, Any]], local: List[Dict[str, st
ip = str(item.get("ip") or "").strip()
local_id = str(item.get("id") or "").strip()
if not ip and len(local) == 1:
ip = str(local[0].get("ip") or "").strip()
local_id = str(local[0].get("id") or "").strip()
con_url = ip if ip else ""
merged.append(
StationRecord(
@@ -374,7 +411,6 @@ def build_vo_xml(controller_host: str, station_name: str, selector: str) -> str:
mk("TTS", f"/api/exec?action=tts&station={qs}&text=<v>&volume=35", True),
mk("Command", f"/api/exec?action=command&station={qs}&text=<v>", True),
mk("Play", f"/api/exec?action=player&station={qs}&cmd=play"),
mk("Pause", f"/api/exec?action=player&station={qs}&cmd=pause"),
mk("Stop", f"/api/exec?action=player&station={qs}&cmd=stop"),
mk("Next", f"/api/exec?action=player&station={qs}&cmd=next"),
mk("Prev", f"/api/exec?action=player&station={qs}&cmd=prev"),
@@ -458,10 +494,9 @@ defineVirtualDevice(DEVICE_ID, {{
raw_json: {{ title: {{ en: "Raw JSON", ru: "RAW JSON" }}, type: "text", value: "{{\\"command\\":\\"stop\\"}}", readonly: false, order: 220 }},
raw_send: {{ title: {{ en: "Send RAW", ru: "Отправить RAW" }}, type: "pushbutton", value: false, order: 230 }},
play: {{ title: {{ en: "Play", ru: "Play" }}, type: "pushbutton", value: false, order: 300 }},
pause: {{ title: {{ en: "Pause", ru: "Pause" }}, type: "pushbutton", value: false, order: 310 }},
stop: {{ title: {{ en: "Stop", ru: "Stop" }}, type: "pushbutton", value: false, order: 320 }},
next: {{ title: {{ en: "Next", ru: "Next" }}, type: "pushbutton", value: false, order: 330 }},
prev: {{ title: {{ en: "Prev", ru: "Prev" }}, type: "pushbutton", value: false, order: 340 }},
stop: {{ title: {{ en: "Stop", ru: "Stop" }}, type: "pushbutton", value: false, order: 310 }},
next: {{ title: {{ en: "Next", ru: "Next" }}, type: "pushbutton", value: false, order: 320 }},
prev: {{ title: {{ en: "Prev", ru: "Prev" }}, type: "pushbutton", value: false, order: 330 }},
status_json: {{ title: {{ en: "Status JSON", ru: "Статус JSON" }}, type: "text", value: "{{}}", readonly: true, order: 400 }}
}}
}});
@@ -505,7 +540,6 @@ function _player(cmd) {{
}}
defineRule(DEVICE_ID + "_play", {{ whenChanged: DEVICE_ID + "/play", then: _player("play") }});
defineRule(DEVICE_ID + "_pause", {{ whenChanged: DEVICE_ID + "/pause", then: _player("pause") }});
defineRule(DEVICE_ID + "_stop", {{ whenChanged: DEVICE_ID + "/stop", then: _player("stop") }});
defineRule(DEVICE_ID + "_next", {{ whenChanged: DEVICE_ID + "/next", then: _player("next") }});
defineRule(DEVICE_ID + "_prev", {{ whenChanged: DEVICE_ID + "/prev", then: _player("prev") }});
@@ -593,6 +627,7 @@ def write_wb_rules_templates(station: StationRecord, controller_host: str) -> Di
def generate_templates(template_kind: str, controller_host: str) -> Dict[str, Any]:
"""Генерирует шаблоны и zip-архив для выбранной платформы."""
stations = load_stations_records()
if not stations:
raise RuntimeError("stations.json is empty, run station refresh first")
@@ -636,6 +671,11 @@ def is_wiren_board() -> bool:
def install_wb_rules(station_id: Optional[str] = None) -> Dict[str, Any]:
"""Копирует подготовленные wb-rules в системный каталог и перезапускает wb-rules.
Если прямой записи нет, используется `sudo -n` (без интерактива),
чтобы функция была пригодна для автоматизации из web/CLI.
"""
target_dir = Path("/etc/wb-rules")
if not target_dir.exists():
raise RuntimeError("/etc/wb-rules not found")
@@ -671,6 +711,7 @@ def install_wb_rules(station_id: Optional[str] = None) -> Dict[str, Any]:
async def refresh_stations(token: str, timeout: float = 3.0) -> Dict[str, Any]:
"""Обновляет `stations.json` на основе облака + локальной сети."""
token = token.strip()
if not token:
raise RuntimeError("token is required")
@@ -705,6 +746,7 @@ async def handle_static(request: web.Request) -> web.Response:
async def api_status(_: web.Request) -> web.Response:
"""Сводный статус для web UI: токен, станции, окружение, конфиг."""
stations = load_json(STATIONS_PATH, [])
cfg = load_json(CONFIG_PATH, {})
return web.json_response(
@@ -721,7 +763,10 @@ async def api_status(_: web.Request) -> web.Response:
async def api_set_token(request: web.Request) -> web.Response:
body = await request.json()
try:
body = await request.json()
except Exception:
return web.json_response({"ok": False, "error": "invalid json body"}, status=400)
token = str(body.get("token") or "").strip()
if not token:
return web.json_response({"ok": False, "error": "token is required"}, status=422)
@@ -730,7 +775,10 @@ async def api_set_token(request: web.Request) -> web.Response:
async def api_set_host(request: web.Request) -> web.Response:
body = await request.json()
try:
body = await request.json()
except Exception:
return web.json_response({"ok": False, "error": "invalid json body"}, status=400)
host = str(body.get("controller_host") or "").strip()
if not host:
return web.json_response({"ok": False, "error": "controller_host is required"}, status=422)
@@ -739,6 +787,7 @@ async def api_set_host(request: web.Request) -> web.Response:
async def api_refresh(_: web.Request) -> web.Response:
"""Перестраивает список станций и возвращает диагностику объединения."""
try:
out = await refresh_stations(read_token(""))
return web.json_response(out)
@@ -789,6 +838,7 @@ async def api_install_wb(request: web.Request) -> web.Response:
async def run_web(host: str, port: int) -> int:
"""Запускает web UI и REST API плагина."""
app = web.Application()
app.router.add_get("/", handle_index)
app.router.add_get("/web/{name}", handle_static)
@@ -838,6 +888,12 @@ def build_parser() -> argparse.ArgumentParser:
async def main_async() -> int:
"""Точка маршрутизации CLI-команд.
Принцип:
- каждая команда выполняет ровно один сценарий;
- вывод в JSON удобен для автоматизации и диагностики.
"""
ensure_dirs()
parser = build_parser()
args = parser.parse_args()