Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reolink standalone cams work #127

Merged
merged 16 commits into from
Jan 5, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,24 @@ unifi-cam-proxy -H <NVR IP> -i <camera IP> -c client.pem -t <Adoption token> hik

#### Reolink:
* Standalone cameras
* Tested: RLC-410-5MP
* Tested: RLC-410-5MP
```
unifi-cam-proxy -H <NVR IP> -i <camera IP> -c client.pem -t <Adoption token> rtsp -s <rtsp stream> --ffmpeg-args '-c:v copy -vbsf "h264_metadata=tick_rate=60000/1001:fixed_frame_rate_flag=1" -ar 32000 -ac 2 -codec:a aac -b:a 32k'
unifi-cam-proxy -H <NVR IP> -i <camera IP> -c client.pem -t <Adoption token> rtsp -s <rtsp stream> --ffmpeg-args='-c:v copy -bsf:v "h264_metadata=tick_rate=60000/1001:fixed_frame_rate_flag=1" -ar 32000 -ac 2 -codec:a aac -b:a 32k'

```
* Standalone cameras using proper Snapshot URL and Motion-Det API url using Camera Substream (main or sub) *work in progress*

Main camera stream example
```
unifi-cam-proxy -H <NVR IP> -i <camera IP> -c client.pem -t <Adoption token> reolink -u <username> -p <password> -s main --ffmpeg-args='-c:v copy -bsf:v "h264_metadata=tick_rate=60000/1001:fixed_frame_rate_flag=1" -ar 32000 -ac 2 -codec:a aac -b:a 32k'
```
Sub camera stream example
```
unifi-cam-proxy -H <NVR IP> -i <camera IP> -c client.pem -t <Adoption token> reolink -u <username> -p <password> -s sub --ffmpeg-args='-c:v copy -bsf:v "h264_metadata=tick_rate=30000/1001:fixed_frame_rate_flag=1" -ar 32000 -ac 2 -codec:a aac -b:a 32k'
```
* (Note: Camera/channel arguments are either -s main, or -s sub. The substream is limited to a maximum 15fps so note the tick_rate in the example)

Reolink NVR
* NVR (Note: Camera/channel IDs are zero-based)
```
unifi-cam-proxy -H <NVR IP> -i <camera IP> -c client.pem -t <Adoption token> reolink_nvr -u <username> -p <password> -c <camera_id>
Expand Down
4 changes: 3 additions & 1 deletion unifi/cams/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from unifi.cams.dahua import DahuaCam
from unifi.cams.frigate import FrigateCam
from unifi.cams.hikvision import HikvisionCam
from unifi.cams.reolink import Reolink
from unifi.cams.reolink_nvr import ReolinkNVRCam
from unifi.cams.rtsp import RTSPCam

__all__ = ["FrigateCam", "HikvisionCam", "DahuaCam", "RTSPCam", "ReolinkNVRCam"]
__all__ = ["FrigateCam", "HikvisionCam", "DahuaCam",
"RTSPCam", "Reolink", "ReolinkNVRCam"]
12 changes: 8 additions & 4 deletions unifi/cams/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,8 @@ async def trigger_motion_start(
}
)

self.logger.info(f"Triggering motion start (idx: {self._motion_event_id})")
self.logger.info(
f"Triggering motion start (idx: {self._motion_event_id})")
await self.send(
self.gen_response(
"EventSmartDetect" if object_type else "EventAnalytics",
Expand All @@ -142,10 +143,12 @@ async def trigger_motion_start(
self._motion_event_ts = time.time()

# Capture snapshot at beginning of motion event for thumbnail
motion_snapshot_path: str = tempfile.NamedTemporaryFile(delete=False).name
motion_snapshot_path: str = tempfile.NamedTemporaryFile(
delete=False).name
try:
shutil.copyfile(await self.get_snapshot(), motion_snapshot_path)
self.logger.debug(f"Captured motion snapshot to {motion_snapshot_path}")
self.logger.debug(
f"Captured motion snapshot to {motion_snapshot_path}")
self._motion_snapshot = Path(motion_snapshot_path)
except FileNotFoundError:
pass
Expand Down Expand Up @@ -178,7 +181,8 @@ async def trigger_motion_stop(
"smartDetectSnapshot": "motionsnap.jpg",
}
)
self.logger.info(f"Triggering motion stop (idx: {self._motion_event_id})")
self.logger.info(
f"Triggering motion stop (idx: {self._motion_event_id})")
await self.send(
self.gen_response(
"EventSmartDetect" if object_type else "EventAnalytics",
Expand Down
6 changes: 4 additions & 2 deletions unifi/cams/frigate.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ def __init__(self, args: argparse.Namespace, logger: logging.Logger) -> None:
def add_parser(cls, parser: argparse.ArgumentParser) -> None:
super().add_parser(parser)
parser.add_argument("--mqtt-host", required=True, help="MQTT server")
parser.add_argument("--mqtt-port", default=1883, type=int, help="MQTT server")
parser.add_argument("--mqtt-port", default=1883,
type=int, help="MQTT server")
parser.add_argument(
"--mqtt-prefix", default="frigate", type=str, help="Topic prefix"
)
Expand Down Expand Up @@ -115,7 +116,8 @@ async def handle_detection_events(self, client) -> None:
):
# Wait for the best snapshot to be ready before
# ending the motion event
self.logger.info(f"Awaiting snapshot (id: {self.event_id})")
self.logger.info(
f"Awaiting snapshot (id: {self.event_id})")
await self.event_snapshot_ready.wait()
self.logger.info(
f"Ending {self.event_label} motion event"
Expand Down
6 changes: 4 additions & 2 deletions unifi/cams/hikvision.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@ def __init__(self, args: argparse.Namespace, logger: logging.Logger) -> None:
@classmethod
def add_parser(cls, parser: argparse.ArgumentParser) -> None:
super().add_parser(parser)
parser.add_argument("--username", "-u", required=True, help="Camera username")
parser.add_argument("--password", "-p", required=True, help="Camera password")
parser.add_argument("--username", "-u",
required=True, help="Camera username")
parser.add_argument("--password", "-p",
required=True, help="Camera password")
parser.add_argument(
"--channel", "-c", default=1, type=int, help="Camera channel index"
)
Expand Down
123 changes: 123 additions & 0 deletions unifi/cams/reolink.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import argparse
import json
import logging
import tempfile
from pathlib import Path

import aiohttp
from yarl import URL

from unifi.cams.base import UnifiCamBase


class Reolink(UnifiCamBase):
def __init__(self, args: argparse.Namespace,
logger: logging.Logger) -> None:
super().__init__(args, logger)
self.snapshot_dir: str = tempfile.mkdtemp()
self.motion_in_progress: bool = False
self.substream = args.substream

@classmethod
def add_parser(cls, parser: argparse.ArgumentParser) -> None:
super().add_parser(parser)
parser.add_argument(
"--username",
"-u",
required=True,
help="Camera username"
)
parser.add_argument(
"--password",
"-p",
required=True,
help="Camera password"
)
parser.add_argument(
"--channel",
"-c",
default=0,
help="Camera channel (not needed, leaving for possible future)"
)

parser.add_argument(
"--substream",
"-s",
default='main',
type=str,
choices=['main', 'sub'],
required=True,
help="Camera rtsp url substream index main, or sub"
)

async def get_snapshot(self) -> Path:
img_file = Path(self.snapshot_dir, "screen.jpg")
url = (
f"http://{self.args.ip}"
f"/cgi-bin/api.cgi?cmd=Snap&channel={self.args.channel}"
f"&rs=6PHVjvf0UntSLbyT&user={self.args.username}"
f"&password={self.args.password}"
)
self.logger.info(f"Grabbing snapshot: {url}")
await self.fetch_to_file(url, img_file)
return img_file

async def run(self) -> None:
url = (
f"http://{self.args.ip}"
f"/api.cgi?cmd=GetMdState&user={self.args.username}"
f"&password={self.args.password}"
)
encoded_url = URL(url, encoded=True)

body = (
f'[{{ "cmd":"GetMdState", "param":{{ "channel":{self.args.channel} }} }}]'
)
while True:
self.logger.info(f"Connecting to motion events API: {url}")
try:
async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(None)
) as session:
while True:
async with session.post(encoded_url, data=body) as resp:
data = await resp.read()

try:
json_body = json.loads(data)
if "value" in json_body[0]:
if json_body[0]["value"]["state"] == 1:
if not self.motion_in_progress:
self.motion_in_progress = True
self.logger.info(
"Trigger motion start")
await self.trigger_motion_start()
elif json_body[0]["value"]["state"] == 0:
if self.motion_in_progress:
self.motion_in_progress = False
self.logger.info(
"Trigger motion end")
await self.trigger_motion_stop()
else:
self.logger.error(
"Motion API request responded with "
"unexpected JSON, retrying. "
f"JSON: {data}"
)

except json.JSONDecodeError as err:
self.logger.error(
"Motion API request returned invalid "
"JSON, retrying. "
f"Error: {err}, "
f"Response: {data}"
)

except aiohttp.ClientError as err:
self.logger.error(
f"Motion API request failed, retrying. Error: {err}")

def get_stream_source(self, stream_index: str) -> str:
return (
f"rtsp://{self.args.username}:{self.args.password}@{self.args.ip}:554"
f"//h264Preview_{int(self.args.channel) + 1:02}_{self.args.substream}")
18 changes: 12 additions & 6 deletions unifi/cams/reolink_nvr.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,12 @@ def __init__(self, args: argparse.Namespace, logger: logging.Logger) -> None:
@classmethod
def add_parser(cls, parser: argparse.ArgumentParser) -> None:
super().add_parser(parser)
parser.add_argument("--username", "-u", required=True, help="NVR username")
parser.add_argument("--password", "-p", required=True, help="NVR password")
parser.add_argument("--channel", "-c", required=True, help="NVR camera channel")
parser.add_argument("--username", "-u",
required=True, help="NVR username")
parser.add_argument("--password", "-p",
required=True, help="NVR password")
parser.add_argument("--channel", "-c", required=True,
help="NVR camera channel")

async def get_snapshot(self) -> Path:
img_file = Path(self.snapshot_dir, "screen.jpg")
Expand Down Expand Up @@ -59,12 +62,14 @@ async def run(self) -> None:
if json_body[0]["value"]["state"] == 1:
if not self.motion_in_progress:
self.motion_in_progress = True
self.logger.info("Trigger motion start")
self.logger.info(
"Trigger motion start")
await self.trigger_motion_start()
elif json_body[0]["value"]["state"] == 0:
if self.motion_in_progress:
self.motion_in_progress = False
self.logger.info("Trigger motion end")
self.logger.info(
"Trigger motion end")
await self.trigger_motion_stop()
else:
self.logger.error(
Expand All @@ -82,7 +87,8 @@ async def run(self) -> None:
)

except aiohttp.ClientError as err:
self.logger.error(f"Motion API request failed, retrying. Error: {err}")
self.logger.error(
f"Motion API request failed, retrying. Error: {err}")

def get_stream_source(self, stream_index: str) -> str:
return (
Expand Down
3 changes: 2 additions & 1 deletion unifi/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ def __init__(self, args, camera, logger):
self.ssl_context.load_cert_chain(args.cert, args.cert)

async def run(self) -> None:
uri = "wss://{}:7442/camera/1.0/ws?token={}".format(self.host, self.token)
uri = "wss://{}:7442/camera/1.0/ws?token={}".format(
self.host, self.token)
headers = {"camera-mac": self.mac}
has_connected = False

Expand Down
19 changes: 16 additions & 3 deletions unifi/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@

import coloredlogs

from unifi.cams import DahuaCam, FrigateCam, HikvisionCam, ReolinkNVRCam, RTSPCam
from unifi.cams import (
DahuaCam,
FrigateCam,
HikvisionCam,
Reolink,
ReolinkNVRCam,
RTSPCam,
)
from unifi.core import Core
from unifi.version import __version__

Expand All @@ -15,6 +22,7 @@
"hikvision": HikvisionCam,
"lorex": DahuaCam,
"dahua": DahuaCam,
"reolink": Reolink,
"reolink_nvr": ReolinkNVRCam,
"rtsp": RTSPCam,
}
Expand All @@ -23,7 +31,8 @@
def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument("--version", action="version", version=__version__)
parser.add_argument("--host", "-H", required=True, help="NVR ip address and port")
parser.add_argument("--host", "-H", required=True,
help="NVR ip address and port")
parser.add_argument(
"--cert",
"-c",
Expand All @@ -32,7 +41,11 @@ def parse_args():
help="Client certificate path",
)
parser.add_argument("--token", "-t", required=True, help="Adoption token")
parser.add_argument("--mac", "-m", default="AABBCCDDEEFF", help="MAC address")
parser.add_argument(
"--mac",
"-m",
default="AABBCCDDEEFF",
help="MAC address")
parser.add_argument(
"--ip",
"-i",
Expand Down