diff --git a/.gitignore b/.gitignore
index 368d535..2836d9e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
+secrets.cfg
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
diff --git a/Camera.py b/Camera.py
deleted file mode 100644
index a60490a..0000000
--- a/Camera.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from api import APIHandler
-
-
-class Camera(APIHandler):
-
- def __init__(self, ip, username="admin", password="", https=False):
- """
- Initialise the Camera object by passing the ip address.
- The default details {"username":"admin", "password":""} will be used if nothing passed
- :param ip:
- :param username:
- :param password:
- """
- # For when you need to connect to a camera behind a proxy, pass
- # a proxy argument: proxy={"http": "socks5://127.0.0.1:8000"}
- APIHandler.__init__(self, ip, username, password, https=https)
-
- # Normal call without proxy:
- # APIHandler.__init__(self, ip, username, password)
-
- self.ip = ip
- self.username = username
- self.password = password
- super().login()
diff --git a/ConfigHandler.py b/ConfigHandler.py
deleted file mode 100644
index 67e8d62..0000000
--- a/ConfigHandler.py
+++ /dev/null
@@ -1,17 +0,0 @@
-import io
-
-import yaml
-
-
-class ConfigHandler:
- camera_settings = {}
-
- @staticmethod
- def load() -> yaml or None:
- try:
- stream = io.open("config.yml", 'r', encoding='utf8')
- data = yaml.safe_load(stream)
- return data
- except Exception as e:
- print("Config Property Error\n", e)
- return None
diff --git a/README.md b/README.md
index 19332b7..4fcdd37 100644
--- a/README.md
+++ b/README.md
@@ -2,9 +2,9 @@
-
-
-
+
+
+
diff --git a/api/__init__.py b/api/__init__.py
deleted file mode 100644
index 491da40..0000000
--- a/api/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-from .APIHandler import APIHandler
-
-__version__ = "0.0.5"
-VERSION = __version__
diff --git a/api/recording.py b/api/recording.py
deleted file mode 100644
index 8827249..0000000
--- a/api/recording.py
+++ /dev/null
@@ -1,111 +0,0 @@
-import requests
-import random
-import string
-from urllib import parse
-from io import BytesIO
-from PIL import Image
-from RtspClient import RtspClient
-
-
-class RecordingAPIMixin:
- """API calls for recording/streaming image or video."""
-
- def get_recording_encoding(self) -> object:
- """
- Get the current camera encoding settings for "Clear" and "Fluent" profiles.
- See examples/response/GetEnc.json for example response data.
- :return: response json
- """
- body = [{"cmd": "GetEnc", "action": 1, "param": {"channel": 0}}]
- return self._execute_command('GetEnc', body)
-
- def get_recording_advanced(self) -> object:
- """
- Get recording advanced setup data
- See examples/response/GetRec.json for example response data.
- :return: response json
- """
- body = [{"cmd": "GetRec", "action": 1, "param": {"channel": 0}}]
- return self._execute_command('GetRec', body)
-
- def set_recording_encoding(self,
- audio=0,
- main_bit_rate=8192,
- main_frame_rate=8,
- main_profile='High',
- main_size="2560*1440",
- sub_bit_rate=160,
- sub_frame_rate=7,
- sub_profile='High',
- sub_size='640*480') -> object:
- """
- Sets the current camera encoding settings for "Clear" and "Fluent" profiles.
- :param audio: int Audio on or off
- :param main_bit_rate: int Clear Bit Rate
- :param main_frame_rate: int Clear Frame Rate
- :param main_profile: string Clear Profile
- :param main_size: string Clear Size
- :param sub_bit_rate: int Fluent Bit Rate
- :param sub_frame_rate: int Fluent Frame Rate
- :param sub_profile: string Fluent Profile
- :param sub_size: string Fluent Size
- :return: response
- """
- body = [{"cmd": "SetEnc",
- "action": 0,
- "param":
- {"Enc":
- {"audio": audio,
- "channel": 0,
- "mainStream": {
- "bitRate": main_bit_rate,
- "frameRate": main_frame_rate,
- "profile": main_profile,
- "size": main_size},
- "subStream": {
- "bitRate": sub_bit_rate,
- "frameRate": sub_frame_rate,
- "profile": sub_profile,
- "size": sub_size}}
- }}]
- return self._execute_command('SetEnc', body)
-
- ###########
- # RTSP Stream
- ###########
- def open_video_stream(self, callback=None, profile: str = "main", proxies=None):
- """
- 'https://support.reolink.com/hc/en-us/articles/360007010473-How-to-Live-View-Reolink-Cameras-via-VLC-Media-Player'
- Blocking function creates a generator and returns the frames as it is spawned
- :param profile: profile is "main" or "sub"
- :param proxies: Default is none, example: {"host": "localhost", "port": 8000}
- """
- rtsp_client = RtspClient(
- ip=self.ip, username=self.username, password=self.password, proxies=proxies, callback=callback)
- return rtsp_client.open_stream()
-
- def get_snap(self, timeout: int = 3, proxies=None) -> Image or None:
- """
- Gets a "snap" of the current camera video data and returns a Pillow Image or None
- :param timeout: Request timeout to camera in seconds
- :param proxies: http/https proxies to pass to the request object.
- :return: Image or None
- """
- data = {}
- data['cmd'] = 'Snap'
- data['channel'] = 0
- data['rs'] = ''.join(random.choices(string.ascii_uppercase + string.digits, k=10))
- data['user'] = self.username
- data['password'] = self.password
- parms = parse.urlencode(data).encode("utf-8")
-
- try:
- response = requests.get(self.url, proxies=proxies, params=parms, timeout=timeout)
- if response.status_code == 200:
- return Image.open(BytesIO(response.content))
- print("Could not retrieve data from camera successfully. Status:", response.stats_code)
- return None
-
- except Exception as e:
- print("Could not get Image data\n", e)
- raise
diff --git a/examples/basic_usage.py b/examples/basic_usage.py
new file mode 100644
index 0000000..0ba744c
--- /dev/null
+++ b/examples/basic_usage.py
@@ -0,0 +1,11 @@
+import reolinkapi
+
+if __name__ == "__main__":
+ cam = reolinkapi.Camera("192.168.0.102", defer_login=True)
+
+ # must first login since I defer have deferred the login process
+ cam.login()
+
+ dst = cam.get_dst()
+ ok = cam.add_user("foo", "bar", "admin")
+ alarm = cam.get_alarm_motion()
diff --git a/examples/download_motions.py b/examples/download_motions.py
new file mode 100644
index 0000000..59ec181
--- /dev/null
+++ b/examples/download_motions.py
@@ -0,0 +1,44 @@
+"""Downloads all motion events from camera from the past hour."""
+import os
+from configparser import RawConfigParser
+from datetime import datetime as dt, timedelta
+from reolinkapi import Camera
+
+
+def read_config(props_path: str) -> dict:
+ """Reads in a properties file into variables.
+
+ NB! this config file is kept out of commits with .gitignore. The structure of this file is such:
+ # secrets.cfg
+ [camera]
+ ip={ip_address}
+ username={username}
+ password={password}
+ """
+ config = RawConfigParser()
+ assert os.path.exists(props_path), f"Path does not exist: {props_path}"
+ config.read(props_path)
+ return config
+
+
+# Read in your ip, username, & password
+# (NB! you'll likely have to create this file. See tests/test_camera.py for details on structure)
+config = read_config('../secrets.cfg')
+
+ip = config.get('camera', 'ip')
+un = config.get('camera', 'username')
+pw = config.get('camera', 'password')
+
+# Connect to camera
+cam = Camera(ip, un, pw)
+
+start = (dt.now() - timedelta(hours=1))
+end = dt.now()
+# Collect motion events between these timestamps for substream
+processed_motions = cam.get_motion_files(start=start, end=end, streamtype='sub')
+
+dl_dir = os.path.join(os.path.expanduser('~'), 'Downloads')
+for i, motion in enumerate(processed_motions):
+ fname = motion['filename']
+ # Download the mp4
+ resp = cam.get_file(fname, output_path=os.path.join(dl_dir, f'motion_event_{i}.mp4'))
diff --git a/examples/streaming_video.py b/examples/streaming_video.py
index 90dc2a9..9049ed8 100644
--- a/examples/streaming_video.py
+++ b/examples/streaming_video.py
@@ -1,6 +1,5 @@
import cv2
-
-from Camera import Camera
+from reolinkapi import Camera
def non_blocking():
diff --git a/reolinkapi/__init__.py b/reolinkapi/__init__.py
new file mode 100644
index 0000000..0a886c6
--- /dev/null
+++ b/reolinkapi/__init__.py
@@ -0,0 +1,4 @@
+from reolinkapi.handlers.api_handler import APIHandler
+from .camera import Camera
+
+__version__ = "0.1.2"
diff --git a/reolinkapi/camera.py b/reolinkapi/camera.py
new file mode 100644
index 0000000..98c208b
--- /dev/null
+++ b/reolinkapi/camera.py
@@ -0,0 +1,38 @@
+from reolinkapi.handlers.api_handler import APIHandler
+
+
+class Camera(APIHandler):
+
+ def __init__(self, ip: str,
+ username: str = "admin",
+ password: str = "",
+ https: bool = False,
+ defer_login: bool = False,
+ **kwargs):
+ """
+ Initialise the Camera object by passing the ip address.
+ The default details {"username":"admin", "password":""} will be used if nothing passed
+ For deferring the login to the camera, just pass defer_login = True.
+ For connecting to the camera behind a proxy pass a proxy argument: proxy={"http": "socks5://127.0.0.1:8000"}
+ :param ip:
+ :param username:
+ :param password:
+ :param https: connect to the camera over https
+ :param defer_login: defer the login process
+ :param proxy: Add a proxy dict for requests to consume.
+ eg: {"http":"socks5://[username]:[password]@[host]:[port], "https": ...}
+ More information on proxies in requests: https://stackoverflow.com/a/15661226/9313679
+ """
+ # For when you need to connect to a camera behind a proxy, pass
+ # a proxy argument: proxy={"http": "socks5://127.0.0.1:8000"}
+ APIHandler.__init__(self, ip, username, password, https=https, **kwargs)
+
+ # Normal call without proxy:
+ # APIHandler.__init__(self, ip, username, password)
+
+ self.ip = ip
+ self.username = username
+ self.password = password
+
+ if not defer_login:
+ super().login()
diff --git a/reolinkapi/handlers/__init__.py b/reolinkapi/handlers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/api/APIHandler.py b/reolinkapi/handlers/api_handler.py
similarity index 58%
rename from api/APIHandler.py
rename to reolinkapi/handlers/api_handler.py
index a4a6f07..46d4637 100644
--- a/api/APIHandler.py
+++ b/reolinkapi/handlers/api_handler.py
@@ -1,26 +1,34 @@
-from .recording import RecordingAPIMixin
-from .zoom import ZoomAPIMixin
-from .device import DeviceAPIMixin
-from .display import DisplayAPIMixin
-from .network import NetworkAPIMixin
-from .system import SystemAPIMixin
-from .user import UserAPIMixin
-from .ptz import PtzAPIMixin
-from .alarm import AlarmAPIMixin
-from .image import ImageAPIMixin
-from resthandle import Request
+import requests
+from typing import Dict, List, Optional, Union
+from reolinkapi.mixins.alarm import AlarmAPIMixin
+from reolinkapi.mixins.device import DeviceAPIMixin
+from reolinkapi.mixins.display import DisplayAPIMixin
+from reolinkapi.mixins.download import DownloadAPIMixin
+from reolinkapi.mixins.image import ImageAPIMixin
+from reolinkapi.mixins.motion import MotionAPIMixin
+from reolinkapi.mixins.network import NetworkAPIMixin
+from reolinkapi.mixins.ptz import PtzAPIMixin
+from reolinkapi.mixins.record import RecordAPIMixin
+from reolinkapi.handlers.rest_handler import Request
+from reolinkapi.mixins.stream import StreamAPIMixin
+from reolinkapi.mixins.system import SystemAPIMixin
+from reolinkapi.mixins.user import UserAPIMixin
+from reolinkapi.mixins.zoom import ZoomAPIMixin
-class APIHandler(SystemAPIMixin,
- NetworkAPIMixin,
- UserAPIMixin,
+class APIHandler(AlarmAPIMixin,
DeviceAPIMixin,
DisplayAPIMixin,
- RecordingAPIMixin,
- ZoomAPIMixin,
+ DownloadAPIMixin,
+ ImageAPIMixin,
+ MotionAPIMixin,
+ NetworkAPIMixin,
PtzAPIMixin,
- AlarmAPIMixin,
- ImageAPIMixin):
+ RecordAPIMixin,
+ SystemAPIMixin,
+ UserAPIMixin,
+ ZoomAPIMixin,
+ StreamAPIMixin):
"""
The APIHandler class is the backend part of the API, the actual API calls
are implemented in Mixins.
@@ -30,12 +38,13 @@ class APIHandler(SystemAPIMixin,
All Code will try to follow the PEP 8 standard as described here: https://www.python.org/dev/peps/pep-0008/
"""
- def __init__(self, ip: str, username: str, password: str, https=False, **kwargs):
+ def __init__(self, ip: str, username: str, password: str, https: bool = False, **kwargs):
"""
Initialise the Camera API Handler (maps api calls into python)
:param ip:
:param username:
:param password:
+ :param https: connect over https
:param proxy: Add a proxy dict for requests to consume.
eg: {"http":"socks5://[username]:[password]@[host]:[port], "https": ...}
More information on proxies in requests: https://stackoverflow.com/a/15661226/9313679
@@ -69,7 +78,10 @@ class APIHandler(SystemAPIMixin,
print(self.token)
return False
else:
- print("Failed to login\nStatus Code:", response.status_code)
+ # TODO: Verify this change w/ owner. Delete old code if acceptable.
+ # A this point, response is NoneType. There won't be a status code property.
+ # print("Failed to login\nStatus Code:", response.status_code)
+ print("Failed to login\nResponse was null.")
return False
except Exception as e:
print("Error Login\n", e)
@@ -89,7 +101,8 @@ class APIHandler(SystemAPIMixin,
print("Error Logout\n", e)
return False
- def _execute_command(self, command, data, multi=False):
+ def _execute_command(self, command: str, data: List[Dict], multi: bool = False) -> \
+ Optional[Union[Dict, bool]]:
"""
Send a POST request to the IP camera with given data.
:param command: name of the command to send
@@ -105,8 +118,24 @@ class APIHandler(SystemAPIMixin,
try:
if self.token is None:
raise ValueError("Login first")
- response = Request.post(self.url, data=data, params=params)
- return response.json()
+ if command == 'Download':
+ # Special handling for downloading an mp4
+ # Pop the filepath from data
+ tgt_filepath = data[0].pop('filepath')
+ # Apply the data to the params
+ params.update(data[0])
+ with requests.get(self.url, params=params, stream=True) as req:
+ if req.status_code == 200:
+ with open(tgt_filepath, 'wb') as f:
+ f.write(req.content)
+ return True
+ else:
+ print(f'Error received: {req.status_code}')
+ return False
+
+ else:
+ response = Request.post(self.url, data=data, params=params)
+ return response.json()
except Exception as e:
print(f"Command {command} failed: {e}")
raise
diff --git a/resthandle.py b/reolinkapi/handlers/rest_handler.py
similarity index 70%
rename from resthandle.py
rename to reolinkapi/handlers/rest_handler.py
index d2f98b7..66f172a 100644
--- a/resthandle.py
+++ b/reolinkapi/handlers/rest_handler.py
@@ -1,13 +1,13 @@
-import json
-
import requests
+from typing import List, Dict, Union, Optional
class Request:
proxies = None
@staticmethod
- def post(url: str, data, params=None) -> requests.Response or None:
+ def post(url: str, data: List[Dict], params: Dict[str, Union[str, float]] = None) -> \
+ Optional[requests.Response]:
"""
Post request
:param params:
@@ -17,11 +17,8 @@ class Request:
"""
try:
headers = {'content-type': 'application/json'}
- r = requests.post(url, verify=False, params=params, json=data, headers=headers, proxies=Request.proxies)
- # if params is not None:
- # r = requests.post(url, params=params, json=data, headers=headers, proxies=proxies)
- # else:
- # r = requests.post(url, json=data)
+ r = requests.post(url, verify=False, params=params, json=data, headers=headers,
+ proxies=Request.proxies)
if r.status_code == 200:
return r
else:
@@ -31,7 +28,7 @@ class Request:
raise
@staticmethod
- def get(url, params, timeout=1) -> json or None:
+ def get(url: str, params: Dict[str, Union[str, float]], timeout: float = 1) -> Optional[requests.Response]:
"""
Get request
:param url:
@@ -41,7 +38,6 @@ class Request:
"""
try:
data = requests.get(url=url, verify=False, params=params, timeout=timeout, proxies=Request.proxies)
-
return data
except Exception as e:
print("Get Error\n", e)
diff --git a/reolinkapi/mixins/__init__.py b/reolinkapi/mixins/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/api/alarm.py b/reolinkapi/mixins/alarm.py
similarity index 85%
rename from api/alarm.py
rename to reolinkapi/mixins/alarm.py
index 2f48efb..53bc6ee 100644
--- a/api/alarm.py
+++ b/reolinkapi/mixins/alarm.py
@@ -1,7 +1,10 @@
+from typing import Dict
+
+
class AlarmAPIMixin:
"""API calls for getting device alarm information."""
- def get_alarm_motion(self) -> object:
+ def get_alarm_motion(self) -> Dict:
"""
Gets the device alarm motion
See examples/response/GetAlarmMotion.json for example response data.
diff --git a/api/device.py b/reolinkapi/mixins/device.py
similarity index 80%
rename from api/device.py
rename to reolinkapi/mixins/device.py
index deee890..684be45 100644
--- a/api/device.py
+++ b/reolinkapi/mixins/device.py
@@ -1,6 +1,11 @@
+from typing import List, Dict
+
+
class DeviceAPIMixin:
"""API calls for getting device information."""
- def get_hdd_info(self) -> object:
+ DEFAULT_HDD_ID = [0]
+
+ def get_hdd_info(self) -> Dict:
"""
Gets all HDD and SD card information from Camera
See examples/response/GetHddInfo.json for example response data.
@@ -9,12 +14,14 @@ class DeviceAPIMixin:
body = [{"cmd": "GetHddInfo", "action": 0, "param": {}}]
return self._execute_command('GetHddInfo', body)
- def format_hdd(self, hdd_id: [int] = [0]) -> bool:
+ def format_hdd(self, hdd_id: List[float] = None) -> bool:
"""
Format specified HDD/SD cards with their id's
:param hdd_id: List of id's specified by the camera with get_hdd_info api. Default is 0 (SD card)
:return: bool
"""
+ if hdd_id is None:
+ hdd_id = self.DEFAULT_HDD_ID
body = [{"cmd": "Format", "action": 0, "param": {"HddInfo": {"id": hdd_id}}}]
r_data = self._execute_command('Format', body)[0]
if r_data["value"]["rspCode"] == 200:
diff --git a/api/display.py b/reolinkapi/mixins/display.py
similarity index 53%
rename from api/display.py
rename to reolinkapi/mixins/display.py
index bf2b4ae..5c4c48c 100644
--- a/api/display.py
+++ b/reolinkapi/mixins/display.py
@@ -1,7 +1,10 @@
+from typing import Dict
+
+
class DisplayAPIMixin:
"""API calls related to the current image (osd, on screen display)."""
- def get_osd(self) -> object:
+ def get_osd(self) -> Dict:
"""
Get OSD information.
See examples/response/GetOsd.json for example response data.
@@ -10,7 +13,7 @@ class DisplayAPIMixin:
body = [{"cmd": "GetOsd", "action": 1, "param": {"channel": 0}}]
return self._execute_command('GetOsd', body)
- def get_mask(self) -> object:
+ def get_mask(self) -> Dict:
"""
Get the camera mask information.
See examples/response/GetMask.json for example response data.
@@ -19,8 +22,8 @@ class DisplayAPIMixin:
body = [{"cmd": "GetMask", "action": 1, "param": {"channel": 0}}]
return self._execute_command('GetMask', body)
- def set_osd(self, bg_color: bool = 0, channel: int = 0, osd_channel_enabled: bool = 0, osd_channel_name: str = "",
- osd_channel_pos: str = "Lower Right", osd_time_enabled: bool = 0,
+ def set_osd(self, bg_color: bool = 0, channel: float = 0, osd_channel_enabled: bool = 0,
+ osd_channel_name: str = "", osd_channel_pos: str = "Lower Right", osd_time_enabled: bool = 0,
osd_time_pos: str = "Lower Right") -> bool:
"""
Set OSD
@@ -28,18 +31,24 @@ class DisplayAPIMixin:
:param channel: int channel id
:param osd_channel_enabled: bool
:param osd_channel_name: string channel name
- :param osd_channel_pos: string channel position ["Upper Left","Top Center","Upper Right","Lower Left","Bottom Center","Lower Right"]
+ :param osd_channel_pos: string channel position
+ ["Upper Left","Top Center","Upper Right","Lower Left","Bottom Center","Lower Right"]
:param osd_time_enabled: bool
- :param osd_time_pos: string time position ["Upper Left","Top Center","Upper Right","Lower Left","Bottom Center","Lower Right"]
+ :param osd_time_pos: string time position
+ ["Upper Left","Top Center","Upper Right","Lower Left","Bottom Center","Lower Right"]
:return: whether the action was successful
"""
- body = [{"cmd": "SetOsd", "action": 1, "param": {
- "Osd": {"bgcolor": bg_color, "channel": channel,
- "osdChannel": {"enable": osd_channel_enabled, "name": osd_channel_name,
- "pos": osd_channel_pos},
- "osdTime": {"enable": osd_time_enabled, "pos": osd_time_pos}
- }
- }}]
+ body = [{"cmd": "SetOsd", "action": 1,
+ "param": {
+ "Osd": {
+ "bgcolor": bg_color,
+ "channel": channel,
+ "osdChannel": {
+ "enable": osd_channel_enabled, "name": osd_channel_name,
+ "pos": osd_channel_pos
+ },
+ "osdTime": {"enable": osd_time_enabled, "pos": osd_time_pos}
+ }}}]
r_data = self._execute_command('SetOsd', body)[0]
if r_data["value"]["rspCode"] == 200:
return True
diff --git a/reolinkapi/mixins/download.py b/reolinkapi/mixins/download.py
new file mode 100644
index 0000000..ebd1603
--- /dev/null
+++ b/reolinkapi/mixins/download.py
@@ -0,0 +1,18 @@
+class DownloadAPIMixin:
+ """API calls for downloading video files."""
+ def get_file(self, filename: str, output_path: str) -> bool:
+ """
+ Download the selected video file
+ :return: response json
+ """
+ body = [
+ {
+ "cmd": "Download",
+ "source": filename,
+ "output": filename,
+ "filepath": output_path
+ }
+ ]
+ resp = self._execute_command('Download', body)
+
+ return resp
diff --git a/api/image.py b/reolinkapi/mixins/image.py
similarity index 66%
rename from api/image.py
rename to reolinkapi/mixins/image.py
index 6cdb823..8e30568 100644
--- a/api/image.py
+++ b/reolinkapi/mixins/image.py
@@ -1,24 +1,26 @@
+from typing import Dict
+
class ImageAPIMixin:
"""API calls for image settings."""
def set_adv_image_settings(self,
- anti_flicker='Outdoor',
- exposure='Auto',
- gain_min=1,
- gain_max=62,
- shutter_min=1,
- shutter_max=125,
- blue_gain=128,
- red_gain=128,
- white_balance='Auto',
- day_night='Auto',
- back_light='DynamicRangeControl',
- blc=128,
- drc=128,
- rotation=0,
- mirroring=0,
- nr3d=1) -> object:
+ anti_flicker: str = 'Outdoor',
+ exposure: str = 'Auto',
+ gain_min: float = 1,
+ gain_max: float = 62,
+ shutter_min: float = 1,
+ shutter_max: float = 125,
+ blue_gain: float = 128,
+ red_gain: float = 128,
+ white_balance: str = 'Auto',
+ day_night: str = 'Auto',
+ back_light: str = 'DynamicRangeControl',
+ blc: float = 128,
+ drc: float = 128,
+ rotation: float = 0,
+ mirroring: float = 0,
+ nr3d: float = 1) -> Dict:
"""
Sets the advanced camera settings.
@@ -66,11 +68,11 @@ class ImageAPIMixin:
return self._execute_command('SetIsp', body)
def set_image_settings(self,
- brightness=128,
- contrast=62,
- hue=1,
- saturation=125,
- sharpness=128) -> object:
+ brightness: float = 128,
+ contrast: float = 62,
+ hue: float = 1,
+ saturation: float = 125,
+ sharpness: float = 128) -> Dict:
"""
Sets the camera image settings.
diff --git a/reolinkapi/mixins/motion.py b/reolinkapi/mixins/motion.py
new file mode 100644
index 0000000..b95746d
--- /dev/null
+++ b/reolinkapi/mixins/motion.py
@@ -0,0 +1,80 @@
+from typing import Union, List, Dict
+from datetime import datetime as dt
+
+
+# Type hints for input and output of the motion api response
+RAW_MOTION_LIST_TYPE = List[Dict[str, Union[str, float, Dict[str, str]]]]
+PROCESSED_MOTION_LIST_TYPE = List[Dict[str, Union[str, dt]]]
+
+
+class MotionAPIMixin:
+ """API calls for past motion alerts."""
+ def get_motion_files(self, start: dt, end: dt = dt.now(),
+ streamtype: str = 'sub') -> PROCESSED_MOTION_LIST_TYPE:
+ """
+ Get the timestamps and filenames of motion detection events for the time range provided.
+
+ Args:
+ start: the starting time range to examine
+ end: the end time of the time range to examine
+ streamtype: 'main' or 'sub' - the stream to examine
+ :return: response json
+ """
+ search_params = {
+ 'Search': {
+ 'channel': 0,
+ 'streamType': streamtype,
+ 'onlyStatus': 0,
+ 'StartTime': {
+ 'year': start.year,
+ 'mon': start.month,
+ 'day': start.day,
+ 'hour': start.hour,
+ 'min': start.minute,
+ 'sec': start.second
+ },
+ 'EndTime': {
+ 'year': end.year,
+ 'mon': end.month,
+ 'day': end.day,
+ 'hour': end.hour,
+ 'min': end.minute,
+ 'sec': end.second
+ }
+ }
+ }
+ body = [{"cmd": "Search", "action": 1, "param": search_params}]
+
+ resp = self._execute_command('Search', body)[0]
+ result = resp['value']['SearchResult']
+ files = result.get('File', [])
+ if len(files) > 0:
+ # Begin processing files
+ processed_files = self._process_motion_files(files)
+ return processed_files
+ return []
+
+ @staticmethod
+ def _process_motion_files(motion_files: RAW_MOTION_LIST_TYPE) -> PROCESSED_MOTION_LIST_TYPE:
+ """Processes raw list of dicts containing motion timestamps
+ and the filename associated with them"""
+ # Process files
+ processed_motions = []
+ replace_fields = {'mon': 'month', 'sec': 'second', 'min': 'minute'}
+ for file in motion_files:
+ time_range = {}
+ for x in ['Start', 'End']:
+ # Get raw dict
+ raw = file[f'{x}Time']
+ # Replace certain keys
+ for k, v in replace_fields.items():
+ if k in raw.keys():
+ raw[v] = raw.pop(k)
+ time_range[x.lower()] = dt(**raw)
+ start, end = time_range.values()
+ processed_motions.append({
+ 'start': start,
+ 'end': end,
+ 'filename': file['name']
+ })
+ return processed_motions
diff --git a/api/network.py b/reolinkapi/mixins/network.py
similarity index 84%
rename from api/network.py
rename to reolinkapi/mixins/network.py
index 39af7b8..f4fe4a6 100644
--- a/api/network.py
+++ b/reolinkapi/mixins/network.py
@@ -1,7 +1,10 @@
+from typing import Dict
+
+
class NetworkAPIMixin:
"""API calls for network settings."""
- def set_net_port(self, http_port=80, https_port=443, media_port=9000, onvif_port=8000, rtmp_port=1935,
- rtsp_port=554) -> bool:
+ def set_net_port(self, http_port: float = 80, https_port: float = 443, media_port: float = 9000,
+ onvif_port: float = 8000, rtmp_port: float = 1935, rtsp_port: float = 554) -> bool:
"""
Set network ports
If nothing is specified, the default values will be used
@@ -25,7 +28,7 @@ class NetworkAPIMixin:
print("Successfully Set Network Ports")
return True
- def set_wifi(self, ssid, password) -> object:
+ def set_wifi(self, ssid: str, password: str) -> Dict:
body = [{"cmd": "SetWifi", "action": 0, "param": {
"Wifi": {
"ssid": ssid,
@@ -33,7 +36,7 @@ class NetworkAPIMixin:
}}}]
return self._execute_command('SetWifi', body)
- def get_net_ports(self) -> object:
+ def get_net_ports(self) -> Dict:
"""
Get network ports
See examples/response/GetNetworkAdvanced.json for example response data.
@@ -44,15 +47,15 @@ class NetworkAPIMixin:
{"cmd": "GetP2p", "action": 0, "param": {}}]
return self._execute_command('GetNetPort', body, multi=True)
- def get_wifi(self):
+ def get_wifi(self) -> Dict:
body = [{"cmd": "GetWifi", "action": 1, "param": {}}]
return self._execute_command('GetWifi', body)
- def scan_wifi(self):
+ def scan_wifi(self) -> Dict:
body = [{"cmd": "ScanWifi", "action": 1, "param": {}}]
return self._execute_command('ScanWifi', body)
- def get_network_general(self) -> object:
+ def get_network_general(self) -> Dict:
"""
Get the camera information
See examples/response/GetNetworkGeneral.json for example response data.
@@ -61,7 +64,7 @@ class NetworkAPIMixin:
body = [{"cmd": "GetLocalLink", "action": 0, "param": {}}]
return self._execute_command('GetLocalLink', body)
- def get_network_ddns(self) -> object:
+ def get_network_ddns(self) -> Dict:
"""
Get the camera DDNS network information
See examples/response/GetNetworkDDNS.json for example response data.
@@ -70,7 +73,7 @@ class NetworkAPIMixin:
body = [{"cmd": "GetDdns", "action": 0, "param": {}}]
return self._execute_command('GetDdns', body)
- def get_network_ntp(self) -> object:
+ def get_network_ntp(self) -> Dict:
"""
Get the camera NTP network information
See examples/response/GetNetworkNTP.json for example response data.
@@ -79,7 +82,7 @@ class NetworkAPIMixin:
body = [{"cmd": "GetNtp", "action": 0, "param": {}}]
return self._execute_command('GetNtp', body)
- def get_network_email(self) -> object:
+ def get_network_email(self) -> Dict:
"""
Get the camera email network information
See examples/response/GetNetworkEmail.json for example response data.
@@ -88,7 +91,7 @@ class NetworkAPIMixin:
body = [{"cmd": "GetEmail", "action": 0, "param": {}}]
return self._execute_command('GetEmail', body)
- def get_network_ftp(self) -> object:
+ def get_network_ftp(self) -> Dict:
"""
Get the camera FTP network information
See examples/response/GetNetworkFtp.json for example response data.
@@ -97,7 +100,7 @@ class NetworkAPIMixin:
body = [{"cmd": "GetFtp", "action": 0, "param": {}}]
return self._execute_command('GetFtp', body)
- def get_network_push(self) -> object:
+ def get_network_push(self) -> Dict:
"""
Get the camera push network information
See examples/response/GetNetworkPush.json for example response data.
@@ -106,7 +109,7 @@ class NetworkAPIMixin:
body = [{"cmd": "GetPush", "action": 0, "param": {}}]
return self._execute_command('GetPush', body)
- def get_network_status(self) -> object:
+ def get_network_status(self) -> Dict:
"""
Get the camera status network information
See examples/response/GetNetworkGeneral.json for example response data.
diff --git a/api/ptz.py b/reolinkapi/mixins/ptz.py
similarity index 67%
rename from api/ptz.py
rename to reolinkapi/mixins/ptz.py
index 463624e..80841a0 100644
--- a/api/ptz.py
+++ b/reolinkapi/mixins/ptz.py
@@ -1,46 +1,49 @@
+from typing import Dict
+
+
class PtzAPIMixin:
"""
API for PTZ functions.
"""
- def _send_operation(self, operation, speed, index=None):
- if index is None:
- data = [{"cmd": "PtzCtrl", "action": 0, "param": {"channel": 0, "op": operation, "speed": speed}}]
- else:
- data = [{"cmd": "PtzCtrl", "action": 0, "param": {
- "channel": 0, "op": operation, "speed": speed, "id": index}}]
+ def _send_operation(self, operation: str, speed: float, index: float = None) -> Dict:
+ # Refactored to reduce redundancy
+ param = {"channel": 0, "op": operation, "speed": speed}
+ if index is not None:
+ param['id'] = index
+ data = [{"cmd": "PtzCtrl", "action": 0, "param": param}]
return self._execute_command('PtzCtrl', data)
- def _send_noparm_operation(self, operation):
+ def _send_noparm_operation(self, operation: str) -> Dict:
data = [{"cmd": "PtzCtrl", "action": 0, "param": {"channel": 0, "op": operation}}]
return self._execute_command('PtzCtrl', data)
- def _send_set_preset(self, operation, enable, preset=1, name='pos1'):
+ def _send_set_preset(self, enable: float, preset: float = 1, name: str = 'pos1') -> Dict:
data = [{"cmd": "SetPtzPreset", "action": 0, "param": {
"channel": 0, "enable": enable, "id": preset, "name": name}}]
return self._execute_command('PtzCtrl', data)
- def go_to_preset(self, speed=60, index=1):
+ def go_to_preset(self, speed: float = 60, index: float = 1) -> Dict:
"""
Move the camera to a preset location
:return: response json
"""
return self._send_operation('ToPos', speed=speed, index=index)
- def add_preset(self, preset=1, name='pos1'):
+ def add_preset(self, preset: float = 1, name: str = 'pos1') -> Dict:
"""
Adds the current camera position to the specified preset.
:return: response json
"""
- return self._send_set_preset('PtzPreset', enable=1, preset=preset, name=name)
+ return self._send_set_preset(enable=1, preset=preset, name=name)
- def remove_preset(self, preset=1, name='pos1'):
+ def remove_preset(self, preset: float = 1, name: str = 'pos1') -> Dict:
"""
Removes the specified preset
:return: response json
"""
- return self._send_set_preset('PtzPreset', enable=0, preset=preset, name=name)
+ return self._send_set_preset(enable=0, preset=preset, name=name)
- def move_right(self, speed=25):
+ def move_right(self, speed: float = 25) -> Dict:
"""
Move the camera to the right
The camera moves self.stop_ptz() is called.
@@ -48,7 +51,7 @@ class PtzAPIMixin:
"""
return self._send_operation('Right', speed=speed)
- def move_right_up(self, speed=25):
+ def move_right_up(self, speed: float = 25) -> Dict:
"""
Move the camera to the right and up
The camera moves self.stop_ptz() is called.
@@ -56,7 +59,7 @@ class PtzAPIMixin:
"""
return self._send_operation('RightUp', speed=speed)
- def move_right_down(self, speed=25):
+ def move_right_down(self, speed: float = 25) -> Dict:
"""
Move the camera to the right and down
The camera moves self.stop_ptz() is called.
@@ -64,7 +67,7 @@ class PtzAPIMixin:
"""
return self._send_operation('RightDown', speed=speed)
- def move_left(self, speed=25):
+ def move_left(self, speed: float = 25) -> Dict:
"""
Move the camera to the left
The camera moves self.stop_ptz() is called.
@@ -72,7 +75,7 @@ class PtzAPIMixin:
"""
return self._send_operation('Left', speed=speed)
- def move_left_up(self, speed=25):
+ def move_left_up(self, speed: float = 25) -> Dict:
"""
Move the camera to the left and up
The camera moves self.stop_ptz() is called.
@@ -80,7 +83,7 @@ class PtzAPIMixin:
"""
return self._send_operation('LeftUp', speed=speed)
- def move_left_down(self, speed=25):
+ def move_left_down(self, speed: float = 25) -> Dict:
"""
Move the camera to the left and down
The camera moves self.stop_ptz() is called.
@@ -88,7 +91,7 @@ class PtzAPIMixin:
"""
return self._send_operation('LeftDown', speed=speed)
- def move_up(self, speed=25):
+ def move_up(self, speed: float = 25) -> Dict:
"""
Move the camera up.
The camera moves self.stop_ptz() is called.
@@ -96,7 +99,7 @@ class PtzAPIMixin:
"""
return self._send_operation('Up', speed=speed)
- def move_down(self, speed=25):
+ def move_down(self, speed: float = 25) -> Dict:
"""
Move the camera down.
The camera moves self.stop_ptz() is called.
@@ -104,14 +107,14 @@ class PtzAPIMixin:
"""
return self._send_operation('Down', speed=speed)
- def stop_ptz(self):
+ def stop_ptz(self) -> Dict:
"""
Stops the cameras current action.
:return: response json
"""
return self._send_noparm_operation('Stop')
- def auto_movement(self, speed=25):
+ def auto_movement(self, speed: float = 25) -> Dict:
"""
Move the camera in a clockwise rotation.
The camera moves self.stop_ptz() is called.
diff --git a/reolinkapi/mixins/record.py b/reolinkapi/mixins/record.py
new file mode 100644
index 0000000..375b5ca
--- /dev/null
+++ b/reolinkapi/mixins/record.py
@@ -0,0 +1,72 @@
+from typing import Dict
+
+
+class RecordAPIMixin:
+ """API calls for the recording settings"""
+
+ def get_recording_encoding(self) -> Dict:
+ """
+ Get the current camera encoding settings for "Clear" and "Fluent" profiles.
+ See examples/response/GetEnc.json for example response data.
+ :return: response json
+ """
+ body = [{"cmd": "GetEnc", "action": 1, "param": {"channel": 0}}]
+ return self._execute_command('GetEnc', body)
+
+ def get_recording_advanced(self) -> Dict:
+ """
+ Get recording advanced setup data
+ See examples/response/GetRec.json for example response data.
+ :return: response json
+ """
+ body = [{"cmd": "GetRec", "action": 1, "param": {"channel": 0}}]
+ return self._execute_command('GetRec', body)
+
+ def set_recording_encoding(self,
+ audio: float = 0,
+ main_bit_rate: float = 8192,
+ main_frame_rate: float = 8,
+ main_profile: str = 'High',
+ main_size: str = "2560*1440",
+ sub_bit_rate: float = 160,
+ sub_frame_rate: float = 7,
+ sub_profile: str = 'High',
+ sub_size: str = '640*480') -> Dict:
+ """
+ Sets the current camera encoding settings for "Clear" and "Fluent" profiles.
+ :param audio: int Audio on or off
+ :param main_bit_rate: int Clear Bit Rate
+ :param main_frame_rate: int Clear Frame Rate
+ :param main_profile: string Clear Profile
+ :param main_size: string Clear Size
+ :param sub_bit_rate: int Fluent Bit Rate
+ :param sub_frame_rate: int Fluent Frame Rate
+ :param sub_profile: string Fluent Profile
+ :param sub_size: string Fluent Size
+ :return: response
+ """
+ body = [
+ {
+ "cmd": "SetEnc",
+ "action": 0,
+ "param": {
+ "Enc": {
+ "audio": audio,
+ "channel": 0,
+ "mainStream": {
+ "bitRate": main_bit_rate,
+ "frameRate": main_frame_rate,
+ "profile": main_profile,
+ "size": main_size
+ },
+ "subStream": {
+ "bitRate": sub_bit_rate,
+ "frameRate": sub_frame_rate,
+ "profile": sub_profile,
+ "size": sub_size
+ }
+ }
+ }
+ }
+ ]
+ return self._execute_command('SetEnc', body)
diff --git a/reolinkapi/mixins/stream.py b/reolinkapi/mixins/stream.py
new file mode 100644
index 0000000..5d6e419
--- /dev/null
+++ b/reolinkapi/mixins/stream.py
@@ -0,0 +1,52 @@
+import string
+from random import random
+from typing import Any, Optional
+from urllib import parse
+from io import BytesIO
+
+import requests
+from PIL.Image import Image, open as open_image
+
+from reolinkapi.utils.rtsp_client import RtspClient
+
+
+class StreamAPIMixin:
+ """ API calls for opening a video stream or capturing an image from the camera."""
+
+ def open_video_stream(self, callback: Any = None, proxies: Any = None) -> Any:
+ """
+ 'https://support.reolink.com/hc/en-us/articles/360007010473-How-to-Live-View-Reolink-Cameras-via-VLC-Media-Player'
+ Blocking function creates a generator and returns the frames as it is spawned
+ :param callback:
+ :param proxies: Default is none, example: {"host": "localhost", "port": 8000}
+ """
+ rtsp_client = RtspClient(
+ ip=self.ip, username=self.username, password=self.password, proxies=proxies, callback=callback)
+ return rtsp_client.open_stream()
+
+ def get_snap(self, timeout: float = 3, proxies: Any = None) -> Optional[Image]:
+ """
+ Gets a "snap" of the current camera video data and returns a Pillow Image or None
+ :param timeout: Request timeout to camera in seconds
+ :param proxies: http/https proxies to pass to the request object.
+ :return: Image or None
+ """
+ data = {
+ 'cmd': 'Snap',
+ 'channel': 0,
+ 'rs': ''.join(random.choices(string.ascii_uppercase + string.digits, k=10)),
+ 'user': self.username,
+ 'password': self.password,
+ }
+ parms = parse.urlencode(data).encode("utf-8")
+
+ try:
+ response = requests.get(self.url, proxies=proxies, params=parms, timeout=timeout)
+ if response.status_code == 200:
+ return open_image(BytesIO(response.content))
+ print("Could not retrieve data from camera successfully. Status:", response.status_code)
+ return None
+
+ except Exception as e:
+ print("Could not get Image data\n", e)
+ raise
diff --git a/api/system.py b/reolinkapi/mixins/system.py
similarity index 83%
rename from api/system.py
rename to reolinkapi/mixins/system.py
index 0eadc6a..dcb590a 100644
--- a/api/system.py
+++ b/reolinkapi/mixins/system.py
@@ -1,11 +1,14 @@
+from typing import Dict
+
+
class SystemAPIMixin:
"""API for accessing general system information of the camera."""
- def get_general_system(self) -> object:
+ def get_general_system(self) -> Dict:
""":return: response json"""
body = [{"cmd": "GetTime", "action": 1, "param": {}}, {"cmd": "GetNorm", "action": 1, "param": {}}]
return self._execute_command('get_general_system', body, multi=True)
- def get_performance(self) -> object:
+ def get_performance(self) -> Dict:
"""
Get a snapshot of the current performance of the camera.
See examples/response/GetPerformance.json for example response data.
@@ -14,7 +17,7 @@ class SystemAPIMixin:
body = [{"cmd": "GetPerformance", "action": 0, "param": {}}]
return self._execute_command('GetPerformance', body)
- def get_information(self) -> object:
+ def get_information(self) -> Dict:
"""
Get the camera information
See examples/response/GetDevInfo.json for example response data.
@@ -23,7 +26,7 @@ class SystemAPIMixin:
body = [{"cmd": "GetDevInfo", "action": 0, "param": {}}]
return self._execute_command('GetDevInfo', body)
- def reboot_camera(self) -> object:
+ def reboot_camera(self) -> Dict:
"""
Reboots the camera
:return: response json
@@ -31,11 +34,11 @@ class SystemAPIMixin:
body = [{"cmd": "Reboot", "action": 0, "param": {}}]
return self._execute_command('Reboot', body)
- def get_dst(self) -> object:
+ def get_dst(self) -> Dict:
"""
Get the camera DST information
See examples/response/GetDSTInfo.json for example response data.
:return: response json
"""
body = [{"cmd": "GetTime", "action": 0, "param": {}}]
- return self._execute_command('GetTime', body)
\ No newline at end of file
+ return self._execute_command('GetTime', body)
diff --git a/api/user.py b/reolinkapi/mixins/user.py
similarity index 89%
rename from api/user.py
rename to reolinkapi/mixins/user.py
index 9d430f6..c382c2d 100644
--- a/api/user.py
+++ b/reolinkapi/mixins/user.py
@@ -1,6 +1,9 @@
+from typing import Dict
+
+
class UserAPIMixin:
"""User-related API calls."""
- def get_online_user(self) -> object:
+ def get_online_user(self) -> Dict:
"""
Return a list of current logged-in users in json format
See examples/response/GetOnline.json for example response data.
@@ -9,7 +12,7 @@ class UserAPIMixin:
body = [{"cmd": "GetOnline", "action": 1, "param": {}}]
return self._execute_command('GetOnline', body)
- def get_users(self) -> object:
+ def get_users(self) -> Dict:
"""
Return a list of user accounts from the camera in json format.
See examples/response/GetUser.json for example response data.
@@ -45,7 +48,7 @@ class UserAPIMixin:
r_data = self._execute_command('ModifyUser', body)[0]
if r_data["value"]["rspCode"] == 200:
return True
- print("Could not modify user:", username, "\nCamera responded with:", r_data["value"])
+ print(f"Could not modify user: {username}\nCamera responded with: {r_data['value']}")
return False
def delete_user(self, username: str) -> bool:
@@ -58,5 +61,5 @@ class UserAPIMixin:
r_data = self._execute_command('DelUser', body)[0]
if r_data["value"]["rspCode"] == 200:
return True
- print("Could not delete user:", username, "\nCamera responded with:", r_data["value"])
+ print(f"Could not delete user: {username}\nCamera responded with: {r_data['value']}")
return False
diff --git a/api/zoom.py b/reolinkapi/mixins/zoom.py
similarity index 77%
rename from api/zoom.py
rename to reolinkapi/mixins/zoom.py
index 2bf0021..0f5778d 100644
--- a/api/zoom.py
+++ b/reolinkapi/mixins/zoom.py
@@ -1,54 +1,57 @@
+from typing import Dict
+
+
class ZoomAPIMixin:
"""
API for zooming and changing focus.
Note that the API does not allow zooming/focusing by absolute
values rather that changing focus/zoom for a given time.
"""
- def _start_operation(self, operation, speed):
+ def _start_operation(self, operation: str, speed: float) -> Dict:
data = [{"cmd": "PtzCtrl", "action": 0, "param": {"channel": 0, "op": operation, "speed": speed}}]
return self._execute_command('PtzCtrl', data)
- def _stop_zooming_or_focusing(self):
+ def _stop_zooming_or_focusing(self) -> Dict:
"""This command stops any ongoing zooming or focusing actions."""
data = [{"cmd": "PtzCtrl", "action": 0, "param": {"channel": 0, "op": "Stop"}}]
return self._execute_command('PtzCtrl', data)
- def start_zooming_in(self, speed=60):
+ def start_zooming_in(self, speed: float = 60) -> Dict:
"""
The camera zooms in until self.stop_zooming() is called.
:return: response json
"""
return self._start_operation('ZoomInc', speed=speed)
- def start_zooming_out(self, speed=60):
+ def start_zooming_out(self, speed: float = 60) -> Dict:
"""
The camera zooms out until self.stop_zooming() is called.
:return: response json
"""
return self._start_operation('ZoomDec', speed=speed)
- def stop_zooming(self):
+ def stop_zooming(self) -> Dict:
"""
Stop zooming.
:return: response json
"""
return self._stop_zooming_or_focusing()
- def start_focusing_in(self, speed=32):
+ def start_focusing_in(self, speed: float = 32) -> Dict:
"""
The camera focuses in until self.stop_focusing() is called.
:return: response json
"""
return self._start_operation('FocusInc', speed=speed)
- def start_focusing_out(self, speed=32):
+ def start_focusing_out(self, speed: float = 32) -> Dict:
"""
The camera focuses out until self.stop_focusing() is called.
:return: response json
"""
return self._start_operation('FocusDec', speed=speed)
- def stop_focusing(self):
+ def stop_focusing(self) -> Dict:
"""
Stop focusing.
:return: response json
diff --git a/reolinkapi/utils/__init__.py b/reolinkapi/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/RtspClient.py b/reolinkapi/utils/rtsp_client.py
similarity index 83%
rename from RtspClient.py
rename to reolinkapi/utils/rtsp_client.py
index 6cf37c1..e260a74 100644
--- a/RtspClient.py
+++ b/reolinkapi/utils/rtsp_client.py
@@ -1,20 +1,21 @@
import os
from threading import ThreadError
-
+from typing import Any
import cv2
-
-from util import threaded
+from reolinkapi.utils.util import threaded
class RtspClient:
"""
+ This is a wrapper of the OpenCV VideoCapture method
Inspiration from:
- https://benhowell.github.io/guide/2015/03/09/opencv-and-web-cam-streaming
- https://stackoverflow.com/questions/19846332/python-threading-inside-a-class
- https://stackoverflow.com/questions/55828451/video-streaming-from-ip-camera-in-python-using-opencv-cv2-videocapture
"""
- def __init__(self, ip, username, password, port=554, profile="main", use_udp=True, callback=None, **kwargs):
+ def __init__(self, ip: str, username: str, password: str, port: float = 554, profile: str = "main",
+ use_udp: bool = True, callback: Any = None, **kwargs):
"""
RTSP client is used to retrieve frames from the camera in a stream
@@ -36,12 +37,8 @@ class RtspClient:
self.password = password
self.port = port
self.proxy = kwargs.get("proxies")
- self.url = "rtsp://" + self.username + ":" + self.password + "@" + \
- self.ip + ":" + str(self.port) + "//h264Preview_01_" + profile
- if use_udp:
- capture_options = capture_options + 'udp'
- else:
- capture_options = capture_options + 'tcp'
+ self.url = f'rtsp://{self.username}:{self.password}@{self.ip}:{self.port}//h264Preview_01_{profile}'
+ capture_options += 'udp' if use_udp else 'tcp'
os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = capture_options
@@ -91,9 +88,6 @@ class RtspClient:
"""
Opens OpenCV Video stream and returns the result according to the OpenCV documentation
https://docs.opencv.org/3.4/d8/dfe/classcv_1_1VideoCapture.html#a473055e77dd7faa4d26d686226b292c1
-
- :param callback: The function to callback the cv::mat frame to if required to be non-blocking. If this is left
- as None, then the function returns a generator which is blocking.
"""
# Reset the capture object
diff --git a/util.py b/reolinkapi/utils/util.py
similarity index 92%
rename from util.py
rename to reolinkapi/utils/util.py
index c824002..83cf0ba 100644
--- a/util.py
+++ b/reolinkapi/utils/util.py
@@ -8,4 +8,4 @@ def threaded(fn):
thread.start()
return thread
- return wrapper
\ No newline at end of file
+ return wrapper
diff --git a/requirements.txt b/requirements.txt
index 30b468d..a8aabcd 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,6 @@
-requests
-opencv-python
-numpy
-socks
\ No newline at end of file
+numpy==1.19.4
+opencv-python==4.4.0.46
+Pillow==8.0.1
+PySocks==1.7.1
+PyYaml==5.3.1
+requests>=2.18.4
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 98eba70..3764023 100644
--- a/setup.py
+++ b/setup.py
@@ -1,31 +1,8 @@
#!/usr/bin/python3
-
import os
import re
import codecs
-from setuptools import setup
-
-# Package meta-data.
-NAME = 'reolink-api'
-DESCRIPTION = 'Reolink Camera API written in Python 3.6'
-URL = 'https://github.com/Benehiko/ReolinkCameraAPI'
-AUTHOR_EMAIL = ''
-AUTHOR = 'Benehiko'
-LICENSE = 'GPL-3.0'
-INSTALL_REQUIRES = [
- 'pillow',
- 'pyyaml',
- 'requests>=2.18.4',
- 'numpy',
- 'opencv-python',
- 'pysocks'
-]
-
-
-here = os.path.abspath(os.path.dirname(__file__))
-# read the contents of your README file
-with open(os.path.join(here, 'README.md'), encoding='utf-8') as f:
- long_description = f.read()
+from setuptools import setup, find_packages
def read(*parts):
@@ -41,32 +18,40 @@ def find_version(*file_paths):
raise RuntimeError("Unable to find version string.")
-setup(name=NAME,
- python_requires='>=3.6.0',
- version=find_version('api', '__init__.py'),
- description=DESCRIPTION,
- long_description=long_description,
- long_description_content_type='text/markdown',
- author=AUTHOR,
- author_email=AUTHOR_EMAIL,
- url=URL,
- license=LICENSE,
- install_requires=INSTALL_REQUIRES,
- py_modules=[
- 'Camera',
- 'ConfigHandler',
- 'RtspClient',
- 'resthandle',
- 'api.APIHandler',
- 'api.device',
- 'api.display',
- 'api.network',
- 'api.ptz',
- 'api.recording',
- 'api.system',
- 'api.user',
- 'api.zoom',
- 'api.alarm',
- 'api.image'
- ]
- )
+# Package meta-data.
+NAME = 'reolinkapi'
+DESCRIPTION = 'Reolink Camera API client written in Python 3'
+URL = 'https://github.com/ReolinkCameraAPI/reolinkapipy'
+AUTHOR_EMAIL = 'alanoterblanche@gmail.com'
+AUTHOR = 'Benehiko'
+LICENSE = 'GPL-3.0'
+INSTALL_REQUIRES = [
+ 'numpy==1.19.4',
+ 'opencv-python==4.4.0.46',
+ 'Pillow==8.0.1',
+ 'PySocks==1.7.1',
+ 'PyYaml==5.3.1',
+ 'requests>=2.18.4',
+]
+
+
+here = os.path.abspath(os.path.dirname(__file__))
+# read the contents of your README file
+with open(os.path.join(here, 'README.md'), encoding='utf-8') as f:
+ long_description = f.read()
+
+
+setup(
+ name=NAME,
+ python_requires='>=3.6.0',
+ version=find_version('reolinkapi', '__init__.py'),
+ description=DESCRIPTION,
+ long_description=long_description,
+ long_description_content_type='text/markdown',
+ author=AUTHOR,
+ author_email=AUTHOR_EMAIL,
+ url=URL,
+ license=LICENSE,
+ install_requires=INSTALL_REQUIRES,
+ packages=find_packages(exclude=['examples', 'tests'])
+)
diff --git a/test.py b/test.py
deleted file mode 100644
index 796f5a2..0000000
--- a/test.py
+++ /dev/null
@@ -1,5 +0,0 @@
-from Camera import Camera
-
-c = Camera("192.168.1.112", "admin", "jUa2kUzi")
-# print("Getting information", c.get_information())
-c.open_video_stream()
diff --git a/tests/test_camera.py b/tests/test_camera.py
new file mode 100644
index 0000000..67851d0
--- /dev/null
+++ b/tests/test_camera.py
@@ -0,0 +1,40 @@
+import os
+from configparser import RawConfigParser
+import unittest
+from reolinkapi import Camera
+
+
+def read_config(props_path: str) -> dict:
+ """Reads in a properties file into variables.
+
+ NB! this config file is kept out of commits with .gitignore. The structure of this file is such:
+ # secrets.cfg
+ [camera]
+ ip={ip_address}
+ username={username}
+ password={password}
+ """
+ config = RawConfigParser()
+ assert os.path.exists(props_path), f"Path does not exist: {props_path}"
+ config.read(props_path)
+ return config
+
+
+class TestCamera(unittest.TestCase):
+
+ @classmethod
+ def setUpClass(cls) -> None:
+ cls.config = read_config('../secrets.cfg')
+
+ def setUp(self) -> None:
+ self.cam = Camera(self.config.get('camera', 'ip'), self.config.get('camera', 'username'),
+ self.config.get('camera', 'password'))
+
+ def test_camera(self):
+ """Test that camera connects and gets a token"""
+ self.assertTrue(self.cam.ip == self.config.get('camera', 'ip'))
+ self.assertTrue(self.cam.token != '')
+
+
+if __name__ == '__main__':
+ unittest.main()