Каждый месяц у меня срабатывал календарь, и телефон исправно присылал сообщение: «Передайте пожалуйста показания счётчиков» — и тут же готовые цифры по воде и электричеству. Дом всё знал сам: показания снимаются автоматически, цифры лежат в Home Assistant, остаётся только… открыть сайт, залогиниться, найти свои приборы и вбить пять чисел руками. Автоматизация заканчивалась ровно там, где начиналась скука. В этот раз я решил дотянуть её до конца — и узнал, почему в этот сайт нельзя просто послать HTTP-запрос, и сколько предохранителей приходится ставить, когда робот вводит цифры, которые потом превращаются в счёт.
Как было: робот доводил меня до сайта и бросал
Предыстория такая. Воду считают два самодельных ESP32: они ловят импульсы с водяных счётчиков на кухне и в санузле и отдают показания в Home Assistant по MQTT. Электричество HA тоже знает. То есть все пять чисел в доме есть всегда и в любой момент — без лазания с фонариком в шкаф и без переписывания на бумажку.
Дальше в календаре стояло повторяющееся событие «Показания», на него была повешена автоматизация, и она делала единственное, что умела: собирала текущие цифры и слала напоминание в телеграм — мне и жене, чтобы кто-нибудь точно дошёл. Работало это годами и честно закрывало главную проблему «забыл передать вовремя». Но последний шаг оставался ручным: зайти в личный кабинет, найти форму водоканала, вбить четыре значения, потом перейти к форме энергосбыта и вбить ещё. Десять минут унылого копирования чисел из чата в браузер, двенадцать раз в год.
Мысль «пусть цифры вводит тот же, кто их посчитал» напрашивалась давно. Мешало ощущение, что дело на вечер, а не на неделю. Как выяснилось, ощущение было верным — но только после того, как первая идея провалилась.
Почему нельзя просто послать запрос
План был стандартный для таких затей: открыть DevTools, посмотреть, какой запрос уходит при отправке показаний, и повторить его из скрипта. Открыл — и понял, что повторять нечего.
Личный кабинет написан на Vaadin Flow, а это фреймворк, где интерфейс живёт на сервере, а браузер работает тонким клиентом. Наружу уходит не «сохрани показание 12345 для счётчика такого-то», а служебный пакет вида «в компоненте с внутренним идентификатором таким-то произошло событие, состояние сессии — вот такое». Идентификаторы узлов и счётчик синхронизации выдаются сервером на лету и живут ровно одну сессию. Собрать такой запрос заранее нельзя, воспроизвести вчерашний — тем более: сервер ответит, что состояние разъехалось.
Вывод, который стоит держать в голове при любой автоматизации чужих сайтов: если интерфейс серверный (Vaadin, старый ASP.NET WebForms и родня), эмуляция HTTP-запросов — тупик. Не потому что сложно, а потому что ты воюешь с состоянием, которого у тебя нет. Остаётся единственный честный путь — настоящий браузер, который это состояние получает сам.

Отдельный контейнер ради браузера
Headless Chromium с Playwright — это сотни мегабайт зависимостей, и тащить их внутрь Home Assistant мне не хотелось совершенно: HA и так центр всего дома, ломать его ради одной задачи раз в месяц — плохой размен. Поэтому мост уехал в отдельный LXC на Proxmox: гигабайт диска, полгигабайта памяти, одно ядро, Ubuntu и systemd-юнит. Умрёт — умрёт только он.
Внутри — маленькое FastAPI-приложение с четырьмя ручками:
GET /health без ключа — жив ли сервис и в каком режиме
GET /meters какие приборы он знает
GET /inspect зайти на сайт и показать, что нашлось
(ничего не вводит — разведка)
POST /send ввести показания
X-API-Key: <токен> сверяется через hmac.compare_digest
Пара слов про мелочи, которые кажутся занудством, но стоят одной строки. Ключ сверяется hmac.compare_digest, а не обычным ==: обычное сравнение строк выходит из цикла на первом несовпавшем символе, и по времени ответа ключ теоретически подбирается посимвольно. Порт слушается только в локальной сети и наружу через роутер не проброшен — снаружи этой штуки не существует. А /inspect оказался самой полезной ручкой при отладке: заходит на сайт и рассказывает, что он там видит, не трогая ни одного поля ввода.
Цена ошибки, или почему тут столько предохранителей
Вот тут начинается самое интересное. Когда робот нажимает кнопки в умном доме, худшее, что случается — включается не тот свет. Когда робот вводит показания счётчиков, ошибка превращается в квитанцию. Передал лишний ноль — и ты уже объясняешься с поставщиком, а не «откатываешь коммит». Поэтому защита строилась от вопроса «как эта штука может мне навредить», а не «как сделать, чтобы работало».
- Округление только вниз. Дробные показания с датчиков обрезаются в меньшую сторону. Передать больше, чем реально накручено на счётчике, невозможно даже случайно.
- Показание не может уменьшиться. Если новое значение меньше того, что уже стоит на сайте — это сбой датчика, а не показание. Стоп.
- Лимит прироста. Для воды и электричества заданы потолки месячного расхода. Всё, что выше — считается мусором, а не рекордным месяцем.
- Непонятно прошлое — не вводим новое. Если предыдущее показание на странице не читается однозначно, мост останавливается: без точки отсчёта проверить два предыдущих правила нечем.
- Рубильник «холостого хода». В конфиге есть флаг сухого режима, и запрос может его только ужесточить, но не отключить. То есть выключить проверочный режим случайным параметром в запросе нельзя — только руками в конфиге на сервере.
А дальше — то, чем я доволен больше всего, порядок шагов в самой отправке.
Сначала всегда сухой прогон. Мост заходит на сайт, логинится, находит все приборы, доходит до форм — и ничего не отправляет. Если на этом этапе что-то не сошлось, приходит уведомление с кодом и текстом ответа, а боевого запроса просто не случается. Только если репетиция прошла чисто, идёт настоящая отправка.
Отметка «за этот месяц уже отправляли» ставится до боевого запроса, а не после. Это контринтуитивно — хочется записывать факт после успеха. Но представьте: показания ушли, сайт их принял, а ответ до нас не доехал (таймаут, оборвалась сеть, перезапустился сервис). Если отмечать по факту успеха, отметки не будет, и следующий запуск отправит всё заново — второй раз, поверх уже принятого. Поэтому отметка ставится заранее: лучше не отправить и сказать об этом, чем отправить дважды молча.
Повторов нет вообще. Никаких ретраев на боевой отправке. Если что-то упало, сообщение приходит с честной припиской «повтора не будет — проверьте на сайте, форма могла уже принять показания». Обычный Restart=on-failure в мире, где действие нельзя отменить, из друга превращается во врага.
Что сломалось ночью
Без этого, конечно, не обошлось. Первые боевые запуски дали пару ответов 502, и в логе был таймаут Playwright на функции закрытия диалога. Сайт показывает модальное окно с карточкой прибора, и мост должен его закрыть, прежде чем идти к следующему счётчику. Кнопка «закрыть» иногда не оказывалась на месте вовремя — и вся цепочка вставала посреди формы.
Отсюда главный практический совет всем, кто автоматизирует сайты headless-браузером: научите скрипт снимать скриншот при падении. Одна строчка в обработчике ошибки, папка с картинками — и вместо гадания «что там было на экране» ты просто открываешь снимок момента падения и видишь: вот оно, окно ещё не закрылось. Без скриншотов отладка headless-браузера — это отладка вслепую, по стектрейсу от библиотеки, которая понятия не имеет о смысле происходящего.
Итог
Теперь в нужный день календарь дёргает скрипт, скрипт собирает цифры с датчиков, мост открывает невидимый браузер, проходит обе формы — и в телеграм падает короткое «показания переданы». Мне остаётся прочитать сообщение. Сайт поставщика я с тех пор не открывал ни разу.
Забавно, что самой сложной частью оказалась не автоматизация браузера — Playwright как раз делает своё дело отлично. Сложнее было честно ответить себе на вопрос «а что будет, если эта штука ошибётся» и потратить на предохранители больше кода, чем на полезное действие. Для домашнего света так не заморачиваешься. Для того, что превращается в счёт — стоит.
