Skip to content

feat: SIP-уведомления о звонках, кнопка «Открыть дверь» в Telegram, webhook и видеоклип - #41

Open
niklzz wants to merge 6 commits into
moleus:devfrom
niklzz:feat-sip-telegram
Open

niklzz wants to merge 6 commits into
moleus:devfrom
niklzz:feat-sip-telegram

Conversation

@niklzz

@niklzz niklzz commented Sep 9, 2026

Copy link
Copy Markdown

Зачем

В исходной реализации, решение умеет показывать камеры и открывать дверь, при этом при открытии двери, звонок домофона не прекращался до таймаута, а событие звонка было только внутри официального приложения.

Этот PR добавляет то самое отсутствующее звено — событие входящего звонка. Прокси регистрируется на домофоне как второй SIP-абонент (реквизиты выдаёт сам API оператора), поэтому узнаёт о звонке в момент INVITE, одновременно с приложением. Дальше это событие можно разослать куда угодно: в Telegram, в webhook, в Home Assistant.

Что теперь происходит при звонке

  1. Гость нажимает кнопку на панели. Прокси получает INVITE и отвечает 180 Ringing, то есть ведёт себя как обычная трубка и ничего не ломает в звонке для приложения.
  2. В Telegram сразу приходит сообщение с фотографией того, кто звонит — кадр 1920×1080, снятый в момент звонка.
  3. Под фото — кнопка «Открыть дверь». Одно нажатие прямо из уведомления: не нужно ни приложение, ни эта веб-страница. Кнопка живёт бессрочно, работает и после рестарта контейнера — привязки хранятся на диске.
  4. Нажатие открывает дверь и завершает звонок: панель перестаёт пищать, а вызов не висит все 25 секунд. Раньше дверь открывалась, а домофон продолжал звонить, пока звонок не отвалится сам. Как именно завершать вызов — выбирается режимом (answer-bye гасит вызов на панели; проверено на живом домофоне).
  5. Через полминуты вслед за фото приходит видео: 15 секунд до звонка и 15 после. Видно, кто подошёл к двери, что он делал до нажатия кнопки и ушёл ли внутрь после открытия. Это не запись live-потока в буфер, а вырезка из облачного архива домру по времени звонка — прокси ничего не пишет на диск и не держит поток постоянно. Этот шаг требует платной подписки дом.ру, поэтому он отключён отдельным флагом.
  6. Параллельно, если нужно, уходит webhook {"event":"Ringing"} — для Home Assistant и любых своих сценариев.

Всё это необязательно и по умолчанию выключено: без переменных окружения поведение прокси не меняется совсем. Можно включить только Telegram, только webhook или только SIP.

Основан на #40 (камеры соседних подъездов): в ветке есть его коммит 7ca663d, вливать после него. Свои изменения — начиная с d9c8cb8.

Как это сделано

  • pkg/sipclient (sipgo v0.28) — REGISTER + Digest у SIP-домофона, на INVITE отвечает 180, держит вызов RingTime (25 с) и завершает его. Реквизиты берутся из POST /accesscontrols/{id}/sipdevices с постоянным installationId (файл рядом с accounts.json). Ретрансмиты INVITE дедуплицируются по Call-ID/From-tag, так что на звонок приходит ровно одно событие, а не пачка.
  • pkg/callcontrol — единственная точка открытия двери для новых интерфейсов: TryLock (параллельное нажатие → busy, без очереди) и режимы завершения вызова off / reject (486) / answer-bye (200→ACK→BYE с recvonly G.711). Открытие с явным ID старого уведомления никогда не завершает новый вызов — старая кнопка в истории чата не оборвёт свежий звонок.
  • pkg/telegram — long polling, sendPhoto со снимком домофона и бессрочной inline-кнопкой. Состояние в telegram-state.json: привязки кнопка→вызов/чат/сообщение, offset, результаты callback. Callback помечается processing до открытия, после рестарта становится unknown — повторного открытия двери не будет. Ошибки никогда не содержат URL с токеном.
  • pkg/webhook{"event":"Ringing"} с Idempotency-Key = ID вызова, повтор только на 429/5xx.
  • pkg/videoclip — ролик 30 с (15 до и 15 после звонка) вслед за фото. Не буфер live-потока, а облачный архив оператора: /video?TS=<unix> воспроизводит запись в реальном времени, поэтому один запрос на звонок заменяет любой ring buffer. FLV (H.264 + MP3) ремуксится в MP4 в памяти, без ffmpeg и без записи на диск.
  • Снимок в нативном разрешении. Дверной /accesscontrols/{id}/snapshots физически отдаёт только миниатюру 500×281 и на width/height её апскейлит (запрос 1920×1080 возвращает мыльные 1920×1079). Настоящий кадр из потока даёт /forpost/cameras/{externalCameraId}/snapshots?width=1920&height=1080 — ровно 1920×1080; на нём читаются номера машин и вывески. Цена — ~2 с на захват кадра против ~0.2 с у миниатюры; от размера картинки это время не зависит, так что понижать разрешение смысла нет. Дверной путь остался фолбэком, если камера неизвестна.
  • Статус на главной — строка под шапкой: SIP, Telegram, режим завершения, активный звонок, ошибка webhook; берётся из /api/integrations/state раз в 10 с и скрыта, когда интеграции выключены.

Правки существующего кода вне интеграций: authorizedhttp буферизует тело (≤1 MiB) и повторяет POST после 401 под мьютексом обновления токена, ответы закрываются; auth.CredentialsStore пишет accounts.json атомарно с правами 0600 (pkg/atomicfile). Они полезны сами по себе — если SIP/Telegram целиком не нужны, могу вынести их в отдельный PR.

Переменные окружения

Полная таблица — в README. Кратко: DOMRU_SIP_ENABLED + DOMRU_SIP_IP, DOMRU_SIP_ACCESS_CONTROL_ID, DOMRU_SIP_END_MODE; DOMRU_TELEGRAM_BOT_TOKEN + DOMRU_TELEGRAM_CHAT_ID + DOMRU_TELEGRAM_VIDEO; DOMRU_WEBHOOK_URL. Для SIP нужна host-сеть — пример в docker-compose.sip.yml.

Грабли sipgo, важные для ревью

  • Обработчик INVITE обязан блокироваться на всё время вызова: Server.handleRequest вызывает tx.Terminate() сразу после возврата, иначе CANCEL получает 481, а отложенный 486 не уходит. Каждый запрос обрабатывается в своей горутине, так что блокировка безопасна.
  • ServeUDP добавляет слушатель в пул асинхронно; запрос до этого открывает второй сокет на том же порту и падает, поэтому старт ждёт появления соединения через GetConnection.
  • sipgo пишет в глобальный zerolog — он глушится в sipclient.New, чтобы SIP-реквизиты не попали в лог.

Проверка

gofmt, go vet, go test ./... и go test -race ./... зелёные; тесты покрывают дедупликацию INVITE, 486 после CANCEL, режимы callcontrol, идемпотентность webhook, персистентность Telegram-кнопок между рестартами, ремукс FLV→MP4 и выбор эндпоинта снимка.

Живьём на своём домофоне: reject (486) завершает только нашу ветку вызова, панель продолжает звонить; answer-bye гасит вызов на панели — именно он и даёт «нажал кнопку, дверь открылась, домофон замолчал». Дефолт в коде поэтому оставлен off: гасить чужой звонок молча — не то поведение, которое стоит включать без спроса. Кнопка в Telegram, фото и видеоклип проверены на реальных звонках.

Dom.ru now exposes cameras of neighboring entrances via
/rest/v1/places/{placeId}/screen-sections (viewable with a Pro
subscription). The home page loads that endpoint per place and renders
one card per camera, with its own Home Assistant snippet.

Cameras are matched by externalCameraId / forpostGroupId instead of the
array index, which broke with more than one place or camera.

Door permissions are taken from /rest/v1/places/{placeId}/accesscontrols:
after a subscription the embedded list in subscriberplaces advertises
allowOpen for neighboring doors that cannot actually be opened. Neighbor
cards never get an "Open" button.

Extra endpoints failing (or 404 on operators without screen-sections)
degrade gracefully: the basic camera list stays visible.
Add optional integrations that watch incoming intercom calls and let the
user open the door while ending the call:

- pkg/sipclient: sipgo v0.28 client for one intercom (REGISTER + Digest,
  180/486 by timer, 200/ACK/BYE with recvonly G.711 and RTP sink);
  credentials via POST .../sipdevices with a persistent installationId.
- pkg/callcontrol: single door-opening coordinator with modes off,
  reject and answer-bye; one operation at a time, an old notification
  never ends a newer call.
- pkg/telegram: long-polling bot, photo notification with a permanent
  "Открыть дверь" button, state in telegram-state.json, silent success and
  a single warning reply on failure.
- pkg/webhook: {"event":"Ringing"} with Idempotency-Key per call.
- /api/integrations/state, /api/.../open-and-end-call and optional
  per-call diagnostics; the home page button and HA snippet of the SIP
  intercom use open-and-end-call.

HTTP client fixes needed by the background components: replay the POST
body after a 401 refresh, close the previous response, serialize token
refresh, atomic 0600 writes of credentials, never retry door POSTs.

Live check on one intercom: only answer-bye silences the panel; reject
(486) ends just our leg. Default mode stays off.
The photo sent on INVITE came from the door's own snapshot endpoint, which
only ever renders a 500x281 thumbnail and upscales it when width/height are
given (1920x1080 comes back as a blurry 1920x1079). Take the frame from the
forpost camera endpoint keyed by externalCameraId instead: exactly 1920x1080,
grabbed from the stream, ~2 s. The door path stays as a fallback when the
camera is unknown, and the snapshot timeout grows from 3 to 6 s accordingly.

The photo is now followed by a 30 s MP4 (15 s before and after the call),
cut from the operator's cloud archive rather than buffered from the live
stream: a request with TS=<unix> replays the recording in real time, so one
request per call replaces any ring buffer. pkg/videoclip remuxes the
HTTP-FLV (H.264 + MP3) into MP4 in memory. Off by default, DOMRU_TELEGRAM_VIDEO
turns it on; a failed clip only shows up in the integrations status, since
the photo and the door button have already gone out.
…thout recording

The cloud archive (/video?TS=) comes with the subscription; without it the
streamer silently answers a TS request with live video and no clip is sent.

- videoclip.Buffer keeps the last 20 s of the camera's light stream
  (960x528, ~0.45 Mbit/s) in RAM: frames only, no decoding, nothing on disk.
  Reconnects with backoff, a 10 s watchdog replaces the missing body read
  timeout, timestamps of a new connection are stitched to the previous one,
  the window always starts at a keyframe (the FLV demuxer prepends SPS/PPS).
- videoclip.Auto serves the archive and switches to the buffer for good on
  the first errLive (startup probe or a call); that call still gets the
  seconds after the ring.
- DOMRU_TELEGRAM_VIDEO: true = archive with fallback to the buffer,
  buffer = live buffer only. Remux is split into parseFLV and mux; the MP4
  now starts at dts 0 (live timestamps are in the hundreds of millions).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant