diff --git a/README.md b/README.md index e4a60bd..eaf638a 100644 --- a/README.md +++ b/README.md @@ -1,244 +1,530 @@ # Alice Stations Plugin -Standalone-плагин для управления Яндекс Станциями, генерации шаблонов Loxone / wb-rules и web UI. +Плагин для поиска Яндекс Станций, сохранения токена, генерации конфигов для 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` - готовые команды для типовых действий. +- [Что умеет плагин](#что-умеет-плагин) +- [Что не умеет и слабые места](#что-не-умеет-и-слабые-места) +- [Структура проекта](#структура-проекта) +- [Куда устанавливать](#куда-устанавливать) +- [Быстрый старт](#быстрый-старт) +- [Полная установка](#полная-установка) +- [Токен](#токен) +- [CLI](#cli) +- [Прямые Python-команды](#прямые-python-команды) +- [Web UI](#web-ui) +- [Systemd](#systemd) +- [Где лежат файлы](#где-лежат-файлы) +- [Диагностика и логи](#диагностика-и-логи) +- [Частые ошибки](#частые-ошибки) +- [Удаление](#удаление) -## Требования +## Что умеет плагин -Минимум для Debian/Ubuntu, Wiren Board, Raspberry Pi OS: +- сохранить OAuth-токен Яндекса; +- получить список станций из облака и сопоставить его с локальным mDNS-поиском; +- сохранить итоговый список в `data/stations.json`; +- сгенерировать конфиги для `configs/loxone`; +- сгенерировать конфиги для `configs/wb-rules`; +- скачать ZIP-архивы через web UI; +- на Wiren Board установить `wb-rules` шаблоны прямо из web UI или CLI; +- поднять web UI для первичной настройки. + +## Что не умеет и слабые места + +- без валидного OAuth-токена обновление списка станций не работает; +- если станции и контроллер находятся в разных сетях, локальные IP могут не определиться; +- web UI не исправляет проблемы сети или токена, он только показывает состояние и запускает команды; +- генерация шаблонов зависит от `data/stations.json`, поэтому сначала всегда нужен поиск колонок; +- `wb-install` имеет смысл только на Wiren Board или на системе, где есть `/etc/wb-rules`. + +## Структура проекта + +```text +alice/ +├── alice_plugin.py +├── cli.sh +├── configs/ +│ ├── loxone/ +│ └── wb-rules/ +├── data/ +├── install.sh +├── README.md +├── requirements.txt +├── start.sh +├── start_plugin_web.sh +├── systemd/ +│ ├── shd-alice-plugin.service +│ └── shd-alice.service +├── web/ +│ └── index.html +└── yastation.py +``` + +Назначение файлов: + +- `alice_plugin.py` — CLI и backend для web UI; +- `yastation.py` — демон управления станциями и HTTP API на `:9124`; +- `cli.sh` — основной способ работы из терминала; +- `start.sh` — запуск демона для systemd; +- `start_plugin_web.sh` — запуск web UI для systemd; +- `install.sh` — установка в `/opt/shd/plugins/alice`, создание `.venv`, каталогов и systemd-юнитов. + +## Куда устанавливать + +Штатный путь проекта зафиксирован: + +```text +/opt/shd/plugins/alice +``` + +`install.sh` сам синхронизирует текущий каталог в этот путь. После установки основной рабочий каталог должен быть именно таким: + +```bash +/opt/shd/plugins/alice +``` + +## Быстрый старт + +```bash +cd /путь/к/распакованному/репозиторию +chmod +x *.sh +./install.sh + +printf %s 'YANDEX_OAUTH_TOKEN' > /opt/shd/plugins/alice/token.txt +chmod 600 /opt/shd/plugins/alice/token.txt + +cd /opt/shd/plugins/alice +./cli.sh columns +./cli.sh loxone +./cli.sh wb-rules +./cli.sh web +``` + +После этого web UI обычно доступен по адресу: + +```text +http://:9140/ +``` + +## Полная установка + +### 1. Установить системные зависимости ```bash sudo apt update sudo apt install -y python3 python3-venv python3-pip ``` -Важно: -- команды установки и запуска нужно выполнять из каталога проекта; -- если нет `python3-venv`, `install.sh` завершится ошибкой и сервисы не установятся. +Для более удобной синхронизации в `/opt` желателен `rsync`, но без него установка тоже работает. -## Рекомендуемая структура на контроллере - -```text -/home/shd/scripts/alice -``` - -Если репозиторий скопирован в другое место, нужно скорректировать пути в `systemd/*.service`. - -## Установка (пошагово) - -1. Скопировать проект: +### 2. Распаковать проект и запустить установку ```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 +cd /путь/к/распакованному/репозиторию chmod +x *.sh ./install.sh ``` -3. Сохранить OAuth-токен: +Что делает `install.sh`: + +- копирует проект в `/opt/shd/plugins/alice`; +- создаёт `.venv`, если его нет; +- ставит зависимости из `requirements.txt`; +- создаёт каталоги `data/`, `configs/loxone/`, `configs/wb-rules/`; +- копирует systemd-юниты в `/etc/systemd/system/`; +- выполняет `systemctl daemon-reload` и `systemctl enable` для сервисов. + +### 3. Сохранить токен + +Через файл: ```bash -printf %s 'YANDEX_OAUTH_TOKEN' > /home/shd/scripts/alice/token.txt -chmod 600 /home/shd/scripts/alice/token.txt +printf %s 'YANDEX_OAUTH_TOKEN' > /opt/shd/plugins/alice/token.txt +chmod 600 /opt/shd/plugins/alice/token.txt ``` -4. Включить и запустить сервисы: +Или через CLI/web-команду: + +```bash +cd /opt/shd/plugins/alice +./cli.sh web --token 'YANDEX_OAUTH_TOKEN' +``` + +### 4. Обновить список станций + +```bash +cd /opt/shd/plugins/alice +./cli.sh columns +``` + +### 5. Сгенерировать конфиги + +```bash +./cli.sh loxone +./cli.sh wb-rules +``` + +### 6. Включить сервисы ```bash -sudo systemctl daemon-reload sudo systemctl enable --now shd-alice.service shd-alice-plugin.service ``` -## Проверка после установки +## Токен -Проверка сервисов: +Проект ищет токен в таком порядке: + +1. аргумент `--token`; +2. переменная окружения `YANDEX_TOKEN`; +3. файл `token.txt`; +4. `data/config.json`. + +Надёжнее всего использовать `token.txt`: ```bash -sudo systemctl status shd-alice.service --no-pager -sudo systemctl status shd-alice-plugin.service --no-pager +/opt/shd/plugins/alice/token.txt ``` -Проверка web API: - -```bash -curl -s http://127.0.0.1:9140/api/status -``` - -Web UI: +Если токен неверный, при обновлении списка станций будет ошибка: ```text -http://:9140/ +YANDEX_OAUTH_TOKEN invalid: update token and retry ``` -## Обязательные команды (CLI) +## 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 | +Основной интерфейс из терминала — `cli.sh`. -Примеры запуска: +Перед использованием: ```bash -cd /home/shd/scripts/alice -./cmd_1_get_columns.sh -./cmd_2_get_loxone_templates.sh -./cmd_3_get_wb_rules_templates.sh +cd /opt/shd/plugins/alice ``` -### Команды `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}' +./cli.sh columns ``` -Для станции в шаблоне есть: -- `VirtualOut` команды: `TTS`, `Command`, `Play`, `Pause`, `Stop`, `Next`, `Prev`, `Volume`, `Raw`. -- `VirtualIn` статусы: `Daemon OK`, `WS Running`, `Playing`, `Volume`. +С токеном напрямую: -## Работа через web UI +```bash +./cli.sh --token 'YANDEX_OAUTH_TOKEN' columns +``` -На странице можно: -- сохранить OAuth-токен; -- найти/обновить список колонок; +С таймаутом локального поиска: + +```bash +./cli.sh columns --timeout 5 +``` + +Что делает команда: + +- проверяет токен; +- читает облачный список устройств; +- делает локальный mDNS-поиск; +- объединяет результаты; +- пишет `data/stations.json`. + +### Генерация Loxone + +```bash +./cli.sh loxone +``` + +С ручным IP контроллера: + +```bash +./cli.sh loxone --controller-host 192.168.1.50 +``` + +Что создаётся: + +- каталоги по станциям в `configs/loxone/`; +- `VO.xml`; +- `VI.xml`; +- `README.md` по каждой станции; +- `configs/loxone/loxone_templates.zip`. + +### Генерация wb-rules + +```bash +./cli.sh wb-rules +``` + +С ручным IP контроллера: + +```bash +./cli.sh wb-rules --controller-host 192.168.1.50 +``` + +Что создаётся: + +- каталоги по станциям в `configs/wb-rules/`; +- `wb-rules-station.js`; +- `README.md` по каждой станции; +- `configs/wb-rules/wb_rules_templates.zip`. + +### Установка wb-rules в систему + +```bash +./cli.sh wb-install +``` + +Для одной станции: + +```bash +./cli.sh wb-install --station-id +``` + +Что делает команда: + +- берёт файлы из `configs/wb-rules/`; +- копирует их в `/etc/wb-rules/`; +- перезапускает `wb-rules`. + +### Запуск web UI + +```bash +./cli.sh web +``` + +С токеном при первом запуске: + +```bash +./cli.sh --token 'YANDEX_OAUTH_TOKEN' web +``` + +С другим адресом и портом: + +```bash +./cli.sh web --host 0.0.0.0 --port 9140 +``` + +## Прямые Python-команды + +Все публичные Python-команды задокументированы ниже. Их можно использовать напрямую, но штатный способ — `cli.sh`. + +### Обновить станции + +```bash +python3 alice_plugin.py --token 'YANDEX_OAUTH_TOKEN' stations-refresh +``` + +Дополнительно: + +```bash +python3 alice_plugin.py --token 'YANDEX_OAUTH_TOKEN' stations-refresh --timeout 5 +``` + +### Сгенерировать Loxone + +```bash +python3 alice_plugin.py templates-loxone +``` + +С ручным IP контроллера: + +```bash +python3 alice_plugin.py templates-loxone --controller-host 192.168.1.50 +``` + +### Сгенерировать wb-rules + +```bash +python3 alice_plugin.py templates-wb-rules +``` + +С ручным IP контроллера: + +```bash +python3 alice_plugin.py templates-wb-rules --controller-host 192.168.1.50 +``` + +### Установить wb-rules + +```bash +python3 alice_plugin.py wb-install +``` + +Для одной станции: + +```bash +python3 alice_plugin.py wb-install --station-id +``` + +### Запустить web UI + +```bash +python3 alice_plugin.py --token 'YANDEX_OAUTH_TOKEN' web +``` + +На другом адресе/порту: + +```bash +python3 alice_plugin.py web --host 0.0.0.0 --port 9140 +``` + +## Web UI + +Web UI умеет: + +- сохранить токен; +- сохранить IP контроллера; +- обновить список колонок; - скачать ZIP для Loxone; - скачать ZIP для wb-rules; -- на Wiren Board доступны кнопки установки wb-rules в `/etc/wb-rules`. +- на Wiren Board установить `wb-rules` по кнопке. -## Установка wb-rules из CLI - -Установить все станции: - -```bash -cd /home/shd/scripts/alice -.venv/bin/python3 alice_plugin.py wb-install -``` - -Установить только одну станцию: - -```bash -cd /home/shd/scripts/alice -.venv/bin/python3 alice_plugin.py wb-install --station-id -``` - -## Сервисы и управление - -- Демон Alice: `shd-alice.service` -- Web UI: `shd-alice-plugin.service` - -Перезапуск: - -```bash -sudo systemctl restart shd-alice.service shd-alice-plugin.service -``` - -Остановка: - -```bash -sudo systemctl stop shd-alice.service shd-alice-plugin.service -``` - -## Типовые ошибки и решения - -### `Unit shd-alice.service not found` - -Причина: `install.sh` не завершился успешно, поэтому юниты не были скопированы в `/etc/systemd/system`. - -Что делать: -1. Исправить ошибку установки (часто это отсутствие `python3-venv`). -2. Снова запустить: - -```bash -cd /home/shd/scripts/alice -./install.sh -sudo systemctl daemon-reload -sudo systemctl enable --now shd-alice.service shd-alice-plugin.service -``` - -### `The virtual environment was not created successfully because ensurepip is not available` - -Причина: отсутствует пакет `python3-venv`. - -Решение: - -```bash -sudo apt update -sudo apt install -y python3-venv python3-pip -cd /home/shd/scripts/alice -./install.sh -``` - -### `./install.sh: No such file or directory` - -Причина: команда выполняется не из каталога проекта. - -Решение: - -```bash -cd /home/shd/scripts/alice -./install.sh -``` - -### Токен не подхватывается - -Проверь, что файл существует именно по пути: +Страница лежит по адресу: ```text -/home/shd/scripts/alice/token.txt +http://:9140/ ``` -А не в `/home/shd/scripts/token.txt`. +Если станции ещё не найдены, страница покажет пустой список и предложит сначала сохранить токен и нажать кнопку поиска. -## Переменные окружения (опционально) +## Systemd -- `YANDEX_TOKEN` - OAuth токен (если не используется `token.txt`). -- `ALICE_CONTROLLER_HOST` - host/IP, который вставляется в шаблоны. -- `ALICE_PLUGIN_WEB_PORT` - порт web UI (по умолчанию `9140`). +В проекте есть два сервиса: -## Удаление с контроллера +- `shd-alice.service` — основной демон `yastation.py`; +- `shd-alice-plugin.service` — web UI. + +Юниты используют фиксированный путь: + +```text +/opt/shd/plugins/alice +``` + +Проверка состояния: ```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 +sudo systemctl status shd-alice.service +sudo systemctl status shd-alice-plugin.service +``` + +Логи: + +```bash +journalctl -u shd-alice.service -f +journalctl -u shd-alice-plugin.service -f +``` + +Ручной запуск: + +```bash +cd /opt/shd/plugins/alice +./start.sh +./start_plugin_web.sh +``` + +## Где лежат файлы + +```text +/opt/shd/plugins/alice/ +├── token.txt +├── data/ +│ ├── config.json +│ └── stations.json +└── configs/ + ├── loxone/ + │ └── loxone_templates.zip + └── wb-rules/ + └── wb_rules_templates.zip +``` + +Ключевые файлы: + +- `token.txt` — OAuth-токен; +- `data/config.json` — сохранённый IP контроллера и служебные настройки; +- `data/stations.json` — найденные станции; +- `configs/loxone/loxone_templates.zip` — ZIP для Loxone; +- `configs/wb-rules/wb_rules_templates.zip` — ZIP для wb-rules. + +## Диагностика и логи + +Проверить, что Python и зависимости в порядке: + +```bash +cd /opt/shd/plugins/alice +.venv/bin/python3 -m py_compile alice_plugin.py yastation.py +``` + +Проверить web API: + +```bash +curl http://127.0.0.1:9140/api/status +``` + +Проверить список станций руками: + +```bash +./cli.sh columns +``` + +Проверить генерацию шаблонов: + +```bash +./cli.sh loxone +./cli.sh wb-rules +``` + +## Частые ошибки + +### `YANDEX_OAUTH_TOKEN invalid: update token and retry` + +Токен неверный или протух. Перезапиши `token.txt` и повтори поиск колонок. + +### `stations.json is empty, run station refresh first` + +Сначала выполни: + +```bash +./cli.sh columns +``` + +### `No stations found` + +Локальный поиск ничего не нашёл. Проверь: + +- что станции в той же сети; +- что mDNS не режется роутером/VLAN; +- что токен валиден и облачный список вообще отдаётся. + +### `No such file or directory` в systemd + +Обычно проект лежит не в `/opt/shd/plugins/alice`. Перезапусти установку: + +```bash +./install.sh +``` + +### `/etc/wb-rules not found` + +Команда `wb-install` запускается не на Wiren Board и не на системе с установленным `wb-rules`. + +## Удаление + +Остановить сервисы: + +```bash +sudo systemctl disable --now shd-alice.service shd-alice-plugin.service +``` + +Удалить юниты: + +```bash +sudo rm -f /etc/systemd/system/shd-alice.service +sudo rm -f /etc/systemd/system/shd-alice-plugin.service +sudo systemctl daemon-reload +``` + +Удалить каталог проекта: + +```bash +sudo rm -rf /opt/shd/plugins/alice ``` diff --git a/alice_plugin.py b/alice_plugin.py index 0b4e98b..de62b3f 100644 --- a/alice_plugin.py +++ b/alice_plugin.py @@ -24,7 +24,9 @@ import yastation BASE_DIR = Path(__file__).resolve().parent DATA_DIR = BASE_DIR / "data" -LOXONE_DIR = BASE_DIR / "loxone" +CONFIGS_DIR = BASE_DIR / "configs" +LOXONE_DIR = CONFIGS_DIR / "loxone" +WB_RULES_DIR = CONFIGS_DIR / "wb-rules" STATIONS_PATH = DATA_DIR / "stations.json" CONFIG_PATH = DATA_DIR / "config.json" TOKEN_PATH = BASE_DIR / "token.txt" @@ -59,7 +61,9 @@ 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) + WB_RULES_DIR.mkdir(parents=True, exist_ok=True) def safe_name(value: str, fallback: str) -> str: @@ -533,19 +537,17 @@ defineRule(DEVICE_ID + "_status_poll", {{ """ -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") +def write_loxone_templates(station: StationRecord, controller_host: str) -> Dict[str, str]: + station_folder = LOXONE_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" @@ -561,6 +563,31 @@ def write_station_templates(station: StationRecord, controller_host: str, target "folder": str(station_folder.relative_to(BASE_DIR)), "vo": str(vo_path.relative_to(BASE_DIR)), "vi": str(vi_path.relative_to(BASE_DIR)), + } + + +def write_wb_rules_templates(station: StationRecord, controller_host: str) -> Dict[str, str]: + station_folder = WB_RULES_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() + + wb_path = station_folder / "wb-rules-station.js" + readme_path = station_folder / "README.md" + + 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 wb-rules 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)), "wb": str(wb_path.relative_to(BASE_DIR)), } @@ -573,28 +600,33 @@ def generate_templates(template_kind: str, controller_host: str) -> Dict[str, An 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) + if template_kind == "loxone": + target_dir = LOXONE_DIR + zip_name = "loxone_templates.zip" + for station in stations: + result.append(write_loxone_templates(station, controller_host)) + elif template_kind == "wb-rules": + target_dir = WB_RULES_DIR + zip_name = "wb_rules_templates.zip" + for station in stations: + result.append(write_wb_rules_templates(station, controller_host)) + else: + raise RuntimeError(f"unsupported template kind: {template_kind}") - zip_name = "loxone_templates.zip" if template_kind == "loxone" else "wb_rules_templates.zip" - zip_path = LOXONE_DIR / zip_name + zip_path = target_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"): + folder = target_dir / safe_name(station.name or station.station_id, station.station_id or "station") + if template_kind == "loxone": 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") + elif template_kind == "wb-rules": + for fn in ["wb-rules-station.js", "README.md"]: + p = folder / fn + if p.exists(): + zf.write(p, arcname=f"{folder.name}/{fn}") return {"ok": True, "templates": result, "zip": str(zip_path.relative_to(BASE_DIR))} @@ -616,10 +648,10 @@ def install_wb_rules(station_id: Optional[str] = None) -> Dict[str, Any]: installed: List[str] = [] for station in stations: - folder = LOXONE_DIR / safe_name(station.name or station.station_id, station.station_id or "station") + folder = WB_RULES_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) + write_wb_rules_templates(station, detect_controller_host()) src = folder / "wb-rules-station.js" dst = target_dir / f"alice_station_{safe_name(station.station_id, 'station')}.js" @@ -643,7 +675,14 @@ async def refresh_stations(token: str, timeout: float = 3.0) -> Dict[str, Any]: if not token: raise RuntimeError("token is required") - cloud = await fetch_cloud_stations(token) + try: + cloud = await fetch_cloud_stations(token) + except Exception as exc: + msg = str(exc) + if "AUTH_TOKEN_INVALID" in msg: + raise RuntimeError("YANDEX_OAUTH_TOKEN invalid: update token and retry") from exc + raise + local = await discover_local(timeout=timeout) merged = merge_cloud_with_local(cloud, local) @@ -728,7 +767,7 @@ async def api_download(request: web.Request) -> web.StreamResponse: 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 + zip_path = (LOXONE_DIR if kind == "loxone" else WB_RULES_DIR) / zip_name if not zip_path.exists(): generate_templates(kind, detect_controller_host()) return web.FileResponse(zip_path) @@ -779,21 +818,21 @@ def build_parser() -> argparse.ArgumentParser: 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("stations-refresh", help="Обновить список станций") + sp.add_argument("--timeout", type=float, default=3.0, help="Секунды ожидания локального mDNS-поиска") - sp = sub.add_parser("templates-loxone", help="2) generate Loxone templates in ./loxone") - sp.add_argument("--controller-host", default="") + sp = sub.add_parser("templates-loxone", help="Сгенерировать шаблоны Loxone в ./configs/loxone") + sp.add_argument("--controller-host", default="", help="IP или host контроллера для ссылок на API") - 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("templates-wb-rules", help="Сгенерировать шаблоны wb-rules в ./configs/wb-rules") + sp.add_argument("--controller-host", default="", help="IP или host контроллера для ссылок на API") - 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("wb-install", help="Установить wb-rules шаблоны в /etc/wb-rules") + sp.add_argument("--station-id", default="", help="Установить только одну станцию по station id") - 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) + sp = sub.add_parser("web", help="Запустить web UI") + sp.add_argument("--host", default="0.0.0.0", help="Хост для web UI") + sp.add_argument("--port", type=int, default=9140, help="Порт для web UI") return p diff --git a/cli.sh b/cli.sh new file mode 100755 index 0000000..237e879 --- /dev/null +++ b/cli.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -euo pipefail +BASE_DIR="$(cd "$(dirname "$0")" && pwd)" +PY="$BASE_DIR/.venv/bin/python3" + +usage() { + cat <<'EOF' +Usage: + ./cli.sh columns [--token TOKEN] [--timeout SEC] + ./cli.sh loxone [--controller-host HOST] + ./cli.sh wb-rules [--controller-host HOST] + ./cli.sh wb-install [--station-id ID] + ./cli.sh web [--token TOKEN] [--host HOST] [--port PORT] + +Global options may be placed before or after the command. +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" || "${1:-}" == "help" || $# -eq 0 ]]; then + usage + if [[ $# -eq 0 ]]; then + exit 1 + fi + exit 0 +fi + +if [[ ! -x "$PY" ]]; then + echo "Python venv not found: $BASE_DIR/.venv/bin/python3" + echo "Run ./install.sh first" + exit 3 +fi + +GLOBAL_ARGS=() +POSITIONAL=() +while [[ $# -gt 0 ]]; do + case "$1" in + --token) + if [[ $# -lt 2 ]]; then + echo "Missing value for --token" + exit 1 + fi + GLOBAL_ARGS+=("$1" "$2") + shift 2 + ;; + -h|--help|help) + usage + exit 0 + ;; + *) + POSITIONAL+=("$1") + shift + ;; + esac +done + +CMD="${POSITIONAL[0]:-}" +if [[ -z "$CMD" ]]; then + usage + exit 1 +fi +REST_ARGS=("${POSITIONAL[@]:1}") + +case "$CMD" in + columns) + exec "$PY" "$BASE_DIR/alice_plugin.py" "${GLOBAL_ARGS[@]}" stations-refresh "${REST_ARGS[@]}" + ;; + loxone) + exec "$PY" "$BASE_DIR/alice_plugin.py" "${GLOBAL_ARGS[@]}" templates-loxone "${REST_ARGS[@]}" + ;; + wb-rules) + exec "$PY" "$BASE_DIR/alice_plugin.py" "${GLOBAL_ARGS[@]}" templates-wb-rules "${REST_ARGS[@]}" + ;; + wb-install) + exec "$PY" "$BASE_DIR/alice_plugin.py" "${GLOBAL_ARGS[@]}" wb-install "${REST_ARGS[@]}" + ;; + web) + exec "$PY" "$BASE_DIR/alice_plugin.py" "${GLOBAL_ARGS[@]}" web "${REST_ARGS[@]}" + ;; + *) + echo "Unknown command: $CMD" + usage + exit 1 + ;; +esac diff --git a/cmd_1_get_columns.sh b/cmd_1_get_columns.sh deleted file mode 100644 index 9f2034a..0000000 --- a/cmd_1_get_columns.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -BASE_DIR="$(cd "$(dirname "$0")" && pwd)" -exec "$BASE_DIR/.venv/bin/python3" "$BASE_DIR/alice_plugin.py" stations-refresh "$@" diff --git a/cmd_2_get_loxone_templates.sh b/cmd_2_get_loxone_templates.sh deleted file mode 100644 index 5d57e20..0000000 --- a/cmd_2_get_loxone_templates.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -BASE_DIR="$(cd "$(dirname "$0")" && pwd)" -exec "$BASE_DIR/.venv/bin/python3" "$BASE_DIR/alice_plugin.py" templates-loxone "$@" diff --git a/cmd_3_get_wb_rules_templates.sh b/cmd_3_get_wb_rules_templates.sh deleted file mode 100644 index 5ea9101..0000000 --- a/cmd_3_get_wb_rules_templates.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -BASE_DIR="$(cd "$(dirname "$0")" && pwd)" -exec "$BASE_DIR/.venv/bin/python3" "$BASE_DIR/alice_plugin.py" templates-wb-rules "$@" diff --git a/loxone/.gitkeep b/configs/loxone/.gitkeep similarity index 100% rename from loxone/.gitkeep rename to configs/loxone/.gitkeep diff --git a/configs/wb-rules/.gitkeep b/configs/wb-rules/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/install.sh b/install.sh old mode 100644 new mode 100755 index b8b1f1d..b476db5 --- a/install.sh +++ b/install.sh @@ -1,39 +1,89 @@ #!/usr/bin/env bash set -euo pipefail -BASE_DIR="$(cd "$(dirname "$0")" && pwd)" -cd "$BASE_DIR" +APP_DIR="/opt/shd/plugins/alice" +SRC_DIR="$(cd "$(dirname "$0")" && pwd)" +IN_PLACE="${1:-}" -chmod +x *.sh +sync_project() { + mkdir -p /opt/shd/plugins + if command -v rsync >/dev/null 2>&1; then + rsync -a --delete --exclude '.venv/' --exclude '__pycache__/' --exclude '*.pyc' --exclude '*.zip' "$SRC_DIR/" "$APP_DIR/" + else + mkdir -p "$APP_DIR" + cp -a "$SRC_DIR/." "$APP_DIR/" + rm -rf "$APP_DIR/.venv" "$APP_DIR/__pycache__" + find "$APP_DIR" -type d -name '__pycache__' -prune -exec rm -rf {} + + find "$APP_DIR" -type f \( -name '*.pyc' -o -name '*.zip' \) -delete + fi +} -if [[ -d .venv ]]; then - rm -rf .venv 2>/dev/null || sudo rm -rf .venv +if [[ "$SRC_DIR" != "$APP_DIR" && "$IN_PLACE" != "--in-place" ]]; then + echo "Sync project to $APP_DIR" + if [[ -w /opt/shd/plugins || -w /opt/shd || -w /opt ]]; then + sync_project + else + if ! command -v sudo >/dev/null 2>&1; then + echo "Need write access to /opt/shd/plugins or sudo" + exit 1 + fi + sudo mkdir -p /opt/shd/plugins + sudo rm -rf "$APP_DIR" + sudo mkdir -p "$APP_DIR" + if command -v rsync >/dev/null 2>&1; then + sudo rsync -a --delete --exclude '.venv/' --exclude '__pycache__/' --exclude '*.pyc' --exclude '*.zip' "$SRC_DIR/" "$APP_DIR/" + else + sudo cp -a "$SRC_DIR/." "$APP_DIR/" + sudo rm -rf "$APP_DIR/.venv" "$APP_DIR/__pycache__" + sudo find "$APP_DIR" -type d -name '__pycache__' -prune -exec rm -rf {} + + sudo find "$APP_DIR" -type f \( -name '*.pyc' -o -name '*.zip' \) -delete + fi + if [[ -n "${SUDO_USER:-}" ]]; then + sudo chown -R "$SUDO_USER":"$SUDO_USER" "$APP_DIR" + fi + fi + exec "$APP_DIR/install.sh" --in-place +fi + +cd "$APP_DIR" +chmod +x cli.sh install.sh start.sh start_plugin_web.sh + +if [[ ! -d .venv ]]; then + python3 -m venv .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" +python3 -c "import aiohttp, zeroconf" >/dev/null -mkdir -p "$BASE_DIR/loxone" "$BASE_DIR/data" +mkdir -p data configs/loxone configs/wb-rules +: > data/.gitkeep + +install_service() { + local src="$1" + local dst="/etc/systemd/system/$(basename "$src")" + if [[ -w /etc/systemd/system ]]; then + cp -f "$src" "$dst" + else + sudo cp -f "$src" "$dst" + fi +} 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 + install_service "$APP_DIR/systemd/shd-alice.service" + install_service "$APP_DIR/systemd/shd-alice-plugin.service" + if [[ -w /etc/systemd/system ]]; then + systemctl daemon-reload + systemctl enable shd-alice.service shd-alice-plugin.service || true + else + sudo systemctl daemon-reload + sudo systemctl enable shd-alice.service shd-alice-plugin.service || true + fi fi -echo "Install done." +echo "Install done: $APP_DIR" +echo "Next:" +echo " 1) save token: printf %s 'YANDEX_OAUTH_TOKEN' > $APP_DIR/token.txt && chmod 600 $APP_DIR/token.txt" +echo " 2) update stations: $APP_DIR/cli.sh columns" +echo " 3) start web UI: $APP_DIR/cli.sh web" diff --git a/restart.sh b/restart.sh deleted file mode 100644 index 71cddbd..0000000 --- a/restart.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -if command -v systemctl >/dev/null 2>&1 && systemctl list-unit-files | grep -q '^shd-alice.service'; then - systemctl restart shd-alice.service || sudo systemctl restart shd-alice.service || true -else - "$(cd "$(dirname "$0")" && pwd)/stop.sh" - nohup "$(cd "$(dirname "$0")" && pwd)/start.sh" >/tmp/shd-alice.log 2>&1 & -fi diff --git a/start.sh b/start.sh old mode 100644 new mode 100755 index 40f95db..b9f3e39 --- a/start.sh +++ b/start.sh @@ -4,24 +4,25 @@ BASE_DIR="$(cd "$(dirname "$0")" && pwd)" cd "$BASE_DIR" if [[ ! -d .venv ]]; then - python3 -m venv .venv + 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 + 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")" + TOKEN="$(tr -d ' +' < "$BASE_DIR/token.txt")" fi if [[ -z "$TOKEN" ]]; then - echo "YANDEX_TOKEN is empty and token.txt not found" - exit 2 + echo "YANDEX_TOKEN is empty and token.txt not found" + exit 2 fi BIND_HOST="${YASTATION_BIND_HOST:-127.0.0.1}" @@ -29,8 +30,4 @@ 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" +exec "$BASE_DIR/.venv/bin/python3" "$BASE_DIR/yastation.py" --token "$TOKEN" serve --host "$BIND_HOST" --port "$BIND_PORT" --http-host "$HTTP_HOST" --http-port "$HTTP_PORT" diff --git a/start_plugin_web.sh b/start_plugin_web.sh old mode 100644 new mode 100755 index 86e5c44..ccf6247 --- a/start_plugin_web.sh +++ b/start_plugin_web.sh @@ -4,14 +4,14 @@ BASE_DIR="$(cd "$(dirname "$0")" && pwd)" cd "$BASE_DIR" if [[ ! -d .venv ]]; then - python3 -m venv .venv + 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 + echo "Missing deps in .venv. Run install.sh" + exit 3 } WEB_HOST="${ALICE_PLUGIN_WEB_HOST:-0.0.0.0}" diff --git a/stop.sh b/stop.sh deleted file mode 100644 index d487ed8..0000000 --- a/stop.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -if command -v systemctl >/dev/null 2>&1 && systemctl list-unit-files | grep -q '^shd-alice.service'; then - systemctl stop shd-alice.service || sudo systemctl stop shd-alice.service || true -else - pkill -f '/home/shd/scripts/alice/yastation.py' || true -fi diff --git a/systemd/shd-alice-plugin.service b/systemd/shd-alice-plugin.service index 24f4f1b..9cf3558 100644 --- a/systemd/shd-alice-plugin.service +++ b/systemd/shd-alice-plugin.service @@ -5,9 +5,9 @@ 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' +WorkingDirectory=/opt/shd/plugins/alice +EnvironmentFile=-/opt/shd/plugins/alice/.env +ExecStart=/bin/bash /opt/shd/plugins/alice/start_plugin_web.sh Restart=always RestartSec=3 diff --git a/systemd/shd-alice.service b/systemd/shd-alice.service index bcb599b..f608b42 100644 --- a/systemd/shd-alice.service +++ b/systemd/shd-alice.service @@ -5,9 +5,9 @@ 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' +WorkingDirectory=/opt/shd/plugins/alice +EnvironmentFile=-/opt/shd/plugins/alice/.env +ExecStart=/bin/bash /opt/shd/plugins/alice/start.sh Restart=always RestartSec=3 diff --git a/web/index.html b/web/index.html index 9ff2a27..c4fbf3b 100644 --- a/web/index.html +++ b/web/index.html @@ -264,7 +264,14 @@ function renderStations(items) { const tbody = q('#stations'); tbody.innerHTML = ''; - (items || []).forEach((s) => tbody.appendChild(stationRow(s))); + const rows = items || []; + if (!rows.length) { + const tr = document.createElement('tr'); + tr.innerHTML = `Станции пока не найдены. Сохрани токен и нажми «Найти колонки».`; + tbody.appendChild(tr); + return; + } + rows.forEach((s) => tbody.appendChild(stationRow(s))); } async function refreshStatus() { @@ -312,7 +319,7 @@ setStatus('Генерирую Loxone шаблоны и готовлю архив...'); await api('/api/templates/loxone', 'POST'); window.location.href = '/api/download/loxone'; - setStatus('Скачивание Loxone ZIP запущено'); + setStatus('Скачивание Loxone ZIP запущено. Локальная копия лежит в configs/loxone/loxone_templates.zip'); } catch (e) { setStatus(e.message, false); } @@ -323,7 +330,7 @@ setStatus('Генерирую wb-rules шаблоны и готовлю архив...'); await api('/api/templates/wb-rules', 'POST'); window.location.href = '/api/download/wb-rules'; - setStatus('Скачивание wb-rules ZIP запущено'); + setStatus('Скачивание wb-rules ZIP запущено. Локальная копия лежит в configs/wb-rules/wb_rules_templates.zip'); } catch (e) { setStatus(e.message, false); } diff --git a/yastation.py b/yastation.py index 12340de..a8485be 100644 --- a/yastation.py +++ b/yastation.py @@ -479,9 +479,20 @@ async def serve_daemon(args) -> int: 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() + try: + for st in selected: + conns[st.device_id] = StationConn(st, oauth, args.wait) + await conns[st.device_id].start() + except Exception as exc: + for c in conns.values(): + try: + await c.close() + except Exception: + pass + msg = str(exc) + if "AUTH_TOKEN_INVALID" in msg: + raise RuntimeError("YANDEX_OAUTH_TOKEN invalid: update token and retry") from exc + raise async def resolve_conn(station_sel: str) -> Optional[StationConn]: station_sel = (station_sel or "").strip()