# HTTP API Опциональный веб-API XiControl: управление с телефона и из домашней автоматизации (Home Assistant, Node-RED, что угодно, что умеет HTTP). По умолчанию **выключен** — включается в **Настройки → HTTP API**. Документ описывает протокол целиком: адреса, авторизацию, коды ответов, исходящее событие (вебхук) и готовый сценарий для Home Assistant. --- ## 1. Включение за три шага 1. **Настройки → HTTP API → «Включить»**. Порт по умолчанию `58125`, меняется там же. 2. **«Сгенерировать токен»**. Токен показывается **один раз** — скопируйте сразу. У нас хранится только его SHA-256, восстановить исходный невозможно (можно только выпустить новый, старый при этом перестанет работать). 3. **Разрешите нужные команды** поштучно. По умолчанию доступно только чтение состояния: включённый API сам по себе ничего переключать не позволяет. Внизу вкладки лежат готовые команды с вашим фактическим портом — скопировать и подставить токен. По умолчанию сервер слушает `127.0.0.1`, то есть доступен только с самого ноутбука. Чтобы ходить с телефона, включите **«Доступ из локальной сети»** — тогда bind идёт на все интерфейсы и создаётся правило брандмауэра со скоупом `LocalSubnet` (только ваша подсеть). Выключение тумблера правило удаляет. --- ## 2. Авторизация Каждый запрос несёт заголовок: ``` Authorization: Bearer <токен> ``` Токен сверяется по SHA-256 в постоянном времени (`CryptographicOperations.FixedTimeEquals`) — ни длина, ни содержимое не утекают по времени ответа. Пока токен не сгенерирован, **все** запросы получают `401`: свежеустановленное приложение с включённым API остаётся закрытым. Транспорт — обычный HTTP без TLS. Это осознанный компромисс: радиус поражения ограничен белым списком команд, а сертификаты для локальной железки означали бы либо самоподписанные (то есть предупреждения и привычку их игнорировать), либо внешний CA ради трафика внутри квартиры. --- ## 3. Маршруты Других маршрутов физически не существует: список зашит в `switch` внутри `ApiRouter`, а не собирается из конфига. Настройки приложения, автозапуск и запуск программ через API недоступны и не появятся без отдельного решения. ### `GET /status` Единственный маршрут, доступный сразу. Отвечает снимком состояния: ```json { "mode": "Auto", "care": true, "travel": false, "owl": false, "batteryPercent": 60, "charging": true, "watts": null, "health": 100 } ``` | Поле | Что означает | |---|---| | `mode` | Режим производительности: `Eco`, `Quiet`, `Balance`, `Auto`, `Turbo`, `FullSpeed` | | `care` | Включена ли защита заряда («беречь батарею») | | `travel` | Активен ли разовый заряд до 100% («В дорогу») | | `owl` | Включён ли «режим совы» (не спать) | | `batteryPercent` | Заряд, %; `null` — батарея неизвестна | | `charging` | Идёт ли заряд прямо сейчас | | `watts` | Мощность со знаком: «+» заряд, «−» разряд. **`null` от сети** — датчик тока у батареи, и на питании от сети ему нечего сказать | | `health` | Здоровье батареи, % от заводской ёмкости; `null` — прошивка не ответила | ### Команды (`POST`, тело — JSON) | Путь | Тело | Что делает | |---|---|---| | `/mode` | `{"value":"turbo"}` | Режим: `eco`, `quiet`, `balance`, `auto`, `turbo`, `fullspeed` (регистр не важен) | | `/care` | `{"on":true}` | «Беречь батарею» вкл/выкл — на настроенный в приложении порог | | `/travel` | `{"on":true}` | «В дорогу»: разовый заряд до 100% | | `/owl` | `{"on":true}` | «Режим совы»: не давать системе уснуть | Команда исполняется теми же путями, что и нажатие в интерфейсе: приложение спрашивает прошивку, и если та отказала — состояние не меняется. ### Коды ответов | Код | Когда | Тело | |---|---|---| | `200` | Готово | `{"ok":true}` или снимок состояния | | `400` | Тело не разобрано или значение не из списка | `{"error":"bad request"}` | | `401` | Нет заголовка, неверный токен или токен ещё не сгенерирован | `{"error":"unauthorized"}` | | `403` | Команда выключена на вкладке (или выключена сама фича — например, сова) | `{"error":"command disabled"}` | | `404` | Такого маршрута нет | `{"error":"not found"}` | | `413` | Тело больше 4 КБ — столько команде не нужно, читать не станем | `{"error":"body too large"}` | --- ## 4. Вебхук: событие наружу Обратное направление: XiControl сам сообщает, что **заряд дошёл до порога**. Умная розетка выключает питание без участия человека, а Home Assistant перестаёт опрашивать нас по кругу. Адрес получателя и тумблер — на той же вкладке; кнопка «Проверить» шлёт тестовое событие тем же путём и с тем же телом, что и настоящее (отличается только `event`). Кнопка работает **независимо от тумблера** — чтобы адрес можно было проверить до включения отправки. Поэтому «Доставлено» само по себе не означает, что события пойдут: за это отвечает тумблер «Сообщать о достижении порога». ```json { "event": "chargeLimit", "limit": 60, "hardwareLimit": true, "time": "2026-09-22T15:20:05Z", "mode": "Auto", "care": true, "travel": false, "owl": false, "batteryPercent": 60, "charging": true, "watts": null, "health": 100 } ``` Поля состояния — те же и с теми же именами, что у `GET /status`: получателю не нужна вторая раскладка для тех же величин. `event` — повод (`chargeLimit` или `test`), `limit` — порог, до которого дошёл заряд. **`hardwareLimit` — самое важное поле для автоматизации.** Оно говорит, чей это порог: | Значение | Что произошло | Что это значит для сценария | |---|---|---| | `true` | Порог держит прошивка — **заряд уже остановлен** | Выключить розетку полезно (не держать блок под нагрузкой), но не срочно: не сработало — ничего страшного | | `false` | Порог программный ([XIC-74](), модель без аппаратного лимита) — **заряд продолжается** | Розетка — единственное, что его остановит. Здесь уместны повторные попытки и уведомление владельцу | Имя события в обоих случаях одно и то же намеренно: автоматизация, написанная на `chargeLimit`, не должна молча перестать видеть программный случай — а он как раз тот, где цена промаха выше. Правила, по которым это работает: - **Один раз за зарядку.** Событие взводится заново, когда зарядник отключили. - **Не настроен — ни одного запроса.** Пустое поле адреса означает, что сетевого кода не существует: ни клиента, ни таймера, ни DNS. То же обещание, что у проверки обновлений. - **Только `http`/`https`**, за редиректами не ходим, ответ не читаем — хватает кода состояния. Запрос делает процесс с правами администратора, и переадресация с чужого адреса на локальный была бы классическим способом прокатиться на чужих правах. - **HTTPS работает**, но сертификат проверяется по системному хранилищу: самоподписанный сертификат получателя будет отвергнут. Поставьте свой CA в доверенные корневые Windows либо используйте `http` внутри своей подсети. - **Три попытки** с паузами 2 и 6 секунд, таймаут 5 секунд на попытку: розетка могла моргнуть, но вешать на этом приложение нельзя. - **Не зависит от самого сервера.** Исходящее событие работает, даже если входящий API выключен: открывать слушающий сокет ради одного POST наружу незачем. --- ## 5. Home Assistant: полный сценарий Задача: розетка выключается, когда ноутбук зарядился до порога, и включается, когда заряд опустился ниже 40%. **Шаг 1. Приём события.** В `configuration.yaml` — автоматизация на вебхук: ```yaml automation: - alias: "XiControl: заряд дошёл до порога" trigger: - platform: webhook webhook_id: xicontrol_charge allowed_methods: [POST] local_only: true # событие приходит из своей же сети action: - service: switch.turn_off target: entity_id: switch.laptop_plug - service: notify.persistent_notification data: message: >- Ноутбук зарядился до {{ trigger.json.batteryPercent }}% (порог {{ trigger.json.limit }}%) — розетка выключена. # Программный порог: заряд идёт, пока розетка не выключилась. Проверяем через минуту, # что команда дошла, и зовём владельца, если нет. На аппаратном пороге это не нужно — # там прошивка уже всё остановила, и реле лишь избавляет блок от холостой нагрузки. - if: "{{ not trigger.json.hardwareLimit }}" then: - delay: "00:01:00" - condition: state entity_id: switch.laptop_plug state: "on" - service: notify.mobile_app_phone data: message: >- Розетка не выключилась, а заряд идёт дальше ({{ trigger.json.batteryPercent }}%) — выдерни провод. ``` Адрес вебхука для поля в настройках XiControl: `http://<адрес-home-assistant>:8123/api/webhook/xicontrol_charge` Токен HA здесь не нужен — идентификатор вебхука и есть секрет, поэтому не делайте его угадываемым. **Шаг 2. Обратная сторона — опрос заряда**, чтобы включить розетку, когда батарея села: ```yaml sensor: - platform: rest name: XiControl resource: http://<адрес-ноутбука>:58125/status headers: Authorization: !secret xicontrol_token # «Bearer <токен>» целиком value_template: "{{ value_json.batteryPercent }}" json_attributes: [mode, care, charging, health] scan_interval: 300 # раз в 5 минут: чаще незачем automation: - alias: "XiControl: батарея села — включить розетку" trigger: - platform: numeric_state entity_id: sensor.xicontrol below: 40 action: - service: switch.turn_on target: entity_id: switch.laptop_plug ``` Ноутбук должен быть доступен по сети: включите «Доступ из локальной сети» на вкладке. Пока ноутбук спит, `rest`-сенсор станет недоступным — это нормально, автоматизация на `numeric_state` на недоступность не срабатывает. --- ## 6. Где что лежит в коде | Файл | За что отвечает | |---|---| | `src/SystemIntegration/HttpApi.cs` | Хост на `HttpListener` (http.sys), приём и ответ | | `src/SystemIntegration/ApiRouter.cs` | Авторизация и белый список маршрутов — чистая логика под тестами | | `src/SystemIntegration/ApiSettings.cs` | `api.json` в `%ProgramData%\XiControl` под ACL «запись только администраторам» | | `src/SystemIntegration/ApiFirewall.cs` | Правило `netsh` для LAN-режима, только по явному тумблеру | | `src/SystemIntegration/ChargeLimitWatcher.cs` | Событие «заряд дошёл до порога» | | `src/SystemIntegration/Webhook.cs` | Исходящий POST: проверка адреса, тело, ретраи | | `src/Ui/Settings/ApiTab.cs` | Вкладка настроек | Почему настройки API живут не в `config.json`: тот переписывается любым процессом пользователя, и держать там флаг включения сетевого входа в admin-процесс нельзя — сторонний софт включил бы API и вписал свой токен. `api.json` пишет только наш elevated-процесс; при чтении проверяется владелец файла (Administrators/SYSTEM), чужой — игнорируется целиком.