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 @@

Reolink Approval - GitHub - GitHub tag (latest SemVer) - PyPI + GitHub + GitHub tag (latest SemVer) + PyPI Discord

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()