Удивительно, что в 2021-м все еще можно обсуждать такую избитую тему. Однако, мне пришлось пройти довольно длинный путь от покупки охранных камер до готового решения, покрывающего мои, довольно нехитрые, задачи. Под катом вы найдете скрипт, который показался мне достаточно удачным, чтобы опубликовать его на Хабре, и некоторые пояснения к нему. Надеюсь, кому-то поможет.
Немного предыстории: мне достались IP камеры Rubetek RV-3414 и Hikvision DS-2CD2023. Мне нужно было организовать оперативный доступ к ним с мобильных устройств и непрерывную запись на жесткий диск. В качестве видеорегистратора и медиасервера я использую Intel NUC младшей модели из-за низкого потребления энергии, что сильно увеличивает автономность всей системы. Обе камеры работают по протоколу RTSP – Real Time Streaming Protocol. Теперь обо всем по порядку.
Инструкции производителей
Первое, что я сделал после установки камер, - подключил их согласно прилагаемым инструкциям. И тут началось приключение, полное сюрпризов и неожиданных поворотов.
Rubetek предложил проприетарный софт для просмотра видеопотока, который работает с оборудованием собственного производства (и почти не глючит). Но при подключении нескольких клиентов к камере это чудо техники почему-то отказывается отдавать поток на основное устройство (у меня это мобильный телефон на Андроиде). Ладно, не больно то и хотелось, все равно держать зоопарк программ под каждую камеру я не собирался. Устанавливаю на телефон VLC — работает.
Hikvision порадовал меня тем, что не требует ничего скачивать, и у камеры есть веб интерфейс для настройки. Инструкция предлагает открыть указанный IP адрес в IE, Chrome или Firefox’е. Открываю в Windows 11:
Требует обновить Edge до IE. Смешно. В 11-й, согласно официальным данным, нет возможности установить IE, зато есть режим IE (на скриншоте). Включаю — не открывается. Chrome, FF — то же самое, Linux — то же самое. Весело. Устанавливаю Win 7 в виртуалку, настраиваю камеру. Без комментариев. Справедливости ради должен сказать, что к работе самой камеры у меня претензий нет.
Удаленный доступ
Наконец, пришло время настроить удаленный просмотр видеопотоков. Мои камеры подключены к роутеру с «серым» IP адресом, подключится извне не получится. Самый простой выход из этой ситуации (не считая покупки IP) — прокинуть SSH туннель до ближайшего сервера с «белым» IP:
ssh -NT -o ServerAliveInterval=60 -o ExitOnForwardFailure=yes -R <remote_port>:<local_ip>:<local_port> <login>@<remote_ip>
Команда выполняется на сервере в локальной сети. Все запросы на <remote_ip>:<remote_port> будут проброшены на <local_ip>:<local_port>, т. е. на камеру. Но чтобы удаленный сервер слушал сетевой интерфейс, придется разрешить проброс строкой «GatewayPorts yes» в sshd_config. Это не безопасно, делать так не надо. Кроме того, логин/пароль от камеры в этом случае гуляет по интернету в открытом виде и легко может быть скомпрометирован.
К счастью, существуют готовые решения на этот случай, например rtsp-simple-server. Отличный скрипт на Go, покрывает почти все мои потребности. Но хочется больше контроля и больше функционала, а значит, придется разбираться в деталях.
Техзадание
Опыт, полученный до этого момента, помог мне сформулировать требования к будущему серверу.
Надежное подключение клиентов в локальной сети. Оперативное подключение к камерам должно работать железобетонно. По моим наблюдениям, камеры глючит при большом числе подключений (большое — это больше одного — двух). Поэтому в идеале нужно организовать одно подключение к каждой камере, независимо от количества клиентов.
Минимальная задержка подключения клиентов, как минимум, первого локального.
Низкая нагрузка на процессор. Для меня это важно, потому что иначе мой Intel NUC начнет есть батарею бесперебойника и противно шуршать охлаждающей турбинкой.
Проксирование потоков с IP камер неограниченному количеству клиентов в локальной сети и ограничение количества веб клиентов.
Запись на жесткий диск с разбивкой на фрагменты и суточной ротацией.
Восстановление соединения с камерами и записи на диск после отключения камер. Рано или поздно отключение произойдет обязательно, например, из-за перебоя питания.
Подключения нужно логировать.
Ну все, ТЗ есть, приступаю к реализации.
Сбор данных
Для начала нужно взглянуть, как VLC общается с камерами. Включаю Wireshark с фильтром tcp.port == 554 и вижу:
Rubetek
Ask: OPTIONS rtsp://192.168.0.114:554/onvif1 RTSP/1.0
CSeq: 2
User-Agent: LibVLC/3.0.16 (LIVE555 Streaming Media v2021.08.24)
Reply: RTSP/1.0 200 OK
CSeq: 2
Public: OPTIONS, DESCRIBE, SETUP, TEARDOWN, PLAY, PAUSE, GET_PARAMETER, SET_PARAMETER,USER_CMD_SET
Ask: DESCRIBE rtsp://192.168.0.114:554/onvif1 RTSP/1.0
CSeq: 3
User-Agent: LibVLC/3.0.16 (LIVE555 Streaming Media v2021.08.24)
Accept: application/sdp
Reply: RTSP/1.0 200 OK
CSeq: 3
Content-Type: application/sdp
Content-Length: 422
v=0
o=- 1421069297525233 1 IN IP4 192.168.0.113
s=H.264 Video, RtspServer_0.0.0.2
t=0 0
a=tool:RtspServer_0.0.0.2
a=type:broadcast
a=control:*
a=range:npt=0-
m=video 0 RTP/AVP 96
c=IN IP4 0.0.0.0
b=AS:500
a=rtpmap:96 H264/90000
a=fmtp:96 packetization-mode=1;profile-level-id=42001F;sprop-parameter-sets=Z0IAH5WoFAFuQA==,aM48gA==
a=control:track1
m=audio 0 RTP/AVP 8
a=control:track2
a=rtpmap:8 PCMA/8000
Ask: SETUP rtsp://192.168.0.114:554/onvif1/track1 RTSP/1.0
CSeq: 4
User-Agent: LibVLC/3.0.16 (LIVE555 Streaming Media v2021.08.24)
Transport: RTP/AVP;unicast;client_port=45150-45151
Reply: RTSP/1.0 200 OK
CSeq: 4
Transport: RTP/AVP;unicast;destination=192.168.0.165;source=192.168.0.113;client_port=45150-45151;server_port=7060-7061
Session: 7c2467db;timeout=60
Ask: SETUP rtsp://192.168.0.114:554/onvif1/track2 RTSP/1.0
CSeq: 5
User-Agent: LibVLC/3.0.16 (LIVE555 Streaming Media v2021.08.24)
Transport: RTP/AVP;unicast;client_port=35736-35737
Session: 7c2467db
Reply: RTSP/1.0 200 OK
CSeq: 5
Transport: RTP/AVP;unicast;destination=192.168.0.165;source=192.168.0.113;client_port=35736-35737;server_port=7062-7063
Session: 7c2467db;timeout=60
Ask: PLAY rtsp://192.168.0.114:554/onvif1 RTSP/1.0
CSeq: 6
User-Agent: LibVLC/3.0.16 (LIVE555 Streaming Media v2021.08.24)
Session: 7c2467db
Range: npt=0.000-
Reply: RTSP/1.0 200 OK
CSeq: 6
Range: npt=0.000-
Session: 7c2467db
RTP-Info: url=rtsp:192.168.0.113:554/onvif1/track1;seq=57651;rtptime=61388916750,url=rtsp:192.168.0.113:554/onvif1/track2;seq=58422;rtptime=5456792600
*** Тут камера начинает посылать поток на указанный порт ***
Ask: TEARDOWN rtsp://192.168.0.114:554/onvif1 RTSP/1.0
CSeq: 7
User-Agent: LibVLC/3.0.16 (LIVE555 Streaming Media v2021.08.24)
Session: 7c2467db
Hikvision
Ask: OPTIONS rtsp://192.168.0.110:554/ISAPI/Streaming/Channels/101 RTSP/1.0
CSeq: 2
User-Agent: LibVLC/3.0.16 (LIVE555 Streaming Media v2021.08.24)
Reply: RTSP/1.0 200 OK
CSeq: 2
Public: OPTIONS, DESCRIBE, PLAY, PAUSE, SETUP, TEARDOWN, SET_PARAMETER, GET_PARAMETER
Date: Mon, Nov 22 2021 09:57:17 GMT
Ask: DESCRIBE rtsp://192.168.0.110:554/ISAPI/Streaming/Channels/101 RTSP/1.0
CSeq: 3
User-Agent: LibVLC/3.0.16 (LIVE555 Streaming Media v2021.08.24)
Accept: application/sdp
Reply: RTSP/1.0 401 Unauthorized
CSeq: 3
WWW-Authenticate: Digest realm="IP Camera(G2669)", nonce="17215f510ab5085c7aef996a1d42769f", stale="FALSE"
Date: Mon, Nov 22 2021 09:57:17 GMT
Ask: DESCRIBE rtsp://192.168.0.110:554/ISAPI/Streaming/Channels/101 RTSP/1.0
CSeq: 4
Authorization: Digest username="login", realm="IP Camera(G2669)", nonce="17215f510ab5085c7aef996a1d42769f", uri="rtsp://192.168.0.110:554/ISAPI/Streaming/Channels/101", response="69ce13d857b38e6e68f7f5a4a85cd709"
User-Agent: LibVLC/3.0.16 (LIVE555 Streaming Media v2021.08.24)
Accept: application/sdp
Reply: RTSP/1.0 200 OK
CSeq: 4
Content-Type: application/sdp
Content-Base: rtsp://192.168.0.110:554/ISAPI/Streaming/Channels/101/
Content-Length: 587
v=0
o=- 1637575037561170 1637575037561170 IN IP4 192.168.0.110
s=Media Presentation
e=NONE
b=AS:5050
t=0 0
a=control:rtsp://192.168.0.110:554/ISAPI/Streaming/Channels/101/
m=video 0 RTP/AVP 96
c=IN IP4 0.0.0.0
b=AS:5000
a=recvonly
a=x-dimensions:1920,1080
a=control:rtsp://192.168.0.110:554/ISAPI/Streaming/Channels/101/trackID=1
a=rtpmap:96 H265/90000
a=fmtp:96 sprop-sps=QgEBAWAAAAMAsAAAAwAAAwB7oAPAgBDljb5JMvTcBAQEAg==; sprop-pps=RAHA8vA8kAA=
a=Media_header:MEDIAINFO=494D4B48010300000400050000000000000000000000000081000000000000000000000000000000;
a=appversion:1.0
Ask: SETUP rtsp://192.168.0.110:554/ISAPI/Streaming/Channels/101/trackID=1 RTSP/1.0
CSeq: 5
Authorization: Digest username="login", realm="IP Camera(G2669)", nonce="17215f510ab5085c7aef996a1d42769f", uri="rtsp://192.168.0.110:554/ISAPI/Streaming/Channels/101/", response="2341d81156d9cee08db0004835486f51"
User-Agent: LibVLC/3.0.16 (LIVE555 Streaming Media v2021.08.24)
Transport: RTP/AVP;unicast;client_port=59446-59447
Reply: RTSP/1.0 200 OK
CSeq: 5
Session: 695167870;timeout=60
Transport: RTP/AVP;unicast;client_port=59446-59447;server_port=8302-8303;ssrc=568ed713;mode="play"
Date: Mon, Nov 22 2021 09:57:17 GMT
Ask: PLAY rtsp://192.168.0.110:554/ISAPI/Streaming/Channels/101/ RTSP/1.0
CSeq: 6
Authorization: Digest username="login", realm="IP Camera(G2669)", nonce="17215f510ab5085c7aef996a1d42769f", uri="rtsp://192.168.0.110:554/ISAPI/Streaming/Channels/101/", response="a2a71ba4866e2f77d14f7368f368da5f"
User-Agent: LibVLC/3.0.16 (LIVE555 Streaming Media v2021.08.24)
Session: 695167870
Range: npt=0.000-
Reply: RTSP/1.0 200 OK
CSeq: 6
Session: 695167870
RTP-Info: url=rtsp://192.168.0.110:554/ISAPI/Streaming/Channels/101/trackID=1;seq=54784;rtptime=2171307498
Date: Mon, Nov 22 2021 09:57:17 GMT
Ask: GET_PARAMETER rtsp://192.168.0.110:554/ISAPI/Streaming/Channels/101/ RTSP/1.0
CSeq: 7
Authorization: Digest username="login", realm="IP Camera(G2669)", nonce="17215f510ab5085c7aef996a1d42769f", uri="rtsp://192.168.0.110:554/ISAPI/Streaming/Channels/101/", response="192d15433a0964eb2782026d8e908ed3"
User-Agent: LibVLC/3.0.16 (LIVE555 Streaming Media v2021.08.24)
Session: 695167870
Reply: RTSP/1.0 200 OK
CSeq: 7
Date: Mon, Nov 22 2021 09:58:15 GMT
Ask: GET_PARAMETER rtsp://192.168.0.110:554/ISAPI/Streaming/Channels/101/ RTSP/1.0
CSeq: 9
Authorization: Digest username="login", realm="IP Camera(G2669)", nonce="17215f510ab5085c7aef996a1d42769f", uri="rtsp://192.168.0.110:554/ISAPI/Streaming/Channels/101/", response="192d15433a0964eb2782026d8e908ed3"
User-Agent: LibVLC/3.0.16 (LIVE555 Streaming Media v2021.08.24)
Session: 695167870
Reply: RTSP/1.0 200 OK
CSeq: 9
Date: Mon, Nov 22 2021 10:00:11 GMT
*** Тут камера начинает посылать поток на указанный порт ***
Ask: TEARDOWN rtsp://192.168.0.110:554/ISAPI/Streaming/Channels/101/ RTSP/1.0
CSeq: 16
Authorization: Digest username="login", realm="IP Camera(G2669)", nonce="17215f510ab5085c7aef996a1d42769f", uri="rtsp://192.168.0.110:554/ISAPI/Streaming/Channels/101/", response="e6ef5c8c7ab615db158e7e77c8f7b77a"
User-Agent: LibVLC/3.0.16 (LIVE555 Streaming Media v2021.08.24)
Session:
Reply: RTSP/1.0 200 OK
CSeq: 16
Session: 695167870
Date: Mon, Nov 22 2021 10:06:25 GMT
Общение происходит по протокол RTSP, который достаточно хорошо описан в интернете, поэтому останавливаться на нем я не вижу смысла. А вот о различиях стоит сказать пару слов.
Первая камера не требует авторизации и отдает картинку всем желающим, вторая же требует дайджест-аутентификации (строка WWW-Authenticate: Digest в листинге). Поэтому на первый запрос DESCRIBE она отвечает 401 Unauthorized, но дает ключи шифрования realm и nonce. Имея эти ключи, в следующем запросе нужно указать параметр response, который вычисляется так:
response = md5(md5(login:realm:password):nonce:md5(option:url),
где login:password — логин/пароль от камеры, option — текущий метод.
Еще одно важное отличие состоит в наличии у первой камеры микрофона, поэтому клиент запрашивает SETUP дважды: для видео (track1) и звуковой дорожки (track2).
Для каждой дорожки VLC запрашивает client_port, куда камера будет посылать данные по протоколу UDP.
Скрипт
Теперь, пожалуй, данных достаточно, чтобы написать первую версию сервера. Писать я буду на «чистом» Python 3.7+, без сторонних зависимостей. Поскольку все подключения и потоки должны обрабатываться параллельно, неплохо бы использовать библиотеку asyncio.
Идея скрипта предельно проста: один раз подключиться к каждой камере, запомнить ее параметры и отвечать клиентам ровно то же самое (за исключением мелких деталей). Видеопоток нужно раздавать (проксировать) клиентам по мере их подключения.
Ниже приведен листинг минимальной работающей версии сервера, исключительно для облегчения понимания. Полная версия доступна на Github.
main.py
import asyncio
from config import Config
from camera import Camera
from client import Client
async def main():
for hash in Config.cameras.keys():
await Camera(hash).connect()
await Client.listen()
if __name__ == '__main__':
asyncio.run(main())
Здесь я сразу подключаю все указанные в конфиге камеры и начинаю слушать TCP порт 4554. В реальной жизни делать так не нужно — клиентов ведь может и не быть, а электричество надо экономить:)
config.py
import socket
class Config:
cameras = {
'хеш/любая-URL-совместимая-строка': {
'path': 'относительный путь к хранилищу',
'url': 'rtsp://<логин>:<пароль>@<хост>:554/<uri>'},
}
rtsp_port = 4554
start_udp_port = 5550
local_ip = socket.gethostbyname(socket.gethostname())
# Ограничить число веб-клиентов
web_limit = 2
log_file = '/var/log/python-rtsp-server.log'
Доступ к камерам я буду предоставлять по адресам вида rtsp://<адрес сервера>:<порт>/<хеш камеры>, где хеш камеры — любая URL-совместимая строка, включая символы UTF. Хеши и реальные адреса камер нужно указать в словаре cameras.
camera.py
import asyncio
import re
import time
from hashlib import md5
from shared import Shared
from config import Config
from log import Log
class Camera:
def __init__(self, hash):
self.hash = hash
self.url = self._parse_url(Config.cameras[hash]['url'])
async def connect(self):
""" Открываем TCP сокет и подключаем камеру
"""
self.udp_ports = self._get_self_udp_ports()
self.cseq = 1
self.realm, self.nonce = None, None
try:
self.reader, self.writer = await asyncio.open_connection(self.url['host'], self.url['tcp_port'])
except Exception as e:
print(f"Can't connect to camera [{self.hash}]: {e}")
return
await self._request('OPTIONS', self.url['url'])
reply, code = await self._request(
'DESCRIBE',
self.url['url'],
'User-Agent: python-rtsp-server',
'Accept: application/sdp')
if code == 401:
self.realm, self.nonce = self._get_auth_params(reply)
reply, code = await self._request(
'DESCRIBE',
self.url['url'],
'Accept: application/sdp')
self.description = self._get_description(reply)
track_ids = self._get_track_ids(reply)
reply, code = await self._request(
'SETUP',
f'{self.url["url"]}/{track_ids[0]}',
('Transport: RTP/AVP;unicast;'
f'client_port={self.udp_ports["track1"][0]}-{self.udp_ports["track1"][1]}'))
self.session_id = self._get_session_id(reply)
if len(track_ids) > 1:
reply, code = await self._request(
'SETUP',
f'{self.url["url"]}/{track_ids[1]}',
('Transport: RTP/AVP;unicast;'
f'client_port={self.udp_ports["track2"][0]}-{self.udp_ports["track2"][1]}'),
f'Session: {self.session_id}')
reply, code = await self._request(
'PLAY',
self.url['url'],
f'Session: {self.session_id}',
'Range: npt=0.000-')
Shared.data[self.hash] = {
'description': self.description,
'rtp_info': self._get_rtp_info(reply),
# 'transports': {},
'clients': {}}
Log.add(f'Camera [{self.hash}] connected')
await self._listen()
async def _listen(self):
""" Открываем UDP сокет и начинаем проксировать фреймы
"""
await self._start_server('track1')
if self.description['audio']:
await self._start_server('track2')
async def _request(self, option, url, *lines):
""" Запрос к камере с заданным OPTION и другими строками.
Возвращает декодированный ответ и статус
"""
command = f'{option} {url} RTSP/1.0\r\n' \
f'CSeq: {self.cseq}\r\n'
auth_line = self._get_auth_line(option)
if auth_line:
command += f'{auth_line}\r\n'
for row in lines:
if row:
command += f'{row}\r\n'
command += '\r\n'
print(f'*** Ask:\n{command}')
self.writer.write(command.encode())
reply = (await self.reader.read(4096)).decode()
print(f"*** Reply:\n{reply}")
self.cseq += 1
res = re.match(r'RTSP/1.0 (\d{3}) ([^\r\n]+)', reply)
if not res:
print('Error: invalid reply\n')
return reply, 0
return reply, int(res.group(1))
def _get_auth_params(self, reply):
""" Достать параметры realm и nonce для "digest" авторизации
"""
realm_nonce = re.match(r'.+?\nWWW-Authenticate:.+?realm="(.+?)", ?nonce="(.+?)"', reply, re.DOTALL)
if not realm_nonce:
raise RuntimeError('Invalid digest auth reply')
return realm_nonce.group(1), realm_nonce.group(2)
def _get_auth_line(self, option):
""" Собрать "response" хеш авторизации
"""
if not self.realm or not self.nonce:
return
ha1 = md5(f'{self.url["login"]}:{self.realm}:{self.url["password"]}'.encode('utf-8')).hexdigest()
ha2 = md5(f'{option}:{self.url["url"]}'.encode('utf-8')).hexdigest()
response = md5(f'{ha1}:{self.nonce}:{ha2}'.encode('utf-8')).hexdigest()
line = f'Authorization: Digest username="{self.url["login"]}", ' \
f'realm="{self.realm}" nonce="{self.nonce}", uri="{self.url["url"]}", response="{response}"'
return line
def _get_description(self, reply):
""" Достать SDP (Session Description Protocol) из ответа
"""
blocks = reply.split('\r\n\r\n', 2)
if len(blocks) < 2:
raise RuntimeError('Invalid DESCRIBE reply')
sdp = blocks[1].strip()
details = {'video': {}, 'audio': {}}
res = re.match(r'.+?\nm=video (.+?)\r\n', sdp, re.DOTALL)
if res:
details['video'] = {'media': res.group(1), 'bandwidth': '', 'rtpmap': '', 'format': ''}
res = re.match(r'.+?\nm=video .+?\nb=([^\r\n]+)', sdp, re.DOTALL)
if res:
details['video']['bandwidth'] = res.group(1)
res = re.match(r'.+?\nm=video .+?\na=rtpmap:([^\r\n]+)', sdp, re.DOTALL)
if res:
details['video']['rtpmap'] = res.group(1)
res = re.match(r'.+?\nm=video .+?\na=fmtp:([^\r\n]+)', sdp, re.DOTALL)
if res:
details['video']['format'] = res.group(1)
res = re.match(r'.+?\nm=audio (.+?)\r\n', sdp, re.DOTALL)
if res:
details['audio'] = {'media': res.group(1), 'rtpmap': ''}
res = re.match(r'.+?\nm=audio .+?\na=rtpmap:([^\r\n]+)', sdp, re.DOTALL)
if res:
details['audio']['rtpmap'] = res.group(1)
return details
def _get_rtp_info(self, reply):
""" Достать строку "RTP-Info" из ответа
"""
res = re.match(r'.+?\r\n(RTP-Info: .+?)\r\n', reply, re.DOTALL)
if not res:
raise RuntimeError('Invalid RTP-Info')
rtp_info = res.group(1)
seq = re.findall(r';seq=(\d+)', rtp_info)
rtptime = re.findall(r';rtptime=(\d+)', rtp_info)
if not seq or not rtptime:
raise RuntimeError('Invalid RTP-Info')
return {'seq': seq, 'rtptime': rtptime, 'starttime': time.time()}
def _get_track_ids(self, reply):
""" Достать ID дорожек из ответа
"""
track_ids = re.findall(r'\na=control:.*?(track.*?\d)', reply, re.DOTALL)
if not track_ids:
raise RuntimeError('Invalid track ID in reply')
return track_ids
def _get_session_id(self, reply):
""" Достать ID сессии из ответа
"""
res = re.match(r'.+?\nSession: *([^;]+)', reply, re.DOTALL)
if not res:
raise RuntimeError('Invalid session ID')
return res.group(1)
def _get_self_udp_ports(self):
""" Получить свободный динамический UDP порт
"""
start_port = Config.start_udp_port
idx = list(Config.cameras.keys()).index(self.hash) * 4
return {
'track1': [start_port + idx, start_port + idx + 1],
'track2': [start_port + idx + 2, start_port + idx + 3]}
def _parse_url(self, url):
""" Разобрать url камеры на части
"""
parsed_url = re.match(r'(rtsps?)://(.+?):([^@]+)@(.+?):(\d+)(.+)', url)
if not parsed_url or len(parsed_url.groups()) != 6:
raise RuntimeError('Invalid rtsp url')
return {
'login': parsed_url.group(2),
'password': parsed_url.group(3),
'host': parsed_url.group(4),
'tcp_port': int(parsed_url.group(5)),
'url': url.replace(f'{parsed_url.group(2)}:{parsed_url.group(3)}@', '')}
async def _start_server(self, track_id):
""" Запустить UDP сервер
"""
loop = asyncio.get_running_loop()
await loop.create_datagram_endpoint(
lambda: CameraUdpProtocol(self.hash, track_id),
local_addr=('0.0.0.0', self.udp_ports[track_id][0]))
class CameraUdpProtocol(asyncio.DatagramProtocol):
""" Этот колбэк вызывается при подключении к каждой камере
"""
def __init__(self, hash, track_id):
self.hash = hash
self.track_id = track_id
def connection_made(self, transport):
self.transport = transport
def datagram_received(self, data, addr):
if not Shared.data[self.hash]['clients']:
return
for _sid, client in Shared.data[self.hash]['clients'].items():
self.transport.sendto(data, (client['host'], client['ports'][self.track_id][0]))
Обслуживает камеры. Единственный публичный метод Camera.connect тривиально реализует подключение по протоколу RTSP, описанное в листингах выше. Для каждой подключенной камеры после команды PLAY запускается сервер asyncio.create_datagram_endpoint(), проксирование входящих потоков происходит в колбэке CameraUdpProtocol.
client.py
# https://docs.python.org/3/library/asyncio-protocol.html
import asyncio
import re
import string
import time
from random import choices, randrange
from config import Config
from shared import Shared
from log import Log
class Client:
def __init__(self):
self.camera_hash = None
self.udp_ports = {}
@staticmethod
async def listen():
""" Слушаем здесь подключения всех клиентов
"""
host = '0.0.0.0'
print(f'*** Start listening {host}:{Config.rtsp_port} ***\n')
loop = asyncio.get_running_loop()
server = await loop.create_server(
lambda: ClientTcpProtocol(),
host, Config.rtsp_port)
async with server:
await server.serve_forever()
def handle_request(self, transport, host, data):
""" Общяемся с клиентами по протоколу RTSP
"""
ask, option = self._request(data)
session_id = self._get_session_id(ask)
if option == 'OPTIONS':
self._response(
transport,
'Public: OPTIONS, DESCRIBE, SETUP, TEARDOWN, PLAY')
if option == 'DESCRIBE':
sdp = self._get_description()
self._response(
transport,
'Content-Type: application/sdp',
f'Content-Length: {len(sdp) + 2}',
'',
sdp)
elif option == 'SETUP':
udp_ports = self._get_ports(ask)
track_id = 'track1' if not self.udp_ports else 'track2'
self.udp_ports[track_id] = udp_ports
self._response(
transport,
f'Transport: RTP/AVP;unicast;client_port={udp_ports[0]}-{udp_ports[1]};server_port=5998-5999',
f'Session: {session_id};timeout=60')
elif option == 'PLAY':
self._response(
transport,
f'Session: {session_id}',
self._get_rtp_info())
# if session_id not in Shared.data[self.camera_hash]['clients']:
Shared.data[self.camera_hash]['clients'][session_id] = {
'host': host, 'ports': self.udp_ports, 'transport': transport}
self._check_web_limit(host)
Log.add(f'Play [{self.camera_hash}] [{session_id}] [{host}]')
elif option == 'TEARDOWN':
self._response(transport, f'Session: {session_id}')
return self.camera_hash, session_id
def _get_rtp_info(self):
""" Строим строку "RTP-Info" для клиента, изменив время rtptime для корректной работы счетчика.
По хорошему, надо просто запросить камеру, но в режиме TCP (interleaved) им это не нравится.
"""
rtp_info = Shared.data[self.camera_hash]['rtp_info']
print(rtp_info)
delta = time.time() - rtp_info['starttime']
rtptime = int(rtp_info["rtptime"][0]) + int(delta * 90000)
# 90000 is clock frequency in SDP a=rtpmap:96 H26*/90000
res = f'RTP-Info: url=rtsp://{Config.local_ip}:{Config.rtsp_port}/track1;' \
f'seq={rtp_info["seq"][0]};rtptime={rtptime}'
if len(rtp_info['seq']) < 2:
return res
rtptime = int(rtp_info["rtptime"][1]) + int(delta * 8000)
# 90000 is clock frequency in SDP a=rtpmap:8 PCMA/8000
res += f',url=rtsp://{Config.local_ip}:{Config.rtsp_port}/track2;' \
f'seq={rtp_info["seq"][1]};rtptime={rtptime}'
return res
def _request(self, data):
""" Разбираем ответ клиента
"""
try:
ask = data.decode()
except Exception:
raise RuntimeError(f"can't decode this ask:\n{data}")
print(f'*** Ask:\n{ask}')
# res = re.match(r'(.+?) rtsps?://.+?:\d+/(.+?)(/track.*?)? .+?\r\n', ask)
res = re.match(r'(.+?) rtsps?://.+?:\d+/?(.*?) .+?\r\n', ask)
if not res:
raise RuntimeError('invalid ask')
self.cseq = self._get_cseq(ask)
if not self.camera_hash:
hash = res.group(2)
if hash not in Config.cameras:
raise RuntimeError('invalid camera hash')
if hash not in Shared.data:
raise RuntimeError('camera is offline')
self.camera_hash = hash
return ask, res.group(1)
def _response(self, transport, *lines):
""" Отдаем клиенту данные строки
"""
reply = 'RTSP/1.0 200 OK\r\n' \
f'CSeq: {self.cseq}\r\n'
for row in lines:
reply += f'{row}\r\n'
reply += '\r\n'
transport.write(reply.encode())
print(f'*** Reply:\n{reply}')
def _get_cseq(self, ask):
""" Текущий счетчик из запроса клиента
"""
res = re.match(r'.+?\r\nCSeq: (\d+)', ask, re.DOTALL)
if not res:
raise RuntimeError('invalid incoming CSeq')
return int(res.group(1))
def _get_session_id(self, ask):
""" ID сессии из запроса клиента
"""
res = re.match(r'.+?\nSession: *([^;\r\n]+)', ask, re.DOTALL)
if res:
return res.group(1).strip()
return ''.join(choices(string.ascii_lowercase + string.digits, k=9))
def _get_ports(self, ask):
""" Номера портов из запроса клиента
"""
res = re.match(r'.+?\nTransport:[^\n]+client_port=(\d+)-(\d+)', ask, re.DOTALL)
if not res:
raise RuntimeError('invalid transport ports')
return [int(res.group(1)), int(res.group(2))]
def _get_description(self):
""" Блок SDP из запроса клиента
"""
sdp = Shared.data[self.camera_hash]['description']
res = 'v=0\r\n' \
f'o=- {randrange(100000, 999999)} {randrange(1, 10)} IN IP4 {Config.local_ip}\r\n' \
's=python-rtsp-server\r\n' \
't=0 0'
if not sdp['video']:
return res
res += f'\r\nm=video {sdp["video"]["media"]}\r\n' \
'c=IN IP4 0.0.0.0\r\n' \
f'b={sdp["video"]["bandwidth"]}\r\n' \
f'a=rtpmap:{sdp["video"]["rtpmap"]}\r\n' \
f'a=fmtp:{sdp["video"]["format"]}\r\n' \
'a=control:track1'
if not sdp['audio']:
return res
res += f'\r\nm=audio {sdp["audio"]["media"]}\r\n' \
f'a=rtpmap:{sdp["audio"]["rtpmap"]}\r\n' \
'a=control:track2'
return res
def _check_web_limit(self, host):
""" Ограничим веб подключения. Локальные - без ограничений.
"""
if not Config.web_limit or self._get_client_type(host) == 'local':
return
web_sessions = []
for session_id, data in Shared.data[self.camera_hash]['clients'].items():
if self._get_client_type(data['host']) == 'web':
web_sessions.append(session_id)
if len(web_sessions) > Config.web_limit:
ws = web_sessions[:-Config.web_limit]
for session_id in ws:
print('Web limit exceeded, cloce old connection\n')
Shared.data[self.camera_hash]['clients'][session_id]['transport'].close()
# Shared.data item will be deleted by ClientTcpProtocol.connection_lost callback
def _get_client_type(self, host):
""" Хелпер для определения типа подключения.
Если IP клиента совпадает с локальным адресом сервера, то это веб клиент за ssh туннелем
"""
if host == '127.0.0.1' \
or host == 'localhost' \
or (host.startswith('192.168.') and host != Config.local_ip):
return 'local'
return 'web'
class ClientTcpProtocol(asyncio.Protocol):
""" Этот колбэк вызывается при подключении к серверу каждого нового клиента
"""
def __init__(self):
self.client = Client()
self.camera_hash, self.session_id = None, None
# self.event = event
def connection_made(self, transport):
peername = transport.get_extra_info('peername')
self.transport = transport
self.host = peername[0]
print(f'*** New connection from {peername[0]}:{peername[1]} ***\n\n')
def data_received(self, data):
try:
self.camera_hash, self.session_id = self.client.handle_request(self.transport, self.host, data)
except Exception as e:
print(f'Error in clent request handler: {e}\n\n')
self.transport.close()
def connection_lost(self, exc):
if not self.session_id or self.session_id not in Shared.data[self.camera_hash]['clients']:
return
del(Shared.data[self.camera_hash]['clients'][self.session_id])
Log.add(f'Close [{self.camera_hash}] [{self.session_id}] [{self.host}]')
Обслуживает клиентов. Metod Client.handle_request делает то же, что и Camera.connect, но на этот раз прикинувшись камерой. За прослушивание порта 4554 отвечает asyncio.create_server (один на всех) и его колбэк ClientTcpProtocol (по экземпляру на каждого клиента).
shared.py
class Shared:
""" Все задачи (tasks) будут общаться через этот объект
"""
data = {}
Объект Sared.data используется для обмена данными между задачами (tasks) и корутинами.
log.py
import time
from config import Config
class Log:
@staticmethod
def add(info):
print(f'*** {info} ***\n\n')
try:
with open(Config.log_file, 'a') as f:
f.write(f'{time.strftime("%Y-%m-%d %H:%M:%S")} {info}\n')
except Exception as e:
print(f'Log error: {e}\n\n')
pass
Отвечает за логирование подключений, а заодно и за вывод отладочной информации.
Тестирование
Интересно, что «медленный» Python может противопоставить высокоэффективному многопоточному Go? На скриншотах представлены результаты тестирования приведенного выше скрипта и rtsp-simple-server. Условия равные, одна камера и 10 клиентов в каждом тесте. Сервер легко поднимает и большее количество клиентов, но вот мой десктоп начинает дико тормозить при запуске нескольких десятков окон. Выпадений кадров на глаз не заметно в обоих тестах.
Судите сами. На мой вкус, неплохо. Есть смысл продолжить и довести скрипт до логического конца.
Что дальше?
В реальной жизни, к сожалению, все немного сложнее, чем в теории. Получить UDP пакеты через упомянутый выше SSH туннель не получится. Да и вообще, гонять UDP по интернету кажется мне не самой лучшей идеей. Значит, придется реализовывать получение потоков через TCP.
Если верить показаниям моего WireShark’а, различие в общении VLC с камерами в режиме TCP (с ключом —rtsp-tcp) сводится к изменению строки
Transport: RTP/AVP/TCP;unicast;interleaved=0-1
в запросе SETUP. Как видите, здесь добавляется параметр TCP, а вместо портов клиента указываются каналы чередования interleaved=0-1 (для звуковой дорожки interleaved=2-3). Входящий поток в этом случае будет направлен в тот же TCP порт, фреймы с бинарными данными будут начинаться со знака доллара, за которым следует номер канала. Реализовать такое немного сложнее, так как нужно поиграть в чехарду на единственном сокете, слушающим TCP порт.
Версия на Github содержит простую реализацию чередования, детали можно посмотреть там.
Кроме того, в ТЗ входит видеорегистратор и watchdog к нему — все это находится в файле storage.py и слишком тривиально для обсуждения.
Резюме
Похоже, полученный результат удовлетворяет всем заявленным требованиям. Во всяком случае, я использую этот скрипт на своих серверах без проблем, правда, в ограниченном окружении.
Обратите внимание, приведенный код не готов к промышленной эксплуатации, но может послужить отправной точкой и сэкономить массу времени тем, кто захочет получить больше контроля над своими камерами видеонаблюдения.
Вопросы, пожелания и конструктивная критика приветствуются!
Комментарии (29)
aborouhin
23.12.2021 09:33А чем zoneminder для этой задачи не угодил, что пришлось изобретать свой велосипед?
vladpen Автор
23.12.2021 09:38Эм... Там, в середине статьи, есть требования к серверу, мне нужно было выполнить все пункты. А так у меня все работало на простейших bash скриптах и юнитах. Ну или можно было бы ffserver поднять, если бы он не умер...
aborouhin
23.12.2021 09:45Ну так я прочитал ТЗ и, собственно, вопрос и возник. Zoneminder всё перечисленное (и сильно больше) умеет из коробки. У меня он на 9 камер работает уже несколько лет. При этом работает на очень древнем железе - если не распознавать движение в кадре (что в ТЗ отсутствует) и не перекодировать видео на лету (режим H.264 passthrough, ЕМНИП), то аппетиты вполне скромные.
vladpen Автор
23.12.2021 10:02Насколько я знаю, Zoneminder писать видео не умеет, только картинки. Ну и потом, нравится мне возиться с железками и софтом. Похоже, в моем случае "разработчик" - это диагноз:)
aborouhin
23.12.2021 10:06Ну как он там внутри себя хранит видео - это его внутреннее дело :) , но при необходимости выгрузить видеоролик делает это легко. Хотя именно выгружать мне редко требуется, а для просмотра истории есть удобный интерфейс timeline. Плюс отличный мобильный и десктопный клиенты (я пользуюсь zmNinja, но их на выбор разных).
viteralex
23.12.2021 09:46+1Не понятно, что именно не получалось в браузере? Картинку смотреть? 2023 от Хика довольно старая камера, для них хорошо использовать Firefox 52 ESR. Тогда в браузере картинка будет. Но если можно получить поток в VLC, то от браузера требуется только открыть настройки, а результат смотреть в плеере
vladpen Автор
23.12.2021 09:54Вы правы, от браузера нужны были только настройки. Как минимум, IP прописать. Кроме того, по умолчанию камера отдает картинку в H.264, а мне H.264+ хотелось, очень много места на диске экономит.
За старый FF спасибо, надо попробовать.viteralex
23.12.2021 16:12+1Видимо, вы не сталкивались часто с Hikvision. У него и у многих других реализован протокол SADP, который позволяет на L2 уровне общаться с камерой и как минимум задать сетевые настройки. Т.е. камеру можно просто воткнуть в сетевой порт компа и активировать, сбросить в заводские параметры, задать сетевые параметры, привязать к HikConnect.
Правда, сейчас Hik под санкциями в штатах, поэтому мы в компании для бюджетных решений переходим на Dahua и Partizan
Shaman_RSHU
23.12.2021 14:49Всё-таки через некоторое время трава всё равно начинает проростать через асфальтную крошку :(
sirocco
23.12.2021 17:43+1Устанавливаю Win 7 в виртуалку, настраиваю камеру
А можно было бы установить IVMS от Hikvision, и получить контроль обновления прошивок, те же настройки, и массу другого функционала. Почему ещё хорошо пользоваться ivms? Потому что полноценно работает с камерами Hikvision. Иногда камера не прошивается через браузер, иногда настройки не применяются из-за проблем с кешем, таких проблем нет при работе через ivms.
vladpen Автор
23.12.2021 17:54Вот все-таки здорово, что я потратил время на эту статью! И что-то новое узнаешь, и старое переосмысливаешь... Спасибо, попробую!
vladvalmont
24.12.2021 07:38Не знаю, насколько здесь это уместно, но можно так:
-
Для видеозаписи берём ffmpeg и упаковываем RTSP в уютный mp4 интервалами по n минут. У меня интервалы по 15 минут. Добавляем в cron:
*/15 * * * * ffmpeg -i rtsp://user:password@camip:554/ISAPI/Streaming/Channels/101 -c:v copy -t 15:00 "/security/cam1/(date +'%d_%m_%Y %H_%M').mp4" > /dev/null
-
Удаляем записи старше n интервала. У меня это одна неделя. Добавляем в cron:
*/15 * * * * find "/security/cam1/$(date +'%d-%m-%Y %a' -d "1 week ago")" -type f -mmin +10060 -exec rm -rf {} ;
-
Удаляем пустые папки, образовавшиеся в результате удаления старых записей. Добавляем в cron:
15 0 * * * find /security/cam1/ -type d -empty -delete
-
Создаём новые папки для будущих записей. Добавляем в cron:
45 23 * * * mkdir "/security/cam1/$(date +'%d-%m-%Y %a' -d "tomorrow")"
Для просмотра камеры онлайн - iVMS-4500 на смартфоне и iVMS-4200 на ПК.
Если на сервере есть DLNA, то можно смотреть записи на Smart TV. В моём случае это Serviio.
Конфиг: несколько камер Hikvision DS-2CD2022WD-I на участке, мост из Ubiquiti NS5ACL, сервер на Rocky Linux, для просмотра - два смартфона и LG TV.
vladpen Автор
24.12.2021 08:01-1Вполне уместно. У меня примерно так и было до этой статьи, только я вместо крона таймеры systemd использовал - следующий гарантированно запустится после остановки предыдущего. Для примера:
camera2.sh
#!/bin/sh URL="rtsp://login:password@192.168.0.110:554/ISAPI/Streaming/Channels/101" DIR="/path-to-storage/camera2" DATE="`date +%Y-%m-%d`" TTL=600 cd $DIR if [ ! -d $DATE ]; then mkdir $DATE fi cd $DATE NAME="`date +%H-%M`" timeout $TTL mencoder -nocache -really-quiet -rtsp-stream-over-tcp $URL -ovc copy -o $NAME.avi if [ "$NAME" \> "06-00" ] && [ "$NAME" \< "06-15" ]; then find $DIR -type d -mtime +12 -exec rm -rf {} \; echo "ok, daily deleting of old dir" else echo "ok" fi
/etc/systemd/system/camera2.timer
[Unit] Description="Saving movie from camera 2" [Timer] OnCalendar=*:0/10 Persistent=true [Install] WantedBy=timers.target
/etc/systemd/system/camera2.service
[Unit] Description="Saving movie from camera 2" [Service] ExecStart=/path-to-script/camera2.sh [Install] WantedBy=multi-user.target
Запустить нужно таймер (не сервис):
sudo systemctl enable camera1.timer
Ротацию можно сделать еще проще, без ифов, как в примере на гитхабе:
find <storage_path> -type d -mtime +<storage_period_days> -delete find <storage_path> -type f -mtime +<storage_period_days> -delete
И не делать отдельную пару таймер/сервис под каждую камеру, а использовать @.
В статье просто весь функционал собран в одном месте.
Кстати, с ffmpeg для H.265+ у меня не срослось - если указать -c:v copy, он портит заголовки видеофайла, а если указать кодек явно, то начинает транскодировать, есть CPU и теряет плюс (который 265+). А он мне очень нужен для экономии места на диске.
Если вдруг кто знает, как это лечить, поделитесь, пожалуйста!
-
ssedov
25.12.2021 08:32А чем вам облако не нравится от hikvision? В камерах карты памяти на 128 Гб, через интернет в облако они подключены, запись по движению в кадре (новые камеры hik стали хорошо определять людей и машины, на старых камерах эта опция не работала у меня). На телефоне приложение Ezviz, нужен только интернет и смотри или онлайн или записи событий. Через приложение работают уведомления о событиях (движение перед камерой). С компа по локалке через vpn (wireguard) прямой доступ к камере для настройки или просмотра. Новые камеры стали работать в актуальных браузерах, старый только в IE запускал.
Подобный софт именно мне был бы интересен чтобы написать бота для телеграм чата (сторожа в снт и те кто живёт зимой там) в который прилетали бы фото событий с камер и ссылки на этот кусочек видео. Это как решение вопроса доступа к камерам. Сейчас доступ (расшаривается камера для выбранного аккаунта в Ezviz) даю через Ezviz, около 10 человек подключил.
vladpen Автор
25.12.2021 08:49Облако нравится, не нравятся возможные перебои с и-нетом и питанием. Надежней писать локально, полагаясь на бесперебойник. Ну и отрыть камеру напрямую с мобильника, опять же, надежнее. Это если ты находишься на месте. Если на объекте никого нет, можно писать на удаленном сервере (у меня установлены две копии скрипта - одна на объекте, вторая в Германии), ну или да, в облако.
Определение движения для улицы работает у меня из рук вон плохо - ветер имеет привычку шевелить предметы, а про снег, особенно ночью, уже и говорить нечего. А вот H.265+, на удивление, отлично помогает в этом вопросе - во файлах, которые заметно больше соседних по размеру, точно есть движение (у меня фрагменты по 10 минут).ssedov
25.12.2021 09:28Может не точно выразился, но я писал что в камерах у меня стоят карты памяти на 128 Гб, так что хранилище локальное и от интернета не зависит, главное чтобы питание не пропадало. В облако запись не идёт, через него я получаю доступ к камере для просмотра онлайн или архива.
В этом году старые камеры заменил на новые, которые ColorVu. Значительно лучше они по всем параметрам (цветная картинка ночью прямо круто). Особенно по определению объектов в кадре, у меня включена реакция только на людей и автомобилей. Ну очень редко бывает что реагирует на птиц или просто ложное срабатывание. Старые камеры тоже остались в деле, но перевесил на не ответственные позиции. Так вот она может записать весь день, если на улице ветер/метель/снег. Это победить на старых моделях не получалось никак у меня.
vladpen Автор
25.12.2021 09:40Да, пропустил момент про карты. Я в свое время от них отказался из-за безопасности. Если камеру с картой снимут, запись тоже пропадет. А так хотябы останется физиономия вандала.
За ColorVu спасибо. Правда, PoE не нашел в этой серии на вскидку.ssedov
25.12.2021 11:48+1PoE точно есть, у меня вот такая модель DS-2CD2347G2-LU, работает от PoE коммутатора. У меня в принципе нет камер без PoE даже из старых закупок.
В облаке Ezviz у меня настроено оповещение с камер. Камера в момент события делает фото и его шлет через облако в приложение на телефон. В телефоне срабатывает уведомление. Вот не помню будет ли в облаке фото события доступно после отключения камеры из сети, нужно проверить.
Ранее настраивал отправку фото на email, это штатная функция во всех моделях Hik. Идея там такая же, в момент события делается 3 фото с заданным интервалом (я делал 2 секунды) и сразу же отправляется на email (можно и ftp). Сейчас отказался дабы не дублировать фото с облака еще и на почту.
MAXH0
Спасибо. Надеюсь решение рабочее. Есть желание мониторинг на дачу сделать, чтобы контролировать собак и печь...
PS/ Подумав, решил написать, что из-за таких статей Хабр для меня по прежнему торт. Свой опыт и велосипед, что может быть лучше. Разве, что тема синей изоленты не раскрыта.
vladpen Автор
Тему синей изоленты раскрыть сложно, это как теория простых чисел:)
Зато могу сказать, что я уличное оборудование краской вымазываю, чтобы стырить не хотелось. Не синяя изолента, конечно, но тоже лайфхак:)
Soorin
Мой опыт без бубна: DS-2DE2022-DE3 с сёстрами пишет на Synology (может писать и на ХРenology), а там уже есть android-приложение Synology DS Cam (которое, правда, не любит слабенькие роутеры).
vladpen Автор
Тема выбора NAS (если вы об этом) довольно обширна. Когда-то пробовал разные варианты: Raspberry Pi, Intel Atom. На Synology тогда был ценник выше, плюс хотелось универсальности. Intel NUC, по факту, не намного дороже Малинки, но закрывает вообще все вопросы - ставим Linux и делаем все, что душе угодно.
sirocco
Согласитесь, делали вы всё это по одной причине - just for fun.
Можно поставить Xpenology, не изобретая велосипедов, и получить богатейший функционал, я даже могу сказать - профессиональный функционал по меркам классических систем видеонаблюдения. Там есть всё, и контроль состояния дисков и данных, и разные способы бекапов, богатейший API, есть встроенная система автоматизации, типа если ваш телефон в географической зоне "x", то сделать действие "y"(например повернуть камеру ptz), есть логирование всех действий и поведения камер, разграничения прав юзеров, просмотр видеоархива на скорости до ×100... да блин, это вообще тема даже не статьи а цикла статей. Ну и это только приложение в NAS, все остальные функции никуда не делись и также доступны, хоть тот же Докер, в котором что угодно можно поднять.
vladpen Автор
Абсолютно согласен, в точку!:) И спасибо за информацию.
lorc
Раз уж без бубна, то Ubiquity Dream Machine Pro решает все вопросы сразу, и не только с камерами, но заодно и с локальной сетью (а если надо, то и с телефонией и с контролем физического доступа). Относительно дорого, правда...
vladpen Автор
Да, ценник приличный. И это, похоже, под 19" стойку. Даже не знаю, каждому свое, наверное)
lorc
Ну я в доме выделил отдельную комнату под мастерскую, повесил там заодно телекоммуникационый ящик на 6U, завел туда всю слаботочку, разместил все оборудование. Не так уж много места занимает, зато все аккуратно и красиво.
Коллега сделал то же самое у себя на чердаке, благо он у него отапливаемый.