Refine wb-rules examples and align player/TTS API
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user