Автоматическая подача показаний в личный кабинет ЕИРЦ ЛО (lk.epd47.ru)
и, параллельно, в кабинет Петроэлектросбыт (ikus.pesc.ru).
Реальные показания берутся прямо со счётчика через API Waviot — вводить
числа руками не нужно.
┌─► ЕИРЦ (lk.epd47.ru) python eirc.py
Waviot (счётчик) ──► округление ─┤ 0000000000000 «день»
electro_..._t1 │ 0000000000001 «ночь»
electro_..._t2 │
└─► ПЭС (ikus.pesc.ru) python pesc.py
scaleId 2 «День»
scaleId 3 «Ночь»
Два кабинета — две независимые команды с одинаковым интерфейсом
(--list, --dry-run, --days, --summary). Недоступность одного
не отменяет подачу во второй.
Публичного API у кабинета нет, поэтому скрипт воспроизводит веб-флоу.
lk.epd47.ru — OIDC-клиент, id.epd47.ru — сервер авторизации. Кука сессии
.AspNetCore.Cookies (чанкуется в C1/C2) выдаётся только ответом на
POST /signin-oidc, и только если в сессии уже лежат .AspNetCore.Correlation.*
и .AspNetCore.OpenIdConnect.Nonce.*.
Эти куки ставит сам lk в начале флоу, поэтому цепочка обязана начинаться
с него, а state / nonce / code_challenge одноразовые — переиспользовать
их из HAR нельзя.
1. GET lk/ClientIdentity/Account/ExternalLogin?...&handler=Challenge
-> 302 на authorize, ставит Correlation + Nonce
2. GET id/connect/authorize -> 302 на /Auth/Login
3. GET id/Auth/Login -> форма, __RequestVerificationToken
4. POST id/Auth/Login -> 302 обратно на authorize
5. GET id/connect/authorize -> HTML-форма form_post (code, iss, state)
6. POST lk/signin-oidc -> .AspNetCore.Cookies + C1 + C2
7. GET lk/SN/YourIndications -> счётчики
8. POST lk/SN/Result -> подача
Счётчики не хардкодятся: они описаны data-атрибутами на странице показаний
(data-id, data-service, data-number, data-name), которые один-в-один
ложатся в payload.
GET https://lk.waviot.ru/api.data/get_full_element_info/?id=<id>&key=<key>
Регистраторы: electro_ac_p_lsum_t1 — день, ..._t2 — ночь, ..._tsum — сумма.
В конфиге можно писать псевдонимы день / ночь / сумма.
С ПЭС проще, чем с ЕИРЦ: под Angular-кабинетом живёт нормальный JSON API
на /api, авторизация — Bearer-токеном. Никакого OIDC и парсинга HTML.
Мешает одно: вход всегда требует второй фактор — SMS, письмо, звонок или TOTP. Обходится он не взломом, а штатным механизмом самого кабинета: успешное подтверждение возвращает три токена.
| Токен | Что это |
|---|---|
auth |
Bearer для всех запросов, живёт недолго |
verified |
«это устройство уже подтверждено» — с ним можно логиниться заново без второго фактора |
access |
назначение неясно, хранится как есть |
Отсюда схема: код подтверждения вводится один раз, дальше скрипт
молча перелогинивается по verified из pesc_tokens.json.
разовая настройка (python pesc.py --setup):
1. POST api/v8/users/auth {login,password,type} -> 424 + transactionId + types
2. POST api/v7/users/{tx}/{type}/check/confirmation/send -> придёт код
3. POST api/v7/users/{tx}/{type}/check/verification {code} -> auth + verified + access
каждый запуск (python pesc.py):
POST api/v8/users/auth + заголовок Auth-Verification: <verified> -> 200 + auth
подача:
GET api/v6/accounts лицевые счета
GET api/v6/accounts/{id}/meters/info счётчики, шкалы, прошлые
POST api/v7/accounts/{id}/meters/{registration}/reading [{"scaleId":N,"value":V}]
Тариф в ПЭС — это шкала (scale) внутри счётчика, а не отдельный
счётчик, как в ЕИРЦ: день и ночь — две шкалы одного registration.
Поэтому в конфиге адресация другая — scaleId, а не counterId.
Если verified протухнет или его отзовут, скрипт не станет молча
пытаться дальше — напишет «выполните python pesc.py --setup»
и вернёт код 1, то есть уйдёт обычным уведомлением об ошибке.
pip install -r requirements.txt
cp .env.example .env # логины + id/key Waviot
cp config.example.json config.json # лицевые счета и счётчики.env:
EIRC_LOGIN=ваш_логин
EIRC_PASSWORD=ваш_пароль
WAVIOT_ID=1234567
WAVIOT_KEY=...
# только для pesc.py, необязательно
PESC_LOGIN=+79990000000
PESC_PASSWORD=...
Узнать свои идентификаторы:
python eirc.py --list # лицевые счета и counterId в ЕИРЦ
python eirc.py --meter # регистраторы и текущие показания счётчика
python pesc.py --list # лицевые счета, счётчики и scaleId в ПЭСДля ПЭС перед --list нужен разовый вход (см. ниже) — иначе он честно
скажет, что токенов нет.
python eirc.py --meter # что показывает счётчик прямо сейчас
python eirc.py --list # счета и последние поданные показания
python eirc.py --dry-run # весь путь + payload, БЕЗ отправки
python eirc.py # реальная подача
python eirc.py -v # подробный лог редиректовДля каждого счётчика в config.json задаётся ровно один из трёх:
| Ключ | Что делает |
|---|---|
register |
берёт реальное показание со счётчика через Waviot |
val |
подаёт указанное абсолютное число |
increment |
прибавляет к последнему поданному в ЕИРЦ |
Waviot отдаёт 668.9570, в ЕИРЦ подают целые кВт·ч. Режим задаётся полем round:
| Режим | 668.9570 |
|---|---|
floor |
668 |
round |
669 |
ceil (по умолчанию) |
669 |
raw |
668.957 |
2 |
668.96 |
ceil выбран по умолчанию: подаём с запасом, переплата возвращается
перерасчётом на следующий месяц, а вот занижение копится долгом.
Значение по умолчанию задано один раз — DEFAULT_ROUNDING в waviot.py.
Счётчик не может крутиться назад. Если новое показание меньше уже поданного в ЕИРЦ, скрипт остановится:
Счётчик 00000000 день: новое показание 1230 МЕНЬШЕ уже поданного 1234.000
Это ловит и опечатки, и последствия ошибочных подач. Обойти — --allow-decrease.
Отдельная команда с тем же набором флагов. Waviot, округление и защита от
отката — общие с eirc.py, отличается только «транспорт» подачи.
python pesc.py --setupСпособы подтверждения:
PHONE SMS
EMAIL письмо на e-mail
FLASHCALL звонок (код — последние 4 цифры номера)
Выберите [PHONE]:
Код отправлен: SMS
Введите код: 1234
Готово. Вошли как Иван Иванов. Токены сохранены в pesc_tokens.json
Дальше SMS не понадобится — запускайте python pesc.py
Способ можно задать сразу: --setup --confirm-type EMAIL.
Сбросить токены и начать заново — python pesc.py --logout.
pesc_tokens.json — это ключ от кабинета. Он в .gitignore, на POSIX
кладётся с правами 600. При переносе на другую машину проще выполнить
--setup заново, чем копировать файл.
python pesc.py --list # лицевые счета, счётчики, scaleId и прошлые показания
python pesc.py --info # услуги, окно приёма, счёт к оплате, баланс
python pesc.py --tariff # ставки: сколько стоит кВт*ч днём и ночью
python pesc.py --period # принимает ли кабинет показания прямо сейчас
python pesc.py --dry-run # весь путь + payload, БЕЗ отправки
python pesc.py # реальная подача
python pesc.py --summary # короткий итог в stdout, лог в stderr--list печатает ровно то, что нужно для конфига:
Лицевой счёт 000000000000 (id 1234567) — г. Санкт-Петербург, ...
счётчик 00000000 (registration XXXXXXXXXX, ACTIVE)
scaleId 2 День последнее: 1234.0 кВт*ч от 2026-08-20
scaleId 3 Ночь последнее: 567.0 кВт*ч от 2026-08-20
--days — календарная эвристика: мы сами решаем, что подавать надо с 1 по
25. Но у ПЭС есть ручка, где кабинет отвечает на этот вопрос сам:
python pesc.py --periodЛицевой счёт 000000000000
приём открыт, Сентябрь 2026 (01.09.2026 - 30.09.2026)
поле deadLine кабинета: 10 (кабинет отдаёт его без пояснений)
Флаг forbidden из этого же ответа проверяется перед каждой подачей: если
приём закрыт, скрипт не станет отправлять и вернёт ошибку с объяснением.
Обойти — --ignore-period, выключить проверку совсем —
"check_period": false. В --dry-run закрытый приём только предупреждает,
чтобы пробный прогон работал в любой день.
Проверка мягкая по построению: ручка новая и на части счетов отвечает
404 — тогда она просто пропускается, и всё работает как раньше. Блокирует
только явный forbidden.
Про deadLine честно: кабинет отдаёт это число (у ЛО — 10), но нигде
его не поясняет, и во фронтенде ПЭС оно не отображается. Похоже на
крайний день попадания в текущий расчёт, но это догадка, поэтому скрипт
его показывает и никак не интерпретирует.
python pesc.py --infoЛицевой счёт 000000000000 «квартира» (id 1234567)
Услуги: ELECTRICITY
Показания: приём открыт, Сентябрь 2026 (01.09.2026 - 30.09.2026)
Счёт к оплате: 1234.56 руб. от 01.08.2026, доступен к скачиванию
Расчёты: Электроэнергия — баланс 0.00, начислено 0.00 (АО "ПСК")
Четыре независимых запроса, каждый в своём try: недоступность одного не скрывает остальные, вместо строки будет «не получено».
Отдельного эндпоинта про цену у кабинета нет — ставки лежат в карточке
лицевого счёта, GET api/v7/accounts/{id}/details, блоком TABLE
рядом с типом плиты и моделью счётчика. --tariff их оттуда достаёт:
Электроэнергия — Двухтарифный, руб. за единицу
диапазон 1 диапазон 2 диапазон 3
Круглосуточный 6.70 12.84 17.26
Ночь 23:00 — 07:00 4.01 8.99 12.09
День 07:00 — 23:00 7.37 13.81 18.55
Цена не одна, их три: ставка растёт при превышении месячного объёма
потребления. Сами пороги диапазонов кабинет в этом ответе не отдаёт —
только ставки, поэтому какой диапазон применят, из API не узнать.
rate_for(tariffs, "День") по умолчанию берёт первый, самый дешёвый.
Разбор устроен на два формата блоков: TABLE — многотарифный учёт
(строки «День» / «Ночь» / «Круглосуточный», ставка на каждый диапазон),
SOLID — одноставочные услуги вроде воды, где всё в строке «Тарифная
ставка» через дробь. Незнакомые блоки просто пропускаются.
Ставка подмешивается и в итог подачи — в том числе в уведомление:
ПЭС: показания переданы
Лицевой счёт 000000000000
00000000 День: 1234.0 -> 1240 (+6 кВт*ч, 44.22 руб.)
00000000 Ночь: 567.0 -> 570 (+3 кВт*ч, 12.03 руб.)
Итого примерно 56.25 руб.
Это стоит одного дополнительного GET за прогон. Любая ошибка на нём
гасится: тарифы не пришли — подача всё равно идёт, просто без сумм.
Отключить — "cost": false в секции pesc, сменить диапазон —
"band": "2".
Слово «примерно» тут не для скромности: берётся первый диапазон, а какой применят на самом деле, из API не видно.
Секция pesc в том же config.json:
"pesc": {
"base_url": "https://ikus.pesc.ru",
"customer": "IKUS-SPB",
"login_type": "PHONE",
"token_file": "pesc_tokens.json",
"account": "000000000000",
"readings": [
{ "scaleId": 2, "register": "день", "round": "ceil" },
{ "scaleId": 3, "register": "ночь", "round": "ceil" }
]
}Вместо scaleId можно указать scaleName ("День"). Если счётчиков
несколько — добавьте meter с серийным номером или registration.
Источник значения (register / val / increment) и round — те же,
что в разделах выше.
Кабинет юрлиц lk.pesc.ru — тот же API: поменяйте base_url,
customer на spb и login_type на EMAIL или LOGIN.
В кабинете есть и TOTP (api/v1/dfa/profile/totp/create включает,
api/v1/dfa/{transactionId}/totp/verify подтверждает). Скрипт его не
использует: verified решает ту же задачу и не требует ни секрета в
.env, ни лишней зависимости. Если однажды verified начнёт протухать
слишком часто, TOTP — готовый путь сделать вход полностью автоматическим
даже после отзыва токена.
Готовые конфигурации — в каталоге deploy/.
ПЭС — каждый день в 23:45, ЕИРЦ — с 1 по 25 число в 23:50:
45 23 * * * cd /config/sendeirc && /usr/bin/python3 pesc.py >> eirc.log 2>&1
50 23 1-25 * * cd /config/sendeirc && /usr/bin/python3 eirc.py >> eirc.log 2>&1Поля: минута час день_месяца месяц день_недели. Диапазон 1-25 в третьем поле
и даёт «с 1 по 25», * — каждый день.
Два кабинета — две строки, а не один скрипт с двумя адресатами: если ПЭС лежит, показания в ЕИРЦ всё равно уйдут, и в логе сразу видно, какой из двух не принял.
Почему у ПЭС расписание другое. Ежедневный запуск — это самовосстановление:
упала сеть, лежал VPN, сервер был выключен — завтрашний запуск подхватит,
и до конца месяца показания всё равно уйдут. Повторная подача ничего не
ломает: счётчик растёт, защита от отката на месте. А календарь тут не нужен
потому, что ПЭС сам отвечает, открыт ли приём (--period), и при закрытом
окне скрипт не отправляет.
У ЕИРЦ такой ручки нет, а в кабинете написано, что в расчёт идут показания до 25-го, — поэтому там остаётся диапазон.
Два подводных камня:
cdобязателен. Скрипт читает.env,config.jsonиcookies.pklиз текущего каталога, а cron стартует из домашнего.- Путь к python — абсолютный. У cron урезанный
PATH.
Таймзона берётся системная. Если сервер в UTC, а нужно 23:50 МСК — ставьте
50 20, либо добавьте в начало crontab CRON_TZ=Europe/Moscow.
В HAOS обычного crontab нет. Три рабочих пути:
| Способ | Когда подходит |
|---|---|
Автоматизация + shell_command |
скрипт лежит в /config, зависимости есть в контейнере HA |
| Add-on «Advanced SSH & Web Terminal» | нужен привычный cron внутри HAOS |
| Отдельный Docker-контейнер | самый изолированный, свои зависимости |
Готовая автоматизация — deploy/homeassistant.yaml, контейнер —
deploy/Dockerfile и deploy/docker-compose.yml.
Автоматизация HA коротко:
shell_command:
send_eirc: "cd /config/sendeirc && python3 eirc.py --days 1-25 >> eirc.log 2>&1"- alias: ЕИРЦ — подача показаний
triggers:
- trigger: time
at: "23:50:00"
conditions:
- condition: template
value_template: "{{ now().day <= 25 }}"
actions:
- action: shell_command.send_eircДля ПЭС в deploy/homeassistant.yaml лежит такая же пара
(shell_command.send_pesc + автоматизация на 23:45, но ежедневная и без
условия по числу месяца). Одно «но»:
python pesc.py --setup из HA не запустить — код подтверждения нужно
вводить руками, поэтому разовый вход делается через add-on
«Advanced SSH & Web Terminal» или docker exec.
Скрипт с --summary печатает в stdout только короткий итог, а весь лог
отправляет в stderr — этот текст и уходит в сообщение:
$ python eirc.py --summary
ЕИРЦ: показания переданы
Лицевой счёт 000000000000
00000000 день: 1234.000 -> 1240
00000000 ночь: 567.000 -> 570
При ошибке — строка ОШИБКА ЕИРЦ: ... и код возврата 1, по которому
автоматизация отличает провал от успеха. Если день вне диапазона --days,
stdout начинается с SKIP — такие запуски можно не слать.
Готовая автоматизация — в deploy/homeassistant.yaml:
shell_command:
send_eirc: >-
/bin/sh -c 'cd /config/sendeirc &&
python3 eirc.py --summary --days 1-25 2>> /config/sendeirc/eirc.log'Обёртка /bin/sh -c обязательна. Если в команде нет шаблона {{ }},
HA не запускает шелл: он делает shlex.split и вызывает программу
напрямую. Тогда cd ищется как несуществующий бинарник /bin/cd,
и получается returncode 1 с пустыми stdout и stderr. По той же
причине без обёртки не работают &&, | и 2>>.
actions:
- action: shell_command.send_eirc
response_variable: result
- choose:
- conditions: "{{ result.stdout is search('^SKIP') }}"
sequence: []
- conditions: "{{ result.returncode == 0 }}"
sequence:
- action: notify.send_message
target:
entity_id: notify.ВАШ_TELEGRAM
data:
message: "✅ {{ result.stdout }}"
default:
- action: notify.send_message
target:
entity_id: notify.ВАШ_TELEGRAM
data:
message: "⚠️ ЕИРЦ: подача не прошла
{{ result.stdout }}"
- action: notify.mobile_app_ВАШ_ТЕЛЕФОН
data:
title: "⚠️ ЕИРЦ: подача не прошла"
message: "{{ result.stdout }}"
data:
priority: high
ttl: 0Успех уходит только в Telegram, ошибка — ещё и пушем с priority: high,
чтобы её нельзя было пропустить.
Если интеграция Telegram Bot уже настроена, токен брать негде и не нужно —
он внутри интеграции. Повторно объявлять telegram_bot: в configuration.yaml
нельзя, HA выдаст ошибку о дублирующей настройке. Нужно лишь узнать имя
готового сервиса: Средства разработчика → Действия → набрать notify.
У notify.send_message адресат задаётся через target.entity_id, и поля
title там нет — заголовок пишется первой строкой message.
Если же Telegram ещё не настроен — токен у @BotFather, chat_id у @userinfobot,
оба в secrets.yaml.
Диапазон дней умеет проверять и сам скрипт:
python eirc.py --days 1-25Вне диапазона он выходит с кодом 0, ничего не подавая. Полезно, если
расписание сработает не в тот день — после перезагрузки, смены таймзоны
или ручного запуска. Можно задать и через EIRC_DAYS в .env.
Важно: в кабинете написано, что при расчёте платы учитываются показания, переданные не позднее 25-го числа. Запуск в 23:50 25-го оставляет 10 минут запаса — если сайт будет недоступен, повтора уже не будет. Надёжнее 20-23 число.
У pesc.py флаг тоже есть (--days, PESC_DAYS), но по умолчанию он не
используется: там подача ежедневная, а окно приёма спрашивается у кабинета.
Это и снимает описанную выше проблему «одна попытка в последний день» —
неудачный вечер просто повторится завтра.
Если подача падает так:
ОШИБКА ЕИРЦ: SSLError: HTTPSConnectionPool(host='id.epd47.ru', port=443): ...
[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate
— дело не в скрипте и не в паролях. id.epd47.ru отдаёт только свой
сертификат, без промежуточного GlobalSign, которым он подписан:
openssl s_client -connect id.epd47.ru:443 -servername id.epd47.ru </dev/null 2>/dev/null \
| openssl x509 -noout -text | grep "CA Issuers"
# CA Issuers - URI:http://secure.globalsign.com/cacert/gsgccr3dvtlsca2020.crtБраузер такой сертификат догружает сам (AIA fetching), Python — нет. Пока промежуточный лежит в хранилище системы, всё работает; в контейнере Home Assistant его нет, и проверка цепочки обрывается.
Лечится своим набором корней — certifi плюс тот самый промежуточный. Собирается один раз, рядом со скриптом:
cd /config/custom_components/SendToEIRC
# 1. корни из certifi того интерпретатора, которым запускается скрипт
docker exec homeassistant python3 -c "import certifi;print(open(certifi.where()).read())" > /tmp/ca-bundle.pem
# 2. промежуточный GlobalSign, DER -> PEM
curl -fsSL http://secure.globalsign.com/cacert/gsgccr3dvtlsca2020.crt \
| openssl x509 -inform DER >> /tmp/ca-bundle.pem
sudo cp /tmp/ca-bundle.pem .
# 3. проверка: должно быть ровно "Verify return code: 0 (ok)"
openssl s_client -connect id.epd47.ru:443 -servername id.epd47.ru -CAfile ca-bundle.pem </dev/null 2>/dev/null \
| grep "Verify return code"Файл с именем ca-bundle.pem рядом со скриптом (или в текущем каталоге)
подхватывается сам — ни конфиг, ни команду запуска править не нужно. Он
действует на все три клиента: ЕИРЦ, ПЭС и Waviot. Другое имя или другое
место задаются полем ca_bundle в config.json (для ПЭС — либо то же поле
верхнего уровня, либо своё внутри pesc) или переменной EIRC_CA_BUNDLE
в .env. Если указанного файла нет, скрипт скажет об этом сразу, а не
свалится посреди подачи.
Проверить, что стало лучше, можно без подачи:
python eirc.py --net-checkВ .gitignore файл добавлен намеренно: набор корней свой у каждой машины,
и certifi в нём со временем устаревает. Когда GlobalSign сменит
промежуточный или обновится certifi — пересоберите файл теми же тремя
командами.
Чего делать не стоит: отключать проверку (verify=False) или совать
промежуточный сертификат в системное хранилище контейнера — при обновлении
HA он оттуда пропадёт, и ошибка вернётся в самый неподходящий день.
Если вход по паролю перестанет работать (например, добавят капчу), куки можно
взять из браузера — DevTools → Application → Cookies для lk.epd47.ru:
# cookies.txt: .AspNetCore.Cookies=chunks-2; .AspNetCore.CookiesC1=...; .AspNetCore.CookiesC2=...
python eirc.py --import-cookies cookies.txteirc.py авторизация в ЕИРЦ, парсинг счётчиков, подача
pesc.py JSON API Петроэлектросбыт, обход 2FA через verified
waviot.py клиент Waviot, округление; работает и отдельно
pesc_tokens.json токены ПЭС, создаётся при --setup (в .gitignore)
ca-bundle.pem свой набор корней, если нужен (в .gitignore)
deploy/crontab строки для cron с пояснениями
deploy/homeassistant.yaml shell_command + автоматизация для HA
deploy/Dockerfile самодостаточный контейнер со своим cron
deploy/docker-compose.yml запуск контейнера